為什麼#
在解釋 signal 是什麼之前,我們看一個案例
// 使用useEffect加定時器實現遞增,下面的 Demo 有什麼問題?為什麼會這樣,該怎麼改?
function Demo() {
const [count, setCount] = useState(0);
useEffect(() => {
setInterval(() => {
setCount(count + 1);
}, 500);
}, []);
useEffect(() => {
setInterval(() => {
console.log(count);
}, 500);
}, []);
return <div>{count}</div>;
}
export default Demo;
這段代碼預期是希望能每間隔 500ms 時,打印出一個遞增的 count。
拋開沒有清除定時器這個問題,實際上這段代碼只會一直打印 0。這是因為 useEffect 的依賴項裡沒有 count,因此 count 始終是 0。
如果有安裝 eslint-plugin-react-hooks 並 react-hooks/exhaustive-deps 的話可以更清楚地看到提示。
常見的解法有以下三種:
- 透過 ref 保存 state,也是大家使用的比較多的方案
import * as React from 'react';
export default function App() {
const countRef = React.useRef(0);
const [count, setCount] = React.useState(0);
React.useEffect(() => {
setInterval(() => {
countRef.current = countRef.current + 1;
// 為了頁面能響應變化,需要 setCount
setCount(countRef.current);
}, 500);
}, []);
return (
<div>
<h1>{count}</h1>
</div>
);
}
這是利用 ref 不變的原理。
- 透過 useReducer
import * as React from 'react';
const initialCount = 0;
const reducer = (count, action) => {
switch (action.type) {
case 'increment':
return count + 1;
default:
throw new Error();
}
};
export default function App() {
const [count, dispatch] = React.useReducer(reducer, initialCount);
React.useEffect(() => {
setInterval(() => {
dispatch({
type: 'increment',
});
}, 500);
}, [dispatch]);
return (
<div>
<h1>{count}</h1>
</div>
);
}
這是利用了 dispatch 不變的原理。
3. 將定時器的回調保存到 ref
import * as React from 'react';
export default function App() {
const savedCallback = React.useRef<() => void>();
const [count, setCount] = React.useState(0);
const callback = () => {
setCount(count + 1);
};
React.useEffect(() => {
savedCallback.current = callback;
});
React.useEffect(() => {
setInterval(() => {
savedCallback.current();
}, 500);
}, []);
return (
<div>
<h1>{count}</h1>
</div>
);
}
稍微簡潔了點,但依然引入了額外的 ref
可以發現,上述的方案都保證了符合 react-hooks/exhaustive-deps,即 useEffect 中不能使用未存在於 deps 的變量。(題外話:是否應該嚴格遵循這條 rule 曾經也引起過爭議)
這是在 react 中常見的做法。而本次想介紹的是另一種可能性 signal
import * as React from 'react';
import { useSignal, useSignalEffect } from '@preact/signals-react';
export default function App() {
const count = useSignal(0);
useSignalEffect(() => {
setInterval(() => {
count.value++;
}, 1000);
});
return (
<div>
<h1>{count}</h1>
</div>
);
}
什麼#
Qwik 裡用到的就是 signal 管理狀態的,其作者(同時也是 Angular 的作者)表示 Signal 是前端框架的未來。Vue、Solid.js、Preact、Svelte 都已實現 Signal。
signal 的流行主要是為了解決兩個問題:
- 更好的 DX(開發者體驗)即無需手動維護 effect 的依賴,無需擔心閉包陷阱
- 更好的細粒度更新性能
更好的 DX 在上述的例子中可以感受到,那麼更好的細粒度更新性能又是從何說起?
細粒度更新#
還是上述的例子,如果我們在非 signal 實現的版本添加一些代碼,如下:
// RefRenderDemo.tsx
import * as React from 'react';
function Child() {
console.log('render child');
return <div>child</div>;
}
export default function App() {
const countRef = React.useRef(0);
const [count, setCount] = React.useState(0);
React.useEffect(() => {
setInterval(() => {
countRef.current = countRef.current + 1;
setCount(countRef.current);
}, 500);
}, []);
console.log('rendering');
return (
<div>
<h1>{count}</h1>
<Child />
</div>
);
}
在控制台上可以看到每次 count 更新時,都將打印一行 rendering,甚至 Child 組件也將打印 render child。
這是因為
React 中的 useState () 只返回狀態值,這意味著它不知道這個狀態值是如何使用的,必須重新呈現整個組件樹以響應狀態更改。
如果換成 signal,rendeing 和 render child 只會被打印一次
// SignalRenderDemo.tsx
import * as React from 'react';
import { useSignal, useSignalEffect } from '@preact/signals-react';
function Child() {
console.log('render child');
return <div>child</div>;
}
export default function App() {
const count = useSignal(0);
useSignalEffect(() => {
setInterval(() => {
count.value++;
}, 1000);
});
console.log('rendering');
return (
<div>
<h1>{count}</h1>
<Child />
</div>
);
}
關鍵的區別在於信號返回 getter 和 setter,而 useState 返回的是值和 setter
上述的 demo 由於語法糖可能不太明顯,我們看看 solidjs 中的 signal 的寫法
import { render } from "solid-js/web";
import { createSignal } from "solid-js";
function Counter() {
const [count, setCount] = createSignal(0);
setInterval(() => setCount(count() + 1), 1000);
return <div>Count: {count()}</div>;
}
count 實際上是個 getter 方法,調用後才能獲取到狀態值。
原理#
使用 signal 時,當 signal 的狀態值改變時,信號本身保持不變,這就避免了組件的 rerendering。那麼如何通知組件更新呢?實際上是透過發布訂閱模式實現
Signals are event emitters that hold a list of subscriptions. They notify their subscribers whenever their value changes
如果了解過 vue 的響應式原理,應該比較容易理解 signal。下面是 createSignal 以及 useSignal 的一個簡易實現
// mySignal.ts
import React from 'react';
let currentListener = undefined;
export const createSignal = (initialValue) => {
// 訂閱列表
const subscribers = new Set();
// 狀態值
let value = initialValue;
const getter = () => {
if (currentListener !== undefined) {
// 當讀取值的時候,收集訂閱
subscribers.add(currentListener);
}
return value;
};
const setter = (newValue) => {
value = newValue;
subscribers.forEach((subscriber) => {
// 當變更值的時候,執行訂閱
subscriber();
});
};
return [getter, setter];
};
export const createSignalEffect = (callback) => {
// 將回調保存到 currentListener
currentListener = callback;
// 這裡的 callback 如果調用到了 signal 的 getter,就會將 currentListener 存儲到 subscribers 中
callback();
// 還原 currentListener
currentListener = undefined;
};
// 封裝成 hook
export const useSignal = (value) =>
React.useMemo(() => createSignal(value), []);
使用時
// MySignalDemo.tsx
import * as React from 'react';
import { useSignal, createSignalEffect } from './mySignal';
export default function App() {
const [count, setCount] = useSignal(0);
const nodeRef = React.useRef();
// 模擬 preact/signals 在組件渲染時訂閱的行為
React.useEffect(() => {
createSignalEffect(() => {
nodeRef.current.innerText = count();
});
}, []);
return <div ref={nodeRef} onClick={() => setCount(count() + 1)} />;
}
【模擬 preact/signals 在組件渲染時訂閱的行為】指的是在源碼的註釋中提到的這部分邏輯
現狀#
我個人的看法是 signal 有很多優點,但目前最好不要在 react 項目中去使用。
首先 react 官方覺得 signal 並非一個好的模式,可以參考 dan 對 signal 的一些看法
https://dev.to/this-is-learning/react-vs-signals-10-years-later-3k71 以及
其次,在實現上,@preact/signals-react 使用了一個屬性__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED
https://github.com/preactjs/signals/blob/main/packages/react/src/index.ts#L22
這個屬性中文翻譯是:內部神秘屬性,不要亂用!否則你會被炒魷魚!關於這個屬性有興趣的可以看看這篇文章
https://zhuanlan.zhihu.com/p/540482955
總結#
signal 提供了更有好的 DX 以及性能,原理是發布訂閱,但目前來說個人覺得並不太適合應用於 react。
Demo 地址#
https://stackblitz.com/edit/react-ts-k36uan?file=App.tsx
參考#
https://overreacted.io/zh-hans/making-setinterval-declarative-with-react-hooks/
https://dev.to/this-is-learning/react-vs-signals-10-years-later-3k71
https://preactjs.com/blog/introducing-signals/
https://www.builder.io/blog/usesignal-is-the-future-of-web-frameworks#code-use-ref-code-does-not-render