官网: https://svelte.dev/
Github: https://github.com/sveltejs/svelte

Svelte 是 Rich Harris 大佬作品,这个大佬也是那个号称下一代 ES6 模块化打包器 —— Rollup 的作者。作者似乎也很喜欢分享自己的编程哲学,读下来还挺有趣的,也就学习和介绍一下他的 Svelte 框架吧。

Svelte 介绍

Svelte 从其官网的介绍来看,主要有三个方面的优势。

编写更少的代码

Write Less Code

作者认为写的代码越多,程序存在Bug的可能性越大。作者对比了完成相同行为在不同框架中的代码量:

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:

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

从这个小例子 Svelte 代码的确干净简洁,作者觉得为啥 HTML 模板需要有一个顶层元素包裹着,为啥 React 为了实现状态的声明和更新要写这么多重复的模版化的代码...

我个人觉得这些模版风格方面的东西没有对开发体验或者开发效率没有本质的提升...熟悉了一套风格之后也可以很熟练很自然用对应的风格快速开发,不会被模版风格给束缚住。代码做的事情和关注的重点不会因为模板风格而改变。

没有 Virtual DOM

Virtual DOM is pure overhead

VDOM 更新数据的流程就是用上一轮 render 的结果和和更新数据后 rerender 的结果做 diff,最后将需要变更的部分转化为 DOM 操作应用到真实 DOM 上。

作者觉得 VDOM 的效率问题主要是在 diff 算法还是带来了额外的性能开销,每次 rerender 重新调用 render 函数,render 函数也可能有性能较低的操作,比如大数组生成、复杂计算等。而且 VDOM 是在 Runtime 对模版进行重计算,打包出来的代码必然会带上一个 Runtime。作者觉得 VDOM 存在的意义就是解放了一定的生产力,无需去追踪 state 的变化的过程,只关心 state 改变后应用最终的状态。

那么 Svetle 的核心卖点,也是和其他框架最大的区别也就是这个了,通过对模板的静态编译,生成出来的代码就直接是裸的 DOM 操作,不包含其他的 Runtime,也没有 VDOM。以此来提升 Web 应用的性能和减少打包体积。

响应式编程

Rethinking reactivity

Svelte 支持响应式编程,且与其他框架最大的不同是,Svelte 直接让原生 JS 的类型支持了响应式,也就是说,任意一申明的变量可直接绑定到模板中,对变量修改会驱动 DOM 刷新。这也就给 Svelte 的 JS 部分带来了风格上的自由,没有类似于 Vue 那样 data, methods, computed 的模板化限制,用一种更纯粹的 JS 思路去解决问题。

<script>
  let count = 0;

  function handleClick() {
  	count += 1;
  }
</script>

<button on:click={handleClick}>
  Clicked {count} {count === 1 ? 'time' : 'times'}
</button>

如何实现不依赖 VDOM

来看看一个最简单的 DEMO。



Svelte 代码如下:

// App.svelte
<script>
	import Comp from './Comp.svelte'
	let name = 'Delbert';
	
	const changeName = () => {
		name = 'Shyrii';
	}

	const hello = (event) => {
		alert('hello', event.detail);
	}
</script>

<style>
	h1 {
		color: purple;
	}
</style>

<h1 on:click={changeName}>Hello {name}!</h1>
<Comp bind:str={name} on:hello={hello}></Comp>
// Comp.svelte
<script>
  import { } from 'svelte'
  const dispatch = createEventDispatcher();
  export let str;

  const emitHello = () => {
    dispatch('hello', str);
  }

</script>

<p on:click={emitHello}>{str}</p>

静态编译

上面提到过,模版最终是会被编译成 Vanilla JS,也就是原生那套 DOM 操作。

组件编译出来的代码如下:

function create_fragment$1(ctx) {
  var h1, t0, t1, t2, t3, updating_str, current, dispose;

  function comp_str_binding(value) {
    ctx.comp_str_binding.call(null, value);
    updating_str = true;
    add_flush_callback(() => updating_str = false);
  }

  let comp_props = {};
  if (ctx.name !== void 0) {
    comp_props.str = ctx.name;
  }
  var comp = new Comp({ props: comp_props, $$inline: true });

  binding_callbacks.push(() => bind(comp, 'str', comp_str_binding));

  return {
    c: function create() {
      h1 = element("h1");
      t0 = text("Hello ");
      t1 = text(ctx.name);
      t2 = text("!");
      t3 = space();
      comp.$$.fragment.c();
      attr(h1, "class", "svelte-i7qo5m");
      add_location(h1, file$1, 15, 0, 173);
      dispose = listen(h1, "click", ctx.changeName);
    },
    
    //...
  };
};

function create_fragment(ctx) {
  var p, t;

  return {
    c: function create() {
      p = element("p");
      t = text(ctx.str);
      add_location(p, file, 4, 0, 38);
    },
    //...
  };
};

其中 text, element 这些是对 document.createXXX 等 DOM 操作的简单封装。

而模版中的 JS,会被抽取成

function instance($$self, $$props, $$invalidate) {
  let name = 'Delbert';

  const changeName = () => {
    $$invalidate('name', name = 'Shyrii');
  };

  function comp_str_binding(value) {
    name = value;
    $$invalidate('name', name);
  }

  return { name, changeName, comp_str_binding };
}

申明的变量、方法最后都会变成一个 context 对象返回出去。

CSS 会被打包到 bundle.css,为了实现局部作用域 CSS,Svelte 为每个局部的 CSS 添加了唯一的类名,同时对选中的元素的 DOM 也加上这个类名。

h1.svelte-i7qo5m{color:purple}

组件 init 的时候会调用 mount 函数将上面生成好的 DOM 节点注入到 target 中。

m: function mount(target, anchor) {
  insert(target, h1, anchor);
  append(h1, t0);
  append(h1, t1);
  append(h1, t2);
  insert(target, t3, anchor);
  mount_component(comp, target, anchor);
  current = true;
},

其中 mount_component 内递归调用子组件的 mount 方法,并注入到合适的 DOM 节点。

实现响应

上面 instance 创建的时候,看到有个神奇的 $$invalidate 参数传进来,我们在原始代码里面写的 name = 'Shyrii' 被替换成了 $$invalidate('name', name = 'Shyrii');,巧妙的调用了 $$invalidate 函数传入了新值,也把闭包里面的 name 更新了。

目光转到 $$invalidate 函数:

(key, value) => {
  if ($$.ctx && not_equal($$.ctx[key], $$.ctx[key] = value)) {
    if ($$.bound[key])
      $$.bound[key](value);
    if (ready)
      make_dirty(component, key);
  }
}

首先会对原来 Context 中的 name 和传入的新 name 的值做一次比较,如果没有变化则不进入接下来的流程,同时更新 Context 中的 name

首次初始化时,$$.bound 为空对象,readyfalse,这时候为组件尚未初始化的状态,即使修改了 state 也不应该对组件做 update, if (ready) 检测就跳过了 update。

当组件成功初始化之后,ready 转为 true,调用 make_dirty

function make_dirty(component, key) {
  if (!component.$$.dirty) {
    dirty_components.push(component);
    schedule_update();
    component.$$.dirty = blank_object();
  }
  component.$$.dirty[key] = true;
}

将组件推入全局的 dirty_components 数组中,计划一次 update,同时将组件内部的 dirty 对象中本次修改的 state 的 key 置为 true

function schedule_update() {
  if (!update_scheduled) {
    update_scheduled = true;
    resolved_promise.then(flush);
  }
}

如果当前没有正在进行的 update,那么使用 Promise.resolve 拉起一个异步调用 flush 方法。

flush 方法做的事情如下:遍历 dirty_components 数组,对每个 dirty 组件调用 update 函数。

function flush() {
  const seen_callbacks = new Set();
  do {
    while (dirty_components.length) {
      const component = dirty_components.shift();
      set_current_component(component);
      update(component.$$);             // 组件在这里被更新
    }
    while (binding_callbacks.length)
      binding_callbacks.pop()();
    for (let i = 0; i < render_callbacks.length; i += 1) {
      const callback = render_callbacks[i];
      if (!seen_callbacks.has(callback)) {
        callback();
        seen_callbacks.add(callback);   // 防止某一 render_callback 被无限调用
      }
    }
    render_callbacks.length = 0;
  } while (dirty_components.length);
  while (flush_callbacks.length) {
    flush_callbacks.pop()();
  }
  update_scheduled = false;
}

update 函数触发 beforeUpdateafterUpdate 的生命周期,其中最重要的是这两行:

function update($$) {
  // ...
  $$.fragment.p($$.dirty, $$.ctx);
  $$.dirty = null;
  // ...
}

让我们看看这个神奇的 p 干了什么

p: function update(changed, ctx) {
  if (!current || changed.name) {
    set_data(t1, ctx.name);
  }

  var comp_changes = {};
  if (!updating_str && changed.name) {
    comp_changes.str = ctx.name;
  }
  comp.$set(comp_changes);
}

之前设置的 dirty 对象中 str 被标记为 true 现在就有用了,触发了对应的 set_data,入参 t 为对应的 DOM 节点,ctx.str 就是 Context 中 str 的值。set_data 做的事情也比较简单了,操作 DOM 把值替换进去。

那么现在完成了文本的响应式更新了....等等!Comp 组件里面的 str 怎么更新的呢?

组件参数传递

目光回到 App 组件的 create_fragment

let comp_props = {};
if (ctx.name !== void 0) {
  comp_props.str = ctx.name;
}
var comp = new Comp({ props: comp_props, $$inline: true });

完成了对 Comp 组件的初始化和 props 的注入。

function comp_str_binding(value) {
  ctx.comp_str_binding.call(null, value);
  updating_str = true;
  add_flush_callback(() => updating_str = false);
}

binding_callbacks.push(() => bind(comp, 'str', comp_str_binding));

那么 bind(comp, 'str', comp_str_binding) 做了什么呢?

function bind(component, name, callback) {
  if (component.$$.props.indexOf(name) === -1)
    return;
  component.$$.bound[name] = callback;
  callback(component.$$.ctx[name]);
}

这里在 Comp 组件的 bound 对象里的保存了这个 props 的 callback。下面重新调用一次 callback 是为了应用 Comp 中给 Props 设置的缺省值。

callback 也就是 ctx.comp_str_binding 里面做的事情如下:

function comp_str_binding(value) {
  name = value;
  $$invalidate('name', name);
}

上面提到 invalidate 会对 name 做一次更新判断,从而阻止了无限触发 update。

还记得 flush 方法吗?里面会遍历 binding_callbacks 数组依次执行回调。那么会在 App 组件初始化的 flush 调用里面执行一遍 bind(comp, 'str', comp_str_binding)。注意,这个 bind 只执行这一次,因为每次 flush 都会清空 binding_callbacks 数组。

万事俱备,只欠触发更新了,App 组件的 update 方法里面还有这么一段:

var comp_changes = {};
if (!updating_str && changed.name) {
  comp_changes.str = ctx.name;
}
comp.$set(comp_changes);

comp.$set(comp_changes) 是关键。

Compinstance 方法中会重写自己的 $set 方法。

$$self.$set = $$props => {
  if ('str' in $$props) $$invalidate('str', str = $$props.str);
};

$set 方法触发了 invalidate,从而重复上面副组件刷新数据的流程,完成对子组件的更新。同时,invalidate 里面调用了上面放在 bound 对象里面的回调,主要是为了手动保证父组件里面 name 和这里的 str 状态一致。(有些没有想明白什么场景会出现不一致的情况)

事件机制

每个组件的 Class 都以一个 $on 方法来管理事件。

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

父组件 createFragment 的时候会调用子组件的 $on 来注册绑定到子组件上的事件:

comp.$on("hello", ctx.hello);

将父组件的 hello 方法放入子组件的 callbacks 中。

子组件中:

const dispatch = createEventDispatcher();
function emitHello() {
  dispatch('hello', str);
}

创建子组件 instance 时,createEventDispatcher 创建了一个闭包,缓存了 current_component,也是子组件自身。

调用 dispatch 时,从组件的 callbacks 里面取出改事件的 callbacks 数组,遍历带上 event detail 调用注册的回调方法即可。

思考

编译器实现难度大,甚至不可靠

静态编译模版虽然思路看起来很清晰,但是编译器的实现是非常复杂和困难的一件事情,很难知道一个模板的写法最后编译出来是否符合需要的效果...JS 各种骚写法(Proxy, Getter/Setter之类的)用上之后 Svelte 能否健壮的处理数据流...出了问题到底是编译的问题还是自己问题需要浪费精力排查,令人担心...

这个库目前的 Github Issue 数量已经达到了 300 多个,问题小多,Commit 中大部分是作者自己在单打独斗,而且最近更新频率放缓,不排除作者玩腻了去造新的轮子了的可能性hhhhh

吐槽:_internal.add_binding_callback is not a function 是啥错误..问咱也不知道...

编译出来的代码体积优势有待验证

虽然 Svelte 不会在打包好的代码里面带上完整的运行时,但是每个组件在编译的时候都会被编译成类似的 JS 代码模版,这些模版代码有大量的重复的部分...模版的每个状态、每个方法、每次绑定都会产生大量的额外代码去实现这些功能。最终随着组件越来越多,打包出来代码的量也会随之膨胀。

我将上面的例子里面的 <Comp bind:str={name} on:hello={hello}></Comp> 复制了 10 遍。编译出来的 bundle.js 由 3992 字节瞬间上升到了 7024 字节,快翻了一倍,虽然几K的大小看起来还行,离 Vue 或者 React Runtime 的大小还远着,但是感觉项目一旦到后期,代码的体积膨胀的速度是非常恐怖的...

Svetle 的性能真的会比没有 VDOM 好吗?

1-DGGy6JXWpsl-EYBzvbxZHA

网上有其他人做了测试JavaScript UI Compilers: Comparing Svelte and Solid,和 React 相比有来有回,并没有拉开可以秒天秒地的性能优势...我个人觉得操作 DOM 的所需要的必要时间是怎么都节省不了的,Svetle 也没有啥的特别的机制去减少操作 DOM 的次数,反而有些简单粗暴了。

小结

感觉还是一个玩具性质的框架~不过我会很乐意去试着在下次个人搞些小项目的时候去尝试 Svetle 这个框架。