前言:作为写了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、useSignalSelector | useEffect + 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 适配,不见不散!