svelte的响应式原理

660 阅读5分钟

svelte有多火:

看看state of js 2022就知道大家对于svelte有多感兴趣了,至于usage现在也是仅次于react,vue,angular的第四大框架了。

天下三分分久必合合久必分,svelte号称自己去掉了virtual dom,没有了virtual dom的diff,那他是怎么实现响应式的呢?

写个demo看下:

我们直接进入正题,在svelte的官方 playground 写一个demo康康:

<div>变量var1是: {var1}</div>
<button on:click={clickEvent}>add1</button>
<script>
	let var1 = 1;
	let clickEvent = () => {
		var1++;
	}
</script>

页面效果如下:

编译后的.svelte文件:

对于响应式的依赖收集部分,在编译阶段是可以分析出来的,本文我们着重看一下在“干掉”virtual dom的前端框架中,是如何实现对比得知监听的变量变化并更新dom的。

svelte将我们的代码编译之后的结果如下:

/* App.svelte generated by Svelte v3.55.1 */
import {
 ...
} from "svelte/internal";

function create_fragment(ctx) {
	let div;
	let t0;
	let t1;
	let t2;
	let button;
	let mounted;
	let dispose;

	return {
		c() {
			div = element("div");
			t0 = text("变量var1是: ");
			t1 = text(/*var1*/ ctx[0]);
			t2 = space();
			button = element("button");
			button.textContent = "add1";
		},
		m(target, anchor) {
			insert(target, div, anchor);
			append(div, t0);
			append(div, t1);
			insert(target, t2, anchor);
			insert(target, button, anchor);

			if (!mounted) {
				dispose = listen(button, "click", /*clickEvent*/ ctx[1]);
				mounted = true;
			}
		},
		p(ctx, [dirty]) {
			if (dirty & /*var1*/ 1) set_data(t1, /*var1*/ ctx[0]);
		},
		i: noop,
		o: noop,
		d(detaching) {
			if (detaching) detach(div);
			if (detaching) detach(t2);
			if (detaching) detach(button);
			mounted = false;
			dispose();
		}
	};
}

function instance($$self, $$props, $$invalidate) {
	let var1 = 1;

	let clickEvent = () => {
		$$invalidate(0, var1++, var1);
	};

	return [var1, clickEvent];
}

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

export default App;

简单看下编译后的产物,分为三个大块:

  1. function create_fragment,前面定义了一堆变量,后面return了几个方法。
    方法c对应create,创建dom的引用变量。
    方法m对应mount,作用是将c中创建的dom给加到dom上面。
    方法p对应update,更新变量会调用该方法(和响应式相关,后面会讲到)。
    方法d对应detach,组件销毁会调用的方法。

  2. class App的定义(这部分就是我们写的自定义组件的class,没什么要关注的)。

  3. function instance,能够看出来该方法包裹了我们在点击事件中对变量修改的方法。当有点击事件发生时,会调用clickEvent,然后调用其中的invalidate方法,在此处加入debugger并查看源码,得到invalidate方法,在此处加入debugger并查看源码,得到invalidate方法的内容为

$$invalidate的内容

(i, ret, ...rest) => {
	const value = rest.length ? rest[0] : ret;
	if ($$.ctx && not_equal($$.ctx[i], $$.ctx[i] = value)) {
		if (!$$.skip_bound && $$.bound[i]) {
			$$.bound[i](value)		
		};
		if (ready) make_dirty(component, i);
	}
	return ret;
}

从上面的.svelte编译产物可以得出方法$$invalidate的入参ret是点击事件之前的变量var1的值“1”,rest[0]是点击事件发生之后的变量var1的值"2",当前后值`not_equal`的话,会调用`make_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));
}

svelte有个约定,当dirty数组为[-1]代表着组件为干净的,我们直接看最后一行,也是svelte响应式的最关键的一行代码:component.$$.dirty[(i / 31) | 0] |= (1 << (i % 31)); 这一看是一个位运算啊!其中i代表着修改的变量的序号。

Bitmask标记变量的状态

方便理解,我们在点击事件中多加几个变量var1 --- var7,并且打个debugger看一下:

  • component.$$.ctx的值是一个数组,里面保存了所有的变量 [var1, var2, var3, var4, var5, var6, var7]。
  • component.$$.dirty也是个数组,且其现在的值是[63],因为这里涉及到js的位运算,位运算中所有的数字都是符合 IEEE-754 标准的 64 位双精度浮点类型。而所有的位运算都只会保留 32 结果的整数(这段话引用自 硬核基础二进制篇(一)0.1 + 0.2 != 0.3 和 IEEE-754 标准),数字“63”的32位2进制表示位:0000, 0000, 0000, 0000, 0000, 0000, 0011, 1111

再来看下component.$$.dirty[(i / 31) | 0] |= (1 << (i % 31)),我们分为两部分理解:

  1. 左侧部分为被赋值的变量component.$$.dirty,其下标为:(i / 31) | 0,就是变量序号除以31再向下取整,意为取dirty数组中的第几个'32位'的变量。在我们的例子中变量没有超过32个,所以取的就是这个dirty[0]。(这里理解下来就是dirty这个数组中的每一个数字都代表着32个变量的状态,如果我们的组件中有33个变量的话,即var1到var33,dirty这个数组的长度就会变为2了,dirty.length === 2

  2. 右侧部分代表着这32个变量中的哪一位需要变dirty,1 << (i % 31)0001向左移动 i % 31位,代表该位的变量是dirty的。(比方说var4变量需要发生变化变为dirty的,那么右侧部分就是把 0000, 0000, 0000, 0000, 0000, 0000, 0000, 0001 向左移动三位,变成了0000, 0000, 0000, 0000, 0000, 0000, 0000, 1000

  3. |=两个部分取“或”,意思是说如果原来第i个变量dirty了,那就是dirty的,不然的话给他设置成为dirty。(比方说原来只有var2是dirty的,现在var4也dirty了,那么就是0000, 0000, 0000, 0000, 0000, 0000, 0000, 0010 | 0000, 0000, 0000, 0000, 0000, 0000, 0000, 1000,结果是0000, 0000, 0000, 0000, 0000, 0000, 0000, 1010也就是10)

schedule_update的内容

dirty完成之后,当然就是更新组件了

export function schedule_update() {
    if (!update_scheduled) {
        update_scheduled = true;
        resolved_promise.then(flush);
    }
}
export function flush() {
    ...
    while (flushidx < dirty_components.length) {
		const component = dirty_components[flushidx];
		flushidx++;
		update(component.$$);
	}
	...
}
function update($$) {
    if ($$.fragment !== null) {
        $$.update();
        run_all($$.before_update);
        const dirty = $$.dirty;
        $$.dirty = [-1];
        $$.fragment && $$.fragment.p($$.ctx, dirty);
        $$.after_update.forEach(add_render_callback);
    }
}

这里在方法update中就会调用组件在create_fragment中返回的p方法了。

p(ctx, [dirty]) {
	if (dirty & /*var1*/ 1) set_data(t1, /*var1*/ ctx[0]);
},

因为这里使用的变量的dom节点是一个text,所以对应的更新方法是set_data,如果是input元素的话,其方法就会是`set_input_value`等等。。。

set_data中的内容

export function set_data(text, data) {
    data = '' + data;
    if (text.wholeText !== data) text.data = data;
}

入参text是对应的dom节点,这里直接将其的内容修改成改变之后的value了。

至此,变量更新到dom更新的流程就结束了。

reference: juejin.cn/post/696574…