对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操作函数的创建 svelte-prj-dir

在编译的时候会对节点进行优化,例如在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 使用位掩码的变化追踪技术,基于赋值语句实现响应式更新,下面将分析当前例子的点击事件触发后,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


大型应用体积差异

framework-size 不同数量的TodoMVC组件,各个框架输出的应用体积,可以看出Svelte在组件数量达到一定程度的时候,就会失去体积优势,详情看 JavaScript Framework TodoMVC Size Comparison,当然可以使用懒加载去进行优化,具体还需看业务场景。



总结

Svelte创新的架构设计,给Web前端框架的性能优化提供了新的优化方向,并且背后有Vercel进行支持,可以看出往后发展应该会比较不错。而Svelte的生态目前还比不上成熟的框架,其大型组件上的表现还需要深入调研,不过听说Svelte5会进行很大的优化,期待以后的发展。