Signal 的概念在社区已经讨论了一段时间了,作为细粒度的响应式概念,Signal 给前端领域带来了不少新内容,如 SolidJS、Preact Signals 等等。而翻看 SolidJS 和 Preact Signals 的源码会发现,在 Signal 的实现上两者并没有太大差异,甚至于和早期用 JavaScript 实现 Signal 的 S.js 也没有太大差异。Signal 的实现如此稳定的原因在于,Signal 本身的概念非常成熟且稳定。
对 Signal 本身和 SolidJS 感兴趣的同学,可以移步SolidJS 是如何实现响应式的? ,对 Signal 和 SolidJS 的响应式实现做了简单的介绍。
思路
动手之前建议先了解 SolidJS 的 createSignal
,对 SolidJS 不是特别了解的同学建议先移步 SolidJS 官方文档:
对 SolidJS 的 createSignal
的使用方式足够了解了之后,就可以开始动手了。
首先明确在 React 中使用 SolidJS 的 Signal 应该是一个什么样的形式:
tsx
import useSignal from './useSignal';
function App() {
const [count, setCount] = useSignal();
return <button onClick={() => setCount(count() + 1)}>
count is {count()}
</button>
}
最终的使用方式应该与 SolidJS 中 createSignal
的使用方式相似,返回 getter
和 setter
,调用 setter
修改 Signal 的值时触发视图更新。
有了这个思路就可以开始动手了。
创建一个名称为 useSignal
的 Hook,直接将 getter
和 setter
返回:
tsx
import { createSignal } from 'solid-js';
export default function useSignal<T>(value: T) {
const [getter, setter] = createSignal(value);
return [getter, setter];
}
回到 App 组件中,点击 button
会发现页面上的 count
并没有更新,是因为 SolidJS 的 Signal 并不能直接触发 React 组件重新渲染。
这个时候就需要想一个办法让组件重新渲染,最常用的办法就是使用 useState 创建一个 State,通过更新 State 触发组件重新渲染。
所以需要将 setter
包一层,在调用 setter
更新 Signal 之后更新 State 触发组件重新渲染:
tsx
import { useState } from 'react';
import { createSignal } from 'solid-js';
export default function useSignal<T>(value: T) {
const [, update] = useState(0);
const [getter, setter] = createSignal(value);
return [
getter,
(value: T) => {
setter(value);
update((ticket) => ticket + 1);
},
];
}
再次回到 App 组件中,点击 button
会发现页面上的 count
依旧没有更新。
为什么呢?很奇怪对不对?是 State 更新了之后没有触发组件重新渲染吗?还是调用 setter
之后根本没有更新 getter
的返回值?可能的原因无非就这两个,接下来一步一步排查。
使用 SolidJS 的 createEffect
在控制台输出一下 getter
的返回值看看,调用 setter
之后是否更新了 getter
的返回值:
tsx
import { useState } from 'react';
import { createSignal, createEffect } from 'solid-js';
export default function useSignal<T>(value: T) {
const [, update] = useState(0);
const [getter, setter] = createSignal(value);
createEffect(() => {
console.log(getter());
});
return [
getter,
(value: T) => {
setter(value);
update((ticket) => ticket + 1);
},
];
}
再次回到 App 组件中,点击 button
会发现控制台先打印出了 1,然后又快速打印出了 0,同时还伴随着一条黄色的 Warning。
先不管 Warning 的内容,因为它暂时不影响我们实现 useSignal
这个 Hook。
结合 React Function Component 的原理,不难发现控制台的表现恰巧是因为调用 setter
之后更新了 getter
的值,同时更新 State 也触发了组件重新渲染,而组件重新渲染会再一次执行 useSignal
Hook,重新调用了 createSignal
创建了一个新的 Signal,之前的 Signal 丢失了。
找到原因之后对症下药,只需要保证每一次重新渲染的时候不重新调用 createSignal
创建新的 Signal,就能保证视图正确更新了。
在 React 中如何保证某个值在多次渲染的时候不发生变化呢?当然是用 useMemo
啦!用 useMemo
包一下 createSignal
,保证 Signal 在组件重新渲染的时候不会重新创建:
tsx
import { useState, useMemo } from 'react';
import { createSignal } from 'solid-js';
export default function useSignal<T>(value: T) {
const [, update] = useState(0);
const [getter, setter] = useMemo(() => createSignal(value), []);
return [
getter,
(value: T) => {
setter(value);
update((ticket) => ticket + 1);
},
];
}
现在再次回到 App 组件中,点击 button
就能看到页面上的 count
正确更新了。
大部分同学到这一步就可以关掉本文了,因为这已经实现了标题想要的效果:在 React 中使用 SolidJS 的 Signal。
但还不够,还有优化空间,感兴趣的同学可以继续看下去。
优化
其实在 React 中使用 SolidJS 的 Signal 还有很多种方式,上面的实现只是其中最简单的一种,存在很多小问题,比如调用 setter
时无论 Signal 是否更新都会触发组件重新渲染,触发了不必要的重新渲染,导致组件性能下降。
因此要实现更好的效果,还需要在此基础上进行优化,使 useSignal
只在 getter
的返回值发生变化的时候才触发组件重新渲染。
要实现这样的效果,就不可避免地需要跟踪 getter
的返回值的变化,最简单的办法就是判断一下 value
与 getter
的返回值是否相同:
tsx
import { useState, useMemo } from 'react';
import { createSignal } from 'solid-js';
export default function useSignal<T>(value: T) {
const [, update] = useState(0);
const [getter, setter] = useMemo(() => createSignal(value), []);
return [
getter,
(value: T) => {
if (getter() === value) return;
setter(value);
update((ticket) => ticket + 1);
},
];
}
虽然这种方式非常简单,但并不能很好地帮我们理解 React 和 SolidJS 之间的差异,所以或许应该用一些更能体现两者之间的差异的方式来实现。
还记得上面用到过的 SolidJS 的 createEffect
吗?可以使用 createEffect
包裹 getter
的调用,它会自动跟踪被包裹的 Signal 的变化,并执行 Effect:
tsx
import { useState, useMemo } from 'react';
import { createSignal, createEffect } from 'solid-js';
export default function useSignal<T>(value: T) {
const [, update] = useState(0);
const [getter, setter] = useMemo(() => createSignal(value), []);
createEffect(() => {
getter();
update((ticket) => ticket + 1);
});
return [getter, setter];
}
理论上来说,当 getter
的返回值发生变化时会触发 State 的更新,从而触发组件重新渲染,但是实际上这样的操作会导致组件不停地重新渲染,进入死循环。
因为 createEffect
在创建 Effect 之后会立即执行一次,也就是会立即更新 State,导致组件重新渲染,而组件重新渲染又会重新执行 useSignal
创建新的 Effect,如此往复,周而复始,进入死循环。
为了避免周而复始进入死循环,需要使用 React 的 useRef
创建一个不会被释放的引用,同时使用 SolidJS 提供的更高阶的 API createReaction
将跟踪 getter
变化和触发重新渲染的 update
分离:
tsx
import { useState, useMemo, useRef } from 'react';
import { createSignal, createRoot, createReaction } from 'solid-js';
export default function useSignal<T>(value: T) {
const [, update] = useState(0);
const [getter, setter] = useMemo(() => createSignal(value), []);
const root =
useRef<{ dispose: () => void; track: (fn: () => void) => void }>();
if (!root.current) {
root.current = createRoot((dispose) => ({
dispose,
track: createReaction(() => update((ticket) => ticket + 1)),
}));
}
root.current.track(() => getter());
return [getter, setter];
}
到这里 useSignal
就算比较完美地实现了。
最后
为什么会有突发奇想,想在 React 中使用 SolidJS 的 API?其实是前段时间在研究 SolidJS 的时候发现 SolidJS 的 Organization 中有一个叫 React Solid State 的库,允许用户在写 React 的时候使用 SolidJS 提供的 Signal 机制:
GitHub - solidjs/react-solid-state: Auto tracking state management for modern React
在 SolidJS 中,Signal 和 UI renderer 的耦合程度是很低的,因此才能实现这样的能力,与 Anthony Fu 的 reactivue 异曲同工:
GitHub - antfu/reactivue: 🙊 Use Vue Composition API in React components
本文中的一些内容主要参考了以上两个仓库,除了文中写到的几种方式,还有一些与 React 提供的 API 结合更紧密的方式,不过这些都属于"茴香豆的茴有几种写法"的范围了: