大家好,我卡颂。
Svelte
问世很久了,一直想写一篇好懂的原理分析文章,拖了这么久终于写了。
本文会围绕一张流程图和两个Demo
讲解,正确的食用方式是用电脑打开本文,跟着流程图、Demo
一边看、一边敲、一边学。
让我么开始吧。
Demo1
Svelte
的实现原理如图:
图中Component
是开发者编写的组件,内部虚线部分是由Svelte
编译器编译而成的。图中的各个箭头是运行时的工作流程。
首先来看编译时,考虑如下App
组件代码:
`<h1>{count}</h1>
<script>
let count = 0;
</script>
`
浏览器会显示:
这段代码经由编译器编译后产生如下代码,包括三部分:
create_fragment
方法
count
的声明语句
class App
的声明语句
``// 省略部分代码…
function create_fragment(ctx) {
let h1;
return {
c() {
h1 = element("h1");
h1.textContent = `${count}`;
},
m(target, anchor) {
insert(target, h1, anchor);
},
d(detaching) {
if (detaching) detach(h1);
}
};
}
let count = 0;
class App extends SvelteComponent {
constructor(options) {
super();
init(this, options, null, create_fragment, safe_not_equal, {});
}
}
export default App;
``
create_fragment
首先来看create_fragment
方法,他是编译器根据App
的UI
编译而成,提供该组件与浏览器交互的方法,在上述编译结果中,包含3个方法:
c
,代表create
,用于根据模版内容,创建对应DOM Element
。例子中创建H1
对应DOM Element
:
``h1 = element("h1");
h1.textContent = `${count}`;
``
m
,代表mount
,用于将c
创建的DOM Element
插入页面,完成组件首次渲染。例子中会将H1
插入页面:
`insert(target, h1, anchor);
`
insert
方法会调用target.insertBefore
:
`
function insert(target, node, anchor) {
target.insertBefore(node, anchor || null);
}
`
d
,代表detach
,用于将组件对应DOM Element
从页面中移除。例子中会移除H1
:
`if (detaching) detach(h1);
`
detach
方法会调用parentNode.removeChild
:
`function detach(node) {
node.parentNode.removeChild(node);
}
`
仔细观察流程图,会发现App
组件编译的产物没有图中fragment
内的p
方法。
这是因为App
没有「变化状态」的逻辑,所以相应方法不会出现在编译产物中。
可以发现,create_fragment
返回的c
、m
方法用于组件首次渲染。那么是谁调用这些方法呢?
SvelteComponent
每个组件对应一个继承自SvelteComponent
的class
,实例化时会调用init
方法完成组件初始化,create_fragment
会在init
中调用:
`class App extends SvelteComponent {
constructor(options) {
super();
init(this, options, null, create_fragment, safe_not_equal, {});
}
}
`
总结一下,流程图中虚线部分在Demo1
中的编译结果为:
fragment
:编译为create_fragment
方法的返回值
UI
:create_fragment
返回值中m
方法的执行结果
ctx
:代表组件的上下文,由于例子中只包含一个不会改变的状态count
,所以ctx
就是count
的声明语句
可以改变状态的Demo
现在修改Demo
,增加update
方法,为H1
绑定点击事件,点击后count
改变:
`<h1 on:click="{update}">{count}</h1>
<script>
let count = 0;
function update() {
count++;
}
</script>
`
编译产物发生变化,ctx
的变化如下:
`// 从module顶层的声明语句
let count = 0;
// 变为instance方法
function instance($$self, $$props, $$invalidate) {
let count = 0;
function update() {
$$invalidate(0, count++, count);
}
return [count, update];
}
`
count
从module
顶层的声明语句变为instance
方法内的变量。之所以产生如此变化是因为App
可以实例化多个:
`// 模版中定义3个App
<App/>
<App/>
<App/>
// 当count不可变时,页面渲染为:<h1>0</h1>
<h1>0</h1>
<h1>0</h1>
`
当count
不可变时,所有App
可以复用同一个count
。但是当count
可变时,根据不同App
被点击次数不同,页面可能渲染为:
`<h1>0</h1>
<h1>3</h1>
<h1>1</h1>
`
所以每个App
需要有独立的上下文保存count
,这就是instance
方法的意义。推广来说,Svelte
编译器会追踪<script>
内所有变量声明:
是否包含改变该变量的语句,比如count++
是否包含重新赋值的语句,比如count = 1
等等情况
一旦发现,就会将该变量提取到instance
中,instance
执行后的返回值就是组件对应ctx
。
同时,如果执行如上操作的语句可以通过模版被引用,则该语句会被$$invalidate
包裹。
在Demo2
中,update
方法满足:
包含改变count
的语句 —— count++
可以通过模版被引用 —— 作为点击回调函数
所以编译后的update
内改变count
的语句被$$invalidate
方法包裹:
`// 源代码中的update
function update() {
count++;
}
// 编译后instance中的update
function update() {
$$invalidate(0, count++, count);
}
`
从流程图可知,$$invalidate
方法会执行如下操作:
更新ctx
中保存状态的值,比如Demo2
中count++
标记dirty
,即标记App UI
中所有和count
相关的部分将会发生变化
调度更新,在microtask
中调度本次更新,所有在同一个macrotask
中执行的$$invalidate
都会在该macrotask
执行完成后被统一执行,最终会执行组件fragment
中的p
方法
p
方法是Demo2
中新的编译产物,除了p
之外,create_fragment
已有的方法也产生相应变化:
`c() {
h1 = element("h1");
// count的值变为从ctx中获取
t = text(/*count*/ ctx[0]);
},
m(target, anchor) {
insert(target, h1, anchor);
append(h1, t);
// 事件绑定
dispose = listen(h1, "click", /*update*/ ctx[1]);
},
p(ctx, [dirty]) {
// set_data会更新t保存的文本节点
if (dirty & /*count*/ 1) set_data(t, /*count*/ ctx[0]);
},
d(detaching) {
if (detaching) detach(h1);
// 事件解绑
dispose();
}
`
p
方法会执行$$invalidate
中标记为dirty
的项对应的更新函数。
在Demo2
中,App UI
中只引用了状态count
,所以update
方法中只有一个if
语句,如果UI
中引用了多个状态,则p
方法中也会包含多个if
语句:
`// UI中引用多个状态
<h1 on:click="{count0++}">{count0}</h1>
<h1 on:click="{count1++}">{count1}</h1>
<h1 on:click="{count2++}">{count2}</h1>
`
对应p
方法包含多个if
语句:
`p(new_ctx, [dirty]) {
ctx = new_ctx;
if (dirty & /*count*/ 1) set_data(t0, /*count*/ ctx[0]);
if (dirty & /*count1*/ 2) set_data(t2, /*count1*/ ctx[1]);
if (dirty & /*count2*/ 4) set_data(t4, /*count2*/ ctx[2]);
},
`
Demo2
完整的更新步骤如下:
点击H1
触发回调函数update
update
内调用$$invalidate
,更新ctx
中的count
,标记count
为dirty
,调度更新
执行p
方法,进入dirty
的项(即count
)对应if
语句,执行更新对应DOM Element
的方法
总结
Svelte
的完整工作流程会复杂的多,但是核心实现便是如此。
我们可以直观的感受到,借由模版语法的约束,经过编译优化,可以直接建立「状态与要改变的DOM节点的对应关系」。
在Demo2
中,状态count
的变化直接对应p
方法中一个if
语句,使得Svelte
执行「细粒度的更新」时对比使用虚拟DOM
的框架更有性能优势。
上述性能分析中第四行「select row」就是一个「细粒度的更新」。想比较之下,React
(倒数第三列)性能就差很多。
参考资料
[1]
Demo1 repl:https://svelte.dev/repl/9945d189204a4168b4c23890f1d92a3a?version=3.19.1
[2]
Demo2 repl:https://svelte.dev/repl/bf22a31a0eff4875b5b3084aa2b85fc3?version=3.19.1