React 中的 Signals:过时闭包、订阅陷阱与响应式图坑点全解析

3 阅读21分钟

前言:作为写了14年前端的老炮,踩过的坑能绕显示器三圈。先跟大家说清楚核心:什么是 Signals?它本质是一种「高性能响应式状态管理容器」,简单说就是一个能存值、自动追踪依赖、值变了自动触发关联更新的“智能数据盒”。我们为什么需要它?因为 React 原生的 useState/useReducer 有三大痛点——手动声明依赖易出错、组件重渲染过多、状态共享复杂,而 Signals 能完美解决这些问题:自动追踪依赖不用写依赖数组、细粒度更新避免无效渲染、全局单例就能实现状态共享,用对了是提升性能的神器,用错了就是埋在项目里的定时炸弹——过时闭包、订阅泄漏、计算属性失效,每一个坑都能让你排查到深夜。本文不聊虚的理论,只讲实战中最容易踩的6个坑,每个坑都配「现象+根源+错误代码+正确写法+避坑技巧」,看完直接避开90%的 Signals 踩坑场景,新手也能直接抄作业,老开发也能查漏补缺。

一、头号重灾区:过时闭包(Stale Closures)—— 异步回调里永远拿不到最新值

这是 Signals 实战中最常见、最隐蔽的坑,没有之一。很多老开发都栽在这上面,更别说刚接触 Signals 的新手,排查起来特别费劲。

1. 现象描述

在 setTimeout、防抖函数(debounce)、Promise.then 或者事件回调(如 onClick)中,读取 Signals 的值,永远是「旧的」—— 明明界面已经更新了,回调里打印的值却始终停留在创建回调那次渲染的状态,完全不跟随信号更新。

举个真实场景:做一个计数器,点击按钮后,延迟500ms打印当前计数,结果不管点多少次,打印的永远是第一次点击时的数值,或者是初始值。

2. 根本原因

核心问题出在「闭包特性」和「React 渲染机制」的结合上:React 组件每一次渲染,都会生成一个独立的「执行快照」,组件内的变量、函数都会被重新创建。而事件处理函数、异步回调(setTimeout、Promise 等)会「捕获创建它那次渲染的变量快照」,而不是在执行回调时实时去读取最新值。

简单说:你在回调里用的变量,是「过去的变量」,不是「现在的变量」。这不是 Signals 的 bug,而是闭包的固有特性,只是在 Signals 中,因为响应式更新频繁,这个问题会更突出。

3. 错误写法(必踩坑,新手慎抄)

// 错误示例:闭包捕获渲染快照,值永远不更新
import { useSignal, useSignalValue } from '@preact/signals-react';

function Counter() {
  const countSig = useSignal(0);
  // 读取信号值,生成当前渲染的快照
  const v = useSignalValue(countSig);

  // 点击按钮,延迟500ms打印v
  const onClick = () => {
    setTimeout(() => {
      console.log('当前计数:', v); // 永远是旧值,不会更新
    }, 500);
  };

  return (
    <div>
      <p>当前计数:{v}</p>
      <button onClick={() => countSig.value++}>增加</button>
      <button onClick={onClick}>延迟打印</button>
    </div>
  );
}

这里的 v 是「当前渲染周期」的常量,被 onClick 回调捕获后,就固定下来了。哪怕后续 countSig 更新,触发组件重新渲染,新的 v 会被创建,但之前的回调里捕获的还是老的 v,所以打印的永远是旧值。

4. 正确写法(两种方案,按需选择)

解决思路很简单:避免闭包捕获固定快照,在回调执行时「实时读取最新值」,Signals 提供了专门的 API 来解决这个问题——peek()。

方案1:用 peek() 读取最新值(推荐)

peek() 是 Signals 专门设计的「无依赖读取最新值」的 API,它不会建立响应式追踪,也不会捕获快照,而是在执行时直接读取信号的当前最新值,完美适配异步、回调场景。

// 正确示例1:用 peek() 实时读取最新值
import { useSignal } from '@preact/signals-react';

function Counter() {
  const countSig = useSignal(0);

  const onClick2 = () => {
    setTimeout(() => {
      // 实时读取最新值,不捕获快照
      console.log('当前计数:', countSig.peek()); // 永远是最新值
    }, 500);
  };

  return (
    <div>
      <p>当前计数:{countSig.value}</p>
      <button onClick={() => countSig.value++}>增加</button>
      <button onClick={onClick2}>延迟打印(正确)</button>
    </div>
  );
}

方案2:注入 getter 函数(兼容复杂场景)

如果回调逻辑比较复杂,多次需要读取最新值,可以把读取逻辑抽成一个独立的 getter 函数,每次执行函数时都主动获取最新值,从根源上避开闭包陷阱。

// 正确示例2:注入 getter 函数
import { useSignal } from '@preact/signals-react';

function Counter() {
  const countSig = useSignal(0);

  // 抽离 getter 函数,每次调用都读取最新值
  const getCount = () => countSig.peek();

  const onClick3 = () => {
    setTimeout(() => {
      console.log('当前计数:', getCount()); // 最新值
      // 复杂逻辑中可多次调用 getCount(),均为最新值
      if (getCount() > 5) {
        console.log('计数超过5了!');
      }
    }, 500);
  };

  return (
    <div>
      <p>当前计数:{countSig.value}</p>
      <button onClick={() => countSig.value++}>增加</button>
      <button onClick={onClick3}>延迟打印(getter版)</button>
    </div>
  );
}

5. 避坑技巧(老开发总结)

  • 同步渲染、JSX 中展示值:用 useSignalValue 或直接访问 .value(自动追踪依赖);
  • 异步回调(setTimeout、Promise)、事件回调(onClick 内部异步逻辑):用 peek() 或 getter 函数;
  • 记住:peek() 只用于「不需要响应式追踪」的场景,同步渲染中不要用,否则会导致响应式失效。

二、隐形内存泄漏:在组件渲染函数里直接创建 signal/computed

这个坑比过时闭包更隐蔽,因为它不会立刻报错,而是在项目运行一段时间后才会暴露——页面越用越卡、内存持续飙升、组件卸载后状态还在更新,排查起来极其困难,很多团队都栽在这上面。

1. 现象描述

  • 页面使用时间越长,卡顿越明显,甚至出现浏览器崩溃;

  • 组件卸载后,控制台依然会打印信号更新的日志,说明订阅没有被清理;

  • 内存占用持续上升,刷新页面后才会恢复正常;

  • 偶尔出现「重复更新」,同一个状态被多次触发变更。

2. 根本原因

React 组件的渲染函数,每一次渲染都会「完整执行一遍」—— 不管是 props 变化、state 变化,还是父组件渲染,子组件的渲染函数都会重新运行。

如果你直接在组件渲染函数里写 signal() 或 computed(),那么每一次渲染都会「新建一个信号/计算属性实例」,而老的实例不会被自动清理(因为没有被缓存,React 无法追踪其生命周期),最终导致「订阅泄漏」—— 大量废弃的信号实例占用内存,并且持续触发更新。

3. 错误写法(百分百泄漏,新手必避)

// 错误示例:组件渲染函数内直接创建 signal/computed
import { signal, computed } from '@preact/signals-react';

function BadComponent() {
  // 错误:每渲染一次,就新建一个 signal 实例
  const local = signal(0);
  // 错误:每渲染一次,就新建一个 computed 实例
  const sum = computed(() => local.get() + 1);

  return (
    <div>
      <p>本地值:{local.value}</p>
      <p>计算值:{sum.value}</p>
      <button onClick={() => local.value++}>增加</button>
    </div>
  );
}

这个组件只要重新渲染(比如父组件更新、自身状态变化),就会新建 local 和 sum 实例,老的实例会被丢弃,但它们的订阅关系还在,会一直占用内存,并且可能继续响应更新,导致内存泄漏和性能抖动。

4. 正确写法:用稳定 Hook 包裹实例(唯一解)

React 环境下,要保证信号/计算属性实例的稳定性,必须用 Signals 提供的 Hook 包裹——useSignalState、useComputed,这些 Hook 内部基于 useMemo 实现,能保证实例「只在组件初始化时创建一次」,后续渲染不会重新创建,从而避免泄漏。

// 正确示例:用 useSignalState 和 useComputed 保证实例稳定
import { useSignalState, useComputed } from '@preact/signals-react';

function GoodComponent() {
  // 正确:useSignalState 保证实例只创建一次
  const [local, setLocal] = useSignalState(0);
  // 正确:useComputed 保证计算属性实例稳定,自动追踪依赖
  const sum = useComputed(() => local + 1);

  return (
    <div>
      <p>本地值:{local}</p>
      <p>计算值:{sum}</p>
      <button onClick={() => setLocal(local + 1)}>增加</button>
    </div>
  );
}

补充说明:useSignalState 是 useSignal 的封装,返回 [value, setValue] 格式,和 React 的 useState 用法类似,更符合 React 开发者的使用习惯;useComputed 则用于创建稳定的计算属性,自动处理依赖追踪和清理。

5. 避坑技巧(老开发总结)

  • 组件内部:强制使用 useSignalState / useComputed,绝对不能直接写 signal() / computed();
  • 全局/模块顶层:可以直接使用 signal() / computed()(因为模块顶层只执行一次,不会重复创建);
  • 如果需要在组件内创建临时信号(仅当前渲染有效),必须在 cleanup 中手动清理,否则依然会泄漏。

三、计算属性失效:依赖 React 快照而非信号,导致不更新

这个坑特别容易被新手忽略,很多人以为「只要用了 useComputed,就能自动响应更新」,结果写出来的计算属性只执行一次,后续信号更新完全没反应,排查半天找不到问题。

1. 现象描述

用 useComputed 创建的计算属性,只在组件初始化时执行一次,之后信号频繁更新,但计算属性的值「完全不变化」,界面上显示的始终是初始值,哪怕依赖的信号已经变了。

比如:计算属性是 count * 2,count 从 0 增加到 5,但计算属性始终显示 0,完全不响应 count 的变化。

2. 根本原因

核心规则:computed(包括 useComputed)的响应式依赖,「只追踪回调内部通过 .get() 访问的信号」。

如果你先通过 useSignalValue 把信号转成普通的 React 快照(一个常量),再把这个快照传入 computed 回调,那么 computed 无法追踪到信号的变化——因为它依赖的是「普通常量」,而不是「信号本身」,自然不会响应信号的更新。

简单说:computed 要想响应更新,必须直接依赖「信号实例」,而不是「信号的快照值」。

3. 错误写法

// 错误示例:computed 依赖 React 快照,无法响应更新
import { useSignal, useSignalValue, useComputed } from '@preact/signals-react';

function DoubleCounter() {
  const countSig = useSignal(0);
  // 先转成 React 快照(普通常量)
  const count = useSignalValue(countSig);

  // 错误:依赖的是 count(快照),不是 countSig(信号)
  const doubled1 = useComputed(() => count * 2); // 只执行一次,不更新

  return (
    <div>
      <p>计数:{count}</p>
      <p>两倍计数(错误):{doubled1}</p>
      <button onClick={() => countSig.value++}>增加</button>
    </div>
  );
}

这里的 doubled1 依赖的是 count(普通常量),而 count 只有在组件重新渲染时才会更新,但 computed 不会追踪普通常量的变化,所以只会执行一次初始化,之后再也不更新。

4. 正确写法(两种场景,按需选择)

场景1:需要响应式更新——在 computed 内部调用 .get()

如果希望计算属性跟随信号实时更新,必须在 useComputed 的回调内部,直接访问信号的 .get() 方法(或 .value,本质一样),让 computed 直接追踪信号实例。

// 正确示例1:computed 内部直接读取信号,建立依赖
import { useSignal, useComputed } from '@preact/signals-react';

function DoubleCounter() {
  const countSig = useSignal(0);

  // 正确:computed 内部直接访问信号的 .get(),追踪依赖
  const doubled2 = useComputed(() => countSig.get() * 2);

  return (
    <div>
      <p>计数:{countSig.value}</p>
      <p>两倍计数(正确):{doubled2}</p>
      <button onClick={() => countSig.value++}>增加</button>
    </div>
  );
}

场景2:仅需要渲染缓存——用 useMemo(而非 useComputed)

如果你不需要计算属性跟随信号实时更新,只是想在渲染层做缓存(避免每次渲染都重新计算),那么直接用 React 原生的 useMemo 更合适,不用强行用 useComputed。

// 正确示例2:仅渲染缓存,用 useMemo
import { useSignal, useSignalValue } from '@preact/signals-react';

function DoubleCounter() {
  const countSig = useSignal(0);
  const count = useSignalValue(countSig);

  // 正确:用 useMemo 做渲染缓存,依赖数组传入 count
  const doubled3 = useMemo(() => count * 2, [count]);

  return (
    <div>
      <p>计数:{count}</p>
      <p>两倍计数(缓存):{doubled3}</p>
      <button onClick={() => countSig.value++}>增加</button>
    </div>
  );
}

补充说明:useMemo 和 useComputed 的核心区别——useMemo 是「渲染层缓存」,依赖 React 渲染周期,只有依赖数组变化时才重新计算;useComputed 是「响应式计算」,依赖信号变化,只要信号变了就会重新计算,和 React 渲染周期无关。

5. 避坑技巧(老开发总结)

  • 要响应式更新:useComputed 回调里必须直接访问信号(.get() 或 .value);
  • 仅需要渲染缓存:用 useMemo + 依赖数组,不要用 useComputed;
  • 记住:useSignalValue 是「把信号转成 React 快照」,适合用于 JSX 展示,不适合用于 computed 依赖。

四、并发模式撕裂:useEffect + setState 手动订阅,导致 UI 与数据不同步

随着 React 并发模式的普及,这个坑出现的频率越来越高。很多老开发习惯了用 useEffect + setState 手动同步外部状态,迁移到 Signals 后依然沿用这个写法,结果在并发模式下出现「数据撕裂」,体验极差。

1. 现象描述

开启 React 并发模式(如使用 Suspense、Transition)后,出现以下问题:

  • 数据撕裂:UI 显示的内容和实际信号值不一致,比如界面显示旧值,控制台打印的是最新值;

  • 闪烁现象:界面先显示旧值,瞬间切换到最新值,视觉体验割裂;

  • 渲染爆炸:组件渲染次数急剧增加,性能严重下降。

2. 根本原因

React 并发模式下,组件渲染是「可中断、可恢复」的,而 useEffect + setState 是「手动订阅」的方式,存在一个致命问题:React 无法在提交阶段前重新读取最新的信号快照。

简单说:当信号更新触发组件重新渲染时,useEffect 里的订阅逻辑可能已经执行,而 React 还没完成渲染提交,导致 setState 写入的是「旧的快照值」,最终出现 UI 与数据不同步的撕裂现象。

而 Signals 提供的 useSignalValue、useSignalSelector,底层基于 React 官方的 useSyncExternalStore API,是专门为「外部状态同步」设计的,能完美适配并发模式,自动避免撕裂。

3. 错误写法(并发模式下必炸)

// 错误示例:useEffect + setState 手动订阅,并发模式下撕裂
import { useState, useEffect } from 'react';
import { useSignal, createEffect } from '@preact/signals-react';

function SyncComponent() {
  const [v, setV] = useState(0);
  const src = useSignal(10);

  // 错误:手动订阅,并发模式下无法避免撕裂
  useEffect(() => {
    // 创建 effect 同步信号值到 state
    const stop = createEffect(() => setV(src.peek()));
    // 清理订阅
    return () => stop();
  }, []); // 空依赖,只执行一次

  return <p>同步后的值:{v}</p>;
}

这个写法在普通模式下可能看似正常,但在并发模式下(比如配合 Suspense 加载数据),setV 写入的可能是旧值,导致 UI 与 src 信号的值不一致,出现撕裂。

4. 正确写法:使用官方封装 Hook(唯一安全方案)

解决这个问题的关键,就是放弃手动订阅,直接使用 Signals 提供的 useSignalValue 或 useSignalSelector,它们底层基于 useSyncExternalStore,能自动处理并发模式下的快照读取,保证无撕裂、同步安全。

// 正确示例:用 useSignalValue 同步信号,无撕裂
import { useSignal, useSignalValue } from '@preact/signals-react';

function SyncComponent() {
  const src = useSignal(10);
  // 正确:一行搞定,自动适配并发模式,无撕裂
  const v = useSignalValue(src);

  return <p>同步后的值:{v}</p>;
}

补充说明:如果需要对信号值进行筛选、转换,可以用 useSignalSelector,用法和 useSelector(Redux)类似,同样基于 useSyncExternalStore,安全无撕裂。

// 补充:useSignalSelector 用于筛选转换信号值
const filteredValue = useSignalSelector(src, (value) => {
  // 筛选:只返回大于5的值
  return value > 5 ? value : 0;
});

5. 避坑技巧(老开发总结)

  • 禁止手写 useEffect + setState 同步 Signals 到 React state;
  • 所有信号值的读取,优先用 useSignalValue / useSignalSelector;
  • 如果必须手动订阅(特殊场景),一定要用 useSyncExternalStore 封装,不要直接用 useEffect。

五、清理函数陷阱:cleanup 里误用 .get(),导致依赖错乱

这个坑非常隐蔽,很多老开发都可能忽略——在清理函数(onCleanup 或 React 的 useEffect 清理函数)里调用信号的 .get() 方法,会意外建立新的依赖关系,导致响应式图错乱、内存泄漏。

1. 现象描述

  • 组件卸载后,依然触发信号更新,控制台报「无法更新卸载组件」的警告;

  • 依赖关系错乱,某个信号更新时,不该触发的计算属性/effect 被触发;

  • 内存占用异常,废弃的组件实例无法被垃圾回收。

2. 根本原因

Signals 的 .get() 方法有一个核心特性:「调用时会自动建立依赖关系」—— 只要在 effect 或 computed 的回调里调用 .get(),就会把当前 effect/computed 加入到信号的订阅列表中。

而清理函数(onCleanup 或 useEffect 的返回函数)的执行时机,是「组件卸载或 effect 重新执行前」,此时如果调用 .get(),会意外把清理函数加入到信号的订阅列表中,导致信号更新时,依然会触发清理函数执行,进而导致依赖错乱和内存泄漏。

3. 错误写法

// 错误示例:cleanup 里误用 .get(),建立意外依赖
import { createEffect, computed, signal } from '@preact/signals-react';

const someSignal = signal(0);
const someComputed = computed(() => someSignal.get() + 1);

createEffect(() => {
  // 正常逻辑:读取计算属性的值
  console.log('当前值:', someComputed.get());

  // 错误:清理函数里调用 .get(),建立意外依赖
  onCleanup(() => {
    const lastValue = someComputed.get(); // 禁止这样写!
    console.log('清理时的值:', lastValue);
  });
});

这里的 onCleanup 回调里调用 someComputed.get(),会意外让清理函数成为 someComputed 的订阅者,即使 createEffect 已经被清理,someComputed 更新时,依然会触发这个清理函数,导致不必要的执行和内存泄漏。

4. 正确写法:提前快照 或 使用 peek()

解决思路:清理阶段绝对不建立新的依赖,要么在 effect 执行时提前保存值的快照,要么用 peek() 读取最新值(peek() 不建立依赖)。

// 正确示例:提前快照 + peek(),避免意外依赖
import { createEffect, computed, signal } from '@preact/signals-react';

const someSignal = signal(0);
const someComputed = computed(() => someSignal.get() + 1);

createEffect(() => {
  // 1. 提前保存快照(在 effect 执行时读取,建立正常依赖)
  const lastValue = someComputed.peek();
  console.log('当前值:', someComputed.get());

  onCleanup(() => {
    // 正确:使用提前保存的快照,不建立新依赖
    console.log('清理时的快照值:', lastValue);
    // 正确:用 peek() 读取最新值,不建立依赖
    console.log('清理时的最新值:', someComputed.peek());
  });
});

补充说明:提前保存快照,是在 effect 执行时读取值(此时建立的是正常依赖),清理时直接使用快照,不会建立新依赖;peek() 则是完全不建立依赖,适合在清理阶段读取最新值。

5. 避坑技巧(老开发总结)

  • 清理阶段(onCleanup、useEffect 返回函数):绝对禁止使用 .get();
  • 清理时需要用到值:要么提前在 effect 执行时保存快照,要么用 peek();
  • 记住:peek() 是清理阶段读取信号值的唯一安全方式。

六、数据源混乱:混合信号与 React 状态,导致 UI 不同步

这个坑是新手最容易犯的错误,也是团队协作中最容易出现的问题——同时使用 Signals 和 React 状态(useState),并且两者都作为数据源,导致更新时序失控,UI 部分更新、部分不更新,视觉体验割裂。

1. 现象描述

  • 界面一部分元素(依赖信号)立刻更新,另一部分元素(依赖 React 状态)延迟更新;

  • 交互时出现「视觉断层」,比如输入框输入内容,实时显示的文本立刻更新,但按钮状态延迟更新;

  • 数据不一致,信号的值和 React 状态的值不匹配,导致业务逻辑出错。

2. 根本原因

Signals 和 React 状态的更新机制完全不同:

  • Signals:同步更新,更新后立即触发依赖它的 effect、computed 和组件渲染,无延迟;

  • React 状态(useState):异步更新,尤其是配合 startTransition 时,会延迟更新,优先保证 UI 响应性。

当你同时使用两者作为数据源,并且没有统一更新时序时,就会出现「一部分同步更新、一部分延迟更新」的情况,导致 UI 不同步。

3. 解决方案(两种方案,统一数据源)

解决这个问题的核心原则:「单一数据源」—— 要么全用 Signals,要么全用 React 状态,不要混合使用。如果必须混合,一定要统一更新时序。

方案1:信号作为唯一数据源,UI 层延迟

以 Signals 作为唯一的数据源,所有状态都存在信号里,React 状态只用于 UI 层的延迟处理(如使用 useDeferredValue),保证数据源统一。

// 正确示例1:信号作为唯一数据源,UI 层延迟
import { useSignal, useSignalValue } from '@preact/signals-react';
import { useDeferredValue } from 'react';

function SearchComponent() {
  // 信号作为唯一数据源
  const querySig = useSignal('');
  const query = useSignalValue(querySig);

  // 仅在 UI 层做延迟处理,不改变数据源
  const deferredQuery = useDeferredValue(query);

  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={(e) => querySig.value = e.target.value}
        placeholder="输入搜索关键词"
      />
      {/* 延迟显示的内容,避免输入时卡顿 */}
      <p>搜索关键词(延迟):{deferredQuery}</p>
      {/* 实时显示的内容,基于信号 */}
      <p>实时输入:{query}</p>
    </div>
  );
}

方案2:React 状态缓冲,统一写入信号

如果需要使用 React 状态做临时缓冲(如表单输入的草稿状态),则在状态更新完成后,统一写入信号,保证信号是唯一的真相源。

// 正确示例2:React 状态缓冲,统一写入信号
import { useSignal, useSignalValue } from '@preact/signals-react';
import { useState, startTransition } from 'react';

function FormComponent() {
  // 信号作为唯一数据源
  const titleSig = useSignal('');
  // React 状态作为草稿缓冲
  const [draft, setDraft] = useState(useSignalValue(titleSig));

  // 提交草稿,统一写入信号
  const handleSubmit = () => {
    // 用 startTransition 延迟写入,避免阻塞 UI
    startTransition(() => {
      titleSig.value = draft;
    });
  };

  return (
    <div>
      <input
        type="text"
        value={draft}
        onChange={(e) => setDraft(e.target.value)}
        placeholder="输入标题"
      />
      <button onClick={handleSubmit}>提交</button>
      <p>已保存标题:{titleSig.value}</p>
    </div>
  );
}

5. 避坑技巧(老开发总结)

  • 全局保持「单一数据源」,优先选择 Signals(性能更优);
  • React 状态仅用于「临时缓冲」(如表单草稿),最终必须同步到信号;
  • 禁止同时用信号和 React 状态存储同一类数据,避免更新时序混乱。

七、核心概念回顾(必记,避免踩坑)

看完上面6个坑,很多人可能会混淆 Signals 的 API 使用场景,这里用一张表格总结核心用法,帮大家快速记忆,避免再踩坑:

使用场景推荐 API禁止做法
同步渲染、JSX 展示值useSignalValue、signal.value用 peek()(会失去响应式)
异步回调、清理函数读取值peek()、getter 函数用 .get()(会建立意外依赖)
组件内部创建信号/计算属性useSignalState、useComputed直接用 signal()、computed()
响应式计算属性useComputed(内部调用 .get())依赖 React 快照(useSignalValue 后的变量)
并发模式同步信号值useSignalValue、useSignalSelectoruseEffect + setState 手动订阅
渲染层缓存(非响应式)useMemo + 依赖数组用 useComputed(浪费性能)

八、总结与后续预告

到这里,React Signals 系列的核心内容就基本讲完了。从信号的核心机制、无撕裂订阅,到这一篇的实战踩坑,我们覆盖了 Signals 在 React 中使用的全场景——其实所有坑的本质,都是「没有理解 Signals 的响应式逻辑」和「混淆了 Signals 与 React 原生状态的使用场景」。

再强调一句:Signals 不是 React 状态的替代品,而是「性能优化的补充」,用对了能大幅提升组件性能,用错了反而会引入更多问题。记住「单一数据源」「实例稳定」「清理时不用 .get()」这三个核心原则,就能避开绝大多数坑。

回顾整个系列,我们已经实现了一个可落地的 Signals 核心系统,以及 React 集成方案:

核心系统

  • signal:基础信号,存储状态值,支持 .get()、.set()、peek();
  • effect:响应式副作用,自动追踪依赖,支持清理;
  • computed:响应式计算属性,基于信号依赖自动更新;
  • 核心机制:推送式脏标记 + 拉取式重新计算,配合微任务批处理调度。

React 集成

  • useSignalValue:读取信号值,适配 React 渲染周期;
  • useComputed:创建稳定的计算属性,自动清理;
  • useSignalSelector:筛选转换信号值,无撕裂;
  • 底层依赖:useSyncExternalStore,完美适配并发模式、严格模式。

后续预告

下一篇,我们会把 Signals 的思想迁移到 Vue 中——其实本文讲的 computed,和 Vue 的 computed 几乎完全等价,所以实现起来会非常自然。我们会实现两个极简的 Vue Hook,让 Vue 模板能直接使用我们的信号和计算属性:

// vue-adapter:Vue 适配层
export function useSignalRef<T>(src: { get(): T; peek(): T }) {
  /* 将信号映射为 Vue ref,通过 onUnmounted 清理订阅 */
}

export function useComputedRef<T>(fn: () => T, equals = Object.is) {
  /* 在 Vue 生命周期内创建计算属性,并转换为 ref */
}

这里有两个关键注意点,提前预告:

  • useComputedRef 的回调里,必须调用 signal.get(),而不是 ref.value,否则会变成纯 Vue 计算属性,失去我们自己的响应式图;
  • 这个适配层只负责将信号值同步到 Vue ref,不会将 Vue 的响应式回连到我们的信号图,避免循环调度依赖。

最后,如果你在项目中使用 Signals 时踩过其他坑,或者有疑问,欢迎在评论区留言,我们一起交流探讨。关注我,下一篇 Vue Signals 适配,不见不散!