前言
第一次看到這個詞 Signals。今日前端早讀課文章由騰訊 @null 分享,公號:前端小館授權。
@null,騰訊文檔 Alloyteam 前端工程師,著有慕課網《web 前端開發修煉指南》,現在負責騰訊文檔渲染層相關的開發
正文從這開始~~
1. 什麼是 Signals?
Signals 是用來處理狀態的一種方式,它參考自 SolidJS,吸收了其大部分的優點。無論應用多麼複雜,它都能保證快速響應。
Signals 的獨特之處在於狀態更改會以最有效的方式來自動更新組件和 UI。
Signals 基於自動狀態綁定和依賴跟蹤提供了出色的工效,並具有針對虛擬 DOM 優化的獨特實現。
2. 為什麼是 Signals?2.1 狀態管理的困境
隨着應用越來越複雜,項目中的組件也會越來越多,需要管理的狀態也越來越多。
為了實現組件狀態共享,一般需要將狀態提升到組件的共同的祖先組件裡面,通過 props 往下傳遞,帶來的問題就是更新時會導致所有子組件跟着更新,需要配合 memo 和 useMemo 來優化性能。
雖然這聽起來還挺合理,但隨着項目代碼的增加,我們很難確定這些優化應該放到哪裡。
即使添加了 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} 這個文本節點。
提示: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/signals4. 用法
我們接下來將會寫一個 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)}>❌</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); // 14.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 最近新出的特性,目前還不穩定,不建議在生產環境使用,如果想嘗試,可以考慮在小型項目中使用。
關於本文作者:@null原文:https://zhuanlan.zhihu.com/p/565514865
關於【preact】相關推薦,歡迎讀者自薦投稿,前端早讀課等你來。+v:zhgb_v2er
【第1774期】詳細preact hook源碼逐行解析【第1105期】Preact:一個備胎的自我修養