如何在 React 中使用 SolidJS 的 Signal?

Signal 的概念在社区已经讨论了一段时间了,作为细粒度的响应式概念,Signal 给前端领域带来了不少新内容,如 SolidJS、Preact Signals 等等。而翻看 SolidJS 和 Preact Signals 的源码会发现,在 Signal 的实现上两者并没有太大差异,甚至于和早期用 JavaScript 实现 Signal 的 S.js 也没有太大差异。Signal 的实现如此稳定的原因在于,Signal 本身的概念非常成熟且稳定。

对 Signal 本身和 SolidJS 感兴趣的同学,可以移步SolidJS 是如何实现响应式的? ,对 Signal 和 SolidJS 的响应式实现做了简单的介绍。

思路

动手之前建议先了解 SolidJS 的 createSignal,对 SolidJS 不是特别了解的同学建议先移步 SolidJS 官方文档:

docs.solidjs.com/

对 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 的使用方式相似,返回 gettersetter,调用 setter 修改 Signal 的值时触发视图更新。

有了这个思路就可以开始动手了。

创建一个名称为 useSignal 的 Hook,直接将 gettersetter 返回:

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 的返回值的变化,最简单的办法就是判断一下 valuegetter 的返回值是否相同:

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 结合更紧密的方式,不过这些都属于"茴香豆的茴有几种写法"的范围了:

StackBlitz - Implement A

StackBlitz - Implement B

StackBlitz - Implement C

相关推荐
Martin -Tang13 分钟前
vite和webpack的区别
前端·webpack·node.js·vite
迷途小码农零零发14 分钟前
解锁微前端的优秀库
前端
王解1 小时前
webpack loader全解析,从入门到精通(10)
前端·webpack·node.js
我不当帕鲁谁当帕鲁1 小时前
arcgis for js实现FeatureLayer图层弹窗展示所有field字段
前端·javascript·arcgis
那一抹阳光多灿烂1 小时前
工程化实战内功修炼测试题
前端·javascript
放逐者-保持本心,方可放逐2 小时前
微信小程序=》基础=》常见问题=》性能总结
前端·微信小程序·小程序·前端框架
毋若成4 小时前
前端三大组件之CSS,三大选择器,游戏网页仿写
前端·css
红中马喽4 小时前
JS学习日记(webAPI—DOM)
开发语言·前端·javascript·笔记·vscode·学习
Black蜡笔小新5 小时前
网页直播/点播播放器EasyPlayer.js播放器OffscreenCanvas这个特性是否需要特殊的环境和硬件支持
前端·javascript·html
秦jh_6 小时前
【Linux】多线程(概念,控制)
linux·运维·前端