对Svelte的一些总结
最近Svelte在社区比较火,创建这个项目的作者同时也是 Rollup 的创始人,大名鼎鼎的 Apple Music 也使用了Svelte进行网页版的实现,其客户端的web content可能也使用了Svelte,本文章将对Svelte的部分原理进行总结。
(本文写的时候Svelte的第4个版本,所以以 Svelte4 为样本进行代码分析)
Svelte 和 React,Vue的差异
简单来说,Svelte是编译器架构,React,Vue等是解释器架构,React是将JSX编译成比较简单的React.createElement,创建组件树后,遍历树根据不同的节点类型,在运行时转为dom操作函数,进行dom的创建和更新。而Svelte是在编译阶段使用render dom函数就完成了dom操作函数的创建
在编译的时候会对节点进行优化,例如在ElementWrapper中,检测到可优化的Text节点,编译成Text节点textContent设置的代码片段
if (can_optimise_to_html_string && (!hydratable || can_optimise_hydration)) {
if (this.fragment.nodes.length === 1 && this.fragment.nodes[0].node.type === 'Text') {
/** @type {import('estree').Node} */
let text = string_literal(
/** @type {import('../Text.js').default} */ (this.fragment.nodes[0]).data
);
if (hydratable) {
const variable = block.get_unique_name('textContent');
block.add_variable(variable, text);
text = variable;
}
block.chunks.create.push(b`${node}.textContent = ${text};`);
性能差异
- Svelte 节省了vDom的创建和转为dom操作函数的过程,首屏性能更好
- Svelte 运行时比较小,同样可以提升首屏性能
- Svelte patch的时候,不需要重新渲染vDom,patch性能更好
- Svelte 不需要在内存存放新旧vDom,占用内存更低
值变化渲染更新方式
Svelte 使用位掩码的变化追踪技术,基于赋值语句实现响应式更新,下面将分析当前例子的点击事件触发后,Svelte是如何进行视图更新的
<script>
let count = 0;
function handleClick() {
count += 1;
}
</script>
<button on:click={handleClick}>
Clicked {count}
{count === 1 ? 'time' : 'times'}
</button>
上面是svelte SFC源码,实现记录用户点击次数,下面是编译结果
/* App.svelte generated by Svelte v4.1.2 */
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 button;
let t0;
let t1;
let t2;
let t3_value = (/*count*/ ctx[0] === 1 ? 'time' : 'times') + "";
let t3;
let mounted;
let dispose;
return {
c() {
button = element("button");
t0 = text("Clicked ");
t1 = text(/*count*/ ctx[0]);
t2 = space();
t3 = text(t3_value);
},
m(target, anchor) {
insert(target, button, anchor);
append(button, t0);
append(button, t1);
append(button, t2);
append(button, t3);
if (!mounted) {
dispose = listen(button, "click", /*handleClick*/ ctx[1]);
mounted = true;
}
},
p(ctx, [dirty]) {
if (dirty & /*count*/ 1) set_data(t1, /*count*/ ctx[0]);
if (dirty & /*count*/ 1 && t3_value !== (t3_value = (/*count*/ ctx[0] === 1 ? 'time' : 'times') + "")) set_data(t3, t3_value);
},
i: noop,
o: noop,
d(detaching) {
if (detaching) {
detach(button);
}
mounted = false;
dispose();
}
};
}
function instance($$self, $$props, $$invalidate) {
let count = 0;
function handleClick() {
$$invalidate(0, count += 1);
}
return [count, handleClick];
}
class App extends SvelteComponent {
constructor(options) {
super();
init(this, options, instance, create_fragment, safe_not_equal, {});
}
}
export default App;
1. dom操作函数创建
function create_fragment(ctx) {
let button;
let t0;
let t1;
let t2;
let t3_value = (/*count*/ ctx[0] === 1 ? 'time' : 'times') + "";
let t3;
let mounted;
let dispose;
return {
// dom节点创建
c() {
button = element("button");
t0 = text("Clicked ");
t1 = text(/*count*/ ctx[0]);
t2 = space();
t3 = text(t3_value);
},
// dom节点挂载
m(target, anchor) {
insert(target, button, anchor);
append(button, t0);
append(button, t1);
append(button, t2);
append(button, t3);
if (!mounted) {
dispose = listen(button, "click", /*handleClick*/ ctx[1]);
mounted = true;
}
},
// dom 节点更新
p(ctx, [dirty]) {
if (dirty & /*count*/ 1) set_data(t1, /*count*/ ctx[0]);
if (dirty & /*count*/ 1 && t3_value !== (t3_value = (/*count*/ ctx[0] === 1 ? 'time' : 'times') + "")) set_data(t3, t3_value);
},
i: noop,
o: noop,
// dom节点卸载
d(detaching) {
if (detaching) {
detach(button);
}
mounted = false;
dispose();
}
};
}
$$.fragment = create_fragment ? create_fragment($$.ctx) : false;
create_fragment函数用于创建组件的dom操作相关的操作函数
2. 组件属性创建
上面的Svelte组件编译后的instance内容
function instance($$self, $$props, $$invalidate) {
let count = 0;
function handleClick() {
$$invalidate(0, count += 1);
}
return [count, handleClick];
}
instance方法用于进行svelte组件属性ctx的创建,Svelte组件初始化的时候会调用instance进行ctx属性的设置 当前例子的组件拥有两个属性,count和handleClick,代表count值和事件处理的函数 (注意下面的$$就是Svelte组件实例)
$$.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)) {
if (!$$.skip_bound && $$.bound[i])
$$.bound[i](value);
if (ready) {
// 进行属性脏标记和组件更新
make_dirty(component, i);
}
}
return ret;
})
: [];
3. 点击事件后触发$$invalidate
/*
* 参数1代表需要更新的ctx的下标,对应count
* 参数2代表值的更新表达式
*/
$$invalidate(0, count += 1)
const $$invalidate = (i, ret, ...rest) => {
const value = rest.length ? rest[0] : ret;
// 在这里会更组件的ctx属性$$.ctx[i] = value,发现值变化了,进行make_dirty
if ($$.ctx && not_equal($$.ctx[i], $$.ctx[i] = value)) {
if (!$$.skip_bound && $$.bound[i])
$$.bound[i](value);
if (ready) {
// make_dirty功能是设置,dirty,即变化的标记,如果没有触发flush,会触发一次flush
// 但是有触发过flush,或者当前就在fulsh中执行(例如computed属性的处理),就不用了再次触发flush
make_dirty(component, i);
}
}
return ret;
}
count的值从0变成了1,ctx[0]和旧的ctx[1]发生了变更,触发make_dirty
4. make_dirty组件脏标记
function make_dirty(component, i) {
// 参数i表示更新的ctx的下标
// 如果发现dirty[0] === -1,证明这个是刚刚新建的,还没设置dirty过,进行初始化
if (component.$$.dirty[0] === -1) {
dirty_components.push(component);
// 进行调度性的组件更新,把更新放到下一个微任务执行
// 这样,update就会在,component.$$.dirty[(i / 31) | 0] |= (1 << (i % 31));后再执行
schedule_update();
// fill方法会将数组里面的元素都替换成参数
component.$$.dirty.fill(0);
}
// 下面是根据i,来设置组件的dirty,来告诉哪些属性需要更新
// 看起来,dirty数组的单个是代表31个下标的更新,每个数组元素都代表31个ctx的属性的更新
// 为什么使用31个,因为31个是js位操作,不包含正负符号的最大极限,32位最开头的1位是符号位,可以看下面的资料
// https://fengmumu1.github.io/2018/06/30/js-number/
// i从0开始,offset的取值是0-30,明明可以存31个,但是为啥offset最大是30,因为,1是0位,偏移1后,就占两位了,所以偏移最大只能是30
// 例如i是0,那么最终设置的值是1,那么偏移是0,i是1,偏移是1,那么最终设置的值是2
// 为什么要用|=,因为需要避免影响到之前的需要更新的ditry
component.$$.dirty[(i / 31) | 0] |= (1 << (i % 31));
}
$$.dirty用于组件脏的标记,使用位掩码的原因是模拟类似C语言的bit类型,节省内存
5. 微任务更新调度
function schedule_update() {
// update_scheduled是false,那么设置下一次微任务执行一次flush
// 防止同一时间触发多个微任务flush
if (!update_scheduled) {
update_scheduled = true;
resolved_promise.then(flush);
}
}
由于ctx属性的值,在一个事件响应的宏任务中,对于同一个下标,可能发生多次更新 对于dom的更新,实际上只需要采用最后的值进行渲染即可,微任务更新调度主要用于节省dom操作
6. 脏检查组件更新
对于标记了脏的dirty_components,进行循环遍历update
function flush() {
const saved_component = current_component;
do {
// first, call beforeUpdate functions
// and update components
while (flushidx < dirty_components.length) {
const component = dirty_components[flushidx];
flushidx++;
set_current_component(component);
update(component.$$);
}
set_current_component(null);
dirty_components.length = 0;
flushidx = 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);
while (flush_callbacks.length) {
flush_callbacks.pop()();
}
update_scheduled = false;
seen_callbacks.clear();
set_current_component(saved_component);
}
// 更新组件,包括computed部分,和视图部分
function update($$) {
if ($$.fragment !== null) {
// 调用update,这个一一般是处理computed的更新
// 值得注意的是$$.update会再次调用$$invalidate更新属性,并调用make_dirty,make_dirty里面有flush的逻辑,但是现在update正在flush中执行
// 但是,由于现在dirty数组不是[-1],所以不会调用flush,所以不会出现递归爆栈的现象
$$.update();
run_all($$.before_update);
const dirty = $$.dirty;
$$.dirty = [-1];
// 执行dom视图的更新
$$.fragment && $$.fragment.p($$.ctx, dirty);
$$.after_update.forEach(add_render_callback);
}
}
该例子,组件的p函数
p(ctx, [dirty]) {
if (dirty & /*count*/ 1) set_data(t1, /*count*/ ctx[0]);
if (dirty & /*count*/ 1 && t3_value !== (t3_value = (/*count*/ ctx[0] === 1 ? 'time' : 'times') + "")) set_data(t3, t3_value);
},
目前count的属性存放在$$.ditry[0]的第一位,通过$$.dirty[0] & 1检查第一位是否是1 检查到为1代表count发现了变更,进行对应的dom更新操作
下面这个例子是拥有第二个属性value的更新
p(ctx, [dirty]) {
if (dirty & /*count*/ 1) set_data(t1, /*count*/ ctx[0]);
if (dirty & /*value*/ 2) set_data(t3, /*value*/ ctx[1]);
if (dirty & /*count*/ 1 && t5_value !== (t5_value = (/*count*/ ctx[0] === 1 ? 'time' : 'times') + "")) set_data(t5, t5_value);
},
单个组件分发优势
由于Svelte的运行时比较小,对于一般的单文件,编译出来的体积比较小 以前写跨框架的组件一般使用原生js,现在可以使用Svelte实现,例如bytemd
大型应用体积差异
不同数量的TodoMVC组件,各个框架输出的应用体积,可以看出Svelte在组件数量达到一定程度的时候,就会失去体积优势,详情看 JavaScript Framework TodoMVC Size Comparison,当然可以使用懒加载去进行优化,具体还需看业务场景。
总结
Svelte创新的架构设计,给Web前端框架的性能优化提供了新的优化方向,并且背后有Vercel进行支持,可以看出往后发展应该会比较不错。而Svelte的生态目前还比不上成熟的框架,其大型组件上的表现还需要深入调研,不过听说Svelte5会进行很大的优化,期待以后的发展。