诞生之初
从命令式到声明式
在上古流行的字符串拼接时代,jQuery一家独大,当时 jQuery 的语法还是停留在那种命令式 DOM 操作之中,
$("ol li").click(function() {})
let li = $("<li>我是一个li</li>");
$("ol").append(li);
而在 2013 年,Facebook 的 Jordan Walke 提出来了:把 2010 年 FaceBook 做出来的 XHP 的拓展功能迁移到 Javascript 中,形成以 JSX 作为拓展的新编码形式,并且把写法由命令式转变为声明式,像这样:
//声明一个 data列表
const Component = (
<ul>
{data.map(item => <MyItem data={item} />)}
</ul>
);
而在声明式框架的建立之时,需要 DOM 操作这种 “行为”,交给框架处理,并引发一些思考:
既然 DOM 操作集中交给框架了,那框架岂不是可以去 “批处理” DOM 操作,更好的减少开销? 既然开始写声明式了,那如何让数据和 DOM 关联起来?如果每次数据发生变化,该如何监听数据源?
虚拟 DOM 乍现
而当时虚拟 DOM,也就是在代码和实际 DOM 操作,由框架做了一层中间层,从而实现 代码 -> 虚拟 DOM 树 -> 真实 DOM 树;
这个概念是由 React率先开拓,随后被许多不同的框架采用,并且当时有一本书《高性能的 javascript》,具体在第三章开头,里面有个观点就是:
而前 React 核心团队 Pete Hunt 也在 2013 年时,对 React 的宣传演讲中吐槽了一波重复性 DOM 操作的 “巨大开销”: 《重新思考典范实例的意义》。
这套虚拟 DOM 的优势在于:
打开函数式 UI 编程的大门,使得组件抽象化,使得代码更易维护 跨平台,因为虚拟 DOM 本质上只是一个 Javascript 对象,作为抽象层还能提供给其他应用使用,比如小程序、IOS 应用、Android应用等。 数据绑定,更新视图时,减少 DOM 操作:可以将多次 DOM 操作合并为一次操作,比如添加 100 个节点原来是一个一个添加,现在是一次性添加,减少浏览器回流(比如 1000 个节点的 DOM 操作,合并为 1 次,进行批处理)
const fragment = document.createDocumentFragment();
for(let i = 0; i < 1000; i++) {
const div = document.createElement('div');
fragment.appendChild(div);
}
// 将文档片段一次性插入到目标容器中
const container = document.getElementById('container');
container.appendChild(fragment);
用相对轻量级的 Javascript 操作进行 DOM diff,避免大量查询和复杂的真实 DOM 的存储(包含大量属性) 虚拟 DOM 借助 DOM diff 可以把多余的操作省略掉,减少页面 reflow、repaint。 缓存 DOM,更新 DOM 时保存节点状态。
虚拟 DOM 现状
为什么现在有部分框架开始摒弃虚拟 DOM?
上方 Pete Hunt 在发表演讲后遭到大量网友的抨击,随地马上做出了解释道:
更有甚一些框架开始以 “无虚拟 DOM” 作为噱头,作为其 “优势”,所以我们要先先直视虚拟 DOM 的一些缺点:
首次渲染大量 DOM 时,由于多了一层虚拟 DOM 的计算,理所当然会比 直接 innerHTML 插入慢 虚拟 DOM 需要在内存中的维护一份虚拟 DOM 面对频繁的更新,虚拟 DOM 将会花费更多的时间处理计算的工作
所以当项目大起来之后,即使现代框架对此进行了优化,虚拟 DOM 的进行对比和计算,还有虚拟 DOM 树都是有一定开销的。
一些评价
Uber:当然有些企业,比如说Uber,通过广泛手动使用 shouldComponentUpdate
来最大限度地减少对渲染的调用。
React:React 16 后面推出了 React fiber,通过对不同事件划分的优先级(lane 模型)的打断机制, 其中对虚拟 DOM 树每每深度遍历,继而阻塞主进程的问题,有一定程度的改善。
Vue:而尤雨溪在《Vue3 的设计》也提及到了致力于寻找对虚拟 DOM 瓶颈的突破,打破这种看起来比较野蛮的算法比较模式:
Svelte:Svelte 作者 RICH HARRIS 在 Svelte 的文档也出了一篇 《Virtual DOM is pure overhead》 来讲述他对虚拟 DOM 这一数据驱动模型在某些情况下,亦或者一些频繁的更新带来的不必要的开销,而虚拟 DOM 也只是当初 React 想要以状态驱动 UI 开发的一种手法而已。
2024 年了,我们到底还需不需要虚拟 DOM 呢?
现阶段无虚拟 DOM 主力军
React 在迭代中不断尝试更合理的调度模式,Vue3 着重于对虚拟 DOM 的 diff 算法优化,ivi 和 Inferno 在引领着虚拟 DOM 框架的性能前沿,目前在虚拟 DOM 仍然盛行在主流框架,无虚拟 DOM 框架 Svelte、Solidjs 带领着他们的新的模式进入大众的视野。
Svelte
Rich Harris 是 Svelte 的作者,也是 rollup 的作者,他把 rollup 关于代码打包策略的造诣带入了 Javascript 框架,并且在走一条自己的道路:
这里我们一般讲的是 Svelte3,Svelte3 作出了巨大的改变,以一种更加轻量级的语法,更少的代码量,去做好响应式的 Javascript 框架。
实际上它在编译阶段,帮我们直接把声明式代码转化为更加高效的命令式代码,并且减少了运行时代码。
<script>
let count = 0;
function handleClick() {
count += 1;
}
$: {
console.log(`the current count is ${count}`);
}
</script>
<div class="x-three-year" on:click={handleClick}>
<div class="no-open" style={{ color: 'blue' }}>{`当前count: ${count}`}</div>
</div>
我们可以看到通过基本的声明,我便得到了一个响应式的变量,继而通过点击事件的绑定,得到一个通过点击驱动视图数据的普通组件
而此时通过 Svelte 的编译后会自动给响应式数据打上标记 $$invalidate
function instance($$self, $$props, $$invalidate) {
let count = 0;
function handleClick() {
$$invalidate(0, count += 1);
}
$$self.$$.update = () => {
if ($$self.$$.dirty & /*count*/ 1) {
$: {
console.log(`the current count is ${count}`);
}
}
};
return [count, handleClick];
}
Vue Vapor mode
尤雨溪曾在知乎上提及过 Vue2 时期引入虚拟 DOM 的问题(Vue 的理念问题)
继 Svelte 将预编译这一套带入大众视野之后,Vue3 在编译时也有自身的编译优化 ---- “带编译时信息的虚拟 DOM”,详情可以在官网的介绍 中查看,其实也就是在编译阶段针对部分静态节点附带上编译信息,使得在虚拟 DOM 树遍历阶段减少不必要的开销,一定程度上优化了虚拟 DOM 带来的问题。
而在 2022 年稀土掘金开发者大会上,尤雨溪《2022 前端生态趋势》在演讲中便提及到对 “无虚拟 DOM” 的探索 —— Vue vapor 模式。
这种预编译模式性能上先不说,首先体积上肯定是更偏向轻量级,其实也属于 vue 对未来前端框架的趋势一种新探索。
Solidjs
Soidjs,你也可以叫它Solid,它和 Svelte 同理,二者都是基于编译的响应式系统,Solidjs 的颗粒度响应是通订阅发布模式进行数据驱动的,并且曾在 js-framework-benchmark 斩获榜首而以性能出名,其语法更接近 React,对 React 重度用户较为友好。
我们在 Solid 的官方 playground 上可以看到框架在编译阶段将 jsx -> html 的输出结果:
Solid 在官网上标为:“真正的响应式”,与其说是真正的响应式,倒不如说像 React,是根据状态变化,更改虚拟 DOM,重新 render(也有可能是父组件更新),对比起来 Solidjs、Svelte 响应单独针对的是数据级别的粒度,React 响应的体量是组件级别的粒度。
下面我们来看看,Solidjs 的 “颗粒度响应” 是的设计与实现。
createSignal
主要看下 createSignal
的状态管理,很多文章会以为 Solid 用的是基于 Proxy 的响应式,实则不然,只是部分 API 用了 Proxy,其响应式还是用的 Knockout 那一套发布订阅的数据响应。
首先我们得先知道 2 个重要的角色类型: SignalState
、 Computation
信号主要通过一个对象存储,类型为 type SignalState
value:当前的值 observers:观察者数组, 类型为 type Computation observerSlots:观察者对象在数组的位置 comparator:比较器,通过比较则更改 value,默认 false,浅比较
export function createSignal<T>(
value?: T,
options?: SignalOptions<T | undefined>
): Signal<T | undefined> {
options = options ? Object.assign({}, signalOptions, options) : signalOptions;
const s: SignalState<T | undefined> = {
value,
observers: null,
observerSlots: null,
comparator: options.equals || undefined
};
if ("_SOLID_DEV_" && !options.internal) {
if (options.name) s.name = options.name;
registerGraph(s);
}
const setter: Setter<T | undefined> = (value?: unknown) => {
if (typeof value === "function") {
if (Transition && Transition.running && Transition.sources.has(s)) value = value(s.tValue);
else value = value(s.value);
}
return writeSignal(s, value);
};
return [readSignal.bind(s), setter];
}
export interface SignalState<T> extends SourceMapValue {
value: T;
observers: Computation<any>[] | null;
observerSlots: number[] | null;
tValue?: T;
comparator?: (prev: T, next: T) => boolean;
}
我们可以看到在创建状态时,实际上就是创建了一个 SignalState,通过 readSignal
和 writeSignal
分别读取和改写 SignalState。
在全局下还有一个 Listener,用于暂存一个 Computation 类型的观察者,在组件渲染(createRenderEffect
),或者在调用createEffect
时,会通过一个叫 updateComputation
的方法对全局的 Listener 进行赋值,为后续的依赖追踪铺垫。
let Listener: Computation<any> | null = null;
export interface Computation<Init, Next extends Init = Init> extends Owner {
fn: EffectFunction<Init, Next>;
state: ComputationState;
tState?: ComputationState;
sources: SignalState<Next>[] | null;
sourceSlots: number[] | null;
value?: Init;
updatedAt: number | null;
pure: boolean;
user?: boolean;
suspense?: SuspenseContextType;
}
function updateComputation(node: Computation<any>) {
if (!node.fn) return;
cleanNode(node);
const owner = Owner,
listener = Listener,
time = ExecCount;
Listener = Owner = node;
runComputation(
node,
Transition && Transition.running && Transition.sources.has(node as Memo<any>)
? (node as Memo<any>).tValue
: node.value,
time
);
//...
Listener = listener;
Owner = owner;
}
由于对 signal 的读取,是通过函数调用的形式进行数据读取
//声明一个 data列表
const Component = (
<ul>
{data.map(item => <MyItem data={item} />)}
</ul>
);
0
所以在任何一个角落读取 SignalState 时,都会调用 readSignal
函数,并且把当前全局下被暂存的 “观察者” Listener,也就是引用到 SignalState 的地方,放入自身的 observers(观察者数组)中,并且把观察者源(source)指向当前 signal,实现数据绑定,并且返回对应的 SignalState。
//声明一个 data列表
const Component = (
<ul>
{data.map(item => <MyItem data={item} />)}
</ul>
);
1
对于信号的写入,则调用 writeSignal
函数,在闭包内改变当前 SignalState 后,遍历在在 readSignal
阶段被收集的观察者数组,于当前 Effect 执行列表中推入观察者。
//声明一个 data列表
const Component = (
<ul>
{data.map(item => <MyItem data={item} />)}
</ul>
);
2
此时我们的 Effect 列表就保存了当时的观察者们,然后遍历执行 runEffects
,进行消息的重新分发,然后在对应的节点(Computation
)重新执行 readSignal
函数,此时我们就可以得到最新的数据结果了。
createEffect
而像 createEffect
这种自动追踪依赖的实现时调用时直接创建一个 computation 对象(createComputation
),也就是一个观察者,随后被添加到 Effects 执行数组中。并且随后会和之前的流程一样,执行 runEffects
-> updateComputation
-> 去执行 createEffect 内部的代码逻辑。
//声明一个 data列表
const Component = (
<ul>
{data.map(item => <MyItem data={item} />)}
</ul>
);
3
通过 updateComputation
,如上面所说 对 Computation
的介绍所说的,在 updateComputation
时,在对全局的 Listener 进行赋值。
组件的更新
组件的更新和 createEffect
同理,只不过组件的引用是走 createRenderEffect
-> updateComputation
//声明一个 data列表
const Component = (
<ul>
{data.map(item => <MyItem data={item} />)}
</ul>
);
4
在点击事件发生后,和我们上面所描述的writeSignal
行为一致,触发updateComputation
,走到对 SignalState 的获取readSignal
,整体调用栈如下:
Solid 的一些需要注意的点
一、Solid 不能使用 rest 和 spread 语法来拆分和合并 props,也就是不能直接对响应式的 props 数据解构。(但是直接传一个 signal 的调用方法则可以)
原因是通过解构的这种浅拷贝的形式(同样的Object.assign
这些方法也不可以),拷贝当时获取的值,会切断 signal 的更新,脱离追踪范围而失去响应。
比如
//声明一个 data列表
const Component = (
<ul>
{data.map(item => <MyItem data={item} />)}
</ul>
);
5
//声明一个 data列表
const Component = (
<ul>
{data.map(item => <MyItem data={item} />)}
</ul>
);
6
而且官方还提供 mergeProps
、splitProps
这类 API 去让子组件修改响应式的 props 数据,内部实际上是通过 Proxy 代理做动态追踪。
二、Solid 的依赖追踪只能针对同步跟踪。
假设你在 createEffect
中使用 setTimeout 来异步直接获取 SignalState ,则无法追踪 SignalState 的更新,比如以下例子:
//声明一个 data列表
const Component = (
<ul>
{data.map(item => <MyItem data={item} />)}
</ul>
);
7
实际上是因为此时走 readSignal 函数读取 Listener 的时候,基本流程已经走完,数据已经被清空(Listener = null
Owner= null
),所以在读取时无法对该 SignalState 进行追踪。
不过可以通过一定方式避免:
//声明一个 data列表
const Component = (
<ul>
{data.map(item => <MyItem data={item} />)}
</ul>
);
8
框架对比
前端框架流行程度一览
npm 下载量查询网址
目前 state of js 只有 2022 的数据(仅供参考),但是从数据上看使用度还是 React、vue、angular 三巨头独霸一方,但是满意程度确实两大无虚拟 DOM 主力军异军突起。
Solid 和 Svelte
就像在国内两极派别的 Vue 和 React,Svelte 和 Solid 的崛起不仅带来了带来了无虚拟 DOM,在编译阶段做更多的事情,还让我们看到新的发展可能性
虽然两者都是无虚拟 DOM 的框架,但是从最新的 js-framework-benchmark 的公示状况(Chrome 119 - OSX)来看,两者的性能情况大差不差,在 DOM 操作时间,Solid 似乎相对有更好的性能数据,而在内存和启动时间,Svelte 有更好的数据。
与其他框架的对比
这边我摘取了 js-framework-benchmark 的公示状况(Chrome 119 - OSX),并选择了 ivi、Inferno、Solid、Svelte、Vue、React 进行整体的对比,就结果上来看 Svelte、Solid 的性能是比我们最熟知的 Vue、React 更好一点的,但是对比 ivi、Inferno 这类以性能出名的虚拟 DOM 框架,并没有优势。
在Ryan Carniato 的 The Fastest Way to Render the DOM 中,他采用 jsx、标签模板和 HyperScript 三种渲染模版用 Solid 进行渲染,再与其他 在 js-framework-benchmark 上性能表现良好,且相同渲染模版的的 Javascript 框架进行对比,以求更公平的性能对比;
而最后得到的结果 虚拟 DOM 框架 和 非虚拟 DOM 框架 从性能上来看是大差不差的(严格来说是针对一些性能良好的虚拟 DOM 框架),所以其实没有最好的技术,在历史不断修正和优化中,虚拟 DOM 并不慢,不断的探索是对技术最大的尊重。
结语
前端框架之争从 jQuery 到日不落 React,把虚拟 DOM 带入了我们的视野,再到如今 Javascript 框架的百家争鸣,更多的技术点在得到重视,改进、发展和探索。
2024 年虚拟 DOM 依旧是大头,但是无论是依赖追踪,还是在编译阶段做更多的事情 / 优化,是目前的发展趋势。
没有最好的技术,只有更好。
参考
State of js 2022
JavaScript UI Compilers: Comparing Svelte and Solid
The Fastest Way to Render the DOM
稀土掘金开发者大会 —— 2022 前端生态趋势
Pete Hunt:React:重新思考典范实例的意义
Virtual DOM is pure overhead
The process: Making Vue 3
js-framework-benchmark
最后
更多岗位,可进入网易招聘官网查看 https://hr.163.com/
推荐站内搜索:最好用的开发软件、免费开源系统、渗透测试工具云盘下载、最新渗透测试资料、最新黑客工具下载……
还没有评论,来说两句吧...