在 Svelte 推出之前,前端框架对于响应式的实现主要有两种方式:
- 通过
Object.defineProperty或者Proxy API来对数据进行拦截进而实现依赖收集和数据变更的监听; - 通过特定的 API 对数据进行修改,比如 React 的 setState,进而知道数据什么时候发生变更;
而 Svelte 既没有使用 Proxy 相关的 API,也不需要开发者通过特定的 API 对数据进行修改就可以实现响应式,自然就会对它如何在这个前提下实现响应式充满了好奇。我们一起带着这个问题来看看 Svelte 的内部奥义。
一个 Demo
先来看看一个简单的 Svelte 组件的 Demo:
<script>
let name = "world";
function updateName() {
name += "!";
}
</script>
<h1>{name}</h1>
<button on:click="{updateName}">Append</button>
这个组件的逻辑很简单,定义了一个name和对应的updateName方法,点击按钮会往name最后追加!,并且把name显示在页面上。
Svelte官方提供了一个REPL页面,可以在线对组件进行编译,看到组件的效果以及编译之后的代码,我们来看看最终运行的代码长什么样:
/* App.svelte generated by Svelte v4.2.17 */
import {
SvelteComponent,
append,
detach,
element,
init,
insert,
listen,
noop,
safe_not_equal,
set_data,
space,
text
} from "svelte/internal";
import "svelte/internal/disclose-version";
function create_fragment(ctx) {
let h1;
let t0;
let t1;
let t2;
let button;
let mounted;
let dispose;
return {
// create缩写,创建模板对应的dom元素
c() {
h1 = element("h1");
t0 = text("hello ");
t1 = text(/*name*/ ctx[0]);
t2 = space();
button = element("button");
button.textContent = "Append";
},
// mount缩写,将dom元素插入到页面中
m(target, anchor) {
insert(target, h1, anchor);
append(h1, t0);
append(h1, t1);
insert(target, t2, anchor);
insert(target, button, anchor);
if (!mounted) {
dispose = listen(button, "click", /*updateName*/ ctx[1]);
mounted = true;
}
},
// patch缩写,更新视图时调用的方法
p(ctx, [dirty]) {
if (dirty & /*name*/ 1) set_data(t1, /*name*/ ctx[0]);
},
// destroy缩写,将dom元素从页面上卸载
d(detaching) {
if (detaching) {
detach(h1);
detach(t2);
detach(button);
}
mounted = false;
dispose();
}
};
}
function instance($$self, $$props, $$invalidate) {
let name = "world";
function updateName() {
$$invalidate(0, name += "!");
}
return [name, updateName];
}
class App extends SvelteComponent {
constructor(options) {
super();
init(this, options, instance, create_fragment, safe_not_equal, {});
}
}
export default App;
很容易看出来,组件的script部分最终被编译成了instance函数,而模板部分则被编译成了create_fragment函数,create_fragment部分我做了简单的注释,代码逻辑比较简单,编译相关的内容会在后续的分享中介绍,我们先来关注运行时相关的内容。
我们先关注script部分的编译结果:
function instance($$self, $$props, $$invalidate) {
let name = "world";
function updateName() {
$$invalidate(0, name += "!");
}
return [name, updateName];
}
响应式的实现主要分两部分:依赖收集和数据变更监听;源码中对于name的更新操作被编译成了$$invalidate(0, name += "!"),原来Svelte是通过将数据的更新操作编译成$$invalidate函数调用来达到数据变更监听和通知的目的。
$$invalidate
那我们来看看$$invalidate具体做了什么?从上面的编译结果可以发现,$$invalidate函数是作为instance函数的第三个参数传入的,而instance函数的调用是发生在哪儿呢?上面script编译之后的代码里面除了instance函数以外,还有一个继承了SvelteComponent的App,App的构造函数会调用一个Svelte内部的init方法,这里会把instance作为参数传进去,那自然很容易想到instance函数的调用是发生在init方法内部的:
export function init(
component,
options,
instance,
create_fragment,
not_equal,
props,
append_styles = null,
dirty = [-1]
) {
const $$ = (component.$$ = {
fragment: null,
ctx: [],
// state
props,
update: noop,
not_equal,
dirty,
});
$$.ctx = instance
? instance(component, options.props || {}, (i, ret, ...rest) => {
const value = rest.length ? rest[0] : ret;
if ($$.ctx && not_equal($$.ctx[i], ($$.ctx[i] = value))) {
make_dirty(component, i);
}
return ret;
})
: [];
$$.update();
ready = true;
$$.fragment = create_fragment ? create_fragment($$.ctx) : false;
if (options.target) {
$$.fragment && $$.fragment.c();
mount_component(component, options.target, options.anchor);
flush();
}
}
这里省略了一些我们暂时不需要关注的代码,这里调用instance函数传递的第三个回调函数实际上就是$$invalidate函数:
(i, ret, ...rest) => {
const value = rest.length ? rest[0] : ret;
if ($$.ctx && not_equal($$.ctx[i], ($$.ctx[i] = value))) {
make_dirty(component, i);
}
return ret;
}
init函数内部创建了一个$$对象,里面有一个ctx属性,这里会被赋值为instance函数的返回值,实际上instance函数的返回值就是script部分代码声明的会在模板或者外部使用的变量,这里的返回值是一个数组,数组的每一项对应script中声明的变量/函数。这里对应到updateName编译之后的逻辑:$$invalidate(0, name += "!")来看,实际上就是对比$$.ctx[0]和name += "!"的值是否相等,如果不相等则调用make_dirty(component, 0):
function make_dirty(component, i) {
if (component.$$.dirty[0] === -1) {
dirty_components.push(component);
schedule_update();
component.$$.dirty.fill(0);
}
component.$$.dirty[(i / 31) | 0] |= 1 << i % 31;
}
在介绍这段逻辑之前,我们先介绍一下Svelte是如何维护和使用$$.dirty的。
dirty
我们上面提到在Svelte的script中定义的变量会通过instance函数以数组的方式返回,也就是ctx。那我们如果要对应去标记这个数组下面某个变量的值发生了变更的话,很自然就想到同样通过另一个数组来维护这个状态,这里有两个方法:一个是维护一个和ctx一样长度的数据,数组的每一个元素的值对应到ctx中相应位置的数据是否发生变更;另一个是维护一个数组,这个数组里面只维护了ctx中发生变更数据的下标。这两种方法各有各的劣势,对于第一种方法来说dirty数组长度和ctx的长度是一致的,对于大型应用来说,组件内部的状态可能会非常多,这就导致dirty的长度也跟着变大;对于第二种方法来说,虽然dirty数组的长度相比第一种方式来说,大多数情况下会小很多,但是由于dirty中只存储了变更的数据的下标,所以我们每次都需要通过数组的includes或者indexOf方法来判断某一个数据是否发生变更,这个对于框架的运行效率是一个问题。
为了保证O(1)的查询效率,dirty数组肯定要保存所有数据的变更标记,Svelte用了一种很巧妙的方式来维护dirty。对于dirty数组来说,其中的每个值只有两种可能:true或者false,或者说0或者1,如果说用数组来维护dirty会导致dirty占用太多内存,那么我们是否有其他更高效的方式来存储一系列0或者1的值呢,答案就是二进制,利用二进制我们在JS中可以在一个数值中存储32位的标记位,同时我们可以利用位运算这种高效的方式来更新和查询某一位上的标记。这就是Svelte维护dirty变量的方式。但是还有一个问题,如果ctx数组的长度超过32位怎么办呢?很简单,增加一个数值来存储,所以dirty仍然是一个数组,只是这个数组的长度并不等于ctx的长度,而是ctx的长度除以32之后向上取整。
有了上面这种存储结构之后,Svelte就可以同时解决内存占用和查询效率的问题,我们回头来看上面make_dirty的实现,其中component.$$.dirty[(i / 31) | 0] |= (1 << (i % 31))就是通过位运算来将dirty对应下标下的二进制数的对应为标位1,通过(i / 31) | 0将入参的i转换成dirty对应的下标,将一个数值与0进行或操作会将这个数值的小数位抹去,保留整数位,所以上面这个表达式就相当于对i/31之后的结果进行向下取整,也就得到了存储i标记位的二进制数所对应在dirty中的下标。接着(1 << (i % 31))的含义是一个第i%31位为1的二进制数,最后通过将这个二进制数与原本存储在dirty[(i / 31) | 0]中的值进行或操作来将dirty[(i / 31) | 0]中第i%31位置为1。总的来说component.$$.dirty[(i / 31) | 0] |= (1 << (i % 31))这个语句的作用就是定位到ctx[i]这个变量的标记位在dirty中的位置,然后将对应的二进制位置为1。
同样这样的存储结构也可以很高效的实现对某一位是否为1的判断,在我们上面的例子中我们每次点击按钮更新name的值的时候都会触发$$invalidate的调用,在$$ivalidate中通过make_dirty将dirty第一个元素二进制的第0位设置成1,最终的效果如下图:
dirty数组初始化的时候只有一个元素,为了很方便理解,上图初始化了三个元素,每个元素的初始值都是0,也就是一个所有位都是0的二进制数,当我们调用$$invalidate(0, name += "!");之后,Svelte会取出第一个元素的值,然后和(1 << (i % 31))的结果进行一个或操作,当i=0的时候就相当于将dirty[0]的最右边一位设置成1。最终的dirty数组就变成了图中右边的结果。
我们再来看看create_fragment返回对象中的p方法,这里有两个地方需要我们关注:
p(ctx, [dirty]),Svelte编译之后的代码对p函数的第二个参数进行了数组解构处理,由于在上面的例子中instance返回的数组长度只有两位,所以我们只会用到dirty数组的第一位,这里Svelte就针对这种场景进行了优化,避免在p函数中再去重复访问dirty[0],如果我们instance方法返回的数组长度超过32位,p函数的第二个参数就不会进行解构处理了。if (dirty & /*name*/ 1),这里将dirty和1进行了一个与操作得到一个值,对这个值进行判断,如果这个值是非0的值就更新t1元素的内容,这里为什么是和1进行与操作呢?我们可以看下面这个图:
上图中左侧是当dirty中的二进制数与1的二进制数在相同的位上都是1的情况,最终的结果还是1;右侧是当dirty中的二进制数与1的二进制数在相同的位上是0的情况,最终的结果是0;很容易就可以看出当我将dirty与一个某一位是1的二进制数进行与操作的时候,只有当dirty的二进制数在相应位上的数也是1的时候最终与操作的结果才会是一个非0的数,而dirty的二进制数据某一位上的数如果是1就是表示ctx对应位置上的数据是dirty的。所以Svelte就通过一个很简单的与操作就完成了对于某一个数据是否dirty的判断,只有当这个值被标记为dirty才会触发对应的DOM操作或者对其他响应式数据的更新操作。
了解了dirty的数据结构和维护方式之后,我们接着看make_dirty的实现。
function make_dirty(component, i) {
if (component.$$.dirty[0] === -1) {
dirty_components.push(component);
schedule_update();
component.$$.dirty.fill(0);
}
component.$$.dirty[(i / 31) | 0] |= (1 << (i % 31));
}
make_dirty中首先会判断dirty[0]===-1,在组件初始化以及每次组件更新完成之后都会把dirty重置成[-1],这里的判断目的是为了避免同一个组件由于触发了多个变量的$$invalidate而被重复加入dirty_component中。将组件实例加入到dirty_component数组中之后,会触发schedule_update,这个方法的逻辑就是将一个叫flush的函数通过promise加入到微任务队列中:
function schedule_update() {
if (!update_scheduled) {
update_scheduled = true;
resolved_promise.then(flush);
}
}
这里的update_scheduled是一个boolean值用来标记是否已经把flush加入到微任务队列中,如果已经加入后续的schedule_update就不会在重复加入,而当flush执行完成之后又会把update_scheduled置成false。
flush
function flush() {
do {
while (flushidx < dirty_components.length) {
const component = dirty_components[flushidx];
flushidx++;
update(component.$$);
}
dirty_components.length = 0;
flushidx = 0;
} while (dirty_components.length);
}
上面是flush函数的核心代码,去除了源码中我们暂时不需要了解的部分。在flush函数中,Svelte会遍历dirty_components数组,依次执行update函数,当dirty_components都执行完之后会重置dirty_components。下面来看看update函数的逻辑:
function update($$) {
if ($$.fragment !== null) {
$$.update();
const dirty = $$.dirty;
$$.dirty = [-1];
$$.fragment && $$.fragment.p($$.ctx, dirty);
}
}
update函数的逻辑就比较直接明了了,首先调用$$.update,这个函数会在后面介绍,接着把dirty数组传到$$.fragment的p方法中,执行$$.fragment.p。
我们再结合之前编译生成的create_fragment方法一起看,就能把整个过程串起来了:
function create_fragment(ctx) {
let h1;
let t0;
let t1;
let t2;
let button;
let mounted;
let dispose;
return {
// patch缩写,更新视图时调用的方法
p(ctx, [dirty]) {
if (dirty & /*name*/ 1) set_data(t1, /*name*/ ctx[0]);
},
};
}
在执行p函数的时候就会进行我们上面提到的dirty位运算,通过位运算来判断ctx对应位置的数据是否发生变更,如果发生变更就执行对应的更新视图的逻辑。
我们先来总结一下整个过程:
- Svelte会将对数据的赋值操作编译成
$$invalidate的调用,同时会将模板中对数据有依赖的元素更新逻辑编译到create_fragment返回的p函数中; - 当代码执行时,发生对数据的赋值操作会触发
$$invalidate的执行,根据对应的ctx下标更新dirty数组; - 更新完
dirty数组会触发update逻辑,执行对应的p函数; - 在
p函数中会对dirty进行位运算来决定哪些视图要进行更新;
相比于现在主流的响应式实现方案,Svelte的依赖收集和数据变更的监听实际上是在编译时进行的,然后在运行时通过dirty数组来达到了精确的视图更新,这种方式可以在不影响运行性能的前提下,极大的减少运行时代码的体积。当然基于编译时的实现方案也会有它的问题,我们最后再来说这个问题。下面我们来看看更复杂的一个场景。
另一个Demo
在实际的开发过程中,处理视图会依赖数据以外,我们可能还会有一些依赖数据变更的逻辑,比如基于若干个数据计算出一个新的数据,又或者当某些数据变更的时候执行一个特定的逻辑,所以我们看看下面这个例子:
<script>
let name = "world";
function updateName() {
name += "!";
}
$: content = `hello ${name}`;
$: console.log(`content updated: ${content}`);
</script>
<h1>{name}:{content}</h1>
<button on:click="{updateName}">Append</button>
这里的$表达式是用来在Svelte中实现类似Vue中computed和watch的功能,在上面的例子中content就是一个computed的值,当name发生变化的时候,content也会发生变化,这个表达式可以等价于下面的代码:
let content = '';
$: content = `hello ${name}`;
另一个表达式会在content发生变化的时候在控制台输出相应的内容。上面的代码最终会编译成下面的代码:
/* App.svelte generated by Svelte v4.2.17 */
function create_fragment(ctx) {
let h1;
let t0;
let t1;
let t2;
let t3;
let button;
let mounted;
let dispose;
return {
c() {
h1 = element("h1");
t0 = text(/*name*/ ctx[0]);
t1 = text(":");
t2 = text(/*content*/ ctx[1]);
t3 = space();
button = element("button");
button.textContent = "Append";
},
p(ctx, [dirty]) {
if (dirty & /*name*/ 1) set_data(t0, /*name*/ ctx[0]);
if (dirty & /*content*/ 2) set_data(t2, /*content*/ ctx[1]);
}
};
}
function instance($$self, $$props, $$invalidate) {
let content;
let name = "world";
function updateName() {
$$invalidate(0, name += "!");
}
$$self.$$.update = () => {
if ($$self.$$.dirty & /*name*/ 1) {
$: $$invalidate(1, content = `hello ${name}`);
}
if ($$self.$$.dirty & /*content*/ 2) {
$: console.log(`content updated: ${content}`);
}
};
return [name, content, updateName];
}
这里只保留了编译结果中跟前一个例子有差异的地方:
- 首先,由于我们在模板中依赖了
content变量,所以在fragment的p方法中多了一个判断语句if (dirty & /*content*/ 2) set_data(t2, /*content*/ ctx[1]);用来判断content是否发生变化; - 其次,两个
$表达式最终被编译成了$$.update函数,这个函数就是我们上面flush update的时候调用的$$.update函数。在$$.update函数中同样通过dirty的位运算来判断依赖的数据是否发生变化来决定是否执行相应的逻辑。 - 最后,关于
$$.update函数有一点需要注意,在Svelte的实现中,$$.update函数中发生的$$invalidate调用并不会再次触发$$.update的执行,也就是说$$.update函数中的执行顺序必须严格按照被依赖的逻辑先执行的顺序来执行,否则会出现执行的时候拿到的变量并不是最新值的情况。举个例子,如果我们把上面的例子改写成下面这个:
<script>
let name = "world";
function updateName() {
name += "!";
}
$: console.log(`content updated: ${content}`);
$: content = `hello ${name}`;
</script>
如果Svelte编译的时候保持源码的顺序,那么$表达式最终会被编译成这样:
$$self.$$.update = () => {
if ($$self.$$.dirty & /*content*/ 2) {
$: console.log(`content updated: ${content}`);
}
if ($$self.$$.dirty & /*name*/ 1) {
$: $$invalidate(1, content = `hello ${name}`);
}
};
如果是这样的话,当我们更新name的时候,console.log会先执行,这时候content还是name更新执行计算出来的值,然后才触发content的更新逻辑,因为按照上面所说的,$$.update中发生的$$invalidate执行并不会触发$$.update的再次执行,所以console.log并不会再次执行,Svelte之所以这么设计是为了避免因为循环引用导致程序死循环。当然,为了开发者可以不会过多担心由于代码书写顺序引起的状态不一致的情况,Svelte在编译时进行了优化,编译器会收集$表达式的依赖关系,根据依赖关系确保最终编译结果是符合依赖顺序的,也就是说就算源码中console.log写在content计算之前,最终Svelte也会帮我们根据依赖关系调整编译之后的执行顺序,变成这样:
$$self.$$.update = () => {
if ($$self.$$.dirty & /*name*/ 1) {
$: $$invalidate(1, content = `hello ${name}`);
}
if ($$self.$$.dirty & /*content*/ 2) {
$: console.log(`content updated: ${content}`);
}
};
又一个Demo
上面的两个例子中的数据都是基础类型,如果name是一个对象,Svelte又会怎么处理呢:
<script>
// https://svelte.dev/repl/c1503827ca3c454089571a99b1c1597a?version=4.2.17
let name = {
value: "world"
};
function updateName() {
name.value += "!";
}
</script>
<h1>{name.value}</h1>
<button on:click="{updateName}">Append</button>
同样我们来看看编译结果于之前的差异:
function create_fragment(ctx) {
let h1;
let t0_value = /*name*/ ctx[0].value + "";
let t0;
let t1;
let button;
let mounted;
let dispose;
return {
p(ctx, [dirty]) {
if (dirty & /*name*/ 1 && t0_value !== (t0_value = /*name*/ ctx[0].value + "")) set_data(t0, t0_value);
}
};
}
function instance($$self, $$props, $$invalidate) {
let name = { value: "world" };
function updateName() {
// 之前例子的这里的编译结果是 $$invalidate(0, name += "!");
$$invalidate(0, name.value += "!", name);
}
return [name, updateName];
}
对于对象更新的编译结果与基础类型的编译结果区别主要体现在两个地方:
$$invalidate函数多了第三个参数,传入name;p函数中除了对于dirty的位运算以外,增加了对name.value前后值的对比;
我们再回头看看$$invalidate的实现:
(i, ret, ...rest) => {
const value = rest.length ? rest[0] : ret;
if ($$.ctx && not_equal($$.ctx[i], ($$.ctx[i] = value))) {
if (ready) make_dirty(component, i);
}
return ret;
}
如果存在第三个参数,那么这里的value就是第三个参数的值,所以这里的not_equal对比就是将ctx[0]和name进行对比;对于not_equal的实现分两种情况:
- Immutable模式下,
not_equal就是把两个值进行简单的对比来判断是否相等; - Mutable模式下,
not_equal会判断数据是否是基础类型,如果是基础类型则会判断两个值是否相等,如果是对象类型那么会直接返回false; 我们这个例子并没有开启immutable模式,所以not_equal对于对象会返回false,也就是说不管我们是否变更name.value这里总是会触发make_dirty的逻辑,这也就是为什么在p函数中除了正常的dirty位运算以外还需要判断value是否变化;在实际开发过程中,如果确保所有数据都是immutable的话,可以开启immutable模式,减少运行时的数据变更判断。
编译时响应式的局限性
数组
上面我们说到了对象更新的情况,响应式数据的实现还有一个难题就是数据的更新,那如果使用编译时的方案对于数组的场景要怎么处理呢:
<script>
let items = [1]
function append() {
items.push(items[items.length] + 1)
}
</script>
<h1>{items.length}</h1>
<button on:click="{append}">Append</button>
这是一个很简单的场景,通过push方法来更新数组items,视图上显示item的长度。如果尝试运行这个例子,你会发现点击Append按钮之后视图没有任何变化,这是因为Svelte的编译是基于赋值操作的,如果没有赋值操作,数据更新的逻辑会保持原样不会被编译,下面是这个例子的编译结果:
function create_fragment(ctx) {
let h1;
let t1;
let button;
let mounted;
let dispose;
return {
p: noop,
};
}
function instance($$self) {
let items = [1];
function append() {
items.push(items[items.length] + 1);
}
return [items, append];
}
这里不仅是item.push没有被编译成$$invalidate,连p函数中也没有内容;如果希望能够按照预期执行的话,需要对例子进行一些修改:
<script>
let items = [1]
function append() {
items.push(items[items.length] + 1);
items = items; // 或者items[items.length] = items[items.length - 1] + 1;
}
</script>
<h1>{items.length}</h1>
<button on:click="{append}">Append</button>
通过items = items的赋值语句来触发Svelte的编译逻辑,这种写法肯定是非常难受的,可是基于编译时的实现方案,我们不得不做一些取舍。
逻辑封装
在编写组件的过程中,我们经常需要把一段公共的逻辑抽取成一个公共函数供组件的多个地方使用或者组件之间进行复用,但是在Svelte中在进行公共逻辑抽取的时候需要特别注意:
<script>
let name = "world";
function updateName() {
name += "!";
}
function computeContent() {
return `hello ${name}`;
}
$: content = computeContent();
$: console.log(`content updated: ${content}`);
</script>
<h1>{name}:{content}</h1>
<button on:click="{updateName}">Append</button>
这里我们在之前例子的基础上将content的计算逻辑封装到了computeContent函数中,最终编译的结果如下:
function instance($$self, $$props, $$invalidate) {
let content;
let name = "world";
function updateName() {
$$invalidate(1, name += "!");
}
function computeContent() {
return `hello ${name}`;
}
$$self.$$.update = () => {
if ($$self.$$.dirty & /*content*/ 1) {
$: console.log(`content updated: ${content}`);
}
};
$: $$invalidate(0, content = computeContent());
return [content, name, updateName];
}
我们发现在$$.update方法中缺少了content的计算逻辑,这是因为Svelte在编译时是根据$中出现的变量来收集content的依赖的,在这个例子中编译器从content = computeContent()这个表达式中并不能知道content依赖了name,所以为了让编译器知道content依赖name这个逻辑,我们需要让name出现在$表达式中,比较常用的一个方法就是将name作为computeContent函数的入参,也就是把表达式改成$: content = computeContent(name)。这里只是一个简单的例子,在实际开发中content可能会依赖多个变量,这时候我们需要把这些依赖都作为函数传参来显示的告诉编译器content的依赖,这显然没有基于运行时的响应式框架来得方便。
除了组件内部的逻辑封装以外,还有一个场景就是组件之间公共逻辑的封装,在React和Vue中我们一般通过hook来达到这个目的,在Svelte中如果我们要实现同样的目的,并不能通过简单的把逻辑放到一个新的文件中来实现,因为Svelte的编译逻辑只会在.svelte文件中生效,所以如果我们把同样的逻辑放到js文件中,这些文件里面声明的变量并不会变成响应式的数据,所以Svelte提供了一套Store API来满足这个场景。虽然可以满足组件公共逻辑封装的需求,但是同样相比React和Vue这些框架来说,增加了不少成本。
Svelte 5
上面介绍了Svelte 4响应式的整体实现方案以及这个方案带来的局限性,对于这些由于编译时实现方案带来的局限性,在Svelte 5中已经得到了很好的解决,简单来说Svelte 5最终还是把响应式的实现改成了基于运行时的实现方案,也就是通过Proxy API来实现依赖收集和数据监听。关于Svelte 5的升级,有兴趣的同学可以看看上一篇文章的内容。
到此为止,我们已经了解了Svelte 4响应式实现方案中运行时相关的内容,下一篇将分享Svelte 4中编译相关的内容,尽请期待!