訊號
訊號是管理應用程式狀態的反應式基本元素。
訊號的獨特之處在於,狀態變更會自動以最有效率的方式更新元件和 UI。自動狀態繫結和依賴追蹤讓訊號能提供極佳的人體工學和生產力,同時消除最常見的狀態管理陷阱。
信號在任何規模的應用程式中都非常有效,其人體工學設計可加速小型應用程式的開發,而效能特性可確保任何規模的應用程式在預設情況下都能快速執行。
簡介
JavaScript 中狀態管理的許多痛苦在於對特定值的變更做出反應,因為值無法直接觀察。解決方案通常透過將值儲存在變數中並持續檢查它們是否已變更來解決此問題,這既繁瑣又不利於效能。理想情況下,我們希望有一種方法來表達一個值,告訴我們它何時變更。這就是信號的作用。
信號的核心是一個物件,具有包含值的 .value
屬性。這有一個重要的特性:信號的值可以變更,但信號本身始終保持不變
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
在 REPL 中執行在 Preact 中,當信號透過樹狀結構作為道具或內容傳遞時,我們只會傳遞信號的參考。信號可以在不重新呈現任何元件的情況下更新,因為元件看到的是信號,而不是它的值。這讓我們可以跳過所有昂貴的呈現工作,並立即跳到實際存取信號的 .value
屬性的樹狀結構中的任何元件。
信號有第二個重要的特性,那就是它們會追蹤何時存取其值以及何時更新其值。在 Preact 中,從元件內部存取信號的 .value
屬性會在該信號的值變更時自動重新呈現元件。
import { signal } from "@preact/signals";
// Create a signal that can be subscribed to:
const count = signal(0);
function Counter() {
// Accessing .value in a component automatically re-renders when it changes:
const value = count.value;
const increment = () => {
// A signal is updated by assigning to the `.value` property:
count.value++;
}
return (
<div>
<p>Count: {value}</p>
<button onClick={increment}>click me</button>
</div>
);
}
在 REPL 中執行最後,Signals 深度整合到 Preact 中,以提供最佳效能和人體工學。在上述範例中,我們存取 count.value
以擷取 count
訊號的目前值,不過這是沒有必要的。相反地,我們可以使用 count
訊號直接在 JSX 中,讓 Preact 為我們完成所有工作
import { signal } from "@preact/signals";
const count = signal(0);
function Counter() {
return (
<div>
<p>Count: {count}</p>
<button onClick={() => count.value++}>click me</button>
</div>
);
}
在 REPL 中執行安裝
可以透過將 @preact/signals
套件新增到專案中來安裝 Signals
npm install @preact/signals
透過您選擇的套件管理員安裝後,您就可以在應用程式中匯入它。
使用範例
讓我們在實際情況中使用 Signals。我們將建立一個待辦事項清單應用程式,您可以在其中新增和移除待辦事項清單中的項目。我們將從建模狀態開始。我們首先需要一個訊號來儲存待辦事項清單,我們可以使用 Array
來表示
import { signal } from "@preact/signals";
const todos = signal([
{ text: "Buy groceries" },
{ text: "Walk the dog" },
]);
為了讓使用者輸入新待辦事項的文字,我們需要另一個訊號,我們將很快將它連接到 <input>
元素。目前,我們可以使用這個訊號來建立一個函式,將待辦事項新增到我們的清單中。請記住,我們可以透過指定 .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
}
:bulb: 提示:只有當您指定新值時,訊號才會更新。如果您指定給訊號的值等於其目前值,則它不會更新。
const count = signal(0); count.value = 0; // does nothing - value is already 0 count.value = 1; // updates - value is different
讓我們檢查到目前為止我們的邏輯是否正確。當我們更新 text
訊號並呼叫 addTodo()
時,我們應該會看到一個新項目新增到 todos
訊號中。我們可以透過直接呼叫這些函式來模擬這個情況 - 目前還不需要使用者介面!
import { signal } from "@preact/signals";
const todos = signal([
{ text: "Buy groceries" },
{ text: "Walk the dog" },
]);
const text = signal("");
function addTodo() {
todos.value = [...todos.value, { text: text.value }];
text.value = ""; // Reset input value on add
}
// Check if our logic works
console.log(todos.value);
// Logs: [{text: "Buy groceries"}, {text: "Walk the dog"}]
// Simulate adding a new todo
text.value = "Tidy up";
addTodo();
// Check that it added the new item and cleared the `text` signal:
console.log(todos.value);
// Logs: [{text: "Buy groceries"}, {text: "Walk the dog"}, {text: "Tidy up"}]
console.log(text.value); // Logs: ""
在 REPL 中執行我們想要新增的最後一個功能是可以從清單中移除待辦事項。為此,我們將新增一個函式,從 todos 陣列中刪除指定的待辦事項
function removeTodo(todo) {
todos.value = todos.value.filter(t => t !== todo);
}
建立 UI
現在我們已經對應用程式的狀態進行建模,是時候建立使用者可以互動的漂亮 UI 了。
function TodoList() {
const onInput = event => (text.value = event.currentTarget.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)}>❌</button>
</li>
))}
</ul>
</>
);
}
這樣我們就有一個功能完備的待辦事項應用程式了!你可以在此試用完整的應用程式 :tada
透過計算訊號衍生狀態
讓我們為待辦事項應用程式新增一個功能:每個待辦事項可以標示為已完成,我們將向使用者顯示他們已完成的項目數量。為此,我們將匯入computed(fn)
函式,它讓我們建立一個新的訊號,該訊號是根據其他訊號的值計算而得。回傳的計算訊號為唯讀,且當從回呼函式內存取的任何訊號變更時,其值會自動更新。
import { signal, computed } from "@preact/signals";
const todos = signal([
{ text: "Buy groceries", completed: true },
{ text: "Walk the dog", completed: false },
]);
// create a signal computed from other signals
const completed = computed(() => {
// When `todos` changes, this re-runs automatically:
return todos.value.filter(todo => todo.completed).length;
});
// Logs: 1, because one todo is marked as being completed
console.log(completed.value);
在 REPL 中執行我們簡單的待辦事項清單應用程式不需要許多計算訊號,但較複雜的應用程式傾向於依賴 computed() 來避免在多個地方重複狀態。
:bulb: 提示:衍生盡可能多的狀態可確保你的狀態始終有一個真實來源。這是訊號的一個關鍵原則。如果應用程式邏輯稍後出現瑕疵,這會讓除錯變得容易許多,因為需要擔心的位置較少。
管理全域應用程式狀態
到目前為止,我們只在元件樹外部建立訊號。這對於像待辦事項清單這樣的小型應用程式來說很好,但對於較大且較複雜的應用程式,這可能會讓測試變得困難。測試通常涉及變更應用程式狀態中的值以重現特定場景,然後將該狀態傳遞給元件並聲明已呈現的 HTML。為此,我們可以將待辦事項清單狀態萃取到一個函式中
function createAppState() {
const todos = signal([]);
const completed = computed(() => {
return todos.value.filter(todo => todo.completed).length
});
return { todos, completed }
}
:bulb: 提示:請注意,我們刻意不將
addTodo()
和removeTodo(todo)
函式包含在這裡。將資料與修改資料的函式分開,通常有助於簡化應用程式架構。如需更多詳細資訊,請查看 資料導向設計。
現在我們可以在執行時傳遞我們的待辦事項應用程式狀態作為道具
const state = createAppState();
// ...later:
<TodoList state={state} />
這在我們的待辦事項清單應用程式中可行,因為狀態是全域性的,但較大的應用程式通常會產生多個元件,這些元件需要存取相同的狀態部分。這通常涉及將狀態「提升」到共用的共用祖先元件。為避免透過道具手動傳遞狀態到每個元件,可以將狀態置入 Context,如此一來,樹狀結構中的任何元件都可以存取該狀態。以下是通常外觀的快速範例
import { createContext } from "preact";
import { useContext } from "preact/hooks";
import { createAppState } from "./my-app-state";
const AppState = createContext();
render(
<AppState.Provider value={createAppState()}>
<App />
</AppState.Provider>
);
// ...later when you need access to your app state
function App() {
const state = useContext(AppState);
return <p>{state.completed}</p>;
}
如果您想進一步了解 Context 的運作方式,請前往 Context 文件。
使用訊號的區域狀態
大多數的應用程式狀態最終會使用道具和 Context 傳遞。但是,有許多情況下,元件有自己的內部狀態,這是特定於該元件的。由於沒有理由讓此狀態作為應用程式全域商業邏輯的一部分,因此應該限制在需要它的元件中。在這些情況下,我們可以使用 useSignal()
和 useComputed()
掛勾,直接在元件中建立訊號以及計算訊號
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>
);
}
這兩個掛勾是 signal()
和 computed()
周圍的薄封裝,它們在元件第一次執行時建構訊號,並在後續的渲染中僅使用相同的訊號。
:bulb: 在幕後,這是實作
function useSignal(value) { return useMemo(() => signal(value), []); }
進階訊號用法
到目前為止,我們已經涵蓋的主題是您開始所需要的一切。以下部分是針對想要透過完全使用訊號來建模其應用程式狀態的讀者。
在元件外部對訊號做出反應
在元件樹外部使用訊號時,您可能已經注意到,除非您主動讀取計算訊號的值,否則它們不會重新計算。這是因為訊號預設為延遲的:它們僅在存取其值時才計算新值。
const count = signal(0);
const double = computed(() => count.value * 2);
// Despite updating the `count` signal on which the `double` signal depends,
// `double` does not yet update because nothing has used its value.
count.value = 1;
// Reading the value of `double` triggers it to be re-computed:
console.log(double.value); // Logs: 2
這提出了問題:我們如何訂閱元件樹外部的訊號?也許我們希望在訊號值變更時將某些內容記錄到主控台,或將狀態保留到 LocalStorage。
若要針對訊號變更執行任意程式碼,我們可以使用 effect(fn)
。與計算訊號類似,效果會追蹤存取哪些訊號,並在這些訊號變更時重新執行其回呼。與計算訊號不同,effect()
不會傳回訊號 - 它是變更序列的結尾。
import { signal, computed, effect } from "@preact/signals-core";
const name = signal("Jane");
const surname = signal("Doe");
const fullName = computed(() => `${name.value} ${surname.value}`);
// Logs name every time it changes:
effect(() => console.log(fullName.value));
// Logs: "Jane Doe"
// Updating `name` updates `fullName`, which triggers the effect again:
name.value = "John";
// Logs: "John Doe"
選擇性地,你可以從提供給 effect()
的回呼函式傳回一個清理函式,它會在執行下一次更新之前執行。這允許你「清理」副作用,並有可能重設任何狀態以供後續觸發回呼函式。
effect(() => {
Chat.connect(username.value)
return () => Chat.disconnect(username.value)
})
你可以透過呼叫傳回的函式來銷毀一個效果,並取消訂閱它所存取的所有訊號。
import { signal, effect } from "@preact/signals-core";
const name = signal("Jane");
const surname = signal("Doe");
const fullName = computed(() => name.value + " " + surname.value);
const dispose = effect(() => console.log(fullName.value));
// Logs: "Jane Doe"
// Destroy effect and subscriptions:
dispose();
// Updating `name` does not run the effect because it has been disposed.
// It also doesn't re-compute `fullName` now that nothing is observing it.
name.value = "John";
:bulb: 提示:如果你廣泛使用效果,請不要忘記清理它們。否則,你的應用程式將會消耗比需要更多的記憶體。
在未訂閱訊號的情況下讀取訊號
在罕見的情況下,如果你需要在 effect(fn)
內寫入一個訊號,但又不想在該訊號變更時重新執行效果,你可以使用 .peek()
來取得訊號的目前值,而不用訂閱它。
const delta = signal(0);
const count = signal(0);
effect(() => {
// Update `count` without subscribing to `count`:
count.value = count.peek() + delta.value;
});
// Setting `delta` reruns the effect:
delta.value = 1;
// This won't rerun the effect because it didn't access `.value`:
count.value = 10;
:bulb: 提示:你不想訂閱訊號的情況很罕見。在多數情況下,你會希望你的效果訂閱所有訊號。只有在真正需要時才使用
.peek()
。
將多個更新合併為一個
還記得我們先前在待辦事項應用程式中使用的 addTodo()
函式嗎?以下是它的簡要說明
const todos = signal([]);
const text = signal("");
function addTodo() {
todos.value = [...todos.value, { text: text.value }];
text.value = "";
}
請注意,該函式觸發了兩個獨立的更新:一個是在設定 todos.value
時,另一個是在設定 text
的值時。這有時可能是不可取的,並保證將兩個更新合併為一個,以提升效能或其他原因。 batch(fn)
函式可用於將多個值更新合併為一個「提交」,在回呼函式的結尾處
function addTodo() {
batch(() => {
todos.value = [...todos.value, { text: text.value }];
text.value = "";
});
}
存取批次中已修改的訊號會反映其更新的值。存取批次中已由其他訊號失效的計算訊號,將僅重新計算必要的相依關係,以回傳該計算訊號的最新值。任何其他失效的訊號不受影響,且僅在批次回呼結束時更新。
import { signal, computed, effect, batch } from "@preact/signals-core";
const count = signal(0);
const double = computed(() => count.value * 2);
const triple = computed(() => count.value * 3);
effect(() => console.log(double.value, triple.value));
batch(() => {
// set `count`, invalidating `double` and `triple`:
count.value = 1;
// Despite being batched, `double` reflects the new computed value.
// However, `triple` will only update once the callback completes.
console.log(double.value); // Logs: 2
});
在 REPL 中執行:bulb: 提示:批次也可以巢狀,在這種情況下,批次更新僅在外層批次回呼完成後才會清除。
渲染最佳化
使用訊號,我們可以繞過虛擬 DOM 渲染,並將訊號變更直接繫結到 DOM 變異。如果您在文字位置將訊號傳遞到 JSX,它將會渲染為文字,並在沒有虛擬 DOM 差異的情況下自動更新。
const count = signal(0);
function Unoptimized() {
// Re-renders the component when `count` changes:
return <p>{count.value}</p>;
}
function Optimized() {
// Text automatically updates without re-rendering the component:
return <p>{count}</p>;
}
若要啟用此最佳化,請將訊號傳遞到 JSX,而不是存取其 .value
屬性。
在將訊號作為 DOM 元素的屬性傳遞時,也支援類似的渲染最佳化。
API
此部分是訊號 API 的概觀。其目的是為已知道如何使用訊號並需要提醒可用功能的人提供快速參考。
signal(initialValue)
建立一個新的訊號,其初始值為給定的引數
const count = signal(0);
在元件中建立訊號時,請使用掛勾變異:useSignal(initialValue)
。
回傳的訊號有一個 .value
屬性,可取得或設定以讀取和寫入其值。若要從訊號讀取而不訂閱它,請使用 signal.peek()
。
computed(fn)
建立一個新的訊號,其計算依據其他訊號的值。回傳的計算訊號為唯讀,且當從回呼函式存取的任何訊號變更時,其值會自動更新。
const name = signal("Jane");
const surname = signal("Doe");
const fullName = computed(() => `${name.value} ${surname.value}`);
在元件中建立計算訊號時,請使用掛勾變體:useComputed(fn)
。
effect(fn)
若要針對訊號變更執行任意程式碼,我們可以使用 effect(fn)
。與計算訊號類似,effect 會追蹤存取哪些訊號,並在這些訊號變更時重新執行其回呼。如果回呼傳回一個函式,此函式將在下次值更新前執行。與計算訊號不同,effect()
不会傳回訊號 - 它是變更順序的結尾。
const name = signal("Jane");
// Log to console when `name` changes:
effect(() => console.log('Hello', name.value));
// Logs: "Hello Jane"
name.value = "John";
// Logs: "Hello John"
在元件中回應訊號變更時,請使用掛勾變體:useSignalEffect(fn)
。
batch(fn)
batch(fn)
函式可用於將多個值更新合併為在提供的回呼結束時執行一次的「提交」。批次可以巢狀,而且僅在外層批次回呼完成後才會清除變更。存取在批次中已修改的訊號將反映其更新的值。
const name = signal("Jane");
const surname = signal("Doe");
// Combine both writes into one update
batch(() => {
name.value = "John";
surname.value = "Smith";
});