深入浅出svelte.js

5,676 阅读4分钟

    最近有一个官网页,打算用svelte体验一下,顺便学习了一下svelte(发音:[svelt]),整体来说,svelte是比较简洁的,上手很快。不过与其说是一个前端框架,不如说是一个“dom操作编译器”。svelte的开发代码,在编译阶段会被编译成一系列的dom操作的代码,运行时的代码很少。因此svelte.js的体积很小(只保留了脏值检测更新和封装dom操作API等core代码)。本文从一下几个方面聊一聊对于svelte的认识。

  • svelte初体验
  • svelte的语法
  • Virtual Dom和Dom
  • 优缺点
  • svelte源码阅读

原文地址在我的博客: github.com/fortheallli…


一、svelte初体验

    我们直接来看官网的例子:

a1

    实现的功能也很简单,就是两个Input的值求和,然后展示出来。用svelte编写的代码为:

<script>
        let a = 1;
        let b = 2;
</script>

<input type="number" bind:value={a}>
<input type="number" bind:value={b}>

<p>{a} + {b} = {a + b}</p>

    上述代码很简洁,像vue一样也是类似style dom script的三段式写法,不过比vue更加简洁一点,比如dom不需要template包裹等等。

同样的上述的例子的代码如果用react书写:

import React, { useState } from 'react';

export default () => { 

    const [a, setA] = useState(1); 
    const [b, setB] = useState(2); 
    function handleChangeA(event) { setA(+event.target.value); } 
    function handleChangeB(event) { setB(+event.target.value); }

    return ( 

       <div> 
          <input type="number" value={a} onChange={handleChangeA}/> 
          <input type="number" value={b} onChange={handleChangeB}/> 
          <p>{a} + {b} = {a + b}</p> 
       </div> 

    );

}

    上述react的写法,必须要先弄懂useState的含义等,此外缺少了默认的双向数据绑定,代码有一点冗余。

    同样的上述的例子的代码如果用vue书写:

<template> 

    <div> 
       <input type="number" v-model.number="a"> 
       <input type="number" v-model.number="b"> 
       <p>{{a}} + {{b}} = {{a + b}}</p> 
   </div> 

</template> 

<script> 

    export default { 
       data: function() { 
          return { a: 1, b: 2 }; 
       } 
    }; 

</script>

三者对比:

框架名称sveltereactvue
demo字符数145445263

    单纯的说,svelte编码只需要145个字符,比vue和react少,因此得出说svelte的编码体积更小,这样是不对的,因为svelte会在编译阶段将代码编译到更加贴近dom操作的代码,上述例子的代码,编译后的结果为:

/* App.svelte generated by Svelte v3.38.3 */

    import {
            SvelteComponent,
            append,
            attr,
            detach,
            element,
            init,
            insert,
            listen,
            noop,
            run_all,
            safe_not_equal,
            set_data,
            set_input_value,
            space,
            text,
            to_number

    } from "svelte/internal";



    function create_fragment(ctx) {
            let input0;
            let t0;
            let input1;
            let t1;
            let p;
            let t2;
            let t3;
            let t4;
            let t5;
            let t6_value = /*a*/ ctx[0] + /*b*/ ctx[1] + "";
            let t6;
            let mounted;
            let dispose;

            return {

                    c() {
                            input0 = element("input");
                            t0 = space();
                            input1 = element("input");
                            t1 = space();
                            p = element("p");
                            t2 = text(/*a*/ ctx[0]);
                            t3 = text(" + ");
                            t4 = text(/*b*/ ctx[1]);
                            t5 = text(" = ");
                            t6 = text(t6_value);
                            attr(input0, "type", "number");
                            attr(input1, "type", "number");
                    },

                    m(target, anchor) {
                            insert(target, input0, anchor);
                            set_input_value(input0, /*a*/ ctx[0]);
                            insert(target, t0, anchor);
                            insert(target, input1, anchor);
                            set_input_value(input1, /*b*/ ctx[1]);
                            insert(target, t1, anchor);
                            insert(target, p, anchor);
                            append(p, t2);
                            append(p, t3);
                            append(p, t4);
                            append(p, t5);
                            append(p, t6);

                            if (!mounted) {
                                    dispose = [
                                            listen(input0, "input", /*input0_input_handler*/ ctx[2]),
                                            listen(input1, "input", /*input1_input_handler*/ ctx[3])
                                    ];
                                    mounted = true;
                            }

                    },

                    p(ctx, [dirty]) {

                            if (dirty & /*a*/ 1 && to_number(input0.value) !== /*a*/ ctx[0]) {
                                    set_input_value(input0, /*a*/ ctx[0]);
                            }

                            if (dirty & /*b*/ 2 && to_number(input1.value) !== /*b*/ ctx[1]) {
                                    set_input_value(input1, /*b*/ ctx[1]);

                            }

    

                            if (dirty & /*a*/ 1) set_data(t2, /*a*/ ctx[0]);

                            if (dirty & /*b*/ 2) set_data(t4, /*b*/ ctx[1]);

                            if (dirty & /*a, b*/ 3 && t6_value !== (t6_value = /*a*/ ctx[0] + /*b*/ ctx[1] + "")) set_data(t6, t6_value);

                    },

                    i: noop,

                    o: noop,

                    d(detaching) {

                            if (detaching) detach(input0);

                            if (detaching) detach(t0);

                            if (detaching) detach(input1);

                            if (detaching) detach(t1);

                            if (detaching) detach(p);

                            mounted = false;

                            run_all(dispose);

                    }

            };

    }

    

    function instance($$self, $$props, $$invalidate) {

            let a = 1;

            let b = 2;

            function input0_input_handler() {

                    a = to_number(this.value);

                    $$invalidate(0, a);

            }

            function input1_input_handler() {

                    b = to_number(this.value);

                    $$invalidate(1, b);

            }
            return [a, b, input0_input_handler, input1_input_handler];

    }

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

    }

    

    export default App;

    在编译后生成的代码其实代码量也不小,是远远大于145个字符的,也不能说因为编译后的代码量大,所以说svelte有点名不副实,并不能减少运行时代码的体积。要考虑到svelte的运行时代码是很少的.我们来对比一下:

框架名称reactvueangularsvelte
体积42k22k89.5k1.6k

    从上述对比中可以看出,svelte的体积很少,虽然其业务代码在编译后会生产较多的代码。得益于较少的运行时代码。虽然svelte代码的随着业务的编写增量速度比较快,得益于其很小的包体积1.6k,对于一般中小型项目而言,整体运行的代码(编译后的代码+包体积)还是比较小的,所以可以说svelte项目的代码较小。不过对于大型项目而言,因为svelte随着业务的进行,运行时代码增量陡峭,大型项目体积并不会比react、vue等小,因此需要辩证看待。

    此外虽说svelte的代码在编译后体积很大,但是在编译前的代码,其实很简洁,这种简洁,一定程度上,可以增强开发体验。

二、 svelte的语法

    svelte的写法跟vue有点类似,是指令式和响应式的。

  1. 基本用法

<script>
    let name = 'world';
</script>

<h1>Hello {name}!</h1>
<style>

  h1{

    color:red

  }
</style>

这是一个最简单的hello world的例子,上述代码中很简洁。在编译后的代码分为js编译和css编译。

  • js编译
/* App.svelte generated by Svelte v3.38.3 */

import {

        SvelteComponent,

        attr,

        detach,

        element,

        init,

        insert,

        noop,

        safe_not_equal

} from "svelte/internal";



function create_fragment(ctx) {

        let h1;



        return {

                c() {

                        h1 = element("h1");

                        h1.textContent = `Hello ${name}!`;

                        attr(h1, "class", "svelte-khrn1o");

                },

                m(target, anchor) {

                        insert(target, h1, anchor);

                },

                p: noop,

                i: noop,

                o: noop,

                d(detaching) {

                        if (detaching) detach(h1);

                }

        };

}



let name = "world";



class App extends SvelteComponent {

        constructor(options) {

                super();

                init(this, options, null, create_fragment, safe_not_equal, {});

        }

}



export default App;

svelte/internal包中是一些封装了dom操作的函数。

  • css编译结果:

    h1.svelte-khrn1o{color:red}
    

css是通过创建style标签引入到最后的dom中的。

  1. 指令形式和数据绑定

<script>

    let a = 1;

    let b = 2;

    $: total =  a+b

</script>



<input type="number" bind:value={a}>

<input type="number" bind:value={b}>

<p>{a} + {b} = {total}</p>

还是以上面的例子为例,上述就是一个指令形式+数据绑定的形式。跟vue的写法很相似,改例子绑定了input和a, input和b.效果如下: a2

这里的$total: 就是reactive statement. 类似vue中的计算属性。

  1. 组件compose

//Name.svelte

<script lang='typescript'>

    export let name = "yuxl"

</script>



<span>

    {name}

</span>



//Age.svelte

<script lang='typescript'>

    export let age = 18

</script>



<span>

    {age}

</span>



//index.svelte



<script>

import Name from './Name.svelte'

import Age from './Age.svelte' 

</script>



<div>

   <Name name="some name"/>

   <Age age = {20} />

</div>

在svelte中的组件的compose也是跟react中类似的,不同的是在react中export的属性就是组件的props,写法上比较简洁,此外,export const 和export function、export class这3个组件的props是只读的,不可写。

  1. 模版语法

    在svelte中,html相关的场景适用于模版语法,最简单的模版语法为:

{#if answer === 42} <p>what was the question?</p> {/if}

    这里介绍几个在svelte中几个比较有趣的模版语法。

  • @debug
<script>  

    export let name = "yuxl"

</script>

 {@debug name}

  <span>

    {name}

  </span>

运行debugger的结果为: a3 @debug 在后面跟的参数name发生变化的时候会进行debugger,从上图我们看到debugger的地方上下文的代码是编译后运行时,跟编码的时候有一点区别,也进一步说明,svelte可以看作是一个前端的编译框架,真正运行时的代码是编译后的结果。

  • @html
<script lang='typescript'>

    export let name = "yuxl"

    const age = '<span>20</span>'

</script>

<div>

    <span>{name}</span>

    {@html age}

</div>
  • #await 用法为:{#await expression}...{:then name}...{:catch name}...{/await}

    <script lang='typescript'>
    
      const promise = new Promise((resolve)=>{
    
        setTimeout(()=>{
    
            resolve("success")
    
        },2000)
    
      })
    </script>
    
    <div>
    
      {#await promise}
    
      <!-- promise is pending -->
    
      <p>waiting for the promise to resolve...</p>
    
      {:then value}
    
          <!-- promise was fulfilled -->
    
          <p>The value is {value}</p>
    
      {/await}
    
    </div>
    
  1. 动画效果

    在svelte中,对于原始的dom元素,自带了一些动画指令,在一般的官网或者活动页中,场景最多的就是动画效果,svelte自带的动画指令,因此在写官网的时候方便了不少。

以transition:fly为例:

<script>

    import { fly } from 'svelte/transition';

    let visible = true;

</script>

<label>
   <input type="checkbox" bind:checked={visible}>
    visible
</label>



{#if visible}

    <p transition:fly="{{ y: 200, duration: 2000 }}">
            Flies in and out
    </p>
{/if}

最后的结果为:

a4

当然在svelte中也支持自定义动画指令。

  1. 组件的生命周期

    svelte组件也提供了完整的生命周期。onMount、beforeUpdateafterUpdateonDestroy等。见名思意,这里不一一介绍,跟react & vue的组件生命周期近似。

除了上述之外,svelte还支持自定义元素(custom element), store以及context等等

三、Virtual Dom和Dom

    这个其实可以,比较客观的去看待,svelte的作者认为,Virtual Dom的性能并没有太大的问题,不管是diff算法还是render的过程都没有什么性能问题,不过作者认为,svelte不需要diff,还是有一点优势的。虽然diff很快,但是没有diff的话,显然会更快的得到渲染结果

    svelte的编译后的结果来看,所有的dom的变动都变为了直接的dom操作行为,是不需要做diff的,这种方法,没有diff/patch,因此从速度来看,肯定更快一些。 比如:

<script>

import { fade } from "svelte/transition";

let visible = false

function handleClick(){

    visible = true

}

</script>

<div>

    <div on:click={handleClick}>点击</div>

    {#if visible}

        <div transition:fade ="{{ duration: 2000 }}" >

            fades in and out

        </div>

    {/if}

</div>

上述这个例子中,修改了visible,编译后的代码知道这个行为,这是一个确定的会如何影响dom的行为,编译后的结果部分为: a5

    可以看到,state的改变如何影响dom在svelte的编译结果中都是很确定的。

    除了性能问题,svelte的作者认为,因为virtualDom的存在,需要保存new object和old object的虚拟dom对象,在react的编程中,每一次渲染都有这两个对象,这两个对象,在正常的开发中,很容易添加一些冗余代码:

function MoreRealisticComponent(props) {

          const [selected, setSelected] = useState(null);

          return (

            <div>

              <p>Selected {selected ? selected.name : 'nothing'}</p>

        

              <ul>

                {props.items.map(item =>

                  <li>

                    <button onClick={() => setSelected(item)}>

                      {item.name}

                    </button>

                  </li>

                )}

              </ul>

            </div>

          );

    }

    在这个例子中,为每一个li都绑定了一个事件,这是不过度优化情况下的正常下发,因为virtualDom虚拟dom的存在,每一次state更新的时候,每一个new object和old object都包含了每一个li的绑定函数,这些是冗余的代码,增加了代码的体积等。

四、优缺点

个人归纳了一下几个优缺点:

  • 优点:

    1. 体积很小,是真的小,包体积只有1.6k,对于小型项目比如官网首页,活动页等确实可以拿来试试。上手也很快,很轻量级。类似活动页这种简单页面的lowcoder系统,也可以尝试一下,因为框架本身提供的api,应该是目前前端框架里面最简单的。
    2. no virtual dom的形式一定程度上确实要快一些,没有了diff/path
  • 缺点

    1. 虽然包的体积小,但是编译后的代码其实并不小,代码总量的增加曲线其实还是有一定陡峭的。在大型项目中没有证明自己。
    2. 生态问题,生态其实并不是很完善,虽然类似的比如组件库之类的都有,但是没有很完善。

    五、源码阅读

    首先svelte的源码分为两部分,compiler和runtime,compiler主要的作用是将开发代码编译成运行时的代码,具体如何编译不是本文所要关注的代码。本文主要关注的是编译后的运行时的代码runtime。

  1. dom操作相关core api

我们以最简单的hello world为例: svelte编译前源码:

<h1>Hello world!</h1>

svelte编译后的代码

import {

        SvelteComponent,

        detach,

        element,

        init,

        insert,

        noop,

        safe_not_equal

} from "svelte/internal";



function create_fragment(ctx) {

        let h1;



        return {

                c() {

                        h1 = element("h1");

                        h1.textContent = "Hello world!";

                },

                m(target, anchor) {

                        insert(target, h1, anchor);

                },

                p: noop,

                i: noop,

                o: noop,

                d(detaching) {

                        if (detaching) detach(h1);

                }

        };

}



class App extends SvelteComponent {

        constructor(options) {

                super();

                init(this, options, null, create_fragment, safe_not_equal, {});

        }

}



export default App;

这里的App就可以直接使用了,比如渲染到一个父dom中可以这样使用:

import App from './App.svelte'



var app = new App({

  target: document.body,

});

export default app;

上述方法就可以把App这个编译后的运行时组件渲染到body中,我们来看编译后的代码。

  • create_fragment

在svelte组件中,与dom相关的部分封装在了create_fragment中,该函数创建了一个Fragment, 该函数返回一个包含dom操作的对象:

export interface Fragment {

        key: string|null;

        first: null;

        /* create  */ c: () => void;

        /* claim   */ l: (nodes: any) => void;

        /* hydrate */ h: () => void;

        /* mount   */ m: (target: HTMLElement, anchor: any) => void;

        /* update  */ p: (ctx: any, dirty: any) => void;

        /* measure */ r: () => void;

        /* fix     */ f: () => void;

        /* animate */ a: () => void;

        /* intro   */ i: (local: any) => void;

        /* outro   */ o: (local: any) => void;

        /* destroy */ d: (detaching: 0|1) => void;

}

在上述的例子中,c对应创建一个子dom元素,m表示创建元素要渲染元素时需要执行的函数,d表示删除元素时的操作。上述的例子中:

function create_fragment(ctx) {

        let h1;



        return {

                c() {

                        h1 = element("h1");

                        h1.textContent = "Hello world!";

                },

                m(target, anchor) {

                        insert(target, h1, anchor);

                },

                p: noop,

                i: noop,

                o: noop,

                d(detaching) {

                        if (detaching) detach(h1);

                }

        };

}

在m中的intert和d中的detach方法,都是原生的dom操作方法,上述Fragment的意思是创建了h1这个dom,并在渲染的时候插入到目标dom节点中,在Fragment这个组件元素被销毁的时候,销毁被创建的子dom元素 h1。

element、insert、detach等方法都是原生的dom操作,具体源码如下所示:

export function element<K extends keyof HTMLElementTagNameMap>(name: K) {

        return document.createElement<K>(name);

}



export function insert(target: NodeEx, node: NodeEx, anchor?: NodeEx) {

        target.insertBefore(node, anchor || null);

}

export function detach(node: Node) {

        node.parentNode.removeChild(node);

}
  • SvelteComponent
export class SvelteComponent {

        $$: T$$;

        $$set?: ($$props: any) => void;



        $destroy() {

                destroy_component(this, 1);

                this.$destroy = noop;

        }



        $on(type, callback) {

                const callbacks = (this.$$.callbacks[type] || (this.$$.callbacks[type] = []));

                callbacks.push(callback);



                return () => {

                        const index = callbacks.indexOf(callback);

                        if (index !== -1) callbacks.splice(index, 1);

                };

        }



        $set($$props) {

                if (this.$$set && !is_empty($$props)) {

                        this.$$.skip_bound = true;

                        this.$$set($$props);

                        this.$$.skip_bound = false;

                }

        }

}

SvelteComponent组件定义了如何销毁组件以及如何设置组件的属性,以及如何增加监听函数,其中最重要的是定义了组件的实例属性 .

interface T$$ {

        dirty: number[];

        ctx: null|any;

        bound: any;

        update: () => void;

        callbacks: any;

        after_update: any[];

        props: Record<string, 0 | string>;

        fragment: null|false|Fragment;

        not_equal: any;

        before_update: any[];

        context: Map<any, any>;

        on_mount: any[];

        on_destroy: any[];

        skip_bound: boolean;

        on_disconnect: any[];

}

发现SvelteComponent组件确实包含了ctx上下文内容,以及组件的生命周期属性,以及组件的脏值检测等相关的属性。

  • init函数 `js export function init(component, options, instance, create_fragment, not_equal, props, dirty = [-1]) {

    const parent_component = current_component;
    
      set_current_component(component);
    
    
    
      const $$: T$$ = component.$$ = {
    
              fragment: null,
    
              ctx: null,
    
    
    
              // state
    
              props,
    
              update: noop,
    
              not_equal,
    
              bound: blank_object(),
    
    
    
              // lifecycle
    
              on_mount: [],
    
              on_destroy: [],
    
              on_disconnect: [],
    
              before_update: [],
    
              after_update: [],
    
              context: new Map(parent_component ? parent_component.$$.context : options.context || []),
    
    
    
              // everything else
    
              callbacks: blank_object(),
    
              dirty,
    
              skip_bound: false
    
      };
    
    
    
      let ready = false;
    
    
    
      $$.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;
    
              })
    
              : [];
    
    
    
      $$.update();
    
      ready = true;
    
      run_all($$.before_update);
    
    
    
      // `false` as a special case of no DOM component
    
      $$.fragment = create_fragment ? create_fragment($$.ctx) : false;
    
    
    
      if (options.target) {
    
          flush();
    
      }
    
    
    
      set_current_component(parent_component);
    }
    

    init函数在SvelteComponent组件内部调用,用于实例属性的初始化。这里最重要的是$$.ctx的赋值部分,后续会用来做脏值检测。ctx中保存了所有的再多次渲染中都存在的值,包含了内部的state以及监听处理函数等等。

    1. 脏值检测和更新部分

这里我们以一个带有鼠标时间的svelte组件为例,

编译前的代码:

<script>

        let m = { x: 0, y: 0 };

        function handleMousemove(event) {

                m.x = event.clientX;

                m.y = event.clientY;

        }

</script>

<div on:mousemove={handleMousemove}>

        The mouse position is {m.x} x {m.y}

</div>

svelte编译后的代码与hello world相比增加的代码:

function create_fragment(ctx) {

        let div;

        let t0;

        let t1_value = /*m*/ ctx[0].x + "";

        let t1;

        let t2;

        let t3_value = /*m*/ ctx[0].y + "";

        let t3;

        return {

               ...

                p(ctx, [dirty]) {

                        if (dirty & /*m*/ 1 && t1_value !== (t1_value = /*m*/ ctx[0].x + "")) set_data(t1, t1_value);

                        if (dirty & /*m*/ 1 && t3_value !== (t3_value = /*m*/ ctx[0].y + "")) set_data(t3, t3_value);

                },

               ...

        };

}



function instance($$self, $$props, $$invalidate) {

        let m = { x: 0, y: 0 };

        function handleMousemove(event) {

                $$invalidate(0, m.x = event.clientX, m);

                $$invalidate(0, m.y = event.clientY, m);

        }

        return [m, handleMousemove];

}

这里多了一个instance函数,而这个instance函数在svelteComponent的init函数中就是用作脏值检测和更新的。

$$.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;

    })

    : [];

如果值发生了变动,就触发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标记了哪一些脏组件,然后对脏组件执行schedule_update方法来更新组件:

export function schedule_update() {

        if (!update_scheduled) {

                update_scheduled = true;

                resolved_promise.then(flush);

        }

}

schedule_update在需要更新时候,在下一个微任务重执行flush:

export function flush() {

        if (flushing) return;

        flushing = true;

        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);

                ...

                render_callbacks.length = 0;

        } while (dirty_components.length);

}

简化后的flush方法如上所示,就是遍历整个脏组件,执行所有的脏组件中的更新方法update.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);

        }

}

update方法标记自身组件为脏,并且制定自身组件fragment中的p(全名:update)也就是前面的fragment中的:

p(ctx, [dirty]) {

                        if (dirty & /*m*/ 1 && t1_value !== (t1_value = /*m*/ ctx[0].x + "")) set_data(t1, t1_value);

                        if (dirty & /*m*/ 1 && t3_value !== (t3_value = /*m*/ ctx[0].y + "")) set_data(t3, t3_value);

                },

在p方法中,直接操作dom改变UI。

总结来看,组件更新的步骤为以下几步:

  1. 事件或者其他操作出发更新流程
  2. 在instance的$$invalidate方法中,比较操作前后ctx中的值有没有发生改变,如果发生改变则继续往下
  3. 执行make_dirty函数标记为脏值,添加带有脏值需要更新的组件,从而继续触发更新
  4. 执行schedule_update函数
  5. 执行flush函数,将所有的脏值组件取出,以此执行其update方法
  6. 在update方法中,执行的是Fragment自身的p方法,p方法做的事情就是确定需要更新组件,并操作和更新dom组件,从而完成了最后的流程