Signal 也许真的能杀死 Virtual DOM !!!

点击下方“前端开发爱好者”,选择“设为星标

第一时间关注技术干货!

No Virtual DOM 浪潮

Signal 提案

最近在前端圈有一个 Github Repo 算是蛮受关注的 - proposal-signals,这是一个由 Daniel Ehrenberg 为主导,向 TC39 提案的项目,主要是希望可以通过一系列名为 Signal 的 API 来为 Javascript 提供一个更方便的状态(State)与视图(View)更新同步方案。

目前这项提案在这篇文章发布时已经进入阶段一。当然,这一提案受到了很多开发者的关注与期待,但其实也有不少质疑的声音,不过这不会是这篇文章的重点,下面我们先用作者提供的程序代码来感受一下它可能为我们带来的改变。

假设今天有一个 counter 变量,而在页面上将会渲染出这个变量的为奇数或偶数,每当 counter 变量改变时,页面上的文字也会随之改变。因此在原生的 Javascript 中,我们可能会这样写:

 let counter = 0;
const setCounter = (value) => {
counter = value;
render();
};

const isEven = () => (counter & 1) == 0;
const parity = () => isEven() ? "even" : "odd";
const render = () => element.innerText = parity();

// Simulate external updates to counter...
setInterval(() => setCounter(counter + 1), 1000);

而这种方式,使得状态和页面渲染紧密的连接,并且可能会产生不必要的渲染(例如 counter 从 2 更新为 4 时)。而 Signal 的产生将会改变这样的写法:

 const counter = new Signal.State(0);
const isEven = new Signal.Computed(() => (counter.get() & 1) == 0);
const parity = new Signal.Computed(() => isEven.get() ? "even" : "odd");

// A library or framework defines effects based on other Signal primitives
declare function effect(cb: () => void): (() => void);

effect(() => element.innerText = parity.get());

// Simulate external updates to counter...
setInterval(() => counter.set(counter.get() + 1), 1000);

如果你是一款前端框架的用户,可能会觉得这样的写法很熟悉,这种写法与目前众多框架的写法有着异曲同工之妙,但这里的 Signal 不是一个框架,而是一个提供状态管理的 API。事实上 Signal 的提案确实得到了不少的框架作者的支持,例如 Vue 的作者 - 尤雨溪。

Vue Vapor Mode

如果你在 2022 年之后,听过尤雨溪在各大技术大会上的演讲,高机率会听到他提到的 Vapor Mode,它是一个正在开发的不同版本的 Vue,而与原版本最大的不同在于,它不再使用 Virtual DOM 来进行 DOM 的比对,而是直接对 DOM 进行操作。在 Vue 的官方文件中你也可以看到关于 Vapor 的描述,并且也可以看到 Signal 的字眼,而会有这个版本的诞生,尤雨溪也避言得表示是受到了 Solid.js 的启发。

2023 年 8 月在举办的 {Laravel x Vue} Conf Taiwan 2023 中,亦有幸邀请尤雨溪到台北现场演讲,提到了 Vapor Mode 的相关内容,并且也未透露正在与其他框架的作者计划画将这样的响应式设计标准化。

Svelte 和 Solid.js

这一两年可谓是前端框架的战国时代,各个框架新秀都有自己的特色,不过其中有两个框架在这波浪潮中脱颖而出,分别是 Svelte 和 Solid.js,而恰巧的是这两个框架都是不使用 Virtual DOM 的框架。

Svelte 是一个由 Rich Harris 所开发的框架,它的特点是所有的编译工作都放在编译阶段,这样在运行时就不需要再进行 Virtual DOM 的比对,而是直接对 DOM 进行操作。Solid.js 由 Ryan Carniato 所开发,它的特色是使用了 React Hooks 的概念,并且也是直接对 DOM 进行操作。而它们也各自的编写风格被称为次世代的 Vue 和 React。

这两个框架目前在 js-framework-benchmark 上也都是名列前茅,因此也可以看出不使用 Virtual DOM 的框架在性能上的优势。

Virtual DOM 跌落神坛?

可以看出,不使用 Virtual DOM 的框架在这波浪潮中有着不小的优势,不仅引发了 Vue Vapor Mode 的诞生,更是让 Signal 提案受到了关注。不过 Virtual DOM 到底发生了什么事,让以往喊着「真香」的开发者们,现在却又开始对它产生怀疑了呢?

在 Virtual DOM 大鸣大放的时代,React 可以说是最大的推手,它透过了 Virtual DOM 机制让更新视图变得抽象,开发者们可以专注于状态的管理上,而不用担心 DOM 的操作。可能曾经听过 “Virtual DOM 相对于直接操作 DOM 来说,有效能上的优势”,但可能并不完全正确。

2013 年时,React 的核心成员 Pete Hunt 在一次演讲 React: Rethinking bestpractices 中提到:

This is actually extremely fast, primarily because most DOM operations tend to be slow. There's been a lot of performance work on the DOM, but most DOM operations tend to drop frames.
这实际上非常快,主要是因为大多数 DOM 操作往往很慢。在 DOM 上做了很多性能工作,但大多数 DOM 操作都容易丢帧。

Pete Hunt 也因为这番话.受到了一些攻击和质疑,随后他也进行了澄清。

而事实是,Virtual DOM 未必会比直接操作 DOM 快,这取决于你的应用程序的复杂度,当然也取决于框架的实现方式。而 Virtual DOM 的优势在于它的抽象性,让开发者可以专注在状态的管理上,而不用担心 DOM 的操作,以及比起将整个 innerHTML 重新渲染,Virtual DOM 可以只更新需要更新的部分。

但相反的是,如果今天只是更新小部分的 DOM,那么直接操作 DOM 可能会比 Virtual DOM 更快,毕竟 Diff 演算法也是需要成本的。或者在初次渲染大量元素时,由于 Virtual DOM 需要先耗费时间首先建立一个 Virtual DOM Tree,这也是一个成本。

因此,现在在 React 官方文件中你看不到其采用 Virtual DOM 是为了效能或速度,而是为了可以使用声明式的程序代码来描述你的 UI,以及跨平台的能力。

其实尤雨溪也曾在一些社群中表达了自己对于 Virtual DOM 的看法,以及为何会在 Vue2 借鉴 React 的 Virtual DOM 机制。

React 的 vdom 实际上性能不怎么样。Vue 2.0 引入 vdom 的关键是把 vdom 的渲染过程抽象化了,从而使得组件的抽象能力也得到提升,并且可以将 DOM 渲染到渲染目标之外。
-- 知乎 - Vue 的理念问题

没有任何框架可以比纯手动的优化 DOM 操作更快,因为框架的 DOM 操作层需要对应任何上层 API 可能产生的操作,它的实现必须是普适的。
-- 知乎 - 网上都说操作真实 DOM 慢,但测试结果却比 React 更快,为什么?

Virtual DOM vs. Real DOM

了解 Virtual DOM 的优势与劣势之后,下面我想用具体的代码来展示用 Virtual DOM 的框架与不用 Virtual DOM 的框架渲染机制上的巨大差异。下面我们会使用 React、Vue 以及 Solid.js 来写一个 App -> Parent -> ChildrenOne -> ChildrenTwo 的组件结构,并观察父组件的状态改变时,子元件的渲染情况。

React (v18.2)

Parent.jsx

 function Parent() {
const [count, setCount] = useState(0)

return (
<>
<div>
<button onClick={() => setCount((count) => count + 1)}>Update: { count }</button>
<div>{ Math.random() }</div>
<ChildrenOne count={count} />
</div>
</>
)
}

ChildrenOne.jsx

 function ChildrenOne(props) {
return (
<>
<div>
<div>{ Math.random() }</div>
<div>Parent Count: { props.count }</div>
<ChildrenTwo />
</div>
</>
)
}

ChildrenTwo.jsx

 function ChildrenTwo() {
return (
<>
<div>{ Math.random() }</div>
</>
)
}

Vue (v3.4)

Parent.vue

 <template>
<div>
<button @click="count++">Update: {{ count }}</button>
<div>{{ Math.random() }}</div>
<ChildrenOne :count="count" />
</div>
</template>

<script setup>
const count = ref(0)
</script>

ChildrenOne.vue

 <template>
<div>
<div>{{ Math.random() }}</div>
<div>Parent Count: {{ count }}</div>
<ChildrenTwo />
</div>
</template>

<script setup>
defineProps({ count: Number })
</script>

ChildrenTwo.vue

 <template>
<div>{{ Math.random() }}</div>
</template>

Solid.js (v1.8)

Parent.jsx

 function Parent() {
const [count, setCount] = createSignal(0)

return (
<>
<div>
<button onClick={() => setCount((count) => count + 1)}>Update: { count() }</button>
<div>{ Math.random() }</div>
<ChildrenOne count={count()} />
</div>
</>
)
}

ChildrenOne.jsx

 function ChildrenOne(props) {
return (
<>
<div>
<div>{ Math.random() }</div>
<div>Parent Count: { props.count }</div>
<ChildrenTwo />
</div>
</>
)
}

ChildrenTwo.jsx

 function ChildrenTwo() {
return (
<>
<div>{ Math.random() }</div>
</>
)
}

差异

我们在各阶层的组件中都加入了 Math.random() 来观察每次渲染时的情况,只要数字有更新就可以判断组件有没有被重新渲染过,另外 ChildrenOne 会接收 Parent 所建立的 count 状态,ChildrenTwo 则是单纯的显示组件。

当我们通过按钮来更新 Parent 中的 count 时候,可以观察到 Math.random() 重新执行的情况:

会有这样的差异,主要来自于各个框架实行的编译及渲染机制,其中 React 和 Vue 因为使用了 Virtual DOM,必须在每次状态更新时重新「render」一组全新的 Virtual DOM Tree 用来比较所导致的,如果要避免多余的渲染,需要额外透过 React.memo 或 computed 来协助。

反观 Solid.js 因为是直接将状态更新编译为独立的 DOM 操作,所以可以让状态响应的单位降低至数据等级。

编译与状态更新

Solid.js (v1.8)

前面说过,Solid 能够在极小的粒度上进行状态更新,因此将获得状态更新编译为独立的 DOM 操作。我们可以用官方提供的 Playground 来查看代码的编译结果:

编译前

 function Counter() {
const [count, setCount] = createSignal(1);
const [disabled, setDisabled] = createSignal(false);

return (
<button disabled={disabled()}>
{ count() }
</button>
);
}

编译后

 function Counter() {
const [count, setCount] = createSignal(1);
const [disabled, setDisabled] = createSignal(false);
return (() => {
var _el$ = _tmpl$();
_$insert(_el$, count);
_$effect(() => _el$.disabled = disabled());
return _el$;
})();
}

可以看到 Solid 和 React 或 Vue 一样将组件编译为一个函数,但不同的是,这个函数只能在初始渲染时执行一次,因为 Solid 不需要「产生新的 vDOM Tree」这个过程。

当中的 createSignal 是 Solid 的核心 API,用于创建一个 Signal,并且当有任何角落透过其返回的 getter(例如示例中的 count)来获取状态的值时,便会透过订阅发布机制(Pub-Sub)来通知订阅者更新。这也是为什么在 Solid 中,我们需要通过 value() 的方式来获取值,而不是像 React useState 所提供的 value 一样直接读取。

接着可以看到一个 IIFE 函数,这个就是主要是渲染函数,其中有几个 API:

  • _tmpl$ 是用来创建元素的方法,背后其实就是执行 createElement。

  • _$effect 背后是创建一个观察者承载对应的 Signal 订阅依赖,这样当 Signal 的值改变时,就会执行确定的回调函数。

  • _$insert 其实背后同样是建立的 effect,只是回调函数中是执行 appendChild 或 replaceChild 此类 DOM 操作。

看完编译结果后就可以知道 Solid 最核心就是透过 Signal 来实现 Pub-Sub 模式,并用直接的 DOM 操作取代 Virtual DOM Render。

React(v18.2)

React 没有 Solid 有那么重的编译程度,只需透过 Babel 的插件将 JSX 转换为 React.createElement。而在每次的更新时,React 都会重新调用 Function Component 中的渲染函数,把新的状态作为参数传递进入,最终产生一个新的 Virtual DOM Tree。

最终再透过 Diff 演算法来比对前后差异,并将差异的部分更新到真实 DOM 上。这也是为什么 React 自己也是官方文件中用 Snapshot 来形容每次的渲染。

Vue (v3.4)

Vue 的编译程度也不低,毕竟它需要将模板语言转换为 Javascript,首先依然会将组件编译为一个 render 函数,不过 Vue 还会在编译阶段为组件中的元素进行 Patch Flag 的标记以及静态提升(Static Hoisting),目的是让其在执行阶段进行页面更新时可以再准确,避免 Diff 算法演算的过度比对。所以说的「靶向更新」,更是 Vue 3 大幅度提升效能的原因之一。

补丁标志 - 编译前

 <template>
<div :class="{ active }"></div>
</template>

补丁标志 - 编译后

 function render(_ctx, _cache, $props, $setup) {
return (_openBlock(), _createElementBlock("div", {
class: _normalizeClass({ active: $setup.active })
}, null, 2 /* CLASS */))
}

静态提升 - 编译前

 <template>
<div>
<p>text</p>
</div>
</template>

静态提升 - 编译后

 const _hoisted_1 = /*#__PURE__*/_createElementVNode(
"p", null, "text", -1 /* HOISTED */
)
function render(_ctx, _cache) {
return (
_openBlock(),
_createElementBlock("div", null, [_hoisted_1])
)
}

另外在状态更新时,Vue 3 也有进行一些最佳化的工作,类似于会检查组件的 props 是否有变动,如果没有震变则不会进行重新渲染,这就是为什么前面的 Vue 范例中 ChildrenTwo 元件不会重新渲染的原因。

 export function shouldUpdateComponent(
prevVNode: VNode,
nextVNode: VNode,
optimized?: boolean,

): boolean {
//...省略大量程式碼
if (prevProps === nextProps) return false
return false
}
Vue Vapor Mode

另外我们还额外来看看 Vue 的 Vapor Mode 会怎么编译程序代码,可以利用 Vapor Mode 提供的 Playground 测试:

编译前

 <script setup>
import { ref } from 'vue';

const count = ref("1")
const disabled = ref(false)
</script>

<template>
<button :disabled="disabled">{{ count }}</button>
</template>

编译后

 // ...省略部分程式碼
const count = ref("1")
const disabled = ref(false)

const t0 = _template("<button></button>")
function render(_ctx) {
const n0 = t0()
_renderEffect(() => _setText(n0, _ctx.count))
_renderEffect(() => _setDynamicProp(n0, "disabled", _ctx.disabled))
return n0
}

这边用了和 Solid 一样的案例,可以看到基本上跟 Solid 编译后的样子非常相似,ref 对应 createSignal;_renderEffect 对应_$effect,而_setText 和_setDynamicProp 可以合理推断背后是直接的 DOM 操作。果然如尤雨溪本人所说,Vue Vapor Mode 确实受到了 Solid 的启发。

Virtual DOM 依然稳站脚步

虽然前面说了那么多看似 Virtual DOM 的缺点,但这并不代表 Virtual DOM 就要被淘汰了,毕竟相比传统的 Virtual DOM Diff 演算法,React 和 Vue 都有进行不少的优化工作。

传统的 Virtual DOM Diff 虽然可以做到精准的差异计算,但计算的成本却是非常昂贵的,当每次状态更新后,Virtual DOM 机制都需要产生一个完整的虚拟树,然后遍历新旧树的每一个个节点进行比对,最后再将差异的部分更新到真实的 DOM 上。

这整个过程的时间复杂度是 O (n^3),当你的 Dom Tree 有 100 个节点时,整个过程将花费 100 万个单位时间。

React & Heuristic Algorithm & Fiber

为了解决传统 Virtual DOM Diff 演算法的效率问题,开发出了启发演算法(Heuristic Algorithm),通过两个假设将原本复杂度为 O (n^3) 的过程最佳化到 O (n) ,另外又在 React 16 中推出了 Fiber 架构,这个架构可以让原本迂回生成虚拟 DOM Tree 的过程转变为链式结构的 Fiber Tree,使得过程中可以中断并恢复,而不会阻塞 UI 的更新,解决避免渲染卡顿掉祯的问题。

Vue & Compiler-Informed Virtual DOM & 雙端 Diff

前面已经提过了 Vue 在执行阶段进行的优化,包含了 Patch Flag 标记、静态提升还有 Block Tree,这些都让 Vue 在执行阶段进行 Virtual DOM Diff 可以更加有效率,Vue 将其称为 Compiler-Informed Virtual DOM。

另外,Vue 中的双端 Diff 演算法也为人津津乐道的,只要搜索「Vue Diff」你就可以看到无数文章的解析,这个演算法在同层节点比对时可以在大多数的情况下大幅提升了。

结语

实际上,作者 Ryan Carniato 自己也在 渲染 DOM 的最快方法一文中提到的:

Similarly, the recent chorus of "The Virtual DOM is slow" is just as ill-informed. Rendering a virtual DOM tree and diffing it is going to be pure overhead compared to not doing so, but does not doing so scale? And what if you have to deal with a data snapshot?
同样,最近流行的 “虚拟 DOM 很慢” 也同样缺乏信息。与不这样做相比,渲染虚拟 DOM 树并对其进行比较将是纯粹的开销,但不这样做会扩展吗?如果您必须处理数据快照怎么办?

所以说一切技术的选用都取决于你的应用场景,没有最好的技术,只有最适合的技术。虚拟 DOM 仍然是一个非常好的抽象层,让开发者可以专注于状态的管理上,而不用担心 DOM 的操作,这也为什么 React 和 Vue 仍然是前端框架的主流。

State of Javascript 2022 - 前端框架使用率排名

但确实在 Svelte 和 Solid.js 这类不使用 Virtual DOM 的框架出现后,开始让大家思考似乎 Virtual DOM 不一定是前端框架的唯一解,而只要有新的概念或实际工作出现时,我们应该抱持着「好奇、了解、实验」的态度,而非一味的跟风或吹捧,虚拟 DOM 亦如此,Signal 亦如此。

关于本文
作者:@Max Lee
原文:https://maxlee.me/posts/signal


写在最后

公众号前端开发爱好者 专注分享 web 前端相关技术文章视频教程资源、热点资讯等,如果喜欢我的分享,给 🐟🐟 点一个 👍 或者 ➕关注 都是对我最大的支持。

欢迎长按图片加好友,我会第一时间和你分享前端行业趋势面试资源学习途径等等。

添加好友备注【进阶学习】拉你进技术交流群

关注公众号后,在首页:

  • 回复 面试题,获取最新大厂面试资料。
  • 回复 简历,获取 3200 套 简历模板。
  • 回复 React实战,获取 React 最新实战教程。
  • 回复 Vue实战,获取 Vue 最新实战教程。
  • 回复 ts,获取 TypeScript 精讲课程。
  • 回复 vite,获取 Vite 精讲课程。
  • 回复 uniapp,获取 uniapp 精讲课程。
  • 回复 js书籍,获取 js 进阶 必看书籍。
  • 回复 Node,获取 Nodejs+koa2 实战教程。
  • 回复 数据结构算法,获取数据结构算法教程。
  • 回复 架构师,获取 架构师学习资源教程。
  • 更多教程资源应有尽有,欢迎 关注获取。

相关推荐

  • 前端面试这样准备,拿45k真的不难。。。
  • 字节面试官:45k+前端面试都问这些!
  • SpringBoot 实现 RAS+AES 自动接口解密
  • 值得练手的多任务RAG问答竞赛KDD-CRAG:兼看文档图像恢复任务及KG检索策略优劣对比
  • 谈谈我们一个月真实的收入。。。
  • 融入团队代码风格,代码越写越烂!
  • JS的这些新特性,你都用过么?
  • vue3自定义hooks大集合,你要的都在这!
  • 一个测试工程师走进酒吧,被开发工程师打了一顿
  • 我为啥没晋升?
  • A股行情让我悟了:ChatGPT无益于理性投资
  • 从 0 到 1 ,实现自己的 Python 虚拟机!
  • 任天堂闪击GitHub,一夜删光8000多个模拟器代码仓库
  • 港大开源图基础大模型OpenGraph: 强泛化能力,前向传播预测全新数据
  • AlphaGo核心算法增强,7B模型数学能力直逼GPT-4,阿里大模型新研究火了
  • 所有生命分子一夜皆可AI预测!AlphaFold 3改变人类对生命的理解,全球科学家都能免费使用
  • RabbitMQ如何保证消息可靠性?
  • 面试题:说一下MyBatis动态代理原理?
  • 面试一个薪资2.8w的offer,被面试官各种刁难,直言我毫无经验,结果面试结束后,HR告诉我说他是公司总监。。。
  • 大语言模型对齐的四种方法!