svelte 更新流程
调试方法
从官网的 REPL 进去,可以看到右侧 JS ouput 的内容,这些就是左侧的 svelte 代码编译后的结果,如左侧内容:
<script>
let name = 'world';
function changeName() {
name = 'Ya';
}
</script>
<h1>Hello {name}!</h1>
右侧 JS output内容:
/* App.svelte generated by Svelte v3.31.0 */
import {
SvelteComponent,
append,
detach,
element,
init,
insert,
noop,
safe_not_equal,
set_data,
text
} from "svelte/internal";
function create_fragment(ctx) {
let h1;
let t0;
let t1;
let t2;
return {
c() {
h1 = element("h1");
t0 = text("Hello ");
t1 = text(/*name*/ ctx[0]);
t2 = text("!");
},
m(target, anchor) {
insert(target, h1, anchor);
append(h1, t0);
append(h1, t1);
append(h1, t2);
},
p(ctx, [dirty]) {
if (dirty & /*name*/ 1) set_data(t1, /*name*/ ctx[0]);
},
i: noop,
o: noop,
d(detaching) {
if (detaching) detach(h1);
}
};
}
function instance($$self, $$props, $$invalidate) {
let name = "world";
function changeName() {
$$invalidate(0, name = "Ya");
}
return [name];
}
class App extends SvelteComponent {
constructor(options) {
super();
init(this, options, instance, create_fragment, safe_not_equal, {});
}
}
export default App;
将上面的代码下载下来,本地安装以来后,运行 yarn dev
,然后观察 public 里的 build 文件夹中的 bundle.js 文件。以下内容贴出来的代码均是 dev 环境下的 bundle.js 文件的内容。
初次渲染流程
init -> instance(初始化 ctx) -> before_update -> $$.fragment.c -> (transition_in 动画) -> mont_component -> fragment.m(挂载到 target 上去) -> after_update -> flush -> render_callbacks
更新流程
更新流程可以划分为三个阶段:
事件触发([点击事件] -> $$invalidate [instance第三个参数]) -> 标记更新(make_dirty) -> 更新(schedule_update -> flush -> update -> \.fragment.p [一些事件也会在此调用])
更新流程中关键的方法
-
instance
function instance($$self, $$props, $$invalidate) { let name = "world"; function changeName() { $$invalidate(0, name = "Ya"); } return [name, changeName]; }
结合 init 方法来看:
function init(component, options, instance, create_fragment, not_equal, props, dirty = [-1]) { // 忽略其他无关代码 $$.ctx = instance ? instance(component, prop_values, (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; }) : []; }
可以看到,当调用 changeName 方法时,实质上是调用了 instance 方法中第三个参数的方法,进行了对比和赋值,然后调用 make_dirty 方法往下走。
-
make_dirty 和 schedule_update
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)); } function schedule_update() { if (!update_scheduled) { update_scheduled = true; resolved_promise.then(flush); } }
当开始更新时,
component.$$.dirty[0]
的值是-1
,开启了schedule_update
,然后会将flush
放置到微任务中。component.$$.dirty[(i / 31) | 0] |= (1 << (i % 31))
的作用是标记需要更新的变量在 ctx 的位置,以便在$$.fragment.p
方法中命中更新DOM的方法。为什么标记方法会这样计算?
这里是使用了位计算。首先我们要知道一个前提:svelte 利用二进制来保存需要更新的变量在ctx的位置,如:
component.$$.dirty[8]
可表示为component.$$.dirty[0000000000000000000000000001000]
,表示是 ctx 数组内第三项需要更新。为什么是31位二进制表示?
www.w3school.com.cn/js/pro_js_o…
在 ECMAScript 中,所有整数字面量默认都是有符号整数。有符号整数使用 31 位表示整数的数值,用第 32 位表示整数的符号,0 表示正数,1 表示负数。数值范围从 -2147483648 到 2147483647。
当页面变量超出了31个,怎么办?
component.$$.dirty[0, 8]
表示是 ctx数组内第 34 个变量发生了变化。现在变量标记已经结束,至于怎么在更新中命中更新 DOM 的方法将会在
$$.fragment.p
中揭晓。 -
flush
do { // first, call beforeUpdate functions // and update components for (let i = 0; i < dirty_components.length; i += 1) { const component = dirty_components[i]; set_current_component(component); update(component.$$); } set_current_component(null); dirty_components.length = 0; while (binding_callbacks.length) binding_callbacks.pop()(); // then, once components are updated, call // afterUpdate functions. This may cause // subsequent updates... for (let i = 0; i < render_callbacks.length; i += 1) { const callback = render_callbacks[i]; if (!seen_callbacks.has(callback)) { // ...so guard against infinite loops seen_callbacks.add(callback); callback(); } } render_callbacks.length = 0; } while (dirty_components.length);
这是
flush
的核心代码,主要有两个作用:一是,如果render_callbacks
非空,则逐一调用render_callbacks
内的事件 ;二是update
对应的dirty_component
。 -
update
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); } }
更新对应的事件和DOM。
$$.fragment.p($$.ctx, dirty)
就是更新对应的DOM的方法。 -
\.fragment.p
function create_fragment(ctx) { // 忽略无关代码 return { (ctx, dirty) { if (dirty[0] & /*name*/ 1) set_data_dev(t1, /*name*/ ctx[0]); if (dirty[0] & /*name1*/ 2) set_data_dev(t5, /*name1*/ ctx[1]); if (dirty[0] & /*name2*/ 4) set_data_dev(t9, /*name2*/ ctx[2]); if (dirty[0] & /*name3*/ 8) set_data_dev(t13, /*name3*/ ctx[3]); // 如果超过 31 个变量/更多变量,则会表示如下 if (dirty[1] & /*name31*/ 1) set_data(t125, /*name31*/ ctx[31]); if (dirty[1] & /*name32*/ 2) set_data(t129, /*name32*/ ctx[32]); if (dirty[1] & /*name33*/ 4) set_data(t133, /*name33*/ ctx[33]); } } }
通过
dirty[index](index 是 i / 31 的值)
和 2的n次幂&
操作来定位更新那些DOM。如:// dirty[0] 是 7 7 & 1 === 1 7 & 2 === 2 7 & 4 === 4 7 & 8 === 0 // 表示ctx数组内第 0,1,2个位置上的变量发生了变化,需要更新对应的DOM // dirty[0] 是 8 8 & 1 === 0 8 & 2 === 0 8 & 4 === 0 8 & 8 === 8 // 表示ctx数组内第 3 个位置上的变量发生了变化,需要更新对应的DOM
简单总结一下:
init
方法里,执行 instance
方法将返回 component 中使用到的变量和方法,并保存在 ctx
变量中,其中变量和方法在 ctx 的次序很重要 ,后续的更新操作就是按这个次序来寻找对应的更新DOM方法去更新视图的。
component 内的事件/方法里对应的变量更新,将会改造为一个方法(该方法是 instance
方法的第三个参数),该变量在 ctx
的次序和变量的更新值将会成为这个方法的第一二变量(该变量如是引用类型,该方法的第三参数将是该变量),执行后,将会触发 make_dirty
方法,该方法将开启这次更新(schedule_update
),并标记需要更新的标量的位置。
这次更新被开启后,flush
将会放在微任务中执行(resolved_promise.then(flush)
)以确保所有更新的变量的次序都被标记了,然后执行,将会调用 update
方法,该方法是更新的执行者,执行相关的事件和调用 $$.fragment.p
将更新绘制到视图中。
至此,一轮更新流程结束。
其中,次序是最重要,最关键的,但又是最容易被忽略的。
通过编译,变量将会在ctx数组中拥有自己的次序,方法在数组中处于变量之后。更新变量的方法中,执行的 $$invalidate
将会传递该变量的在ctx中的次序,后续的所有方法,都会跟这个次序相关,无论是对比,赋值,标记,更新。
所以,变量在 ctx 的次序,将会贯穿它短短的的一生。
简单类型更新
由上,我们知道,当执行更新的时候,最终会到达这步:
// 这是 dev 下的
if (dirty[0] & /*name*/ 1) set_data_dev(t1, /*name*/ ctx[0]);
// set_data_dev
function set_data_dev(text, data) {
data = '' + data;
if (text.wholeText === data)
return;
dispatch_dev('SvelteDOMSetData', { node: text, data });
text.data = data;
}
// build 后的 set_data
function set_data(text, data) {
data = '' + data;
if (text.wholeText !== data)
text.data = data;
}
引用类型-数组类型更新
注意,使用 push/pop 等方法操作数组后,视图并不会更新,直到将新的数组赋值给数组变量后,才会触发更新。
<script>
let arr = [1, 2, 3];
function changeArr() {
// arr 改变了,但视图并不会更新
arr.push(4);
}
</script>
为什么呢?
因为编译后,这一块没有被改造为 $$invalidate
方法,而是原样输出了:
function instance($$self, $$props, $$invalidate) {
// 忽略无关代码
function changeArr() {
arr.push(4);
}
}
arr
的值的确是改变了,但是没有经过更新的流程处理,也就无法渲染到视图上。
此处有个有趣的现象,如果有一个方法(changeArrFn)是可以正常更新 arr 的,那么当 changeArr 运行后,再运行changeArrFn,那么 changeArr 改动将会渲染到视图上。
正确示例:
<script>
let arr = [1, 2, 3];
function changeArr() {
arr.push(4);
arr = arr;
}
</script>
编译后:
function instance($$self, $$props, $$invalidate) {
// 忽略无关代码
function changeArr() {
arr.push(4);
$$invalidate(0, arr);
}
}
引用类型-对象类型更新
<script>
let obj = {
some: 123,
any: 456
}
function changeObj() {
obj.people = 789;
obj = {...obj, some: 1230}
}
</scripit>
编译后:
function instance($$self, $$props, $$invalidate) {
// 忽略无关代码
function changeObj() {
$$invalidate(36, obj.some = 8000, obj);
$$invalidate(36, obj.people = 789, obj);
$$invalidate(36, obj = { ...obj, some: 1230 });
}
}
正常更新即可,不必要 Object.assign / {...obj}
这样更新。
数组更新详细介绍
svelte:
<script>
let arr = [
{
id: 1,
text: 'A'
}, {
id: 2,
text: 'B'
}, {
id: 3,
text: 'C'
}, {
id: 4,
text: 'D'
}
]
</script>
<ul>
{#each arr as item}
<span>{item.text}</span>
{/each}
</ul>
编译后代码(请不要在意次序,因为代码片段是从完整程序里提取出来的):
function get_each_context_1(ctx, list, i) {
const child_ctx = ctx.slice();
child_ctx[42] = list[i];
return child_ctx;
}
function create_each_block_1(ctx) {
let span;
let t_value = /*item*/ ctx[42].text + "";
let t;
const block = {
c: function create() {
span = element("span");
t = text(t_value);
},
m: function mount(target, anchor) {
insert_dev(target, span, anchor);
append_dev(span, t);
},
p: function update(ctx, dirty) {
if (dirty[1] & /*arr*/ 16 && t_value !== (t_value = /*item*/ ctx[42].text + "")) set_data_dev(t, t_value);
},
d: function destroy(detaching) {
if (detaching) detach_dev(span);
}
};
return block;
}
function create_fragment(ctx) {
// 忽略无关代码
const block = {
// 忽略无关代码
p: function update() {
// 忽略无关代码
if (dirty[1] & /*arr*/ 16) {
each_value_1 = /*arr*/ ctx[35];
validate_each_argument(each_value_1);
let i;
for (i = 0; i < each_value_1.length; i += 1) {
const child_ctx = get_each_context_1(ctx, each_value_1, i);
if (each_blocks_1[i]) {
each_blocks_1[i].p(child_ctx, dirty);
} else {
each_blocks_1[i] = create_each_block_1(child_ctx);
each_blocks_1[i].c();
each_blocks_1[i].m(ul, null);
}
}
for (; i < each_blocks_1.length; i += 1) {
each_blocks_1[i].d(1);
}
each_blocks_1.length = each_value_1.length;
}
}
};
return block;
}
着重看 create_fragment
的 update
方法,当运行这行代码时:
const child_ctx = get_each_context_1(ctx, each_value_1, i);
分析一下 get_each_context_1
方法:
function get_each_context_1(ctx, list, i) {
// 深拷贝 ctx
const child_ctx = ctx.slice();
// 当前 ctx 的长度是 41,所以 child_ctx[42] 是额外增加的
// 用于保存当前list第i项的值
child_ctx[42] = list[i];
return child_ctx;
}
可知,child_ctx
就是 ctx
的值和 arr
第 i 个值的集合。
看一下更新DOM的方法:
function create_each_block_1(ctx) {
// 忽略无关代码
const block = {
p: function update(ctx, dirty) {
if (dirty[1] & /*arr*/ 16 && t_value !== (t_value = /*item*/ ctx[42].text + "")) set_data_dev(t, t_value);
},
}
}
对比的时候,也是使用了 ctx 的第 42 项,根据
if (each_blocks_1[i]) {
each_blocks_1[i].p(child_ctx, dirty);
}
这行代码,可知,ctx
就是 child_ctx
。
数组更新时对比判断用到的对比数据是深拷贝ctx
的 child_ctx
的最后一项(ctx.length + 1
)。
根据以上分析,总结一下数组更新对应的视图表现:
-
当顺序地插入数组尾部时(数据表现),svelte会创建节点并插入(视图表现)
a b c --> a b c d
d节点是新创建的,插入到最后去
-
当更新其中的某一/多项时(数据表现),会顺序地更新(视图表现)
a b c --> a d c
b 节点会被更新为 d
-
当插入到某个位置时(数据表现),等同于顺序地更新和创建节点并插入到数组尾部(视图表现)
a b c --> a d b c
a 节点不变,b 节点会被更新为 d,c节点会被更新为 b,然后新增一个节点,保存c,插入到最后。
set_data 后,如何将更新渲染到视图上?
纵观 svelte ,它会创建两种类型的元素:
// TextNode
function text(data) {
return document.createTextNode(data);
}
// Other
function element(name) {
return document.createElement(name);
}
其中,变量发生变化需要需要渲染到视图上的是 TextNode
,该元素具有比较特殊的地方是,当设置它的data属性之后,浏览器将会更新它的值。
let node = document.createTextNode('hello~');
console.log(node.data);
node.data = 'happy~';
可以在 这里 ,将 myFunction 改为以下内容,看下运行的结果。
function myFunction(){ var t=document.createTextNode("Hello World"); document.body.appendChild(t); setTimeout(() => { t.data = '100fen' }, 1000); };
其他元素的情况下,如果是属性更新,如图片属性更新:
<script>
import png1 from './1.jpg';
import png2 from './2.jpg';
let src = png1;
functioin changeSrc() {
src = png2;
}
</script>
<div on:click={changeSrc}>change img</div>
<img src={src} alt="" style="width: 100px;">
那么,它将会调用 attr
方法去更新:
function attr(node, attribute, value) {
if (value == null)
node.removeAttribute(attribute);
else if (node.getAttribute(attribute) !== value)
node.setAttribute(attribute, value);
}
该属性更新后会立即反应到浏览器上,浏览器将会自动更新应用该属性。
还有更多更新的细节,未一一说明。
与 React ,Vue 相比
更新流程
React
setState -> render(beginWork 和 completeWork [diff将会在render阶段完成])-> commit(before mutation, mutation, layout)
Vue
watcher -> vNode(diff将会在此阶段完成) -> patch -> DOM
Svelte
回调事件 -> make_dirty -> flush -> update -> \.fragment.p -> set_data
明显地看到,两者的区别就是React 和 Vue 都需要 diff 计算,而Svelte是通过 make_dirty 来标记更新的(这里也是官方所说的 No Virtual DOM)。
那么 Virtual DOM 主要的作用是:
- 跨平台
- 掩盖操作DOM操作,由命令式编程转变为声明式编程
- 性能提升
Virtual DOM 性能提升体现
场景:在列表中插入一行新数据,如(每个字母代表一个节点)
A B C D E F --> A B G C D E F
Svelte的做法:
A,B不变,C -> G, D -> C, E -> D, F -> E,新增一行 F
React / Vue 可能的做法:
A,B不变,新增一行 F ,插入到 B 的后面,然后 C, D, E, F 往后移动一个位置
对比可知,React / Vue 理想的情况下会尽可能地复用原有的DOM,而 Svelte 将会顺从直觉地覆盖原有的DOM元素,然后在最后新增元素。这是 Virtual DOM 性能提升的体现之处。
Svelte 性能提升体现
正如官方所言:No Virtual DOM。
Virtual DOM 的 diff 会有性能损耗(毕竟需要比对,标记),而 Svelte 的更新不需要经过 Virtual DOM 的 diff 处理,直击要害。