本节摘要:
createSignal与useState都返回"一读一写"两个函数,但契约不同:React 的读是取快照、写是预约重渲染;Solid 的读是登记订阅、写是点对点投递。本节用同一段需求写出两份实现,拆开读取、写入、相等判断三个差异点,并给出 Signal 粒度拆分的工程判断。
某个下午排过来一个常见需求:顶部导航上挂一个未读消息数,点开消息面板就清零,后台推送到达就加一。这个数字会被三个组件读取——铃铛图标、页面标题、面板红点。在 React 里,这个数通常要提升到公共父组件或搬进全局 store;在 Solid 里,它就是一个 Signal,三个读取方各自订阅。这个场景把两者的差异暴露得很充分,本节就以它为引。
React 版本,状态提升到父组件:
import { useState } from "react"; function Nav({ unread, onOpen }) { return <Bell count={unread} onOpen={onOpen} />; } function App() { const [unread, setUnread] = useState(0); // 推送到达 const onPush = () => setUnread(u => u + 1); // 状态经过 props 逐层下发,中间组件全部重渲染 return <Nav unread={unread} onOpen={() => setUnread(0)} />; }
Solid 版本,Signal 直接声明在模块作用域:
import { createSignal } from "solid-js"; // 三个组件共享同一个信号,无需提升、无需 props 中转 const [unread, setUnread] = createSignal(0); function Bell() { return <span class="bell">{unread()}</span>; } function DocTitle() { // 效应类用法留到 2.2,这里先展示读取 return <span>{unread()}</span>; } // 推送到达 setUnread(unread() + 1); // 打开面板 setUnread(0);
差异一眼可见:Solid 不需要"提升状态"这个动作本身。因为 Signal 的订阅与组件层级无关——谁在渲染期间调用了 unread(),谁就被登记为订阅者。React 必须靠 props 逐层搬运值,本质是因为它的数据流绑定在"组件重渲染"这条通道上,通道不通,值就过不去。

unread() 不只是"取值"的语法,它同时是订阅登记点。规则只有一句:在响应式上下文里(JSX 表达式、Effect、Memo)调用 getter,依赖即被记录。相应地,在事件回调里调用 unread() 只是普通取值——点击处理器不是响应式上下文,读取不登记、更新也不会通知它,这恰好符合直觉:回调每次触发都拿到最新值,不需要订阅。
对照 React:unread 是每次渲染重新赋值的常量,读它不需要任何仪式,代价是值的保鲜依赖"组件重跑"这条传送带。两种契约各有一套失误方式:React 的失误是忘了把值传进依赖或传不进来(闭包旧值);Solid 的失误是在错误的上下文读取——比如在 createEffect 外面读了又指望它更新。后者第 2.2 节展开。
setter 支持直接赋值与函数式更新,两框架形状一致,语义细节有差别:
const [count, setCount] = createSignal(0); setCount(5); // 直接赋值 setCount(c => c + 1); // 函数式更新,与 useState 的用法一致 setCount(c => c); // 值没变:默认相等判断下,订阅者不会被通知
Solid 的相等判断默认是严格相等(===),写回同一个值时更新在信号层面就被拦下,下游什么都不发生;可以用 createSignal(0, { equals: false }) 关掉它,适合"值可能相同但语义上算变化"的场景(比如同一个时间戳对象被刷新)。React 侧也有 Object.is 的跳过优化,但"跳过"只发生在渲染调度层面,模型上依然是"先重算再比较"。一个在源头拦,一个在出口拦——位置不同,省下的算力不同。
⚠️ 把对象存进 Signal 要留意:
setUser({...user})每次都是新引用,默认相等判断永远判定"变了"。深层对象的更新要么用 2.3 的createStore,要么让 setter 产生真正有意义的新结构,别用克隆来"强制刷新"。
React 圈的老争论——"多个 useState 还是一个对象 state"——在 Solid 里有更干脆的答案:拆。因为 Signal 的成本模型变了,多一个信号不会带来"多一次可能的整树重渲染",只带来一个更精确的订阅节点。经验法则是:变化时机不同的值,拆成不同 Signal;永远一起变化的值,合成一个(或直接进 store)。 拆得越细,更新落点越准,这正是细粒度响应式的本意;但拆到"每个字段一个信号"也无必要,判断标准始终是变化时机,不是字段数量。
unread() 登记依赖,回调里的 unread() 只是取值。下一节看这些值变化之后如何驱动副作用——createEffect 对 useEffect,依赖数组会在那里消失。