svelte源码共读——Svelte响应式原理

120 阅读2分钟

Svelte响应式原理

为什么需要有响应式呢

响应式是为了构建动态的、交互式的用户界面而设计的。响应式页面能够在数据变化时实时更新,提供了更好的用户体验。用户可以看到页面的实时变化,而无需手动刷新页面

实现原理

image.png

图中 Component 是开发者编写的组件,内部虚线部分是由 Svelte 编译器编译而成的。图中的各个箭头是运行时的工作流程,首先来看编译时,考虑如下 App 组件代码

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

<h1>{count}</h1>

浏览器会显示

<body>
	<h1>0</h1>
</body>

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

  • create_fragment 方法,它是编译器根据 AppUI 编译而成,提供该组件与浏览器交互的方法
  • count 的声明语句
  • class App 的声明语句
// 省略部分代码…
function create_fragment(ctx) {
	let h1;

	return {
		c() {
			// create, 用来创建模板内容
			h1 = element('h1');
			h1.textContent = `${count}`;
		},
		m(target, anchor) {
			//mount,将c创建的DOM Element插入页面,完成组件首次渲染。
			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 方法,他是编译器根据 AppUI 编译而成,提供该组件与浏览器交互的方法,在上述编译结果中,包含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 方法。

image.png

这是因为 App 没有变化状态的逻辑,所以相应方法不会出现在编译产物中。

可以发现,create_fragment 返回的 cm 方法用于组件首次渲染。那么是谁调用这些方法呢?

SvelteComponent

每个组件对应一个继承自 SvelteComponentclass ,实例化时会调用 init 方法完成组件初始化,create_fragment 会在 init 中调用

class App extends SvelteComponent {
	constructor(options) {
		super();
		init(this, options, null, create_fragment, safe_not_equal, {});
	}
}

总结一下,流程图中虚线部分编译结果为:

  • fragment :编译为 create_fragment 方法的返回值
  • UIcreate_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];
}

countmodule 顶层的声明语句变为 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 包裹 update 方法满足
  • 包含改变 count 的语句 —— count++
  • 可以通过模版被引用 —— 作为点击回调函数 所以编译后的 update 内改变 count 的语句被 $$invalidate 方法包裹
// 源代码中的update
function update() {
	count++;
}

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

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

image.png 更新 ctx 中保存状态的值,比如 Democount++

标记 dirty ,即标记 App UI 中所有和 count 相关的部分将会发生变化

调度更新,在 microtask (微任务) 中调度本次更新,所有在同一个 macrotask 中执行的 $$invalidate 都会在该 macrotask 执行完成后被统一执行,最终会执行组件 fragment 中的 p 方法 p 方法是 Demo 中新的编译产物,除了 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 的项对应的更新函数

Demo 中,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]);
},

完整的更新步骤如下

  • 点击 H1 触发回调函数 update

  • update 内调用 $$invalidate ,更新 ctx 中的 count ,标记 countdirty,调度更新

  • 执行 p 方法,进入 dirty 的项(即 count )对应 if 语句,执行更新对应 DOM Element 的方法

总结

  • Svelte 的完整工作流程会复杂的多,但是核心实现便是如此
  • 我们可以直观的感受到,借由模版语法的约束,经过编译优化,可以直接建立状态与要改变的 DOM 节点的对应关系