​Svelte​​问世很久了,一直想写一篇好懂的原理分析文章,拖了这么久终于写了。

本文会围绕一张流程图和两个​​Demo​​​讲解,正确的食用方式是用电脑打开本文,跟着流程图、​​Demo​​一边看、一边敲、一边学。

让我么开始吧。

简单、好懂的Svelte实现原理_if语句

Demo1

​Svelte​​的实现原理如图:

简单、好懂的Svelte实现原理_if语句_02

图中​​Component​​​是开发者编写的组件,内部虚线部分是由​​Svelte​​编译器编译而成的。图中的各个箭头是运行时的工作流程。

首先来看编译时,考虑如下​​App​​组件代码:

<h1>{count}</h1>

<script>
  let count = 0;
</script>

完整代码见Demo1 repl[1]

浏览器会显示:

简单、好懂的Svelte实现原理_模版_03

这段代码经由编译器编译后产生如下代码,包括三部分:


  • ​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​​方法。

简单、好懂的Svelte实现原理_流程图_04

这是因为​​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>

完整代码见Demo2 repl[2]

编译产物发生变化,​​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​​。

简单、好懂的Svelte实现原理_流程图_05

同时,如果执行如上操作的语句可以通过模版被引用,则该语句会被​​$$invalidate​​包裹。

在​​Demo2​​​中,​​update​​方法满足:


  • 包含改变​​count​​的语句 —— ​​count++​
  • 可以通过模版被引用 —— 作为点击回调函数

所以编译后的​​update​​​内改变​​count​​​的语句被​​$$invalidate​​方法包裹:

// 源代码中的update
function update() {
  count++;
}

// 编译后instance中的update
function update() {
  $$invalidate(0, count++, count);
}

从流程图可知,​​$$invalidate​​方法会执行如下操作:

简单、好懂的Svelte实现原理_if语句_02


  • 更新​​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​​完整的更新步骤如下:


  1. 点击​​H1​​触发回调函数​​update​
  2. ​update​​内调用​​$$invalidate​​,更新​​ctx​​中的​​count​​,标记​​count​​为​​dirty​​,调度更新
  3. 执行​​p​​方法,进入​​dirty​​的项(即​​count​​)对应​​if​​语句,执行更新对应​​DOM Element​​的方法

总结

​Svelte​​的完整工作流程会复杂的多,但是核心实现便是如此。

我们可以直观的感受到,借由模版语法的约束,经过编译优化,可以直接建立「状态与要改变的DOM节点的对应关系」

在​​Demo2​​​中,状态​​count​​​的变化直接对应​​p​​​方法中一个​​if​​​语句,使得​​Svelte​​执行「细粒度的更新」时对比使用​​虚拟DOM​​的框架更有性能优势。

简单、好懂的Svelte实现原理_流程图_07

上述性能分析中第四行「select row」就是一个「细粒度的更新」。想比较之下,​​React​​(倒数第三列)性能就差很多。