最近组里准备对我们框架中基于 VDOM 来更新 UI 的这一套架构有所不满,所以此番我调研了一番近期比较火的Svelte,想看看如果不用 VDOM ,能否搞出一番新天地。
首先讲下 Svelte 框架出现的背景,现在的前端框架日益膨胀,有的时候仅仅为了显示一个简单的页面,都要加载几百KB的资料,以github上很火的 RealWorld 项目为例,可以看到用 Svelte 来开发并生成的 bundleSize 非常小,而其他框架最后得到的bundleSize则很大,这是因为很多前端框架都会有 runtime 这一个概念
runtime指的是框架本身的代码也会被打包进用户的代码里,生成一个bundle.js供用户下载,用户的各种操作都会执行runtime中的一些辅助函数(以React为例,这些辅助函数包括了state的计算、state的合并、diff算法的执行……)
Svelte 是不依赖于 VDOM 的,这与当下流行的一些前端框架(React、Vue)有很大的不同,那么为什么 Svelte 要摒弃 VDOM,朝另一个方向上走呢?下面就要来讲讲 VDOM 的优缺点
VDOM 的优点不多说,网上的分析文章一大堆,总结起来就3点
下面来说说 VDOM 的缺点:
首先,Virtual DOM高效是一个误解,React 从来没有说过它的 VDOM 性能很好,有的人会说Virtual DOM高效的一个理由就是它不会直接操作原生的DOM节点,因为这个很消耗性能。
当组件状态变化时它会通过某些diff算法去计算出本次数据更新真实的视图变化,然后只改变“需要改变”的DOM节点。用过React的人可能都会体会到React并没有想象中那么高效,框架有时候会做很多无用功,这体现在很多组件会被“无缘无故”进行重渲染(re-render),特别是在用了 Redux 后,这个现象会愈发明显。
所谓的re-render是你定义的class Component的render方法被重新执行,或者你的组件函数被重新执行,并不是说原生DOM被重新渲染。组件被重渲染是因为Vitual DOM的高效是建立在diff算法上的,而如果要有diff,则一定要将组件重渲染才能知道组件的新状态和旧状态有没有发生改变,从而才能计算出哪些DOM需要被更新。
这里提一下可能有的朋友会说React Fiber不是出来了吗,这个应该不是问题了吧?其实Fiber这个架构解决的问题是不让组件的重渲染和reconcile的过程阻塞主线程的执行,组件重渲染的问题依然存在,而且据反馈,React Hooks出来后组件的重渲染更加频繁了。正是因为框架本身很难避免无用的渲染,React才允许你使用一些诸如shouldComponentUpdate,PureComponent和useMemo的API去告诉框架哪些组件不需要被重渲染,可是这会引入了很多模板代码(boilerplate)。
这里引申讲下VDOM的开销在哪里, 这和它必不可少的3个步骤有关(以更新某个元素的text值为例):
具体代码实例如下:
function MoreRealisticComponent(props) {
const [selected, setSelected] = useState(null);
return (
<div>
<p>Selected {selected ? selected.name : 'nothing'}</p>
<ul>
{props.items.map(item =>
<li>
<button onClick={() => setSelected(item)}>
{item.name}
</button>
</li>
)}
</ul>
</div>
);
}
这里 props.items
这个数据,只要state发生了变化,我们都会重新生成一堆虚拟的 li
,这是毫无意义的,这些琐屑的操作累加起来,最终对性能会造成影响,如果你想要更快的话,一个比较好的做法就是去除这些多余的操作。
一句话总结 Svelte 的核心思想:
通过静态编译减少框架运行时的代码量
举例来说,当前的框架无论是 React、Angular 还是 Vue,不管你怎么编译,使用的时候必然需要“引入”框架本身,也就是所谓的运行时 (runtime)。但是用 Svelte 就不一样,一个 Svelte 组件编译了以后,所有需要的运行时代码都包含在里面了,除了引入这个组件本身,你不需要再额外引入一个所谓的框架运行时.
当然,这不是说 Svelte 没有运行时,但是出于下面2个原因,这个代价可以变得很小:
// 模板代码(编译前)
<a>{{ msg }}</a>
// 运行代码(编译后)
function renderMainFragment ( root, component, target ) {
var a = document.createElement( 'a' );
var text = document.createTextNode( root.msg );
a.appendChild( text );
target.appendChild( a )
return {
update: function ( changed, root ) {
text.data = root.msg;
},
teardown: function ( detach ) {
if ( detach ) a.parentNode.removeChild( a );
}
};
}
说完了核心思想,接下来讲讲 Svelte 的优缺点吧。
这里我认为最重要的优点是这3个:
// SvelteComponent.js 是已经编译后的组件
import SvelteComponent from './SvelteComponent';
const app = new SvelteComponent({
target: document.body
});
说完了 Svelte 的特点,让我们进一步去分析下 Svelte 具体做了黑魔法,让他能够在不使用 VDOM 的情况下做到兼顾效率和产量去更新数据,下面以一个最常见的Hello World为例:
这里的c代表了create,m代表了mount,d代表了detach,即每个组件自身拥有一个完整的生命周期,包含了元素的创建、插入、更新和销毁,从而构建出自己的一整套体系。下面是我整理的Svelte编译后运行时的整体框架图,可以看到整个runtime的架构还是比较清晰的,事件体系通过state管理,同时页面有自己的状态管理和生命周期,且抽取出了常用的工具函数形成工具箱,按需引用,有效减少bundleSize。
说完了runtime架构,下面讲讲,Svelte是如何在不需要VDOM的情况下对数据做到高效更新的,这里以单次数据的更新过程为例:
handleClick
里对 data 的操作,会被一个 $$invalidate
函数给包裹起来,便于日后判断safe_not_equal
函数来比对新老数据,若需要更新,则标记组件为一个脏组件,并触发组件的更新流程,且这里的更新是在浏览器的 microtask 里进行的,最大化地利用了显示器更新的原理
对于一些比较特殊的数据,例如数组里的数据,那么不可避免地会碰到需要更新大量数组的问题,其实 Svelte 对于这类情况的处理方法与 React 和 Vue 类似,都是通过添加 key 来做优化,然后整个的更新策略我觉得是比 React 要好的,在更新时会记录下移动的距离,而不是像 React 一样按个比对和更新,详细更新过程见下图:
Svelte 为我们提供了 VDOM 之外另一种可能性,通过静态编译减少框架运行时的代码量,让编译打包后的产物在完整实现功能的同时又有极高的性能和很小的体积,未来还将有很大的挖掘空间。