在没真正用 Solid 之前,我其实对"细粒度响应式"这个词一直有点将信将疑。React 的 re-render 我太熟了,Vue 的依赖收集我也写过不少,但它们都还停留在"组件或渲染函数"这个粒度上。Solid 不一样,它直接把响应式系统穿透到了 DOM 节点和属性这一个级别。这个项目的标题虽然只有一个"细粒度响应式",但背后涉及的东西并不少:Signal 的设计、编译期的 S 记号、无虚拟 DOM 的更新链路、以及控制流组件的取舍。
这篇文章我会基于自己的实践,把 Solid 的细粒度响应式从设计动机到底层机制再到真实项目中的坑,完整拆一遍。适合已经写过 React 或 Vue、想换个思路理解前端响应式的开发者,也适合正在评估团队要不要引入 Solid 的架构师。我会尽量说人话,涉及代码的地方给够示例,不会只停留在概念层面。
1. 细粒度响应式到底在解决什么问题
1.1 先对比一下主流方案在更新时的动作
要理解 Solid 的细粒度响应式,最直观的方式是看"一个状态变了,框架接下来干了什么"。
React 的模型是"组件函数重新执行"。你在useState里 set 一个值,React 会重新调用整个函数组件,生成新的元素树,然后和之前的虚拟 DOM 做 diff,找到变化的节点再更新真实 DOM。为了不让整棵树都重跑,你要手动用memo、useCallback、useMemo去告诉 React"这块不用重新计算"。本质上 React 的默认粒度是"组件级",性能要靠优化手段去收敛。
Vue 3 比 React 进一步。它的响应式系统可以精确追踪到"哪个组件依赖了哪个状态",状态变化后只有依赖它的渲染函数会重新执行。但 Vue 的渲染函数依然会生成 VNode,依然要做 diff,只是 diff 的范围缩小了。也就是说 Vue 是"响应式到组件,虚拟 DOM 到节点"。
Solid 的做法完全不同。它的响应式系统可以直接把更新指令挂到具体的 DOM 节点上。你声明一个<div>{count()}</div>,编译后这个 div 的文本节点就和countsignal 建立了直接联系。当count变化时,Solid 不会重新执行组件函数,不会生成新的虚拟 DOM,不需要 diff,它做的唯一一件事就是调用一个更新这个文本节点的函数。
在 js-framework-benchmark 这类测试里,Solid 的内存占用和 GC 压力常年排在前列,不是因为它做了什么魔法,而是因为它的更新路径里根本没有"整棵虚拟树"这种中间产物。
1.2 "细粒度"的准确含义:最小订阅单元是表达式
很多教程会把"细粒度"简单解释成"精确更新 DOM",这个说法对,但不完整。真正关键的是订阅单元的粒度。
在 Solid 里,一个count()的读取只是普通 JavaScript 函数调用。当它出现在模板表达式、createEffect或createMemo中时,运行时系统会自动记录"当前这个上下文依赖了count"。这叫做"在追踪上下文中读取"。同一个 signal 可以同时被多个独立的 effect 或 DOM 更新函数订阅,而每个订阅互不干扰。
我用一个生活化类比来理解这件事:传统框架像是物业接到报修后,把整栋楼的油漆重新刷一遍;Solid 像是物业直接记录每一户每一盏灯对应哪个开关,报修哪盏就换哪盏。细粒度响应式带来三个直接收益:
- 性能可预期:状态更新只触发真正依赖它的副作用,不需要开发者手动 memo。
- 心智负担低:不需要
useCallback依赖数组,不需要useMemo手动标记,数据流是"读即依赖"。 - 代码更像纯函数:组件函数只在首次挂载时执行一次,之后不再调用,很多 React 里常见的闭包过期问题天然消失。
2. 核心机制拆解:Signal、Memo 与编译期 S 记号
2.1 Signal:为什么是 getter/setter,而不是值本身
Solid 最基础的响应式原语是createSignal:
import { createSignal } from "solid-js"; const [count, setCount] = createSignal(0); console.log(count()); // 0 setCount(1); console.log(count()); // 1注意count不是值,而是一个函数。读取时必须调用count()。这一点和 React 的useState返回的值、Vue 的ref返回的响应式对象都不一样。
这个设计不是故意别扭,它有两个关键原因。
第一是"引用透明"。count本身只是函数引用,你把它传给任何模块、任何工具函数,都不会丢失响应性。在 React 中,如果你把 state 值解构出来传给子组件,子组件并不会对这个值建立响应;而在 Solid 中,你可以放心地把count这个 getter 作为 props 传来传去,子组件在渲染函数里调用count()时自动建立依赖。
第二是"读取即订阅"这个机制能够成立。Solid 在运行时会维护一个全局的"当前追踪上下文"栈。当你在createEffect回调里调用count(),count内部就知道"当前存在一个活跃的 effect,我是这个 effect 的依赖"。如果 Signal 返回的是一个值,JavaScript 变量本身没有机会插入这种拦截逻辑。
setter 也一样简单:
setCount(prev => prev + 1);它支持传入函数做增量更新,这和 React 一致。但和 React 不同的是,Solid 的 setter 不关心组件生命周期,它只是更新一个内部值,然后通知订阅者。这也是为什么 Solid 的 Signal 可以脱离组件在模块顶层使用,比如全局状态:
// store.js export const [user, setUser] = createSignal(null);组件里直接引入user和setUser使用,没有任何 Provider 嵌套。
2.2 createEffect 与 createMemo:依赖收集是怎么发生的
createEffect是 Solid 中最常用的副作用入口。它的职责是"依赖状态变化时重新执行"。看一个例子:
import { createSignal, createEffect } from "solid-js"; const [width, setWidth] = createSignal(window.innerWidth); const [height, setHeight] = createSignal(window.innerHeight); createEffect(() => { console.log(`尺寸变化: ${width()} x ${height()}`); });这段 effect 首次会立即执行一次,之后只要width或height任意一个变化,它就会重新执行。createEffect的依赖收集完全自动,你不需要像 React 那样声明依赖数组。原理是:当 effect 回调开始执行时,Solid 把当前 context 标记为一个追踪作用域;回调内任何 Signal 的读取都会向这个作用域注册依赖;当回调执行完毕,solid 得到一份完整的依赖列表;之后任何一个依赖发生变化,这个 effect 就会被重新调度。
需要特别注意的是:依赖收集发生在"读取 Signal 的当下"。
createEffect(() => { if (count() > 0) { console.log("count大于0"); } else { console.log("count小于等于0"); } });这里 effect 同时依赖count,因为回调内部无论走到哪个分支都需要读取count。但如果你的写法是条件嵌套,只有某条路径才读取某个 signal,那么只有实际执行到的那条路径上的 signal 才会被记录。这是响应式系统非常常见的陷阱,后面讲问题时会展开。
createMemo则扮演"派生状态缓存"的角色。和 effect 不同,memo 有返回值,并且只有当依赖变化时才会重新计算。看这个例子:
const [list, setList] = createSignal([1, 2, 3]); const total = createMemo(() => { const arr = list(); console.log("计算总数"); return arr.reduce((sum, n) => sum + n, 0); });模板里同时多处使用total(),比如在两个 div 里分别显示,memo 的计算函数也只会在list变化时重新执行一次,其余时间直接返回缓存值。这和useMemo在语义上类似,但关键区别是 Solid 的 memo 是真正的细粒度缓存,不依赖比较机制,依赖更新后自动失效,读取时惰性计算,绝不出现"依赖数组忘了写"这种问题。
2.3 编译期 S 记号:模板如何变成细粒度更新指令
细粒度响应式不能只靠运行时,编译期的工作是另一个主要功臣。Solid 官方文档提到一个称为"编译时 S 记号"的概念,说人话就是:JSX 模板在编译阶段会被分析,标记出哪些位置是"动态表达式",然后为每个动态表达式生成对应的更新函数。
举个例子,这样一个组件:
function Counter() { const [count, setCount] = createSignal(0); return <div class="counter">当前值: {count()}</div>; }Solid 的 Babel 插件编译后大致会生成这样结构化的代码(实际产物更复杂,这里简化展示):
const _tmpl$ = document.createElement("template"); _tmpl$.innerHTML = `<div class="counter">当前值: <!----></div>`; function Counter() { const [count, setCount] = createSignal(0); const _el$ = _tmpl$.content.firstChild.cloneNode(true); insert(_el$, count, _el$.lastChild); return _el$; }这里的insert是 Solid 运行时的指令函数。它在首次执行时把count()的值填到注释节点之后,并且注册一个 effect,让后续count变化时只更新文本节点,完全不碰 div 的 class、完全不重新执行组件函数。
如果你关注过 Vue 3 的编译优化,会发现思路有相似之处——Vue 3 也会标记静态节点和动态节点,编译时生成 patch flags。但两者的关键区别在于:Vue 3 的优化产物依然是"虚拟 DOM 节点 + 更新时 diff",而 Solid 的产物直接就是"DOM 更新指令 + 响应式依赖"。Solid 没有虚拟 DOM,整个更新链路里没有任何 VNode 的创建和比较阶段,这是两者本质上的分水岭。
除了文本节点,Solid 还为不同属性类型生成了不同的更新函数。比如:
<div class={active() ? "on" : "off"} style={{ color: active() ? "red" : "gray" }}>编译时会生成针对className和style.color的独立 effect。某个派生状态变化时,可能只更新 class 字符串,style 不动;另一个状态变化时,只更新 color,class 不重新拼接。这种属性级隔离,是"细粒度"三个字最直观的体现。
2.4 Store、createResource 与其他响应式原语
Signal 处理的是单个值,但真实项目里更多是嵌套对象和异步数据。Solid 为此提供了createStore和createResource。
createStore返回一个代理对象和 setter。
import { createStore } from "solid-js/store"; const [state, setState] = createStore({ user: { name: "Tom", profile: { age: 18 }, }, }); setState("user", "profile", "age", 19);Store 的更新方式是路径式更新,setState接收一组 key 路径,最后是值或函数。这样做有两个重要作用:
- 运行时可以精确追踪更新的最小分支。模板里如果只读取了
state.user.name,那么修改state.user.profile.age不会触发 name 相关的更新。 - 省略
Set和Map等复杂结构维护成本。Store 更新时对未修改的其他字段做浅比较和结构合并,不存在的路径也可以补充创建,减少手写不可变更新的痛苦。
createResource则处理异步请求。最常见的用法是配合resource()的 getter 在模板中读取:
const [user] = createResource(() => userId(), fetchUser);当userId变化时,fetchUser会被重新调用,而user()在模板中读取时,会自动更新相关绑定。这里要注意,createResource的第一个参数必须是函数或数组,它决定请求的依赖源。如果依赖的信号不经过函数包装,直接传入,那createResource就无法建立响应式关系。这个细节也是很多新手踩坑的地方。
3. 在真实组件中做细粒度:模式、陷阱与性能心法
3.1 组件函数只执行一次意味着什么
React 开发者第一次用 Solid 时,最需要扭转的观念是:组件函数不是"渲染函数",而是"创建函数"。Solid 的组件函数在挂载时执行一次,返回一个真实的 DOM 元素或片段,之后无论内部信号怎么变,这个函数都不会被重新调用。
这带来一个巨大优势:所有在 React 里需要useCallback、useMemo、useRef才能解决的问题,在 Solid 中大部分都消失了。比如 props 解构:
function Card(props) { const [name, setName] = createSignal("Tom"); return ( <div> <p>{props.title}</p> <p>{name()}</p> </div> ); }props本身是一个代理对象,模板读取props.title时会建立依赖。你不需要关心Card函数有没有被重跑,只要你没有解构 props,那么父组件传入 props 对应的信号变化时,这个p标签会自己更新。
不过也有一个需要适应的点:Solid 组件函数没有"渲染周期",所以你不能再按照"每次渲染都执行组件函数体"来思考副作用。createEffect才是你处理副作用的唯一正确位置。在组件函数体里直接写setInterval这类代码,只会执行一次,不会像 React 那样每次渲染都重新创建。很多人刚上手时会担心这是不是 bug,其实这正是 Solid 的特性。
3.2 控制流组件为什么不可替代
Solid 的模板里没有v-if、v-for这种指令,也没有 React 的.map()直接返回元素这样随意的写法。它提供的是Show、Switch、For、Index等封装好的控制流组件。
以Show为例:
<Show when={visible()} fallback={<div>加载中...</div>}> <Panel /> </Show>Show在编译后不是简单的visible() ? children : fallback这样每次求值整体替换。它会根据条件为 true/false 动态创建或销毁对应的 DOM 子树,两个分支的节点各自独立,不会在切换时重复执行无关代码。当visible从 false 变为 true 时,只有<Panel />这一分支被创建并挂载,若 fallback 分支已存在则被卸载清理。
For和Index是处理列表的关键。For是 keyed 列表,要求每个数据项有唯一 key,适合动态增删、排序场景;Index是按索引更新的列表,适合定长列表或只更新项内部状态的场景。它们在内部都维护了每个列表项的独立生命周期和依赖关系。这个设计对性能影响很大:如果你在列表项内部读取item()的某个字段,那么只有该字段变化时这一项的 DOM 会更新,其他项完全不受影响。
有一个实际经验:如果列表项需要根据索引取相邻项,比如"显示上一条数据",那Index更合适;如果列表需要按 key 复用 DOM,比如 todo 列表的增删反转,那For才对。选错的话,轻则多渲染,重则 DOM 状态错乱。
3.3 性能优化:细粒度反而要小心"过度优化"
Solid 因为更新粒度足够细,很多时候不需要开发者做太多性能优化动作。但也因为粒度细,容易出现"微观上很高效,宏观上做了很多无用功"的情况。
第一个典型问题是在 createEffect 或 createMemo 里读取大对象。
const [state, setState] = createStore({ user: { profile: { theme: "dark" } } }); createEffect(() => { console.log(state.user.profile.theme); });这里 effect 只依赖theme字段,修改state.user.name不会触发它。这是 Store 的路径追踪能力。但如果你写成:
createEffect(() => { console.log(JSON.stringify(state.user)); });那就等于依赖了state.user的完整内容。Solid 虽然做了路径追踪,但JSON.stringify会读取整个user对象下的所有字段,导致任何字段变化都触发该 effect。这不是框架的锅,是订阅方式写粗了。
第二个问题是"读值位置"。Signal 的读取只有在追踪上下文中才建立依赖。在事件回调里读取 signal 是正常的,它每次读取的都是最新值,不会建立依赖;在setTimeout里读取同理。这些都没问题。容易出问题的是在派生逻辑里读取并传值给其他模块。比如:
const doubled = count() * 2; // 普通变量,不响应这句代码只在组件创建时执行一次,doubled永远等于初始值。正确做法是用createMemo:
const doubled = createMemo(() => count() * 2);第三个问题是手动batch。Solid 默认对同一次状态更新引发的多个变化不会分别同步执行 effect,而是微任务批量处理。所以大多数情况下不需要手动batch。只有当你在同步循环里连续更新大量状态、希望强制合并时才需要。日常开发里我很少主动用batch,Solid 已经处理得很好了。
表格对比一下三种常见场景下的性能策略:
| 方案 | 默认更新粒度 | 需要开发者手动优化吗 | 内存/GC 压力 |
|---|---|---|---|
| React | 组件级重渲染 | 需要 memo、useCallback、useMemo | 较高,虚拟 DOM 树频繁创建 |
| Vue 3 | 组件级响应触发 + VNode diff | 配合 computed、v-memo 局部优化 | 中等,仍有 VNode |
| Solid | 表达式级 DOM 指令 | 基本不需要 | 更低,无虚拟 DOM 中间产物 |
4. 常见问题与排查技巧实录
4.1 解构 Store 导致响应性丢失
这是 Solid 新手最常见的坑。createStore返回的是响应式代理,直接解构代理对象得到的是当前值的快照,不再是响应式引用:
const [state] = createStore({ name: "Tom", age: 18 }); // 错误做法 const { name } = state; name; // 只是一个字符串,后续 state.name 变化不会反映到这里 // 正确做法 const name = () => state.name;用 getter 函数包装一层即可。如果是 store 字段在组件中频繁读取,可以借助solid-js/store导出的useSelector或直接用路径式读取。但最推荐的习惯是:尽量在模板或 effect 里直接读state.xxx,不要解构。
对比之下,createSignal的 getter 函数本身可以安全解构:
const [count, setCount] = createSignal(0); const getCount = count; // 没问题,getter 是引用透明的这也是 Signal 设计成函数的一大优势。它绕开了"解构丢失响应性"这个所有响应式库都要面对的基本难题。
4.2 条件分支里的依赖收集不完整
回应前面提到的那个陷阱,看这段代码:
createEffect(() => { if (isLogin()) { console.log(userName()); } });当isLogin从 true 变为 false 时,这个 effect 会重新执行(因为依赖了isLogin);但执行时不会读取userName,此时 Solid 会判断"新依赖列表里没有userName",于是取消了对userName的订阅。之后isLogin还是 true 且userName变化时,这个 effect 不会追踪到。这就是响应式系统经典的"条件分支导致依赖丢失"问题。
排查思路也很简单:在 effect 内对某个 signal 的条件读取,要么保证所有分支都能读取它,要么就把依赖提升到 effect 外部:
const currentName = userName(); // 先读取,建立依赖 createEffect(() => { if (isLogin()) { console.log(currentName); } });不过这种写法要小心,currentName是普通变量,effect 内用到的是创建时的值。如果需要动态追踪,最好把 userName 的读取放到 memo 或直接让 effect 的两个分支都访问它。真实项目中这个 bug 很隐蔽,因为条件是动态的,依赖会在多次运行间不断增删。
4.3 调试工具与手动验证方法
Solid 官方提供了solid-devtools,目前支持在浏览器扩展中可视化查看 Signal 依赖和更新时间线。安装后可以清晰看到某个 Signal 有几个订阅者、某个 Effect 依赖了哪些 Signal。强烈建议在调试复杂状态流时直接打开它,依赖可视化比堆 console.log 高效得多。
如果不想依赖工具,也有一个手动验证依赖订阅的好方法。在需要排查的 effect 或 memo 里加一个计数器,配合状态变更观察执行次数:
let effectRunCount = 0; createEffect(() => { effectRunCount += 1; console.log("effect 运行", effectRunCount, count()); });当你在页面上持续修改count时,如果effectRunCount的增加次数不符合预期,就能判断出依赖收集是否多收或少收。另一个技巧是给 signal 包一层打印 setter:
const [count, setCount] = createSignal(0); function debugSetCount(next) { console.log("setCount 被调用,新值:", typeof next === "function" ? next(count()) : next); setCount(next); }用debugSetCount代替setCount,可以确认变更发生的位置和时机。这个方法我经常用来排查"某个 state 到底是被谁改的",很实用。
4.4 常见问题速查表
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 模板中显示的变量一直不变 | 把 signal 当普通值读取,未用variable() | 模板中使用 getter 调用,或改用 createMemo |
| 解构 store 后界面不更新 | Store 代理对象被解构成了值快照 | 用 getter 包一层,或直接读取state.path |
| effect 内只读了一个 signal,但多次执行 | 条件分支导致依赖集不稳定 | 重构代码,保证所有分支读取相同依赖集合 |
| 列表项顺序错误、DOM 错乱 | 使用 For 时 key 值不唯一 | 检查For的 key 提取逻辑,确保唯一稳定 |
组件内setInterval执行了无数次 | 把定时器放在组件函数体内,想模仿 React 渲染周期 | 把副作用移到 createEffect,并配合 onCleanup 清理 |
| 修改深层 store 字段不触发视图更新 | 使用了非路径式赋值,比如直接替换整个对象 | 使用setState("path", "to", "field", value)路径更新 |
5. 我个人在实际项目中使用 Solid 的心得
最后分享一些比较主观、但对我帮助很大的体会。
第一个体会是:Solid 不适合作为"随手写个页面"的默认选择。它的学习曲线虽然不长,但因为你不能沿用 React 的思维模式,团队如果不是从零开始接受这套范式,中途切换会有阵痛。它真正发光的地方,是那些状态更新频繁、交互密度高、性能敏感的界面——实时仪表盘、股票行情、协同编辑器的光标同步、复杂流程图编辑器、表格组件里上百行数据配合大量单元格实时更新。这类场景用 React 往往要祭出各种 memo 技巧,用 Solid 则几乎不需要优化,响应式系统会自动把更新范围压到最小。
第二个体会是 Solid 和 TypeScript 配合得很好。因为 Signal 的 getter/setter 是普通函数,类型推断非常自然。Signal 的泛型createSignal<T>(initial: T)直接约束 setter 入参类型,template 里读取时也有完整类型提示。Store 的路径更新配合类型系统让"更新深层字段"这个操作变得不容易写错。对比 Vuex 时代的字符串路径,这种类型安全是实打实的效率提升。
第三个体会是 Solid 生态里一些和 React 不同的工具用法反而很顺手。比如createStore天然支持用路径更新深层嵌套状态,配合createResource做异步请求状态管理,几乎不用额外引入状态管理库。小型项目甚至可以把 store 直接定义在模块顶部,省掉上下文和 Provider 的样板代码。
如果你想继续深挖,可以去看 Solid 的solid-devtools源码,了解依赖图的可视化实现;也可以研究 Solid Start 这个元框架,看看细粒度响应式在 SSR 场景下怎么处理数据请求和水合。我自己最近在尝试把 Solid 嵌入到一个现有 React 项目里做局部模块,用自定义元素隔离开来,跑下来效果意外地好——两边互不干扰,Solid 部分的内存和渲染性能都明显优于之前用 React 实现的版本。这类"渐进式接入"的玩法,我觉得会是很多团队引入 Solid 的一个可行路径。
写这篇文章时我又翻了一遍 Solid 源码里createSignal和createEffect的实现,里面最打动我的一点是:整个核心运行时不过几百行代码,却把虚拟 DOM、调度器、diff 算法这些"标配"全部摈弃,靠的只是一套足够纯粹的响应式原语。这不是炫技,而是一种极简主义的设计选择。对前端框架已经有点审美疲劳的人,Solid 确实值得认真玩一玩。