如何在 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

相关推荐
hackeroink36 分钟前
【2024版】最新推荐好用的XSS漏洞扫描利用工具_xss扫描工具
前端·xss
迷雾漫步者2 小时前
Flutter组件————FloatingActionButton
前端·flutter·dart
向前看-3 小时前
验证码机制
前端·后端
燃先生._.4 小时前
Day-03 Vue(生命周期、生命周期钩子八个函数、工程化开发和脚手架、组件化开发、根组件、局部注册和全局注册的步骤)
前端·javascript·vue.js
高山我梦口香糖5 小时前
[react]searchParams转普通对象
开发语言·前端·javascript
m0_748235245 小时前
前端实现获取后端返回的文件流并下载
前端·状态模式
m0_748240255 小时前
前端如何检测用户登录状态是否过期
前端
black^sugar5 小时前
纯前端实现更新检测
开发语言·前端·javascript
寻找沙漠的人6 小时前
前端知识补充—CSS
前端·css