介紹 Preact Signals

語言: CN / TW / HK

1. 什麼是 Signals?

Signals 是用來處理狀態的一種方式,它參考自 SolidJS,吸收了其大部分的優點。無論應用多麼複雜,它都能保證快速響應。

Signals 的獨特之處在於狀態更改會以最有效的方式來自動更新元件和 UI。

Signals 基於自動狀態繫結和依賴跟蹤提供了出色的工效,並具有針對虛擬 DOM 優化的獨特實現。

2. 為什麼是 Signals?

2.1 狀態管理的困境

隨著應用越來越複雜,專案中的元件也會越來越多,需要管理的狀態也越來越多。

為了實現元件狀態共享,一般需要將狀態提升到元件的共同的祖先元件裡面,通過 props 往下傳遞,帶來的問題就是更新時會導致所有子元件跟著更新,需要配合 memouseMemo 來優化效能。

雖然這聽起來還挺合理,但隨著專案程式碼的增加,我們很難確定這些優化應該放到哪裡。

即使添加了 memoization ,也常常因為依賴值不穩定變得無效,由於 Hooks 沒有可以用於分析的顯式依賴關係樹,所以也沒法使用工具來找到原因。

另一種解決方案就是放到 Context 上面,子元件作為消費者自行通過 useContext 來獲取需要的狀態。

但是有一個問題,只有傳給 Provider 的值才能被更新,而且只能作為一個整體來更新,無法做到細粒度的更新。

為了處理這個問題,只能將 Context 進行拆分,業務邏輯又不可避免地會依賴多個 Context ,這樣就會出現 Context 套娃現象。

2.2 通向未來的 Signals

看到這裡你一定感覺似曾相識,沒錯,通往未來的解決方案一定是我 —— Recoil,不對,這次的主角是 Signals。

signal 的核心是一個通過 value 屬性 來儲存值的物件。它有一個重要特徵,那就是 signal 物件的值可以改變,但 signal 本身始終保持不變。

import { signal } from "@preact/signals";

const count = signal(0);

// Read a signal’s value by accessing .value:
console.log(count.value);   // 0

// Update a signal’s value:
count.value += 1;

// The signal's value has changed:
console.log(count.value);  // 1

在 Preact 中,當 signal 作為 props 或 context 向下傳遞時,傳遞的是對 signal 的引用。這樣就可以在不重新渲染元件的情況下更新 signal,因為傳給元件的是 signal 物件而不是它的值。

這讓我們可以跳過所有昂貴的渲染工作,立即跳到任意訪問 signal .value 屬性的元件。

這裡有 VDOM 和 Signals 在 Chrome 裡面更新時的火焰圖對比,可以發現 Signals 非常快。相比元件樹更新,Signals 渲染會更快一些,這是因為更新狀態圖所需的工作要少得多。

Signals 具有第二個重要特徵,即它們會跟蹤其值何時被訪問以及何時被更新。在 Preact 中,當 signal 的值發生變化時,從元件內訪問 signal 的屬性會自動重新渲染元件。

2.3 栗子

我們可以用一個例子來理解 Signals 的獨特之處:

import { signal } from "@preact/signals";

const count = signal(0);

const App = () => {
  return (
    <Fragment>
      <h1 onClick={() => count.value++;}>
        +
        {console.log("++")}
      </h1>
      <span>{count}</span>
    </Fragment>
  );
};

當我們點選10次加號之後,count會從0變成10,那麼"++"是否會被列印10次呢?

從我們平時寫 React 元件的經驗來說,肯定會被列印10次,但在 Signals 裡面不是這樣。

從這個 Gif 可以看到,"++"一次都沒被打印出來,這就是 Signals 的獨特之處,整個元件沒有被重新渲染。

不僅 h1 沒有重新渲染,甚至連 span 節點都沒有重新渲染,唯一更新的地方就只有 {count} 這個文字節點。

:bulb: 提示:Signal 只有在設定新的值才會更新。如果設定的值沒有發生變化,就不會觸發更新。

除了文字節點,Signals 還能做到對 DOM 屬性的細粒度更新。當點選加號的時候,只有 data-id 被更新了,甚至連 span 裡面的 random 都沒有被執行。

const count = signal(0);

const App = () => {
  return (
    <Fragment>
      <h1 onClick={() => count.value++;}>
        +
        {console.log("++");}
      </h1>
      <span data-id={count}>{Math.random()}</span>
    </Fragment>
  );
};

3. 安裝

可以通過將 @preact/signals 包新增到專案中來安裝 Signals:

npm install @preact/signals

4. 用法

我們接下來將會寫一個 TodoList 的 Demo 來學習 Signals。

4.1 建立狀態

首先需要一個包含待辦事項列表的 signal,可以用陣列來表示:

import { signal } from "@preact/signals";

const todos = signal([
  { text: "Buy groceries" },
  { text: "Walk the dog" },
]);

接著,需要允許使用者編輯輸入框、建立新的 Todo 事項,所以還要建立輸入值的 signal,然後直接設定 .value 來實現修改。

// We'll use this for our input later
const text = signal("");

function addTodo() {
  todos.value = [...todos.value, { text: text.value }];
  text.value = ""; // Clear input value on add
}

我們要新增的最後一個功能是從列表中刪除待辦事項。為此,我們將新增一個從 todos 陣列中刪除給定 todo 項的函式:

function removeTodo(todo) {
  todos.value = todos.value.filter(t => t !== todo);
}

4.2 構建使用者介面

現在我們建立了所有的狀態,接下來需要編寫使用者介面,這裡使用了 Preact。

function TodoList() {
  const onInput = event => (text.value = event.target.value);

  return (
    <>
      <input value={text.value} onInput={onInput} />
      <button onClick={addTodo}>Add</button>
      <ul>
        {todos.value.map(todo => (
          <li>
            {todo.text}{' '}
            <button onClick={() => removeTodo(todo)}>:x:</button>
          </li>
        ))}
      </ul>
    </>
  );
}

到這裡,一個完整的 TodoList 就已經完成了,你可以在這裡體驗完整的功能。

4.3 衍生狀態

在 TodoList 裡面有一個常見的場景,那就是展示已完成事項數量,這個要怎麼去設計狀態呢?

相信你的第一反應肯定是 Mobx 或者 Vue 的衍生狀態,剛好在 Signals 裡面也有。

import { signal, computed } from "@preact/signals";

const todos = signal([
  { text: "Buy groceries", completed: true },
  { text: "Walk the dog", completed: false },
]);

// 基於其他 signals 建立衍生 signal
const completed = computed(() => {
  // 當 todos 變化,這裡會自動重新計算
  return todos.value.filter(todo => todo.completed).length;
});

console.log(completed.value); // 1

4.4 管理全域性狀態

到目前為止,我們都是在元件樹之外建立了 signal ,對於小型應用來說沒什麼問題,但對於大型複雜應用來說,測試會比較困難。

因此,我們可以將 signal 提升至最外層元件裡面,通過 Context 進行傳遞。

import { createContext } from "preact";
import { useContext } from "preact/hooks";

// 建立 App 狀態
function createAppState() {
  const todos = signal([]);

  const completed = computed(() => {
    return todos.value.filter(todo => todo.completed).length
  });

  return { todos, completed }
}

const AppState = createContext();

// 通過 Context 傳遞給子元件
render(
  <AppState.Provider value={createAppState()}>
    <App />
  </AppState.Provider>
);

// 子元件接收後使用
function App() {
  const state = useContext(AppState);
  return <p>{state.completed}</p>;
}

4.5 管理區域性狀態

除了直接通過 signals 來建立狀態,我們也可以使用提供的 hooks 來建立元件內部狀態。

import { useSignal, useComputed } from "@preact/signals";

function Counter() {
  const count = useSignal(0);
  const double = useComputed(() => count.value * 2);

  return (
    <div>
      <p>{count} x 2 = {double}</p>
      <button onClick={() => count.value++}>click me</button>
    </div>
  );
}

useSignal 的實現是基於 signal 的,原理比較簡單,利用了 useMemo 來對 signal 進行快取,避免更新時重新建立了新的 signal

function useSignal(value) {
    return useMemo(() => signal(value), []);
}

4.6 訂閱變化

從前面的例子裡面可以注意到,在元件外訪問 signal 的時候,都是直接讀取它的值,並不涉及到響應值的變化。

在 Mobx 裡面提供了 autoRun 來訂閱值的變化, signal 裡面提供了 effect 方法來訂閱。

effect 接收一個回撥函式作為引數,當回撥函式中依賴的 signal 值發生了變化,這個回撥函式也會被重新執行

import { signal, computed, effect } from "@preact/signals-core";

const name = signal("Jane");
const surname = signal("Doe");
const fullName = computed(() => `${name.value} ${surname.value}`);

// 每次名字變化的時候就打印出來
effect(() => console.log(fullName.value)); // 列印: "Jane Doe"

// 更新 name 的值
name.value = "John";
// 觸發自動列印: "John Doe"

effect 執行後會返回一個新的函式,用於取消訂閱。

const name = signal("Jane");
const surname = signal("Doe");
const fullName = computed(() => name.value + " " + surname.value);

const dispose = effect(() => console.log(fullName.value));

// 取消訂閱
dispose();

// 更新 name,會觸發 fullName 的更新,但不會觸發 effect 回撥執行了
name.value = "John";

在極少情況下,你可能需要在 effect(fn) 裡面更新 signal ,但又不希望在 signal 更新時重新執行,所以可以使用 .peek() 來獲取 signal 但不訂閱。

const delta = signal(0);
const count = signal(0);

effect(() => {
  // 更新 count 但不訂閱變化
  count.value = count.peek() + delta.value;
});

delta.value = 1;

// 不會觸發 effect 回撥函式重新執行
count.value = 10;

4.7 批量更新

有時候我們可能會同時有多個更新,但又不希望觸發多次更新,所以需要像 React 的 setState 一樣合併更新。

Signals 提供了 batch 方法允許我們對 signal 進行批量更新。

以我們建立待辦事項、清空輸入框為例:

effect(() => console.log(todos.length, text.value););

function addTodo() {
  batch(() => {
    // effect 裡面只會執行一次
    todos.value = [...todos.value, { text: text.value }];
    text.value = "";
  });
}

5. 總結

Signals 是 Preact 最近新出的特性,目前還不穩定,不建議在生產環境使用,如果想嘗試,可以考慮在小型專案中使用。

下一篇文章將會從介紹 Signals 的實現原理,也會帶領大家從零開始實現一個 Signals。