WHY#
在解释 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>
);
}
What#
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