在React生态中,状态管理长期围绕useState与useReducer展开,每次更新几乎都会触发组件函数重新执行。Solid.js的Signals与Preact Signals提出了一种不同的响应式模型:信号值的变化直接通知依赖它的具体视图,而不是让整个组件树参与协调。这种机制在React中并非原生支持,但我们可以通过外部存储与订阅模式进行模拟,从而在保留React渲染管线的同时获得细粒度更新能力。

Signals的核心原理与React渲染模型的冲突
Signals本质上是一个具备读写分离能力的值容器。当代码读取信号时,运行时会记录当前正在执行的副作用或计算函数;当信号被写入,仅那些真正读取过它的副作用会被标记为脏并调度更新。Solid.js在编译阶段将组件转化为命令式DOM操作,因此信号能直接驱动文本节点或属性修改。Preact Signals则利用Preact轻量虚拟DOM的差异定位,在信号变更时只更新对应组件实例。
React的渲染模型与此不同。它的函数组件在每次渲染时都会重新运行,JSX返回的虚拟DOM会被调和。如果我们在组件内部直接读取一个外部信号而不订阅,React并不知道这个信号变化需要触发重渲染。因此模拟Signals的第一要务,是让React的调度系统感知到信号变更,同时尽量避免父组件的无谓执行。这就引出了依赖追踪与渲染边界两个关键问题。
另一个冲突点在于批处理。React 18引入了自动批处理,但外部信号的写入往往发生在React事件系统之外,例如定时器或第三方库回调。若不手动批处理,可能出现一次信号变更触发多次独立渲染。理解这些底层差异,是我们在React中正确模拟Signals的前提,而不是简单封装一个get/set对象。
基于useSyncExternalStore的React Signals模拟
React 18提供的useSyncExternalStore是连接外部可变源与React渲染的安全通道。我们可以构建一个信号工厂,每个信号包含getSnapshot、subscribe与set方法。组件通过hook订阅信号,仅当该信号的快照变化时,使用组件的局部渲染才会被调度。这种方式不会让父组件重渲染,除非父组件自己也订阅了同一信号。
下面给出一个最小实现。我们定义一个创建信号的函数,并用模块级Map保存订阅者。写入时遍历订阅者通知变更,React的useSyncExternalStore会比较快照决定是否重渲染。注意快照必须是引用稳定或值相等的,否则会造成无限循环。
function createSignal(initial) {
let value = initial;
const subscribers = new Set();
return {
get() { return value; },
set(next) {
value = next;
subscribers.forEach(fn => fn());
},
subscribe(fn) {
subscribers.add(fn);
return () => subscribers.delete(fn);
}
};
}
const countSignal = createSignal(0);
function Counter() {
const count = React.useSyncExternalStore(
countSignal.subscribe.bind(countSignal),
countSignal.get.bind(countSignal)
);
return React.createElement('button', {
onClick: () => countSignal.set(count + 1)
}, 'count: ' + count);
}
上述代码在React中实现了最基本的信号订阅。它的优势是契合React官方推荐的外部存储模式,不会破坏并发特性。缺点是每个信号需要单独订阅,且若多个信号组合成派生状态,需要自行用useMemo或计算信号封装。相比Solid.js编译期优化,这种运行时订阅在极高频率更新时仍有轻微开销。
为了更接近Preact Signals的computed能力,我们可以扩展信号工厂,增加依赖收集逻辑:当计算信号被读取时,自动订阅其源信号,并在源变更时重算。这样在React中也能写出类似const double = computed(() => count.get() * 2)的声明式派生,且只影响使用double的组件。
Preact Signals在React项目中的桥接实践
Preact官方提供了@preact/signals-react桥接包,它内部利用Babel插件将signal.value的读取自动包裹为订阅。在React中使用时,开发者写法和Preact几乎一致:声明const count = signal(0),在JSX中通过count.value读取。插件会在编译阶段插入useSyncExternalStore调用,从而让React感知变更。
如果没有配置Babel插件,也可以手动用useSignal或useComputed hook达到类似效果。桥接方案的好处是成熟稳定,社区已处理批处理与SSR兼容。例如在Next.js中,信号状态可借助useSignal在客户端激活,避免 hydration 不匹配。但它要求构建链支持对应转换,对纯CRA或Vite项目需额外配置。
import { signal, computed } from '@preact/signals-react';
const width = signal(800);
const height = signal(600);
const area = computed(() => width.value * height.value);
function Panel() {
return (
<div>
<p>面积: {area.value}</p>
<button onClick={() => width.value += 10}>加宽</button>
</div>
);
}
从架构角度看,在React中模拟Signals并不意味着取代Redux或Context。信号更适合局部高频状态,如表单输入、拖拽坐标、动画进度。将这些状态移出React内部state,可以显著降低大型列表的渲染成本。实际落地时,建议先用量化工具测量组件重渲染次数,再决定是否引入信号方案,避免过早优化带来的复杂度上升。
综合来看,Solid.js的细粒度更新依赖编译手段,Preact Signals依靠轻量运行时与构建插件,而在React中我们主要通过useSyncExternalStore与外部存储契约来逼近该模型。掌握信号读写、订阅与批处理三要素,就能在现有React工程中安全地享受Signals带来的性能红利,而不必迁移整个技术栈。