5
头图

0.Intro

这篇文章将为大家介绍前端圈“新”宠 Svelte ,以及其背后的响应式原理。对于 Svelte 你还没用过,但大概率会在一些技术周刊,社区,或者前端年度报告上听到这个名字。如果你使用掘金写文章的话,那其实已经在使用 Svelte 了,因为掘金新版的编辑器 bytemd 就是使用 Svelte 写的 👀 。

(:对于一些讯息源比较广的同学来说,Svelte 可能不算新事物,因为其早在 2016 就开始动工,是我落后了。

这篇文章发布与掘金:https://juejin.cn/post/696574...

1.Svelte 是啥?

一个前端框架,轮子哥 Rich Harris 搞的,你可能对这个人字不太熟悉,但 rollup 肯定听过,同一个作者。

新的框(轮)架(子)意味着要学习新的语法,好像每隔几个月就要学习新的“语言”,不禁让我想晒出那个旧图。

image.png

吐槽归吐槽,该学的还是要学,不然就要被淘汰了👻 。Svelte 这个框架的主要特点是:

image.png

  1. 用最基本的 HTML,CSS,Javascript 来写代码
  2. 直接编译成原生 JS,没有中间商(Virtual DOM) 赚差价
  3. 没有复杂的状态管理机制

2.框架对比

决定是否使用某个框架,需要有一些事实依据,下面我们将从 Star 数,下载趋势,代码体积,性能,用户满意度,等几个维度来对比一下 React、Vue、Angular、Svelte 这几个框架。

ReactVue@angular/coreSvelte
Star 数🌟168,661183,54073,31547,111
代码体积 🏋️‍♀️42k22k89.5k1.6k

Star 数上看,Svelte 只有 Vue(yyds)的四分之一(Svelte(2016) 比 Vue(2013) 慢起步三年)。不过 4.7w Star 数也不低。

代码体积(minizipped)上,Svelte 只有 1.6k !!!可别忘了轮子哥另一个作品是 rollup,打包优化很在行。不过随着项目代码增加,用到的功能多了,Svelte 编译后的代码体积增加的速度会比其他框架快,后面也会提到。

NPM 下载趋势

image.png

Npm trendings 链接直达

下载量差距非常明显,Svelte(231,262) 只有 React(10,965,933) 的百分之二。光看这边表面数据还不够,跑个分 看看。

Benchmark 跑分

image.png

越绿表示分越高,从上图可以看到 Svelte 在性能,体积,内存占用方面表现都相当不错。再看看用户满意度如何。

用户满意度

image.png

同样地,Svelte 排到了第一!!!(Interest 也是)。

初步结论

通过以上的数据对比,我们大致能得到的结论是:Svelte 代码体积小,性能爆表,未来可期,值得深入学习

3.Svelte 基本语法

类 Vue 写法

<script>
  let count = 0;

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

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

<style>
  button {
    color: black;
  }
</style>

以上就是一个 Svelte 组件,可以看到和 Vue 的写法基本一致,一个 .svelte 文件包含了 JS,HTML,CSS,并且使用类似的模板语法,指令绑定。

不一样的点可能是 Style 默认是 scoped 的,HTML 不需要用 <template></template> 包裹,以及没有 new Vuedata 的初始化步骤。直接定义一个变量,直接用就行了。(背后发生了什么放到 Reactivity 章节再讲)

Vue 的写法:

var vm = new Vue({
  data: {
    count: 0
  }
})

神奇的 $: 语法

需要在依赖数据变更时触发运算,在 Vue 中通常是使用 computed 来实现。

var vm = new Vue({
  data: {
     count: 0
  },
  computed: {
    double: function () {
      // `this` 指向 vm 实例
      return this.count * 2
    }
  }
})

Svelte 也有类似的实现,我们使用 $: 关键字来声明 computed 变量。

<script>
  let count = 0;

  function handleClick() {
    count += 1;
  }
    
  $: double = count * 2
</script>

<button on:click={handleClick}>
  Clicked {double} times
</button>

上面的例子中,每次点击按钮,double 都会重新运算并更新到 DOM Tree 上。这是什么黑科技?是原生 JS 代码吗?

还别说,确实是,这里的使用的是 Statements and declarations 语法,冒号:前可以是任意合法变量字符,定义一个 goto 语句。不过语义不一样,这里 Svelte 只是讨巧用了这个被废弃的语法来声明计算属性(还是原生 JS 语法,👻 没有引入黑科技。

该有的都有

作为一个前端框架,Svelte 该有的功能一样不少,例如模板语法,条件渲染,事件绑定,动画,组件生命周期,Context,甚至其他框架没有的它也有,比如自带 Store,Motion 等等非常多,由于这些 API 的学习成本并不高,用到的时候看一下代码就可以了。

接下来进入本篇文章的核心,Svelte 如何实现响应式(Reactivity) 或者说是数据驱动视图的方式和 Vue、React 有什么区别。

4.Reactivity

什么是 Reactivity?

高中化学的课堂我们接触过很多实验,例如使用紫色石蕊试液来鉴别酸碱。酸能使紫色石蕊溶液变成红色,碱能使紫色石蕊溶液变成蓝色。实验的原理是和分子结构有关,分子结构是链接,添加酸/碱是动作,而分子结构变化呈现出的结果就是反应 Reactivity。

image.png

利用好 Reactivity 往往能事半功倍,例如在 Excel/Number 里面的函数运算。

image.png

上例我们定义 E11 单元格的内容为 =SUM(D10, E10)(建立连接),那么每次 D10E10的数据发生变更时(动作),应用自动帮我们执行运算(反应),不用笨笨地手动用计算器运算。

没有 Reactivity 之前是怎么写代码的?

为了更清晰地认识 Reactvity 对编码的影响,设想一下开发一个 Todo 应用,其功能有新增任务,展示任务列表,删除任务,切换任务 DONE 状态等。

image.png

首先需要维护一个 tasks 的数据列表。

const tasks = [
    {
        id: 'id1',
        name: 'task1',
        done: false
    }
]

使用 DOM 操作遍历列表,将它渲染出来。

function renderTasks() {
  const frag = document.createDocumentFragment();
  tasks.forEach(task => {
    // 省略每个 task 的渲染细节
    const item = buildTodoItemEl(task.id, task.name);
    frag.appendChild(item);
  });

  while (todoListEl.firstChild) {
    todoListEl.removeChild(todoListEl.firstChild);
  }
  todoListEl.appendChild(frag);
}

然后每次新增/删除/修改任务时,除了修改 tasks 数据,都需要手动触发重新渲染 tasks(当然这样的实现并不好,每次删除/插入太多 DOM 节点性能会有问题)。

function addTask (newTask) {
    tasks.push(newTask)
    renderTaks()
}

function updateTask (payload) {
    tasks = //...
    renderTaks()
}

function deleteTask () {
    tasks = //...
    renderTaks()
}

注意到问题了吗,每次我们修改数据时,都需要手动更新 DOM 来实现 UI 数据同步。(在 jQuery 时代,我们确实是这么做的,开发成本高,依赖项多了以后会逐渐失控)

而有了 Reactvity,开发者只需要修改数据即可,UI 同步的事情交给 Framework 做,让开发者彻底从繁琐的 DOM 操作里面解放出来。

// vue
this.tasks.push(newTask)

在讲解 Svelte 如何实现 Reactivity 之前,先简单说说 React 和 Vue 分别是怎么做的。

React 的实现

React 开发者使用 JSX 语法来编写代码,JSX 会被编译成 ReactElement,运行时生成抽象的 Virtual DOM。

然后在每次重新 render 时,React 会重新对比前后两次 Virtual DOM,如果不需要更新则不作任何处理;如果只是 HTML 属性变更,那反映到 DOM 节点上就是调用该节点的 setAttribute 方法;如果是 DOM 类型变更、key 变了或者是在新的 Virtual DOM 中找不到,则会执行相应的删除/新增 DOM 操作。

除此之外,抽象 Virtual DOM 的好处还有方便跨平台渲染和测试,比如 react-native, react-art。

使用 Chrome Dev Tool 的 Performance 面板,我们看看一个简单的点击计数的 DEMO 背后 React 都做了哪些事情。

import React from "react";

const Counter = () => {
  const [count, setCount] = React.useState(0);

  return <button onClick={() => setCount((val) => val + 1)}>{count}</button>;
};


function App() {
  return <Counter />;
}

export default App;

image.png

大致可以将整个流程分为三个部分,首先是调度器,这里主要是为了处理优先级(用户点击事件属于高优先级)和合成事件。

第二个部分是 Render 阶段,这里主要是遍历节点,找到需要更新的 Fiber Node,执行 Diff 算法计算需要执行那种类型的操作,打上 effectTag,生成一条带有 effectTag 的 Fiber Node 链表。常说的异步可中断也是发生在这个阶段。

第三个阶段是 Commit,这一步要做的事情是遍历第二步生成的链表,依次执行对应的操作(是新增,还是删除,还是修改...)

所以对我们这个简单的例子,React 也有大量的前置工作需要完成,真正修改 DOM 的操作是的是红框中的部分。

image.png

前置操作完成,计算出原来是 nodeValue 需要更新,最终执行了 firstChild.nodeValue = text

image.png

演示使用的 React 版本是 17.0.2,已经启用了 Concurrent Mode

每次 setState React 都 Schedule Update,然后会遍历发生变更节点的所有子孙节点,所以为了避免不必要的 render,写 React 的时候需要特别注意使用 shouldComponentUpdatememouseCallbackuseMemo 等方法进行优化。

Vue 的实现

写了半天,发现还没写到重点。。。为了控制篇幅 Demo 就不写了(介绍 Vue 响应式原理的文章非常多)。

image.png

大致过程是编译过程中收集依赖,基于 Proxy(3.x) ,defineProperty(2.x) 的 getter,setter 实现在数据变更时通知 Watcher。Vue 的实现很酷,每次修改 data 上的数据都像在施魔法。

5. Svelte 降维打击

无论 React, Vue 都在达到目的(数据驱动 UI 更小)的过程中都多做了一些事情(Vue 也用了 Virtual DOM)。而 Svelte 是怎么做到减少运行时代码的呢?

秘密就藏在 Compiler 里面,大部分工作都在编译阶段都完成了。

核心 Compiler

Svelte 源代码分成 compiler 和 runtime 两部分。

image.png

那 Compiler 怎么收集依赖的呢?其实代码中的依赖关系在编译时是可以分析出来的,例如在模板中渲染一个 {name} 字段,如果发现 name 会在某些时刻修改(例如点击按钮之后),那就在每次name 被赋值之后尝试去触发更新视图。如果 name 不会被修改,那就什么也不用做。

这篇文章不会介绍 Compiler 具体如何实现,来看看经过 Compiler 之后的代码长什么样。
<script>
  let name = 'world';
</script>

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

会被编译成如下代码,为了方便理解,我把无关的代码暂时删除了。

/* App.svelte generated by Svelte v3.38.2 */
import {
  SvelteComponent,
  append,
  detach,
  element,
  init,
  insert,
  listen,
  noop,
  safe_not_equal,
  set_data,
  text
} from "svelte/internal";

function create_fragment(ctx) {
  let h1;

  return {
    c() {
      h1 = element("h1");
      h1.textContent = `Hello ${name}!`;
    },
    m(target, anchor) {
      insert(target, h1, anchor);
    }
}

let name = "world";

create_fragment 方法是和每个组件 DOM 结果相关的方法,提供一些 DOM 的钩子方法,下一小结会介绍。

对比一下如果变量会被修改的代码

<script>
    let name = 'world';
    function setName () {
        name = 'fesky'
    }
</script>

<h1 on:click={setName}>Hello {name}!</h1>

编译后

import {
 SvelteComponent,
 append,
 detach,
 element,
 init,
 insert,
 listen,
 noop,
 safe_not_equal,
 set_data,
 text
} from "svelte/internal";

function create_fragment(ctx) {
  let h1;
  let t0;
  let t1;
  let t2;
  let dispose;

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

      if (!mounted) {
        // 增加了绑定事件
        dispose = listen(h1, "click", /*handleClick*/ ctx[1]);
      }
    },
    // 多一个 p (update)方法
    p(ctx, [dirty]) {
      if (dirty & /*name*/ 1) set_data(t1, /*name*/ ctx[0]);
    }
  };
}
// 多了 instance 方法
function instance($$self, $$props, $$invalidate) {
  let name = "world";

  function setName() {
    $$invalidate(0, name = "fesky");
  }

  return [name, setName];
}

这种情况下编译结果的代码多了一些,简单介绍一下,首先是 fragment中原来的 m 方法内部增加了 click 事件;多了一个 p 方法,里面调了 set_data;新增了一个 instance 方法,这个方法返回每个组件实例中存在的属性和修改这些属性的方法(name, 和 setName),如果有其他属性和方法也是在同一个数组中返回(不要和 Hooks 搞混了)。

一些细节还不太了解没关系,后面都会介绍。重点关注赋值的代码原来的 name = 'fesky' 被编译成了 $$invalidate(0, name = "fesky")

还记得前面我们使用原生代码实现 Todo List 吗?我们在每次修改数据之后,都要手动重新渲染 DOM!我们不提倡这么写法,因为难以维护。

function addTask (newTask) {
    tasks.push(newTask)
    renderTaks()
}

而 Svelte Compile 实际上就是在代码编译阶段帮我们实现了这件事!把需要数据变更之后做的事情都分析出来生成原生 JS 代码,运行时就不需要像 Vue Proxy 那样的运行时代码了。

Selve 提供了在线的实时编译器,可以动动小手试一下。https://svelte.dev/repl/hello...

接下来的部分将是从源码角度来看看 Svelte 整体是如何 run 起来的。

Fragment——都是纯粹的 DOM 操作

每个 Svelte 组件编译后都会有一个 create_fragment 方法,这个方法返回一些 DOM 节点的声明周期钩子方法。都是单个字母不好理解,从 源码 上可以看到每个缩写的含义。

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(create):在这个钩子里面创建 DOM 节点,创建完之后保存在每个 fragment 的闭包内。
m(mount):挂载 DOM 节点到 target 上,在这里进行事件的板顶。
p(update):组件数据发生变更时触发,在这个方法里面检查更新。
d(destroy):移除挂载,取消事件绑定。

编译结果会从 svelte/internal 中引入 text,element,append,detach,listen 等等的方法。源码中可以看到,都是一些非常纯粹的 DOM 操作。

export function element<K extends keyof HTMLElementTagNameMap>(name: K) {
  return document.createElement<K>(name);
}

export function text(data: string) {
  return document.createTextNode(data);
}

export function append(target: Node, node: Node) {
  if (node.parentNode !== target) {
    target.appendChild(node);
  }
}

export function detach(node: Node) {    
  node.parentNode.removeChild(node);
}

export function listen(node: EventTarget, event: string, handler: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions | EventListenerOptions) {
  node.addEventListener(event, handler, options);
  return () => node.removeEventListener(event, handler, options);
}

我们可以确信 Svelte 没有 Virtual DOM 了~

$$invalidate——Schedule Update 的开端

前面说了,Compiler 会把赋值的代码统统使用 $$invalidate 包裹起来。例如 count ++count += 1name = 'fesky' 等等。

这个方法干了什么?看看 源码,(删减了部分不重要的代码)

(i, ret, ...rest) => {
  const value = rest.length ? rest[0] : ret;
  if (not_equal($$.ctx[i], $$.ctx[i] = value)) {
    make_dirty(component, i);
  }
  return ret;
}

第一个参数 i 是什么?代码中运行起来赋值给 ctx 又是怎么回事 $$.ctx[i] = value?,编译结果传入了一个 0???

$$invalidate(0, name = "fesky");

实际上,instance 方法会返回一个数组,里面包括组件实例的一些属性和方法。Svelte 会把返回 instance 方法的返回值赋到 ctx 上保存。所以这里的 i 就是 instance 返回的数组下标。

$$.ctx = instance
    ? instance(component, options.props || {}, (i, ret, ...rest) => {
       //...
    })
    : [];

在编译阶段,Svelte 会按照属性在数组中的位置,生成对应的数字。例如现在有两个变量,

<script>
  let firsName = '';
  let lastName = '';

  function handleClick () {
    firsName = 'evan'
    lastName = 'zhou';
  }
</script>

<h1 on:click={handleClick}>Hello {firsName}{lastName}!</h1>

invalidate 部分代码编译结果就会变成:

function handleClick() {
  // 对应数组下标 0
  $$invalidate(0, firsName = "evan");
  // 对应数组下标 1
  $$invalidate(1, lastName = "zhou");
}

return [firsName, lastName, handleClick];

好了,接着往下,$$invalidate中判断赋值之后不相等时就会调用 make_dirty

Dirty Check

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 的组件添加到 dirty_components 中,然后调用了 schedule_update 方法。(dirty 字段的细节延后)

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

schedule_update 很简单,在 Promise.resolve(microTask) 中调用 flush 方法。

(看源码有点单调无聊,坚持住,马上结束了)

export function flush() {
  for (let i = 0; i < dirty_components.length; i += 1) {
    const component = dirty_components[i];
    set_current_component(component);
    update(component.$$);
  }
}

flush 方法其实就是消费前面的 dirty_components,调用每个需要更新组件的 update 方法。

function update($$) {
  if ($$.fragment !== null) {
    $$.update();
    const dirty = $$.dirty;
    $$.dirty = [-1];
    $$.fragment && $$.fragment.p($$.ctx, dirty);
  }
}

而 Update 方法呢,又回到了每个 fragment 的 p(update) 方法。这样整个链路就很清晰了。再整理以下思路:

  1. 修改数据,调用 $$invalidate 方法
  2. 判断是否相等,标记脏数据,make_dirty
  3. 在 microTask 中触发更新,遍历所有 dirty_component, 更新 DOM 节点
  4. 重置 Dirty

神奇的 Bitmask

上一小结中还有很重要的细节没有解释,就是 dirty 究竟是怎么标记的。

component.$$.dirty[(i / 31) | 0] |= (1 << (i % 31));

看到 31,看到 << 右移符号,那铁定是位运算没跑了。首先我们要知道,JS 中所有的数字都是符合 IEEE-754 标准的 64 位双精度浮点类型。而所有的位运算都只会保留 32 结果的整数。

将这个语句拆解一下:
(i / 31) | 0:这里是用数组下标 i 属于 31,然后向下取整(任何整数数字和 | 0 的结果都是其本身,位运算有向下取整的功效)。
(1 << (i % 31)):用 i31 取模,然后做左移操作。

这样我们就知道了,dirty 是个数组类型,存放了多个 32 位整数,整数中的每个 bit 表示换算成 instance 数组下标的变量是否发生变更。

为了方便理解,我们用四位整数。

[1000] => [8] 表示 instance 中的第一个变量是 dirty。
[1001] => [9] 表示 instance 中的第一个变量和第四个变量是 dirty。
[1000, 0100] => [9, 4] 表示 instance 中的第一个变量和第六个变量是 dirty。

对这些基础知识不太熟悉的朋友可以翻我以前写的另外两篇文章
硬核基础二进制篇(一)0.1 + 0.2 != 0.3 和 IEEE-754 标准
硬核基础二进制篇(二)位运算

再回头看 p 方法,每次调用时都会判断依赖的数据是否发生变更,只有发生变更了,才更新 DOM。

p(ctx, [dirty]) {
  if (dirty & /*firsName*/ 1) set_data(t1, /*firsName*/ ctx[0]);
  if (dirty & /*lastName*/ 2) set_data(t2, /*lastName*/ ctx[1]);
}

对了,还有个约定,如果 dirty 第一个数字存储的是 -1 表示当前组件是干净的。

$$.dirty = [-1];

可以在 Github Issue 中找到相关的讨论,这样实现的好处是,编译后代码体积更小,二进制运算更快一点点。

小结

最后写个 DEMO 同样使用 Performance 面板记录代码运行信息和 React 对比一下。

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

<button on:click={handleClick}>{count}</button>

image.png

(由于实在太高效了,以至于我不得不单独为它做张放大图)💰钱都花在刀刃上

希望看到这里你已经彻底掌握了 Svelte 响应式背后的所有逻辑。我把整个流程画了个草图,可以参考。整体看下来,Svelte 运行时的代码是非常精简,也很好理解的,有时间的话推荐看源码。

image.png

6.生态

决定是否使用某框架还有很打一个因数是框架生态怎么样,我在网上搜集了一部分,列出来供参考。

总体上看,整个生态还不太够强大,有很大空间。如果使用 Svelte 来开发管理后台,可能没有像使用 Antd 那样顺滑,而如果是开发 UI 高度自定义的 H5 活动页就完全不在话下。

7.结语

以前大家选 Vue 而不是 React 的理由,理由听到最多的是说 Vue 体积小,上手快。现在 Svelte 更小(针对小项目)更快更适合用来做活动页,你会上手吗?

Anyways,无论如何武器库又丰富了 💐💐💐,下次做技术选型的时候多了一种选择,了解了不用没听说过所以不用还是有很大区别的。

对于我而言,Svelte 实现 Reactivity 确实特立独行,了解完实现原理也从中学到了很多知识。这篇文章花了我三天时间(找资料、看源码、写 DEMO,做大纲,写文章),如果觉得对你有收获,欢迎点赞 ❤️ + 收藏 + 评论 + 关注,这样我会更有动力产出好文章。

WechatIMG4859.png

时间仓促,水平有限,难免会有纰漏,欢迎指正。

8. 相关链接


FESKY
32 声望4 粉丝