Svelte 响应式原理剖析 —— 从新思考 Reactivity

0.Intro

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

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

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

1.Svelte 是啥?

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

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

image.png

吐槽归吐槽,该学的仍是要学,否则就要被淘汰了👻 。Svelte 这个框架的主要特色是:git

image.png

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

2.框架对比

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

React Vue @angular/core Svelte
Star 数🌟 168,661 183,540 73,315 47,111
代码体积 🏋️‍♀️ 42k 22k 89.5k 1.6k

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

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

NPM 下载趋势

image.png

Npm trendings 连接直达npm

下载量差距很是明显,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. 相关连接

相关文章
相关标签/搜索