SolidJS — 细粒度响应式 UI 框架
已复核SolidJS 是一个用 JSX 写 UI、但把更新落到信号订阅而不是「整函数重跑」的库。日常类比:react 像每改一个菜名就重印整张菜单;Solid 只改那一行字——菜单函数本身不再执行。
import { createSignal } from "solid-js";
function Hello() { const [name, setName] = createSignal("Ada"); return <h1>你好,{name()}</h1>;}固定 1.9.15 的客户端 createComponent 在生产路径里执行 untrack(() => Comp(props)):Hello 当 setup 跑一次。name() 登记订阅;setName 走 writeSignal,默认 === 比较后把观察者标脏。
DOM 插入不在 solid-js 核心:render / hydrate / <Dynamic> / <Portal> 在 solid-js/web,底层是 dom-expressions。Node 默认 main/module 指向 dist/server.*。
不读这一版源码,这些说法会过期或说反:
- 为什么「组件函数只跑一次」对客户端成立,对 SSR
createComponent却不是同一条路径 - 为什么
enableScheduling()不是默认并发——Scheduler初始为null,更新同步冲刷 - 为什么 1.9.15 的
lazy()失败不再把 Suspense 挂死 - 为什么在 astro 里用 Solid 岛时,peer 写成
solid-js ^1.9.13
Solid 仍是理解「信号派」的钥匙;固定版本把调度、SSR 分发和错误边界写成了可核对的合同。
-
写信号 → 队列 → 计算重跑。
writeSignal用默认equalFn(===)决定是否通知。纯计算(createMemo/createComputed)进Updates;不纯 effect 进Effects。runUserEffects先跑createRenderEffect(user为假),再跑createEffect(user: true)。编译后的 JSX 文本绑定走 render effect,不是 VDOM diff。 -
列表调和仍在。「没有 diff」不成立:
For用mapArray按 item===调和;Index用indexArray固定下标、行内再开 signal。改对象身份会让For拆行重建。 -
props 必须保持 getter。顶层
const { x } = props会丢掉响应式。mergeProps后写覆盖、跳过undefined,函数源会包成 memo。splitProps在$PROXY路径按第一组认领重复 key(1.9.15 与非 proxy 路径对齐)。 -
SSR 与客户端不是同一 runtime。服务端
createComponent直接Comp(props),没有untrack;startTransition/enableScheduling是空操作。lazy()在 1.9.15 拒绝时会通知 Suspense/ErrorBoundary,并清掉缓存 promise,允许下次preload()重试。
案例 1:计数器订阅的是 count(),不是组件重跑
Section titled “案例 1:计数器订阅的是 count(),不是组件重跑”import { createSignal } from "solid-js";
function Counter() { const [count, setCount] = createSignal(0); return ( <button onClick={() => setCount((n) => n + 1)}> {count()} </button> );}createSignal 返回 [Accessor, Setter]。setter 收到函数一律当 (prev) => next;要存函数值必须再包一层。{count} 不调用 accessor,不会订阅,页面上只会看到函数本身。
案例 2:条件渲染用 Show,不要在函数体里 if (count() > 5)
Section titled “案例 2:条件渲染用 Show,不要在函数体里 if (count() > 5)”import { createSignal, Show } from "solid-js";
function Panel() { const [count, setCount] = createSignal(0); return ( <button onClick={() => setCount((n) => n + 1)}> <Show when={count() > 5} fallback="还没到"> 超过 5 </Show> </button> );}Show 的 condition memo 用真值相等(!a === !b)。组件函数不会因 count 变化重跑,函数体里的 if 只反映第一次的分支。keyed 才把 child 参数从 guarded accessor 换成原始值。
案例 3:splitProps 重复 key 归第一组
Section titled “案例 3:splitProps 重复 key 归第一组”import { splitProps } from "solid-js";
function Box(props) { const [visual, rest] = splitProps(props, ["class"], ["class", "id"]); return <div class={visual.class} id={rest.id} />;}固定 1.9.15 里,class 只进入第一组 visual;第二组拿不到它。这是为了让 $PROXY 路径与普通对象路径一致。跨组重复声明时按这个合同读,不要假设后面的组能再拿到同名属性。
-
用 React 的「重渲染」读 Solid:客户端生产路径组件函数不重跑。派生与 DOM 更新发生在 memo / effect 里。SSR 路径不同,不能把客户端句子抄到 server bundle。
-
解构 props:
const { x } = props得到一次快照。要写props.x,或splitProps/mergeProps。 -
把
<Dynamic>当成 core 导出:它在solid-js/web。从solid-js默认入口拿不到。 -
假设默认就是 concurrent:只有调用
enableScheduling()才把Scheduler换成requestCallback。测试里常见enableScheduling(null)关回同步。 -
把 1.9.13 tag 当成 npm latest:npm
1.9.15对应版本 bump 提交a252c783...;GitHub 在审查时没有v1.9.15tag。
适用 vs 不适用场景
Section titled “适用 vs 不适用场景”适用:
- 需要细粒度订阅的交互界面,并且团队接受「setup 一次」心智
- 作为 astro 岛——同 pin 的
@astrojs/solid-jspeer 正好是^1.9.13 - 想对照 Vue
ref/ Svelte$state/ Angularsignal的信号模型
不适用:
- 必须直接复用 React 生态(RHF、多数 headless UI)——Solid 有自己的端口,数量少很多
- 把「无 diff、无 runtime 结构」写成绝对句——
For/Index有调和,信号图和 transitiontValue都在 - 需要已发布 Git tag 与 npm 版本严格三方一致,又不能接受「无
v1.9.15tag」这条 provenance 缺口 - 静态营销页且没有任何客户端状态——用 astro 不加水合即可
固定版本边界
Section titled “固定版本边界”- 本文绑定
solidjs/solid@a252c783b...,packages/solid/package.json为1.9.15。 - npm latest 同为
1.9.15,但未暴露gitHead;canonical 仓库没有v1.9.14/v1.9.15tag。最近稳定 tag 是v1.9.13(剥皮3be495ce...)。 - 核心依赖是
csstype、seroval、seroval-plugins;DOM API 在solid-js/web。 - 1.9.15 changelog 还提到
createSelector的 transition 行为与enableExternalSource在 transition 后重新订阅;本文未展开这两条。 - 未安装依赖、未跑上游测试或 JS Framework Benchmark,状态保持
UNVERIFIED。
- JSX 不是 React——同样的尖括号,编译器可以生成「一次 setup + 精确订阅」。
- 默认同步,并发是开关——
enableScheduling改变的是队列如何被推迟,不是信号语义本身。 - SSR 分发必须单独读——server entry、transition stub 和
lazy错误路径与浏览器 bundle 不一致。 - 版本锚点可以缺 tag——包版本与 bump commit 一致仍可绑定,但必须把缺失 tag 写进边界,不能补一个不存在的
v1.9.15。
- 客户端生产路径里,
setCount(1)会让组件函数Counter再执行一次吗? splitProps(props, ["class"], ["class", "id"])之后,第二组还能读到class吗?- 未调用
enableScheduling()时,一次setCount会等到requestIdleCallback才冲刷 effect 吗?
检查点:
- 不会。生产路径是
untrack(() => Comp(props)),重跑的是订阅了count的计算,不是Counter本身。 - 不能。1.9.15 规定重复 key 归第一组。
- 不会。默认
Scheduler为null,completeUpdates同步跑完Updates与Effects。
- 官方文档:solidjs.com
- 固定源码:solidjs/solid —— 本文绑定提交
a252c783b709e84e1e650a774d6cb52af7624ce7 - astro —— 同批次对照的 metaframework;Solid 在那里是可选岛
- react —— 相同 JSX、相反执行模型
- vue ——
ref/ 依赖追踪的另一条工业实现
- react —— 重渲染 vs 细粒度订阅
- vue —— 信号心智的近亲
- astro ——
@astrojs/solid-js把本库嵌进岛 - vite —— Solid 编译插件通常挂在 Vite 上;本 pin 未审查 SolidStart
- mobx —— 寄生在重渲染框架上的信号派
- tanstack-query —— 有
@tanstack/solid-query端口,不在本次源码范围
- parnas-information-hiding-1972 —— Parnas 信息隐藏 1972 — 模块化设计原则
- self-adjusting —— Self-Adjusting Computation — 输入小幅变化时只重算受影响的那部分
- astro —— Astro — 内容站点优先的 Web 框架
- mobx —— MobX — 让 state 像电子表格一样自动重算
- qwik —— Qwik — Resumable UI 框架
- svelte —— Svelte — 编译时 UI 框架