Signals 在目前前端框架的選型中遙遙領先!
國慶節前最後一週在 Code Review 新同學的 React 代碼,發現他想通過 memo 和 useCallback 只渲染被修改的子組件部分。事實上該功能在 React 中是難以做到的。因為 React 狀態變化後,會重新執行 render 函數。也就是在組件中調用 setState 之後,整個函數將會重新執行一次。
React 本身做不到。但是基於 Signals 的框架卻不會這樣,它通過自動狀態綁定和依賴跟蹤使得當前狀態變化後僅僅只會重新執行用到該狀態代碼塊。
個人當時沒有過多的解釋這個問題,只是匆匆解釋了一下 React 的渲染機制。在這裏做一個 Signals 的梳理。
優勢
對比 React,基於 Signals 的框架狀態響應粒度非常細。這裏以 Solid 為例:
import { createSignal, onCleanup } from "solid-js";
const CountingComponent = () => {
// 創建一個 signal
const [count, setCount] = createSignal(0);
// 創建一個 signal
const [count2] = createSignal(666);
// 每一秒遞增 1
const interval = setInterval(() => {
setCount((c) => c + 1);
}, 1000);
// 組件銷燬時清除定時器
onCleanup(() => clearInterval(interval));
return (
<div>
<div>
count: {count()}
{console.log("count is", count())}
</div>
<div>
count2: {count2()}
{console.log("count2 is", count2())}
</div>
</div>
);
};
上面這段代碼在 count 單獨變化時,只會打印 count,壓根不會打印 count2 數據。
控制枱打印如下所示:
- count is 0
- count2 is 666
- count is 1
- count is 2
- ...
從打印結果來看,Solid 只會在最開始執行一次渲染函數,後續僅僅只會渲染更改過的 DOM 節點。這在 React 中是不可能做到的,React 是基於視圖驅動的,狀態改變會重新執行整個渲染函數,並且 React 完全無法識別狀態是如何被使用的,開發者甚至可以通過下面的代碼來實現 React 的重新渲染。
const [, forceRender] = useReducer((s) => s + 1, 0);
除了更新粒度細之外,使用 Signals 的框架心智模型也更加簡單。其中最大的特點是:開發者完全不必在意狀態在哪定義,也不在意對應狀態在哪渲染。如下所示:
import { createSignal } from "solid-js";
// 把狀態從過組件中提取出來
const [count, setCount] = createSignal(0);
const [count2] = createSignal(666);
setInterval(() => {
setCount((c) => c + 1);
}, 1000);
// 子組件依然可以使用 count 函數
const SubCountingComponent = () => {
return <div>{count()}</div>;
};
const CountingComponent = () => {
return (
<div>
<div>
count: {count()}
{console.log("count is", count())}
</div>
<div>
count2: {count2()}
{console.log("count2 is", count2())}
</div>
<SubCountingComponent />
</div>
);
};
上述代碼依然可以正常運行。因為它是基於狀態驅動的。開發者在組件內使用 Signal 是本地狀態,在組件外定義 Signal 就是全局狀態。
Signals 本身不是那麼有價值,但結合派生狀態以及副作用就不一樣了。代碼如下所示:
import {
createSignal,
onCleanup,
createMemo,
createEffect,
onMount,
} from "solid-js";
const [count, setCount] = createSignal(0);
setInterval(() => {
setCount((c) => c + 1);
}, 1000);
// 計算緩存
const doubleCount = createMemo(() => count() * 2);
// 基於當前緩存
const quadrupleCount = createMemo(() => doubleCount() * 2);
// 副作用
createEffect(() => {
// 在 count 變化時重新執行 fetch
fetch(`/api/${count()}`);
});
const CountingComponent = () => {
// 掛載組件時執行
onMount(() => {
console.log("start");
});
// 銷燬組件時執行
onCleanup(() => {
console.log("end");
});
return (
<div>
<div>Count value is {count()}</div>
<div>doubleCount value is {doubleCount()}</div>
<div>quadrupleCount value is {quadrupleCount()}</div>
</div>
);
};
從上述代碼可以看到,派生狀態和副作用都不需要像 React 一樣填寫依賴項,同時也將副作用與生命週期分開(代碼更好閲讀)。
實現機制
細粒度,高性能,同時還沒有什麼限制。不愧被譽為前端框架的未來。那麼它究竟是如何實現的呢?
本質上,Signals 是一個在訪問時跟蹤依賴、在變更時觸發副作用的值容器。
這種基於響應性基礎類型的範式在前端領域並不是一個特別新的概念:它可以追溯到十多年前的 Knockout observables 和 Meteor Tracker 等實現。Vue 的選項式 API 也是同樣的原則,只不過將基礎類型這部分隱藏在了對象屬性背後。依靠這種範式,Vue2 基本不需要優化就有非常不錯的性能。
依賴收集
React useState 返回當前狀態和設置值函數,而 Solid 的 createSignal 返回兩個函數。即:
type useState = (initial: any) => [state, setter];
type createSignal = (initial: any) => [getter, setter];
為什麼 createSignal 要傳遞 getter 方法而不是直接傳遞對應的 state 值呢?這是因為框架為了具備響應能力,Signal 必須要收集誰對它的值感興趣。僅僅傳遞狀態是無法提供 Signal 任何信息的。而 getter 方法不但返回對應的數值,同時執行時創建一個訂閲,以便收集所有依賴信息。
模版編譯
要保證 Signals 框架的高性能,就不得不結合模版編譯實現該功能,框架開發者通過模版編譯實現動靜分離,配合依賴收集,就可以做到狀態變量變化時點對點的 DOM 更新。所以目前主流的 Signals 框架沒有使用虛擬 DOM。而基於虛擬 DOM 的 Vue 目前依靠編譯器來實現類似的優化。
下面我們先看看 Solid 的模版編譯:
const CountingComponent = () => {
const [count, setCount] = createSignal(0);
const interval = setInterval(() => {
setCount((c) => c + 1);
}, 1000);
onCleanup(() => clearInterval(interval));
return <div>Count value is {count()}</div>;
};
對應編譯後的的組件代碼。
const _tmpl$ = /*#__PURE__*/ _$template(`<div>Count value is `);
const CountingComponent = () => {
const [count, setCount] = createSignal(0);
const interval = setInterval(() => {
setCount((c) => c + 1);
}, 1000);
onCleanup(() => clearInterval(interval));
return (() => {
const _el$ = _tmpl$(),
_el$2 = _el$.firstChild;
_$insert(_el$, count, null);
return _el$;
})();
};
- 執行 \_tmpl$ 函數,獲取對應組件的靜態模版
- 提取組件中的 count 函數,通過 \_$insert 將狀態函數和對應模版位置進行綁定
- 調用 setCount 函數更新時,比對一下對應的 count,然後修改對應的 \_el$ 對應數據
其他
大家可以看一看使用 Signals 的主流框架:
- Vue Ref
- Angular Signals
- Preact Signals
- Solid Signals
- Qwik Signals
- Svelte 5(即將推出)
不過目前來看 React 團隊可能不會使用 Signals。
- Signals 性能很好,但不是編寫 UI 代碼的好方式
- 計劃通過編譯器來提升性能
- 可能會添加類似 Signals 的原語
PREACT 作者編寫了 @preact/signals-react 為 React 提供了 Signals。不過個人不建議在生產環境使用。
篇幅有限,後續個人會解讀 @preact/signals-core 的源碼。
參考資料
- 精讀《SolidJS》
- Solid.js
- Introducing runes
鼓勵一下
如果你覺得這篇文章不錯,希望可以給與我一些鼓勵,在我的 github 博客下幫忙 star 一下。
博客地址