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. 相關連接

相關文章
相關標籤/搜索