全景:Solid 的定位与现状
钻进 API 前先答三问:它的心智模型是什么、生态到哪了、四大框架怎么选。
Solid 的立身之本是细粒度响应式:组件函数只跑一次,把数据和 DOM 的依赖关系布好线,之后状态一变就只改那一个节点——更新链上没有组件函数。
一句话心智模型
- 组件函数只执行一次。JSX 长得和 React 一样,语义却相反:React 里它是「每次更新都重跑的渲染函数」,Solid 里它是「一次性的布线说明书」;
- 真正的更新单元是信号(
createSignal),不是组件。版本基准 Solid 1.x,全栈元框架是 SolidStart;它的卖点从来不是生态,是运行时开销。
两步机制,推出三条反直觉规则
- 编译期把静态结构变成一个 HTML 模板字符串(运行时 clone 一次就完事),动态位置只留下一句
insert;运行时insert判断参数是不是函数,是就用createRenderEffect包一层——这一句是整个框架的枢纽; - 于是:读信号要调用(「更新」的实现就是重新调用那个函数)、props 不能解构(解构等于当场取值,读取时机丢了)、条件要用
<Show>(三元在布线时就把分支定死了)。
import { createSignal } from "solid-js";
// 组件函数只跑一次;点击只更新那个文本节点
function Counter() {
const [count, setCount] = createSignal(0);
return (
<button onClick={() => setCount(c => c + 1)}>
点击 {count()} 次
</button>
);
}Solid 不是「最好」,而是一种取舍——赢在运行时性能和无重渲染的心智,输在生态和招聘。
和另外三家怎么选
- 本页的长短同根:把响应性下沉到信号本身,组件只跑一次——于是性能常年基准第一、无需 memo,代价是生态最小、规则反直觉;
- React 生态与人才池最大;Vue 上手曲线最平;Svelte 产物最小、写法最接近原生;
- 性能敏感的交互密集应用、认同信号模型又喜欢 JSX 的团队,Solid 是强选项。
// React:组件函数每次 setState 都重跑
function Counter() {
const [c, setC] = useState(0); // 每次渲染都执行这一行
return <button onClick={() => setC(c + 1)}>{c}</button>;
}
// Solid:组件函数只跑一次,之后只有 {c()} 文本节点更新
function Counter() {
const [c, setC] = createSignal(0); // 只执行一次
return <button onClick={() => setC(c => c + 1)}>{c()}</button>;
}useEffect 思维写 createEffect、用三元代替 <Show>,都会踩响应性的坑。这一页从心智模型讲到全栈,建议按下面的顺序读——先把「组件只跑一次」立住,剩下的都是推论。别一上来就冲 SolidStart。
第一步:立心智(必读)
- 01 上手——把项目跑起来,逐行拆第一个组件,看清 JSX 被编译成了什么。
- 02 响应式核心——
createSignal/createMemo/createEffect三件套,这是全书地基。
第二步:写出真实组件
- 04 组件与 props、05 控制流(
<Show>/<For>)、06 Store(嵌套状态)、07 生命周期与 Context。 - 进阶细节看 03 高级响应式(
batch/untrack)、08 异步资源、09 表单。工程化看 10、11、12。
第三步:全栈 SolidStart(可后读)
- 纯客户端 Solid 学扎实了,再进 13 起步的 SolidStart 部分——文件路由、14 数据加载、15 变更、16 API 路由。最后用 17 规划动手项目。
// 一条主线,四个推论
// 「组件只跑一次」
// ├─ 读信号要调用 → count()
// ├─ 不能解构 props → 写 props.name
// ├─ 条件/列表用组件 → <Show> / <For>
// └─ 副作用进 effect → 别在组件体里裸跑createAsync、action 那些全栈原语会处处别扭——它们全建立在响应式地基上。上手:装环境,跑通第一个组件
从零建项目、看懂 JSX 怎么被编译、装 devtools、读懂最常见的几个报错与告警。
上手 Solid 有两条路——想学语法、做纯前端,用裸 Vite + Solid 模板;想做一个带服务端的站,直接上 SolidStart。别一上来就背全栈,先把语法跑通。
两个脚手架命令
- 裸 Vite + Solid:
npm create vite@latest my-app -- --template solid-ts,然后npm install、npm run dev; - SolidStart:
npm create solid@latest,自带文件路由、SSR 与 Server Functions; - 模板名去掉
-ts就是 JS 版,但学 Solid 建议直接上 TS——props 和信号的类型提示能帮你少踩不少响应性的坑。
怎么选
- 裸 Vite 面积最小,适合先把信号和控制流吃透,代价是没有路由与 SSR;
- SolidStart 一步到位,但同构、服务端函数、水合这些概念会一次涌上来——建议语法通了再上。
# 学语法:裸 Vite + Solid(TS 模板)
npm create vite@latest my-app -- --template solid-ts
cd my-app
npm install
npm run dev
# 做站:SolidStart
npm create solid@latestsolid-ts(或 solid),不是 solidjs,写错会因为找不到模板报错。想做全栈也别用 Vite 模板硬拼路由和 SSR,那是在重造 SolidStart。下面这个计数器只有十行,却藏着三条最关键的规则。
逐行讲
function Counter()——这个函数整个生命周期只执行一次。里面写一句console.log,从头到尾只打印一次,点按钮也不会再打印。const [count, setCount] = createSignal(0)——创建一个信号,返回[读取函数、写入函数]。注意count是个函数,不是值。{count()}——读信号必须调用,加括号。写成{count}只会把函数本身塞进 JSX。onClick={() => setCount(c => c + 1)}——点击时更新信号,Solid 顺着依赖只更新那个显示count()的文本节点,组件函数不重跑。
点一下,到底动了什么
- 点击触发
setCount,Solid 顺着这个信号的订阅者,找到唯一读了count()的地方——那个文本节点,只改它的文字。按钮元素、周围的「点击」「次」这些静态文字一个都不动,更不会重跑组件函数。这就是「细粒度」三个字的字面意思——点两次,文本从0 次变到2 次,而组件函数体只被调用一次。
import { createSignal } from "solid-js";
function Counter() {
console.log("只打印一次"); // 组件函数只执行一次
const [count, setCount] = createSignal(0); // [读取函数, 写入函数]
return (
<button onClick={() => setCount(c => c + 1)}>
点击 {count()} 次 {/* 读信号要加括号 */}
</button>
);
}{count},编译器把它当成一个普通值插进去——此后永不更新,而且不报错。createSignal 给你一对 [getter, setter],读用 count(),写用 setCount(v)。Solid 新手撞的墙,一半根本不报错,只是「静默不更新」;另一半的报错文案又很陌生。这里只列在实验台真见过的两类。
坑一:解构 props,静默不更新(不报错)
- 先装 solid-devtools(浏览器扩展 +
solid-devtoolsnpm 包,开发期工具,正式构建不该包含):它把看不见的响应式图显示出来——组件树、有哪些信号和 memo、它们连着哪些副作用。遇到「点了没反应」的固定流程:① 组件树里找到那个组件;② 看它读的信号有没有连到你改的那个;③ 没连上,十有八九是下面这两类静默错误。 - 没有任何报错,页面只是永远停在第一次的值。正确写法是一路用
props.name,或用splitProps。
坑二:在服务端调用了浏览器专属 API
- SolidStart 会先在服务端跑一遍组件,此时
window/document/localStorage都不存在,报ReferenceError或not defined; - 解法是把这类调用放进
onMount——它只在客户端执行。
// 静默失效:解构 props(父层 v 变了这里不更新,且不报错)
function Broken(props) {
const { v } = props; // 快照,断了响应
return <span>{v}</span>;
}
// 正确:保持访问
function Live(props) {
return <span>{props.v}</span>;
}
// 服务端调用 client-only API 会抛:
// "Client-only API called on the server side...."
// 解法:放进 onMount,或用 <Show> 条件渲染响应式核心:signal / effect / memo
三件套搭起从数据到 DOM 的依赖图——这是 Solid 一切的地基。
createSignal 是 Solid 响应式的最小单元。一句话——它给你一对 [读取函数、写入函数],读要调用,写可以直接给值、也可以基于旧值。
读要调用,写有两种
const [count, setCount] = createSignal(0)——count是访问器函数,读值写count(),不是count。- 写有两式——
setCount(5)直接设值;setCount(c => c + 1)基于旧值更新。连续更新、或不确定当前值时,用函数式更稳。
为什么读是函数调用
- 正因为读是一次函数调用,Solid 才能在你读它的那一刻「记一笔」——谁读了我,谁就订阅了我。这套自动依赖收集是整个响应式的核心。写成裸变量就没有这个「读」的时机,也就无从追踪。
能装什么、初值怎么给
- 信号能装任何值——数字、字符串、布尔、对象、数组,甚至函数(存函数要包一层,见下方 tip)。
createSignal()不给初值时,初值是undefined。 - 在响应式作用域外面读信号完全没问题(比如在事件处理函数里读一下当前值),只是那次读不建立依赖——依赖只在 effect、memo、JSX 这些追踪上下文里才会被收集。
lab 里跑过
- 实验台里
setCount(5)后count()是5,再setCount(c => c + 1)后是6——函数式更新拿到的就是当前最新值。
import { createSignal } from "solid-js";
const [count, setCount] = createSignal(0);
count(); // 读:必须加括号
setCount(5); // 写:直接设值
setCount(c => c + 1); // 写:基于旧值(连续更新更稳)count(函数)当值用。count + 1 是「函数加一」,得到的是一串拼上了函数源码的字符串(不是数字,也不是 NaN);value={count} 这类属性绑定会把函数源码写进属性且永不更新。唯一的例外是 JSX 的插入位置——那里 {count} 和 {count()} 编译成同一句,恰好能用(见 01 章)。别指望这份运气,读信号永远加括号。setCount(fn) 会把 fn 当「基于旧值的更新函数」执行掉。真要存函数,写 setCount(() => fn)。日常存数字、字符串、对象没这问题。createEffect 是响应式的「出口」——把信号的变化接到副作用上(打日志、发请求、手动碰 DOM)。它自动追踪函数体里读到的信号,任何一个变了就重跑,不用手写依赖数组。
自动、动态的依赖
- effect 体里读到哪个信号,就订阅哪个,无需 React 那样的依赖数组。而且依赖是动态的——每次重跑只订阅这一次实际读到的信号。
if (a()) b()里a()为假时,这轮就不订阅b。
时序:初次运行没那么「立刻」
- 在一个响应式作用域(
createRoot或组件的render)里创建的 effect,初次运行会被延后,等所属作用域的同步代码跑完才统一冲刷——不是立刻、也不是微任务。 - 实验台复现——在
createRoot(d => {...})的函数体内部读 effect 写的变量,还是undefined;等createRoot返回之后再读,就已经是最新值了。
创建时先跑一次
- effect 创建时会立刻跑一次(在同步作用域结束时冲刷),目的是拿到初值、把依赖收集上——之后才靠依赖变化驱动重跑。这点和 React 的
useEffect不同——它不需要依赖数组,也没有「只在挂载后跑」的空数组写法,依赖完全由「这一次读了谁」自动决定。
什么该进 effect
- 只放副作用——日志、订阅、手动操作 DOM、和外部系统同步。派生一个值别用 effect,那是
createMemo的活(effect 没有返回值,产物也不能被别人追踪)。
import { createSignal, createEffect } from "solid-js";
const [name, setName] = createSignal("Alice");
const [age, setAge] = createSignal(25);
createEffect(() => {
console.log(`${name()} is ${age()}`); // 读了 name、age → 订阅两者
});
setName("Bob"); // name 变 → effect 重跑
// 动态依赖:showName 为 false 这轮就不订阅 name
createEffect(() => {
if (showName()) console.log(name());
});setXxx 去写自己依赖的信号,容易绕成循环或反复触发。要「从 A 算出 B」,用 createMemo;只在个别场景才用 on(...) 显式指定依赖(详见 03 章)。onCleanup(() => ...) 会在下次重跑前、以及销毁时先执行,天然适合「订阅/退订」成对操作(详见 07 章)。createMemo 声明一个派生值——从别的信号算出来、结果被缓存,依赖不变时重复读也不重算。读法和信号一样,加括号 total()。
缓存、依赖变即算、纯
- 缓存——只要依赖没变,多次读
total()只计算一次,后续直接给缓存值。 - 只在依赖变时重算——
price或qty变了,才重新算一次。 - 必须是纯函数——只做计算、同步返回一个值,别在里面发请求或改 DOM(那是 effect 的活)。
和 effect 的关键区别
createMemo有返回值,且这个值能被别的 effect/memo 追踪;createEffect没有返回值,是响应式的终点。要「A 变了自动算出 B、B 还要被别处用」,就用 memo,别用「effect 加一个额外信号」去凑。
和「写个普通函数」比
- 你当然可以写
const total = () => price() * qty()——但它每次被读都重算,自己也不是一个能被追踪的源。createMemo多给两样东西——缓存(依赖没变就不重算)和信号身份(下游只在它的结果真的变了时才更新)。计算便宜、只读一次,普通函数就够;计算贵、或被多处读,才上 memo。
lab 里跑过
- 实验台里给 memo 的计算函数插桩——连读两次
total(),计算只发生一次;setQty改依赖后立刻发生第二次计算(急切,不等你读它,哪怕没有下游在用),之后读到的是新缓存值。缓存与「依赖变才重算」都成立。
import { createSignal, createMemo } from "solid-js";
const [price, setPrice] = createSignal(2);
const [qty, setQty] = createSignal(3);
const total = createMemo(() => price() * qty());
total(); // 读,加括号
total(); // 依赖没变,直接给缓存,不重算
setQty(4); // 依赖变 → 下次读 total() 得到新值setXxx)。memo 该是同步纯函数,只返回一个值;有副作用会让它的执行时机变得难以预期。副作用请放 createEffect。a() + 1)直接写进 JSX 就行,套 memo 反而多一层。signal、memo、effect 是响应式的三件套,分工很清楚——signal 是源、memo 是派生、effect 是出口。记住这条,就知道该用哪个。
| 原语 | 角色 | 返回值 | 能否被追踪 | 典型用途 |
|---|---|---|---|---|
createSignal | 源(可读可写) | 返回 [读、写] | 能(读它就订阅) | 状态源头——计数、输入值、开关 |
createMemo | 派生(只读) | 有(缓存值) | 能(读它就订阅) | 从信号算出的值——合计、过滤后的列表 |
createEffect | 出口(终点) | 无 | 不能 | 副作用——日志、订阅、同步外部系统 |
怎么选
- 要一个能改的状态源 →
createSignal。 - 要从状态算出一个值、还可能被别处用 →
createMemo。 - 状态变了要做点事、不产出值 →
createEffect。
数据只往一个方向流
- 源 → 派生 → 出口——signal 喂给 memo,memo(或 signal)喂给 effect。别让 effect 回头去 set 上游 signal 制造环;需要「从 A 得 B」时,那是 memo 的职责。
用一个购物车串起来
- signal——
price和qty是用户能改的源。 - memo——
total从两个源派生,界面里多处(小计、结算按钮)都读它。 - effect——总价一变就上报埋点或写日志,这是不产出值的副作用。三者各就各位,数据从源一路流到出口,谁都不回头。
// 源 → 派生 → 出口
const [x, setX] = createSignal(1); // 源
const double = createMemo(() => x() * 2); // 派生(有值、可被追踪)
createEffect(() => console.log(double())); // 出口(无值、终点)setResult(...))。这样多一次冲刷、时序更绕,还容易漏依赖。能用 memo 表达的派生,就用 memo。「我明明 set 了,怎么没反应?」十有八九是相等性在作祟。信号默认用 === 判断新旧值,相等就不触发——对对象直接改属性再 set 回去,引用没变,等于没 set。
坑:改属性 + set 同引用 = 不触发
obj().count++再setObj(obj())——你改了属性,但传进去的还是同一个对象引用,===判定「没变」,依赖它的 effect 和 DOM 不重跑。- 实验台复现——拿到信号里的对象,改它的属性,再
set同一引用,effect 的运行次数没有增加。
三条出路
- 建新对象——
setObj(prev => ({ ...prev, count: prev.count + 1 })),新引用,必触发。 - 用 Store——
createStore在属性级拦截读写,不靠整体引用相等,天生适合嵌套对象/数组(详见 06 章)。 - 关掉相等性——
createSignal(v, { equals: false }),每次 set 都触发(连相同值也触发)。少用,适合「值语义上没变但就是要重跑」的场景。
lab 里跑过
- 实验台里对比过——默认信号 set 同引用,effect 不重跑;同一个对象换成
{ equals: false }的信号再 set,effect 重跑了。印证默认走===、equals:false能强制触发。
const [obj, setObj] = createSignal({ count: 0 });
// ✗ 改属性后 set 同一引用,=== 判定没变,不触发
obj().count++;
setObj(obj());
// ✓ 建新对象(新引用),必触发
setObj(prev => ({ ...prev, count: prev.count + 1 }));
// ✓ 关掉相等性:每次 set 都触发
const [v, setV] = createSignal(0, { equals: false });push、改属性再 set,不报错也不更新。规则是:对象/数组要么每次换新引用,要么直接上 createStore。高级响应式:batch / untrack / on / 相等性
控制何时追踪、何时冲刷、何时算相等——把细粒度响应用对的关键。
默认情况下你每调用一次 setter,依赖它的 createEffect 与 DOM 就被通知一次。batch(() => {…}) 把括号里的多次 set 攒起来,等这个函数跑完,只统一冲刷一次下游。
它到底省了什么
- 实验台里放一个同时读
a()和b()的 effect,再分别setA(1)、setB(1)——effect 被冲刷两次;把这两次 set 包进batch,effect 只跑一次。 - 省掉的就是那次「中间态」的重算:
a变了但b还没变时,没人真正需要那一版结果。
什么时候才真需要它
- 连着改好几个信号、又只想让下游冲刷一次时最有用。Solid 不会自动替你合并——无论是同步事件处理器,还是
await之后、setTimeout/Promise.then里,两次 set 就是两次冲刷(lab 合成事件与真实委托事件都不自动合并)。异步回调里尤其容易连改多个信号,手动batch最常用在那儿,能避免 UI 抖动和重复计算。 - 把「逻辑上属于同一次更新」的多个 set 圈在一起,语义也更清楚。
batch 只推迟「通知」,不冻结「读取」
- lab 在 batch 内
setA(2)后立刻a()读到的就是2;连派生的createMemo读出来也是最新值——Solid 会按需同步重算 memo。 - 被推迟的只是 effect 和 DOM 这类下游副作用的冲刷,不是值本身。别把 batch 当成「事务快照」。
import { batch, createSignal, createEffect } from "solid-js";
const [first, setFirst] = createSignal("Jerry");
const [last, setLast] = createSignal("Lee");
createEffect(() => console.log(first(), last()));
// 不包 batch:两次 set → effect 冲刷 2 次
setFirst("Anna");
setLast("Wang");
// 包进 batch:effect 只冲刷 1 次
batch(() => {
setFirst("Bob");
setLast("Chen");
first(); // 已是 "Bob",读取拿到的是新值
});batch 给你一份「旧值快照」。它推迟的是对 effect / DOM 的通知,信号和 memo 的读取在 batch 内始终是最新的——想在改之前留住旧值,得自己先用变量存下来。batch,几乎不会错,也不会有副作用。在 createEffect / createMemo 里读一个信号,默认就会「订阅」它,之后它一变就重跑。untrack(() => sig()) 让你读到当前值,却不把它记为依赖。
验证过的行为
- lab 里一个 effect 读
a()、又用untrack(() => b())读b:setB(5)之后 effect 不重跑;setA(5)才重跑。也就是说b只是被「顺便看了一眼」,没进依赖表。
典型用途
- effect 主要关心
a的变化,但过程中需要读一下b的当前快照又不想被它触发——比如「a变了就上报,顺带带上此刻的b」。 - 写日志、埋点、和外部命令式库交互时,读些「参考值」但不想它们把 effect 拖着反复跑。
- 在 setter 里基于「别的信号的当前值」算新值,又不希望这个 effect 订阅那个信号时,
untrack正好断开这层依赖。
和 on 的分工
untrack是「减法」:默认全订阅,个别几个不想要就单独包一层。- 下一张卡的
on是「加法」:默认谁都不订阅,只有点名的源才算依赖。想跳过一两个用untrack,想只保留一两个用on,按哪种更少来选。
是把双刃剑
untrack里的信号变化不会让 effect 重新运行,于是你可能读到「上一次 effect 跑完时的旧值」而不自知。- 只在你明确不想订阅时才用;大多数时候「多订阅一个」比「漏订阅一个」安全得多。
import { untrack, createEffect, createSignal } from "solid-js";
const [a, setA] = createSignal(0);
const [b, setB] = createSignal(0);
createEffect(() => {
const curA = a(); // 订阅 a
const snapB = untrack(() => b()); // 只读 b,不订阅
report(curA, snapB);
});
setB(5); // effect 不重跑(b 没被追踪)
setA(1); // effect 重跑,此时才带上最新的 buntrack 去「优化掉」你其实需要的依赖。漏订阅导致的 bug 是「界面莫名不更新」,极难查——它不报错,只是安静地停在旧值上。untrack 最顺手;如果你想反过来「只订阅某几个、其余全不订阅」,用下一张卡的 on 更直观。on(源, 回调) 把「追踪哪些信号」从自动改成手写:只有你列进第一个参数的源才算依赖,回调体里读到的其它信号一律不追踪。它返回一个函数,交给 createEffect / createMemo 用。
它解决什么
- 普通 effect 是「读到什么就订阅什么」,有时会订阅到你不想要的信号。
on把依赖钉死成你指定的那几个,回调里再怎么读别的都不影响。 - 回调签名是
(值, 上一次的值) =>,天然能拿到prev,做「变化前后对比」很方便。
defer:首次不跑
- 默认 effect 会立刻跑一次做初始化。加
{ defer: true }后,首次同步执行时跳过,只有依赖之后真的变了才第一次运行。 - lab
on(x, fn, { defer: true })建好时fn没被调用;setX(2)才第一次跑,setX(3)时能拿到prev为2。适合「初始值不用管,只在用户改动时才响应」的场景。
多个源
- 第一个参数传数组
on([a, b], …)就能同时追踪多个源,回调收到的值也是数组[aVal, bVal],prev同理。
不只配 effect
on(…)返回的就是个普通回调函数,除了createEffect,也能交给createMemo——让一个派生值只在指定源变化时才重算,回调的返回值就是 memo 的新值。- 当你发现「这个 effect / memo 订阅到了不该订阅的信号」,把它改写成
on(真正的源, …)往往是最直接的修法。
import { on, createEffect, createSignal } from "solid-js";
const [x, setX] = createSignal(1);
// defer:true → 建好时不跑,setX 后才第一次运行
createEffect(on(x, (val, prev) => {
console.log("changed", prev, "→", val);
}, { defer: true }));
setX(2); // 第一次运行:prev 为 undefined,val 为 2
setX(3); // prev 为 2,val 为 3
// 追踪多个源
createEffect(on([width, height], ([w, h]) => layout(w, h)));on(源, 回调) 的返回值要交给 createEffect——写成 createEffect(on(x, fn)),而不是 on(x, fn) 单独调用。单独调用只是造了个函数,什么都不会发生。on 当成「显式依赖数组」来记:谁在第一个参数里,谁才是依赖。想跳过「组件一挂载就跑一遍」的初始化行为时,加 { defer: true } 就对了。Solid 有一组「同步执行」的响应式原语——createComputed 和 createRenderEffect。它们和 createEffect 长得一样,区别在什么时候跑。日常几乎用不到,主要是给库作者的。
时序差在哪
createEffect的回调在初始那轮同步执行里被延迟,等所属作用域(render/createRoot)同步跑完才冲刷。lab 里在createRoot体内部读它的产物是undefined。createComputed与createRenderEffect则同步执行:lab 里在 root 体内部就能读到它们算出的结果。
它们各自的定位
createComputed:立即、无缓存地跑,可以在里面写信号。它是最底层的计算原语,容易写出「A 改 B、B 改 A」的连锁更新,官方也建议普通开发别用。createRenderEffect:在渲染阶段同步跑,用于「DOM 提交前就要读写」的场合(如某些测量、第三方 DOM 库对接)。
先别用这两个
- 派生一个值——用
createMemo(同步、有缓存、纯函数,见 02 章)。 - 做副作用(发请求、手动改 DOM、写日志)——用
createEffect。 - 这俩覆盖了 99% 的需求;只有当你在写通用库、需要精确控制「在渲染前同步跑」时,才去碰
createComputed/createRenderEffect。
import {
createRoot, createSignal, createComputed, createEffect,
} from "solid-js";
createRoot(() => {
const [x] = createSignal(5);
let byComputed, byEffect;
createComputed(() => { byComputed = x() * 2; }); // 同步跑
createEffect(() => { byEffect = x() * 2; }); // 被延迟
byComputed; // 10:已经算好
byEffect; // undefined:root 还没跑完,effect 未冲刷
});
// 日常:派生用 createMemo,副作用用 createEffect,就够了createComputed 当「更快的 memo」用——它没有缓存,而且在里面 set 信号很容易造成同步连锁更新,把简单的数据流绕成一团。要缓存派生值请用 createMemo。createMemo;副作用 → createEffect。看到 createComputed / createRenderEffect 出现在示例里,先想想是不是有更合适的原语,多半是的。信号默认用 === 判断「值有没有变」:新值和旧值 === 相等就不通知下游。这条规则对原始值很自然,对对象/数组却是新手最常踩的坑——你改了对象里的字段,引用没变,Solid 就当它没变。
坑长什么样
- lab 复现:
obj.n = 1之后setO(obj)传回同一个引用,effect 不重跑——因为===认定「没变」。这也是 02 章===陷阱的进阶版。 - 解法之一是每次都造新引用(
setO(o => ({ ...o, n: o.n + 1 })));嵌套结构更推荐用createStore(见 06 章),它在字段级拦截,不看整体引用。
用 equals 定制判定
createSignal(v, { equals: false }):关掉相等性检查,每次 set 都通知——lab 里即便传回同一个对象引用,effect 也照样重跑。适合「就是要强制刷新」的场景。createSignal(v, { equals: (a, b) => … }):自定义「算不算变」。返回true表示「相等、别通知」。
memo 也能定制 equals
createMemo(fn, 初值, { equals })第三个参数同样收equals。lab 里给 memo 配(a, b) => Math.floor(a) === Math.floor(b):源从0变到0.5时floor没变,memo 判定「不变」,下游 effect 不跑;变到1.2才跑。用它可以把「无意义的小抖动」挡在下游之外。
import { createSignal, createMemo } from "solid-js";
// 默认 ===:改属性后传回同一引用,不触发
const [o, setO] = createSignal({ n: 0 });
o().n = 1;
setO(o()); // 引用没变 → 下游不动
setO(p => ({ ...p, n: p.n + 1 })); // 新引用 → 触发
// equals:false —— 每次 set 都通知
const [t, setT] = createSignal(0, { equals: false });
// memo 自定义 equals:floor 相同就当没变,挡住小抖动
const level = createMemo(() => raw(), undefined, {
equals: (a, b) => Math.floor(a) === Math.floor(b),
});equals 的返回值别搞反:返回 true 表示「两者相等、不通知」,返回 false 才触发更新。写反了会得到「要么永远不更新、要么永远在更新」的诡异现象。equals: false 是「我就是要每次都刷新」的开关,处理「值可能相同但仍需重新触发」(比如重播同一段动画、重发同一条指令)时很实用。组件与 JSX、props
和 React 同形不同义的 JSX:从「组件只跑一次」到 class / style / key 的逐条差异。
这是理解 Solid 的总开关:组件函数只执行一次,用来搭好「信号 → DOM」的依赖图;此后信号更新,只会去碰真正用到它的那几个节点,组件体不再重跑。Solid 里几乎所有反直觉的规则,都是这一条的推论。
lab 验证
- 一个
Counter在函数体里runs++,点两次按钮把数字从 0 加到 2 之后,runs仍然是 1——更新只改了那个文本节点,函数一次都没重跑。 - 所以你可以放心在组件体里做「只该做一次」的事(建信号、订阅、算初值),不用像 React 那样担心它每次渲染都重来。
和 React 到底差在哪
| 维度 | React 函数组件 | Solid 组件 |
|---|---|---|
| 函数执行次数 | 每次更新都重跑 | 只跑一次 |
| 更新粒度 | 重渲染整棵子树再 diff | 只更新绑定到变化信号的节点 |
| 读状态 | count(值) | count()(调用 getter) |
| 要不要 memo / 依赖数组 | 要,防重渲染 | 不要,天生细粒度 |
由此推出的三条铁律
- 读信号要调用:
count(),不是count。 - props 别解构(下一张卡)——解构那一刻就成了快照。
- 条件和列表用
<Show>/<For>而不是三元和map(见 05 章)——因为组件体不会再跑第二次去重算它们。
import { createSignal } from "solid-js";
let runs = 0;
function Counter() {
runs++; // 点多少次按钮,这里始终只 +1 到 1
const [c, setC] = createSignal(0);
return (
<button onClick={() => setC(c() + 1)}>
{c()} {/* 只有这个文本节点会更新 */}
</button>
);
}createMemo,需要随信号变化的副作用放 createEffect(见 02 章),别指望函数体自己重跑。00 章第 2 卡给过机制的骨架,这里把完整产物摊开——比骨架多出来的是 props 的 thunk 与事件委托两件事。Solid 的 JSX 和 React 的长得一模一样,编译出来却是两个世界:React 的 JSX 变成 createElement 调用、走虚拟 DOM;Solid 的被编译成真实 DOM 操作加细粒度更新。
编译成了什么
- React——
<button>{c}</button>变成createElement("button", null, c),产出虚拟 DOM 节点,更新时 diff 新旧虚拟树再打补丁。 - Solid——同样的 JSX 被编译成创建真实 DOM 元素的代码,把
{c()}这类动态表达式单独包成一个响应式绑定。信号一变,就直接改那个绑定对应的 DOM,没有虚拟 DOM、没有 diff。
是谁在编译
- 裸 Vite 项目里是
vite-plugin-solid,它内部用babel-preset-solid把 JSX 转成上面那种 DOM 指令。所以.jsx/.tsx文件必须经过这个插件——用别的 JSX 转译器(比如默认的 esbuild JSX)会把它当 React 处理,整套响应式就废了。
这决定了那些规则
- 因为动态部分是编译期就分析出来的绑定,
{count()}必须直接出现在 JSX 里,编译器才能把它接进响应式图。这也是「解构 props 会断响应」的底层原因——解构发生在组件体里,编译器看不到,接不进图。
把产物真打出来看(本机 babel-preset-solid 实跑)
源码:
function Counter(props) { const [n, setN] = createSignal(0); return <div class="box"> <span>{n()}</span> <button onClick={() => setN(n() + 1)}>{props.label}</button> </div>; }
编译产物(原样,未删减):
var _tmpl$ = _$template(`<div class=box><span></span><button>`); function Counter(props) { const [n, setN] = createSignal(0); return (() => { var _el$ = _tmpl$(), // 克隆模板,不是 createElement _el$2 = _el$.firstChild, _el$3 = _el$2.nextSibling; // 位置在编译期就定死了 _$insert(_el$2, n); // 传进去的是「函数 n」,不是 n() _el$3.$$click = () => setN(n() + 1); _$insert(_el$3, () => props.label); // props 读取被包成 thunk return _el$; })(); } _$delegateEvents(["click"]); // 事件委托到根,不是逐元素绑定
- 整个静态结构变成了一个 HTML 字符串模板,运行时只做一次 clone 再取几个节点引用——没有虚拟 DOM,也没有 diff;
_$insert(_el$2, n)这一行是全页最值得记住的一行:编译器把{n()}里的调用剥掉,把信号函数本身交给了运行时。所以「更新」是运行时以后自己去调n()——这就是细粒度更新的实现方式,也是组件体只跑一次的原因;() => props.label那层 thunk 解释了「别解构 props」:编译器保住的是「读取这个动作」,你一解构就变成了「当时那个值」,thunk 里再也没有读取可追踪;_$delegateEvents(["click"])说明事件是委托到根节点的,所以onClick不是真的绑在那个 button 上。委托只对一张 22 个事件的白名单生效,名单外的(onFocus、onChange等)编译器会自动退回真正的addEventListener,不用你操心;真正需要手写on:前缀的场合另有其事,见本章的「命名空间前缀」卡。
// 你写的
<button>{count()}</button>
// React 大致编译成(虚拟 DOM 节点)
createElement("button", null, count);
// Solid 大致编译成(真实 DOM + 细粒度绑定,示意)
const el = document.createElement("button");
insert(el, () => count()); // 把 count() 接成响应式绑定,变了只改这里@vitejs/plugin-react,或让 esbuild 处理 JSX)。JSX 会被当 React 编译,信号接不进响应式图,页面「渲染一次就再也不更新」。Solid 的 JSX 必须走 vite-plugin-solid。Solid 的 JSX 和 React 的长得一样、编译器不一样,所以差异不在语法形状上,而在「同一段写法编译成什么」。按后果分三类记最省事:照抄能用、照抄不存在、照抄不报错但行为不同——第三类才是真会咬人的。
逐条对照
| React | Solid | 照抄的后果 |
|---|---|---|
className="x" | class="x" | 运行时照样认(并进 class),但 TSX 报错 |
htmlFor="id" | for="id" | 同上 |
style={{ fontSize: "12px" }} | style={{ "font-size": "12px" }} | 不报错也不生效,这条最坑 |
style={{ width: 20 }} | style={{ width: "20px" }} | Solid 不自动补 px,同样静默失效 |
key={id} | 不需要,<For> 按引用认身份 | 变成一个真的 key HTML 属性 |
dangerouslySetInnerHTML | innerHTML={html()} | 这个属性在 Solid 里不存在 |
useRef() 加 .current | let el; 加 ref={el} | useRef 不存在(见 07 章) |
{list.map(…)} | <For each={list()}> | 能渲染,但一变更整段重建(见 05 章) |
useState | createSignal,读要加括号 | 见 02 章 |
<>…</>、onClick、{cond && …} | 写法完全一样 | 可以直接照抄 |
最坑的一条:style 写驼峰,一半样式静默消失
- (1.9.14 渲染后读回):
style={{ fontSize: "30px", color: "red" }}生成的属性字符串就是fontSize:30px;color:red——fontSize不是合法的 CSS 属性名,浏览器按 CSS 规则把这条声明整条丢弃,读el.style.fontSize得到空字符串;而同一个对象里写对的color照常生效。 - 所以症状是「一部分样式生效、一部分不见了」,控制台一个字都不报。裸数字同理:
{ width: 20 }输出width:20,也是无效声明——React 会替你补成20px,Solid 不会。 - 动态值走的是另一条代码路径,结果一样:
style={{ fontSize: n() + "px" }}在n变化时属性字符串一路更新成fontSize: 99px,但el.style.fontSize始终是空。响应性是好的,CSS 是废的。
一个形态差异:JSX 求值出来就是真 DOM
const node = <div class="x">hi</div>:node的构造器是HTMLDivElement,node instanceof HTMLElement为true,node.outerHTML直接就是<div class="x">hi</div>。React 里同一行拿到的是一个待渲染的描述对象。<>…</>求值出来是一个普通数组(两个子元素就是长度 2 的数组),没有任何包装节点。- 好处是你可以直接
node.focus()、document.body.append(<div />);代价是这些节点已经建好了——把 JSX 放在会被反复求值的地方,就是在反复建 DOM,这正是children()助手存在的原因。
// class / for:写这两个,别写 className / htmlFor
<div class="card" />
<label for="name" />
// style:CSS 原名,且单位要自己带
<div style={{ "font-size": "12px", width: "20px" }} /> // ✅
<div style={{ fontSize: "12px", width: 20 }} /> // ✗ 不报错,也不生效
// 列表:不用 key,用 <For>(按引用 diff)
<For each={items()}>{(item) => <li>{item.name}</li>}</For>
// innerHTML 直接写;ref 是普通变量,没有 .current
let el;
<div innerHTML={html()} />
<input ref={el} />
// JSX 求值出来就是真 DOM(1.9.14)
const node = <div class="x">hi</div>;
node instanceof HTMLElement; // true
node.outerHTML; // '<div class="x">hi</div>'className 和 htmlFor 在运行时确实被当成 class / for 处理——两个都写会合并成 class="a b",一路不报错。直到有人打开 TSX 或 lint,才发现全项目都要改。新代码一律写 class / for。className、htmlFor、key、dangerouslySetInnerHTML、style 驼峰这几条 React 习惯,TypeScript 全部当场报错(fontSize 那条还直接建议你改成 font-size)。纯 JS 项目里它们要么静默生效、要么静默失效,只能靠肉眼(见 10 章)。这是全页最该记牢的一个坑。props 是个带 getter 的响应式代理对象,靠「在响应式上下文里读 props.x」来保持实时。一旦解构,你就把当下的值抠出来存成了普通变量——从此和父组件断了联系。
lab 复现
- 两个组件收同一个会变的
v:Broken里const { v } = props,Live里直接props.v。父层把v从"a"改成"b"后——Broken还显示 a,Live已经变成 b。 - 原因就是「组件只跑一次」:解构发生在那唯一一次执行里,之后再没机会重新解构,值就冻在了
"a"。
连默认值也别用解构写
- React 习惯的
function C({ size = "m" })在 Solid 里同样断响应。给默认值请用mergeProps,拆分请用splitProps(下一张卡),它们专门设计成保持响应。
怎么写才对
- 在 JSX、
createMemo、createEffect这些响应式上下文里读props.x——读取被追踪,父层一变就更新。 - 需要把某个 prop 传进普通函数时,传访问方式(
() => props.x)而不是当前值(props.x),保住那层「读取时机」。
为什么解构就断了:编译产物里能直接看到
- 01 章那张卡打出了真实产物,其中一行是
_$insert(_el$3, () => props.label)——编译器给每处 props 读取都包了一层 thunk,把「读取」推迟到运行时、每次更新重新执行; - 而
const { label } = props是在组件体里、组件体只跑一次的时候执行的:那一刻就把当前值取出来存成了普通变量,thunk 里读到的是这个死值,再也不会去问 props 要新的; - 所以这不是「Solid 的怪规矩」,而是编译期绑定这套机制的必然结果——凡是把「读取」变成「取值」的写法都会断响应:解构、提前赋给局部变量、传参给普通函数,三种都是同一件事;
- 确实需要解构时用
splitProps/mergeProps,它们返回的仍然是带 getter 的代理对象,读取照样被追踪。
// ❌ 解构:拿到快照,父层变了也不更新
function Broken(props) {
const { v } = props;
return <span>{v}</span>; // 永远是初次的值
}
// ✅ 直接读 props.v:保持响应
function Live(props) {
return <span>{props.v}</span>; // 父层 v 变,这里跟着变
}
// 传给普通函数时,传「读法」而非「当下值」
watch(() => props.v); // ✅ 保住读取时机
// watch(props.v) ❌ 传进去的是快照solid/reactivity 规则能帮你揪出这类写法,建议开上。props,用到哪个就 props.xxx 现读。看到 function C({ ... }) 或 const { x } = props,基本就是 bug 预定。既然不能解构,那「设默认值」和「把 props 拆成几组分发」怎么办?Solid 给了两个专用助手:mergeProps 和 splitProps,它们做这两件事的同时全程保持响应。
mergeProps:合并 / 默认值
mergeProps(默认对象, props)返回一个合并后的响应式对象:props 里给了就用 props 的,没给就用默认的。- lab 验证:
Button用mergeProps({ variant: "primary" }, props),一开始没传variant时类名是primary;父层把variant设成danger后,类名实时跟着变成danger。比{ ...defaults, ...props }展开安全——展开会一次性求值、丢掉响应。
splitProps:拆分
splitProps(props, ["a", "b"])返回[local, rest]:local是你点名的那几个,rest是剩下的,两边都保持响应。- lab 验证:把
label拆给自己用、rest(含placeholder)用{...rest}转发给<input>;父层改label和placeholder,两处都实时更新。这正是「用了自己关心的、其余原样透传」的标准写法。
import { mergeProps, splitProps } from "solid-js";
// mergeProps:给默认值,保持响应
function Button(props) {
const merged = mergeProps({ variant: "primary" }, props);
return <button class={merged.variant}>{merged.children}</button>;
}
// splitProps:拆出 label 自用,其余透传
function Field(props) {
const [local, rest] = splitProps(props, ["label"]);
return (
<label>
<span>{local.label}</span>
<input {...rest} /> {/* placeholder 等原样转发且保持响应 */}
</label>
);
}{ ...defaults, ...props } 或 { ...props } 去「合并 / 复制」props——展开会立刻读取所有属性、把响应性拍平成快照。要保持响应,合并只用 mergeProps、拆分只用 splitProps。mergeProps,要拆分找 splitProps。转发一堆属性给底层元素时,const [local, rest] = splitProps(props, [...自用的]) 再 {...rest},是组件库里最常见的骨架。props.children 在 Solid 里可能是「一个待求值的表达式」——你每读一次,就可能重新创建一次里面的节点。当你需要操作子节点(读它、包一层、数个数)时,用 children(() => props.children) 助手,它把子节点解析一次并缓存,返回一个访问器。
它解决什么
- 直接反复读
props.children,可能触发多次求值、甚至重复创建 DOM 元素,行为难料。 children()把结果记忆下来:多次调用返回的是同一批已解析节点,安全又稳定。
lab 验证
- 传入两个
<span>作为 children,用const resolved = children(() => props.children)拿到访问器;resolved()返回的是数组,长度为 2,把它放回 JSX 渲染出的文本是ab。说明它确实解析成了可操作的节点集合。
什么时候用 / 不用
- 只是原样透传 children(
<div>{props.children}</div>)——不用助手,直接放进 JSX 即可。 - 要读 / 遍历 / 加工 children(给每个子项包一层、读它的属性、统计数量)——用
children(),先拿到稳定结果再操作。
import { children } from "solid-js";
function List(props) {
// 解析一次并缓存,resolved 是访问器
const resolved = children(() => props.children);
createEffect(() => {
const items = resolved(); // 数组:可读、可遍历、可数个数
console.log("共", Array.isArray(items) ? items.length : 1);
});
return <div class="list">{resolved()}</div>;
}
// 只透传时不必用助手:
// function Box(props) { return <div>{props.children}</div>; }props.children 反复取值或做数组操作——它可能被多次求值、造成子节点被重复创建。要遍历 / 加工,务必经过 children() 助手拿缓存后的结果。props.children;要对 children 本身做点什么 → 先 children(() => props.children) 拿到稳定的访问器再动手。Solid 给样式绑定准备了三个入口:class 设单个类名,classList 按对象条件切换多个类,style 用对象写内联样式。三者都自动追踪信号——里面读了哪个信号,那个信号一变,样式就跟着更新,组件体依旧不重跑。
classList:条件切多个类
- 写法
classList={{ 类名: 布尔 }}:值为true就加上这个类、false就去掉。适合「激活态 / 错误态 / 主题类」这种按条件开关的场景。 - lab 验证:
classList={{ tag: true, active: active() }},点击把active从false翻到true——active类被加上,静态的tag类始终保留,再点一下active又移除。切换精准,不影响其它类。
style:对象写内联样式
style={{ color: … }}用对象,属性名用 CSS 原名("background-color"这类连字符名要加引号或写成字符串键)。- lab 验证:
style={{ color: on() ? "green" : "gray" }},信号从false变true时,元素颜色实时从gray变green。
class vs classList
- 只有一个、或整串由信号算出的类名,用
class={…}就行。 - 要同时按多个独立条件开关多个类,用
classList最清爽,省去手工拼字符串。
import { createSignal } from "solid-js";
function Tag(props) {
const [active, setActive] = createSignal(false);
return (
<div
classList={{
tag: true, // 静态类,始终在
active: active(), // 按信号开关
"is-urgent": props.urgent, // 连字符类名用字符串键
}}
style={{ color: active() ? "green" : "gray" }}
onClick={() => setActive(a => !a)}
>{props.children}</div>
);
}style 用对象时,属性名是 CSS 原名("font-size"、"background-color"),不是 React 那种驼峰 fontSize。带连字符的键记得加引号,否则是语法错误。写成驼峰不报错也不生效,细节见本章「JSX 语法」卡。classList 只管你列出的那几个键,不会动元素上的其它类——所以静态类可以直接写在 class 里、动态类交给 classList,两者能共存,各管各的。Solid 的 JSX 比 React 多一组带冒号的命名空间前缀,用来在编译期指定「这个东西到底怎么写进 DOM」。React 只有一张固定的属性映射表,没有对应物。日常用不到,但碰上自定义元素、自定义事件、属性与 property 打架时,只有它们能解决。
六个前缀
| 写法 | 编译成 | 用在 |
|---|---|---|
on:click={fn} | el.addEventListener("click", fn) | 要真正绑在这个元素上 |
oncapture:click={fn} | addEventListener(…, true) | 捕获阶段 |
attr:foo={v} | setAttribute(el, "foo", v) | 强制走 attribute(自定义元素、data-*) |
prop:foo={v} | el.foo = v | 强制走 property(值不是字符串时) |
bool:foo={v} | 真值加属性、假值删属性 | 布尔属性 |
use:xxx={v} | 调用你写的 xxx(el, accessor) | 自定义指令(见 09 章) |
onClick 和 on:click 到底差在哪
onClick走事件委托:监听器挂在 render 的根节点上,元素本身干干净净——一个只写了onClick的按钮,outerHTML就是<button id="del"></button>,一个属性、一个监听器都没有。列表长了很省。- 委托只对一张22 个事件的白名单生效(源码里的
DelegatedEvents:click/input/keydown/keyup/pointer*/touch*/focusin/focusout这类)。名单外的比如onFocus、onChange、onSubmit、onMouseEnter,编译器自动退回真正的addEventListener——这件事不用你操心。 onclick全小写和onClick的编译产物完全一样,大小写不敏感(React 里必须精确写onClick)。
那什么时候真的需要 on:
- 出来最实在的一条:祖先上有一个原生监听器调用了
e.stopPropagation(),子元素的onClick就一次都不触发——委托的监听器在根节点上,事件被半路截停就到不了根。同一个位置换成on:click照常触发。和第三方库、非 Solid 管理的那片 DOM 混用时最容易撞上。 - 另外两种场合:自定义事件(名字不在标准表里),以及需要给
addEventListener传选项。
处理器还能收一个数组
onClick={[handler, data]}编译成el.$$click = handler加el.$$clickData = data,运行时把data作为第一个参数、事件对象作为第二个传进去。onClick={[(d, e) => …, "带过来的数据"]}里d拿到的就是那个字符串。- 好处是列表里每一行不必各造一个闭包。
// 事件:委托(默认) vs 原生
<button onClick={fn} /> // 委托到根,元素上不留监听器
<button on:click={fn} /> // 真的 addEventListener
<button oncapture:click={fn} /> // 捕获阶段
// 处理器传数据:data 是第一个参数,事件排第二
<button onClick={[(id, e) => remove(id), item.id]} />
// 强制指定写法
<my-el attr:count={n()} /> // setAttribute("count", …)
<video prop:srcObject={s()} /> // el.srcObject = …(对象走不了 attribute)
<input bool:disabled={busy()} /> // 真值加 disabled,假值删掉attr: / prop: / bool: / use: 在 jsx.d.ts 里对应的是四个空接口,得自己写 declare module "solid-js" 往里加键(见 10 章)。唯一开箱即用的是标准 DOM 事件的 on:xxx——那些已经被逐个枚举好了。[object Object]、事件被祖先截停、布尔属性该删却留着。日常 class / style / onClick 就够。控制流:Show / For / Index / Switch
为什么不用三元和 map——用控制流组件才不破坏细粒度更新。
在 React 里你习惯用 if、三元、&&、.map() 来做条件和列表——因为组件每次更新都会重跑,这些 JS 表达式也跟着重算。Solid 的组件只跑一次,这些表达式也就只在「布线那一刻」算一次,之后不会再自动重算。所以 Solid 给了一套控制流组件:<Show> / <For> / <Index> / <Switch> / <Dynamic>。
根子上是同一条主线
- 「组件只跑一次」意味着:凡是需要随信号变化而重新决定「渲染什么」的地方,都得交给一个自身是响应式的东西去管——控制流组件就是干这个的。
- 它们内部把「条件 / 列表」接进了响应式系统,信号一变,它们就精确地增删 / 复用对应的 DOM,而不必重跑整个组件。
裸三元 / map 的代价
- 把
{cond() ? <A/> : <B/>}写在 JSX 里也能跑,但每次cond变化,Solid 只能整块替换那段结果,里面的组件被销毁重建、内部状态丢失。 <For>/<Index>则会按策略复用已有节点,只动该动的行,性能和状态保持都更好。
一句话记牢
- 条件 →
<Show>/<Switch>;列表 →<For>/<Index>;动态标签 →<Dynamic>。把 JS 控制流留给普通逻辑,把「渲染什么」交给控制流组件。
import { Show, For, Switch, Match } from "solid-js";
// ❌ 裸三元:cond 变化时整块替换,内部组件被重建
// {cond() ? <Heavy /> : <Empty />}
// ✅ 控制流组件:精确增删 / 复用,保住内部状态
<Show when={cond()} fallback={<Empty />}>
<Heavy />
</Show>map 出来的列表就固定了」——真相更糟:数组一变,那段 map 结果会被整体重建,每一行的 DOM 和内部状态全丢。列表务必用 <For> / <Index>。<Show> / <For>。<Show when={…} fallback={…}> 是最基础的条件渲染:when 为真渲染子节点,为假渲染 fallback(不给就渲染空)。它是响应式的——when 一变就切换,不用组件重跑。
基本用法
- lab 验证:
when从false变true,内容从fallback切到子节点。当成「响应式的 if / else」用即可。
回调形式:把 when 收窄成非 null
- 当
when是「可能为 null 的对象」时,把子节点写成函数{(u) => …}:这时回调参数u是一个收窄后的访问器,调用u()拿到保证非 null 的值,TypeScript 类型也随之收窄。 - lab 验证:
when={user()}为null时显示 fallback;置为{ name: "Ann" }后,回调里u().name正常读出Ann,不会碰到「读 null 的属性」。
keyed:要不要重建整棵子树
- 默认(不加
keyed)时,when从一个非空值换成另一个非空值,子树不重建——只是回调里那个访问器发出新值。lab 子组件函数体在挂载时跑 1 次,把when换成另一个对象后累计仍是 1 次。 - 加上
keyed后,when的值一变就销毁旧子树、重建新的,回调参数也从访问器变成值本身(写u.name而不是u().name)。同一实验里子组件体累计跑了 2 次。 - 怎么选:想让子树跟着数据「重新开始」(重置内部 signal、重放入场动画)用
keyed;只是换个数据、想保住子树状态和 DOM,用默认。这和本章<For>与<Index>的取舍是同一类问题——身份换没换。
直接读 vs 回调读
- 直接读(
when真时子节点里再user().name):写起来短,但 TS 里user()仍可能被推断成可空。 - 回调读(
{(u) => u().name}):拿到已收窄的访问器,配合 TypeScript 更安全,处理「登录用户 / 选中项」这类可空对象时首选。
// 基础:响应式 if / else
<Show when={ok()} fallback={<p>请先登录</p>}>
<Dashboard />
</Show>
// 回调形式:u 是收窄后的访问器,u() 保证非 null
<Show when={user()} fallback={<p>匿名</p>}>
{(u) => <h1>欢迎,{u().name}</h1>}
</Show>u() 调用,不是直接 u.name。写成 u.name 拿到的是访问器函数自己的属性——u 是个函数,u.name 读到的是它的函数名(一个空字符串),不是你要的数据。when 是「可能为空的对象」时,优先用回调形式 {(u) => …}:既拿到非 null 保证、又让 TS 帮你把类型收窄,省掉一堆 user()?.name 的可选链。<For each={数组}> 是对象数组的默认选择。它按引用做 diff:数组重排时,只要某个对象引用没变,对应的行组件就被复用、不重建。回调签名是 (item, index)——item 是值本身,index 是信号(要 index() 调用)。
lab 验证「引用稳定就复用」
- 让每行组件在创建时把 id 记进一个数组。初始
[a, b]记下["a", "b"];把数组换成[b, a](交换的是同两个引用)后,记录仍是["a", "b"]——没有新建行,只是把已有的两行换了位置;而 DOM 文本顺序确实变成了ba。 - 这正是
<For>的价值:增删、排序、拖拽时,未变的行连同它们的内部状态(输入框内容、展开态)都原样保留。
为什么 index 是信号
- 因为行会被移动,同一行的下标是会变的。把
index做成信号(index()),行移动时序号能自动更新,而不必重建整行。 - 反过来,
item是值不是信号——<For>认为「一个引用对应一行」,引用在整行不变,所以直接给你值。
复用的前提是引用别变
- 如果你在更新时用
map把每一项都重造成新对象(哪怕内容一样),引用全变了,<For>会认成「全是新行」而整列重建,复用优势就没了。 - 想改某一项,优先只改它的字段(放进 06 章的
createStore里改),保持其余项引用不动,才能享受到按引用复用。
import { For } from "solid-js";
<For each={todos()} fallback={<p>暂无待办</p>}>
{(todo, index) => (
<li classList={{ done: todo.done }}>
{index() + 1}. {todo.text} {/* index 是信号,要调用 */}
</li>
)}
</For>index 是信号,写成 index(不调用)拿到的是函数本身,index + 1 会得到 "...1" 之类的字符串拼接结果。序号一律 index()。<For>。想让复用生效,关键是别在每次渲染里重造对象——保持列表项引用稳定(存 store 里、或只改属性不换对象),<For> 才能认出「还是那一行」。<Index each={数组}> 和 <For> 相反:它按下标做 diff——「第 i 个位置」是稳定的,位置上的内容变了不新建行,只更新那一格。回调签名 (item, index) 里,item 是访问器(要 item() 调用),index 是普通数字。
lab 验证「下标稳定不重建」
- 让每行创建时把下标记进数组。初始
["a", "b"]记下[0, 1];把数组改成["a", "c"](下标 1 的内容变了)后,记录仍是[0, 1]——没新建行,只是下标 1 那格的item()更新成了c,DOM 文本变为ac。
For vs Index 怎么选
- <For>(按引用)优增删 / 重排时按引用复用行,保住内部状态;对象数组的默认选择短若每次都重造对象、引用不稳,就退化成整列重建为何它认「引用 = 一行」,适合身份稳定、会移动的数据
- <Index>(按下标)优下标稳定、内容原地更新;原始值(数字 / 字符串)列表、定长表单不会因值重复而错乱短数组重排时下标全变,可能触发大量原地更新,不适合频繁排序的对象列表为何它认「位置 = 一行」,适合位置固定、只改内容的数据
对照表
| 维度 | <For> | <Index> |
|---|---|---|
| diff 依据 | 元素引用 | 下标位置 |
| item | 值本身 | 访问器 item() |
| index | 信号 index() | 普通数字 |
| 适合 | 对象数组、会增删 / 重排 | 原始值、定长、只改内容 |
import { Index } from "solid-js";
// 定长 / 原始值列表:item 是访问器,index 是数字
<Index each={fields()}>
{(item, index) => (
<input
value={item()} {/* item 是访问器,要调用 */}
onInput={(e) => update(index, e.currentTarget.value)}
/>
)}
</Index>item / index 正好相反,最容易记混:<For> 是 item 值、index 信号;<Index> 是 item 访问器、index 数字。在 <Index> 里忘了给 item 加括号,绑上去的会是函数而非值。<For>,原始值数组(数字 / 字符串)用 <Index>。一排会被编辑的输入框(值可能重复)尤其该用 <Index>——按位置对齐,不会因两格值相同而串位。要在三个以上的分支里选一个,用 <Switch> 包一组 <Match when={…}>。它从上到下找第一个 when 为真的 Match 渲染,全不匹配就渲染 <Switch> 的 fallback。就是响应式版的 switch / if-else if 链。
lab 验证「第一个真」
- 两个条件按
n() > 5、n() > 0顺序排。n为0时都不满足,走 fallback;n=3命中第二条显示small;n=9时两条都真,取最上面那条显示big。 - 另一组测试:两个
when={true}并列,渲染的是第一个。所以顺序很重要——把更具体 / 优先级更高的条件放上面。
什么时候用它
- 互斥的多态状态:加载中 / 成功 / 失败 / 空——每种一个
<Match>,比嵌套一堆<Show>清楚得多。 - 只有两个分支时,用
<Show>的when+fallback就够,不必上<Switch>。
细节
- 每个
<Match>的when都是响应式的,条件里的信号一变,<Switch>就重新从上往下挑一遍,切到新命中的分支。 fallback可省——都不匹配时就渲染空。分支本身也能嵌套<Show>/<Switch>,但层数别堆太深,太深就该考虑拆组件了。
import { Switch, Match } from "solid-js";
<Switch fallback={<span>未知状态</span>}>
<Match when={status() === "loading"}>
<Spinner />
</Match>
<Match when={status() === "error"}>
<p>出错了</p>
</Match>
<Match when={status() === "success"}>
<Result />
</Match>
</Switch><Match> 同时为真时,只有最靠上的那个会渲染,下面的直接被跳过。如果发现某个分支「永远不显示」,先检查是不是被上面更宽松的条件抢先命中了。<Switch> 只认从上往下第一个真。加载 / 错误 / 成功这类互斥状态用它,比层层嵌套的三元清爽太多。当「要渲染成哪个标签 / 哪个组件」在运行时才定,用 <Dynamic component={…}>。它从 solid-js/web 导入(不是 solid-js),component 接一个标签名字符串("h1")或组件引用,其余属性照常往下传。
lab 验证
- 切标签:
component={tag()},tag从"h1"变"h2"后,页面里<h1>消失、<h2>出现——旧标签被移除,不是留着藏起来。 - 切组件:
component={comp()}在两个组件间切换,内容随之从A变B。
它替你省掉什么
- 本来要写一长串
<Switch>/<Match>去枚举「是标题就渲染 h1、是段落就渲染 p……」,用<Dynamic>一行搞定,尤其适合「按配置 / 数据字段决定标签或组件」的场景(如富文本渲染、可配置表单控件)。
属性怎么传
- 除了
component,你在<Dynamic>上写的其它属性会原样传给被渲染的标签 / 组件——<Dynamic component={tag()} class="x">里的class就落到实际那个标签上。 - 属性也是响应式的,
component和其它 prop 各自的信号变化都会精确更新,不必因为切标签就手动搬属性。
导入位置别搞错
<Dynamic>来自solid-js/web;<Show>/<For>/<Switch>那些来自solid-js。从错误的包导入会直接找不到。
import { Dynamic } from "solid-js/web";
// 按信号切换标签:h1 → h2,旧标签被移除
<Dynamic component={tag()}>标题</Dynamic>
// 按数据字段选组件,省掉一长串 Switch / Match
const registry = { text: Text, image: Image, video: Video };
<Dynamic
component={registry[block.type]}
data={block.data} {/* 其余 props 照常下传 */}
/><Dynamic> 出自 solid-js/web 而不是 solid-js——这是最容易踩的一步。另外 component 传的是标签名字符串或组件引用本身,别写成 <Text /> 这种已经实例化的元素。<Dynamic>:一个查表对象(类型 → 组件)配上 component={map[type]},就能干净地渲染异构内容,免去成片的分支代码。弹窗、下拉菜单、Tooltip 常被祖先的 overflow: hidden 剪掉,或被某个祖先造出的层叠上下文压在下面——这不是 z-index 写小了,是 DOM 位置不对。<Portal>(来自 solid-js/web)把子节点渲染到 DOM 树的别处(默认 document.body 下),而它在组件树里的位置一点没变。
lab 验证:DOM 搬走了,组件树没动
- 在
<div id="in">里放一个<Portal><span id="p">,渲染后host.querySelector("#p")是 null——挂载容器里查不到它;#p的父元素是一个<div>,这个 div 挂在<body>下。 - 注意它多包了一层容器元素,不是把子节点裸塞进 body。容器的
outerHTML就是<div><span id="p"…>弹窗</span></div>。要给这层容器加类名或读它,用ref属性(.d.ts 里 ref 拿到的就是这个HTMLDivElement)。
只搬 DOM,不搬所有权
- Context 照穿:Portal 里的组件
useContext拿到的仍是外层 Provider 的值,不是默认值——因为 Provider 关系是在组件树上,不在 DOM 树上。 - 生命周期照旧:把 Portal 外面的
<Show>关掉,body 里的#p连同那层容器 div 一起消失,里面的onCleanup也跑了。不会漏节点。 - 事件同理——写在 Portal 里的
onClick仍按组件树的层级被上层接住,「点弹窗触发了外面容器的处理器」是预期行为,不是 bug。
几个属性(据 .d.ts)
mount(Node):换落点。mount={某个 <section>}后节点就落在那个 section 里。useShadow:把内容放进容器的 Shadow DOM,隔离外部样式。isSVG:落点在 SVG 里时用,容器换成<g>而不是<div>。
import { Portal } from "solid-js/web";
// 弹窗渲染到 body 下,不受 .card 的 overflow / z-index 影响
<div class="card" style={{ "overflow": "hidden" }}>
<Show when={open()}>
<Portal>
<div class="modal">{content()}</div>
</Portal>
</Show>
</div>
// 换落点
<Portal mount={document.getElementById("toast-root")}>…</Portal>container.querySelector(".modal") 会查不到(返回 null),要去 document.body 或用 testing-library 的 screen.* 查(11 章)。同理,靠「后代选择器」写的样式(.card .modal { … })会整条失效,因为 .modal 已经不是 .card 的后代了。overflow / 层叠上下文造成的,改 CSS 治不了根,把 DOM 挪出去才治得了。Store:嵌套状态的细粒度响应
signal 管简单值,store 管嵌套结构:只读 Proxy、路径式更新、produce 与 reconcile。
信号适合单个值,可一旦状态是嵌套对象或数组,用信号就得每次整体建新对象再替换。createStore 就是为这种结构生的——它返回一个只读 Proxy 和一个路径式 setter,让你「只改一个字段」而完全不惊动其它节点。
读像对象,写走 setter
createStore(初值)返回[state, setState]。读取不加括号——state.user.name就是普通属性访问,Proxy 会在读取的那一处悄悄登记依赖。这一点和信号要写count()正相反,别混。- 但
state是只读的:直接写state.user.name = "Bob"不生效——值不变、也不抛异常,但开发构建会打印告警Cannot mutate a Store directly提醒你走setState。这一读一写的不对称,正是它能做到细粒度的关键。
路径式更新的三种末段
setState("user", "name", "Bob"):前面几段是路径,最后一个参数是新值。- 末段传函数基于旧值算:
setState("user", "age", a => a + 1)。 - 路径中间放谓词函数按条件定位数组项:
setState("todos", t => t.id === 1, "done", true)只改命中的那一项。
细粒度到字段
- 把
a.b与a.c分别绑到两个 effect,执行setState("a", "b", 5),只有读了a.b的那个 effect 会重跑——这就是 store 相对信号的核心红利。 - 对照 React 的
setState({...state, user:{...state.user, name}}),路径式写法既短又精确,还不用手动铺开每一层。
路径能有多深,整体怎么合
- 路径想多深有多深:
setState("a", "b", "c", 值)一路点到底,只更新最末那个字段。数组下标也是路径的一段——setState("todos", 0, "done", true)改第一项。 - 偶尔想「一次改好几个顶层字段」,可以直接传一个对象:
setState({ loading: false, error: null })。这是浅合并顶层(lab 没提到的顶层键原样保留),不是把整个 store 换掉。 - 所以
setState一身两用——给路径就精确改某字段,给对象就浅合并顶层。两种都不会像信号那样「整体替换、丢掉细粒度身份」,这也是它比信号更适合嵌套结构的原因。
import { createStore } from "solid-js/store";
const [state, setState] = createStore({
user: { name: "Alice", age: 28 },
todos: [{ id: 1, text: "学习", done: false }],
});
state.user.name; // 读取:不加括号(Proxy 登记依赖)
// state.user.name = "x"; ✗ 只读,不生效(dev 会 warn)
setState("user", "name", "Bob"); // 路径 + 新值
setState("user", "age", a => a + 1); // 末段用更新函数
setState("todos", t => t.id === 1, "done", true); // 谓词路径setState 当信号 setter 用——传一个对象不是整体替换,而是浅合并顶层字段(setState({a:9}) 只改 a,其余顶层键原样保留);要改嵌套字段必须写路径。另外读取不要加括号,state.user.name 是普通属性访问,写成 state.user.name() 会报「不是函数」。a => a + 1。路径中间用谓词 t => t.id === id 精准定位数组里的某一项,比「先 filter 找出来、再整体替换数组」省事得多,而且只碰那一项对应的 DOM。这是从 04 章「别解构 props」延续下来的同一条规则:解构会把响应式的属性访问变成一次性快照。store 也是 Proxy,解构它的顶层属性,拿到的就是那一刻的值,之后 store 变了它不会跟着变。
为什么一解构就断
- store 的响应性靠 Proxy 在读取的那一刻登记依赖。
const { name } = state在解构时读了一次,把值拷进普通变量name——普通变量没有任何响应式关联,store 之后怎么变都与它无关。 state.name则把读取推迟到真正用到的地方(JSX、effect、memo 里),每次用都重新经过 Proxy,于是保持响应。lab 里解构出的name停在"Alice",而props.name/state.name会更新到"Bob"。
那到底怎么用
- 就地访问:需要哪个字段就在用的地方写全路径
state.user.name,别提前拎出来存变量。 - 确实想要「短名字」时,用
createMemo(() => state.user.name)包一层——memo 是响应式的访问器,读它name()保持追踪。 - 把 store 当 props 往下传时同理:子组件里写
props.data.name,别在函数顶部解构。
和解构 props 是同一条规则
- store 和 props 都是 Proxy,「解构等于拍快照、快照断响应」这条对两者一模一样——04 章讲 props 时踩的就是同一个坑。记住一条,两处都记住了。
- 嵌套解构照样断:
const { user: { name } } = state是快照;哪怕只解构中间一层const { user } = state,之后读user.name也已脱离原 store 的响应链。 - 唯一安全的「拆」是官方给的
splitProps(针对 props)和createMemo(针对任意响应式读取)——它们把响应性接着往下传,而不是在解构那一刻掐断。
const [state, setState] = createStore({ name: "Alice" });
// ✗ 解构顶层属性:拿到快照,断掉响应
const { name } = state;
// name 永远是 "Alice",即使之后 setState 改了
// ✓ 就地访问:每次经过 Proxy,保持响应
createEffect(() => console.log(state.name));
setState("name", "Bob"); // effect 打印 Bob;解构出的 name 不变
// ✓ 想要短名字:用 memo 包一层(它是响应式访问器)
const name = createMemo(() => state.name);
name(); // 读它加括号,保持追踪const name = state.user.name 也是快照,和解构一个坑。规则统一成一句——别把响应式来源的读取结果存进普通变量,让读取发生在真正需要它的响应式上下文里。const { x } = 某个响应式来源(props 或 store),几乎都是 bug 的前兆。把它删掉,改成用到的地方写 来源.x,或用 createMemo / splitProps 取代解构,就能保住响应性。store 里的数组也走路径式更新。增删整段用「返回新数组」的函数,改某一项用谓词路径只碰那项。当嵌套太深、路径写起来累时,produce 给你一副 immer 风格的可变草稿,随便 push、随便改属性。
增、删、改
- 增:末段传函数返回新数组
setState("todos", t => [...t, item])。 - 删:同样返回过滤后的新数组
setState("todos", t => t.filter(x => x.id !== id))。 - 改某项:谓词路径
setState("todos", t => t.id === id, "done", d => !d)——只更新命中项的那个字段,其它行的 DOM 一动不动。
produce:可变式草稿
setState(produce(draft => {...}))把一个可变草稿交给你,在回调里直接draft.todos.push(...)、draft.todos[0].done = true,就像改普通对象。- 它就地改(lab 里
push后长度 +1、改的字段生效),底层仍走细粒度更新,不用你手写一长串[...spread]。 - 适合一次改多处或深层嵌套;只改一两个字段时,直接路径式反而更清爽。
produce 只对 store / 数组对象
produce用于 store 和其中的数组、对象;别拿它去改原始值(数字、字符串本就该整体替换)。- 草稿是一次性的——回调返回后就失效,别把
draft存出去在外面接着改。
谓词路径省在哪
- 改一项走谓词路径
setState("todos", t => t.id === id, "done", d => !d),Solid 只更新命中项done字段对应的 DOM——列表里其它行连碰都不碰。 - 要是图省事整体
map出一个新数组再 set,等于告诉框架「整条数组都换新了」,每行都可能被当成新对象,细粒度优势就白费了。能定位到具体项就别替换整段。 - 增删(结构变了)才返回新数组,改字段(结构没变)走谓词路径——按「动的是结构还是值」来选,最不容易出错。
import { createStore, produce } from "solid-js/store";
const [state, setState] = createStore({ todos: [{ id: 1, done: false }] });
// 增:返回新数组
setState("todos", t => [...t, { id: Date.now(), done: false }]);
// 删:filter 出新数组
setState("todos", t => t.filter(x => x.id !== id));
// 改某项:谓词路径,只碰这一项
setState("todos", t => t.id === id, "done", d => !d);
// produce:immer 风格,直接改草稿
setState(produce(draft => {
draft.todos.push({ id: 3, done: false });
draft.todos[0].done = true;
}));produce 之外「先拿 store 的数组、直接 arr.push()」——store 是只读 Proxy,那样改不生效——开发构建会打印 Cannot mutate a Store directly 告警。所有变更都要经过 setState(路径式或包在 produce 里)才会真正落到 store 上并触发更新。produce。两者底层都是细粒度更新,选哪个看可读性,不影响性能。从接口重新拉到一整段数据,想更新到已有 store 里。直接 setState("items", newArr) 是整体替换,会让每一项都被当成新对象,依赖它们的 DOM 全部重建。reconcile 换个思路:拿新快照和旧 store 逐字段 diff,只改真正变了的地方。
reconcile 干的事
setState("items", reconcile(nextSnapshot)):用新快照去比对合并当前 store,没变的项保留原有细粒度身份,变了的字段就地更新。- lab 把
[{id:1,n:"a"},{id:2,n:"b"}]reconcile 成[{id:1,n:"a2"},...],只有第一项的n变成a2,长度、第二项都稳定——正是「整段服务端数据塞回来」想要的效果。 - 可给它配 key(默认按
id)帮助按身份匹配,列表增删重排也能对上号。
unwrap:拿回原始对象
unwrap(store)剥掉 Proxy,返回底层原始对象(lab 里就是普通数组/对象)。- 用途:要把 store 数据交给不认识 Proxy 的外部代码(序列化、发给第三方库、深拷贝)时,先
unwrap拿干净对象。 - 注意
unwrap出来的是引用,直接改它不会触发响应也可能污染 store,只读着用。
什么时候用它
- 典型场景:一个
createResource拉回列表,createEffect里setState("list", reconcile(data()))合并进本地 store,既拿到服务端最新,又保住了行的身份、不闪。异步取数详见 08 章。
按什么匹配,什么时候别用
reconcile默认按id字段认领每一项;主键不叫 id 时用reconcile(next, { key: "uid" })指定。匹配得上,行的身份才稳、才不会整列重建。- 它的价值在「同一份数据的增量刷新」——列表轮询、重新拉取当前页。若拿到的是另一份完全不同的数据(形状差很多),别硬 reconcile,直接整体 set 更直白也更快。
import { createStore, reconcile, unwrap } from "solid-js/store";
const [state, setState] = createStore({
items: [{ id: 1, n: "a" }, { id: 2, n: "b" }],
});
// 服务端新快照 → diff 合并,只改变化的字段
setState("items", reconcile([
{ id: 1, n: "a2" }, // 只有这项的 n 变了
{ id: 2, n: "b" },
]));
// unwrap:剥掉 Proxy,拿原始对象(给外部代码 / 序列化)
const raw = unwrap(state);
JSON.stringify(raw);unwrap 返回的是底层原始引用,不是深拷贝——直接改它既不会触发响应,还可能悄悄改到 store 内部。要修改数据永远走 setState;unwrap 只用来「只读地」把数据交出去。createResource 负责取,reconcile 负责把结果平滑合并进 store 而不是粗暴替换。列表页刷新时用它,能避免整列 DOM 重建导致的闪烁和滚动位置丢失。两个都是状态原语,界线其实很清爽:单个会整体替换的值用 createSignal,嵌套、要按字段局部更新的结构用 createStore。经验值是九成的简单 UI 状态用信号,复杂结构才上 store。
一句话判据
- 问自己:这份状态会不会只改其中一个字段?会 → store(细粒度到字段)。总是整体换 → signal。
- 再问:读取时要加括号吗?signal 要
count(),store 不要state.x——这也是两者最直观的区别。
| 维度 | createSignal | createStore |
|---|---|---|
| 适合的数据 | 原始值 · 需整体替换的对象 | 嵌套对象 / 数组 |
| 读取 | count() 加括号 | state.a.b 不加括号 |
| 更新 | setCount(v) 换整个值 | setState(路径..., v) 改字段 |
| 更新粒度 | 整个信号的依赖都重跑 | 只有依赖该字段的重跑 |
| 相等性坑 | 改对象属性再 set 同引用不触发 | Proxy 拦截属性级读写,天然避开 |
| 典型场景 | 开关 · 计数 · 主题 · 输入框单值 | 表单 · 列表 · 复杂全局状态 |
不确定时的默认
- 先用 signal,简单直接;等发现「每次更新都在手写
{...spread}铺开嵌套」或「改一个字段却触发一大片重算」,就是该换 store 的信号。 - 两者能共存:外层用几个 signal 管开关,一处复杂表单用 store,不必二选一到底。
常见的两种错配
- 把一个大嵌套对象塞进
createSignal,然后每次改都手动{...spread}铺层、还得小心别漏——这是该用 store 的信号,越写越繁琐,也容易撞上相等性坑。 - 反过来,给一个纯布尔开关套
createStore也没必要:徒增 Proxy 开销和「路径式更新」的心智,读取还得记住不加括号。形状扁平就 signal,形状嵌套就 store,别拧着来。 - 表单是最好的分界样例——单个输入框的值用 signal 足够;一整张带嵌套分组、要逐字段校验的表单,用 store 才不至于每次改一个字段就重算一片。
// signal:简单、整体替换
const [theme, setTheme] = createSignal("dark");
setTheme("light"); // 换整个值
theme(); // 读要加括号
// store:嵌套、字段级更新
const [form, setForm] = createStore({
username: "",
address: { city: "", zip: "" },
});
setForm("address", "city", "上海"); // 只改这一字段
form.address.city; // 读不加括号createSignal 是常见的自找麻烦——你会撞上「改属性再 set 同引用不触发更新」的相等性坑(信号默认按 === 比较),然后被迫每次 {...spread}。这种结构一开始就用 createStore,Proxy 在属性级拦截读写,从根上没有这个问题。生命周期、Context 与 ref
onMount / onCleanup 挂在哪、跨层级共享用 Context、拿真实 DOM 用 ref。
组件函数只跑一次、用来搭响应式图,这时候真实 DOM 还没生成。想在「元素已经在页面上」之后做点事——测量尺寸、聚焦、接第三方库——就用 onMount。它在组件首次挂载到真实 DOM 后执行一次,且只在客户端跑。
它解决什么
- 组件体里直接摸 DOM 是空的(还没挂载)。
onMount(fn)把fn推迟到挂载完成后,此时ref已指向真实节点(lab onMount 里读el.tagName拿到"DIV")。 - 只跑一次、不追踪任何信号——它就是「初始化钩子」,类比 React 的
useEffect(fn, [])。lab 里被调用次数正好 1。
为什么强调「只在客户端」
- SSR 时组件在服务端渲染成 HTML 字符串,那里没有 DOM。
onMount不会在服务端执行,只在浏览器水合后跑——所以摸window/document、启动定时器这类只该发生在浏览器的事,放这里最稳。 - 反过来,纯数据的初始化(不碰 DOM)用
createEffect或直接写在组件体里即可,不必都塞进 onMount。
和 createEffect 的分工
onMount= 只跑一次、不追踪依赖的 effect。要「随某个信号变化反复做事」是createEffect的活,别指望 onMount 会再跑第二次。- 顺序上,
onMount的回调在挂载后触发,此时ref已就位、子组件也已渲染,适合做「读真实布局」这类必须等 DOM 齐了的事。
典型用途
- 接需要真实节点的第三方库——图表、地图、富文本编辑器,都在这里
new出实例、挂到ref指的元素上。 - 读初始布局:
el.clientWidth、滚动位置、聚焦第一个输入框。 - 启动只该在浏览器发生的东西:
window监听、requestAnimationFrame循环——记得配onCleanup收尾。
import { onMount, createSignal } from "solid-js";
function Chart() {
let box; // ref 变量
onMount(() => {
// 到这里 DOM 已存在:可以测量、聚焦、接第三方库
console.log(box.clientWidth);
});
return <div ref={box}>图表容器</div>;
}onMount 会像 React 的 effect 那样「依赖变了再跑一次」——它只跑一次、永不重跑。想响应某个信号变化做事,那是 createEffect 的活。也别在组件体里直接读 ref(那时还没挂载,是 undefined),要读就进 onMount。onMount 本质就是「首次运行、不追踪依赖」的 createEffect。要接入图表库、地图、编辑器这类需要真实 DOM 节点的第三方库,几乎都在这里初始化,再配 onCleanup 在卸载时销毁。onCleanup(fn) 登记一个清理函数。它不止在组件卸载时跑——写在任何响应式作用域(尤其 createEffect)里,它会在该作用域每次重跑之前先清理上一轮。定时器、事件监听、订阅,全靠它收尾。
两个触发时机
- 组件卸载:组件从 DOM 移除时,其内所有
onCleanup依次执行——清setInterval、解绑监听就靠这个。 - 作用域重跑前:把
onCleanup写进createEffect,effect 每次因依赖变化重跑之前,会先跑上一轮登记的清理。lab 信号从 0→1→2,清理依次收到 0、1,dispose 时再收到 2。
为什么这个设计很顺手
- 「订阅 / 取消订阅」天然成对:在 effect 里根据当前信号建立订阅,再
onCleanup取消——切换目标时,框架自动先退订旧的、再订新的,不用你手写 diff。 - 清理逻辑就近写在建立副作用的地方,读代码时一眼看到「起」和「收」,不会各写一处对不上。
注意作用域
onCleanup必须在某个响应式作用域内调用(组件体、effect、createRoot里)才有效;脱离作用域调用不会有归属。- 它登记的是「反做」动作——里面别再建立新的持久副作用,容易套娃。
一次注册多个清理
- 同一个作用域里可以多次
onCleanup,各自登记、按注册顺序执行——不用把所有清理硬塞进一个函数。谁起的副作用,就紧跟着登记谁的清理,读起来最清楚。 - 它和
onMount常成对出现:onMount里new出第三方实例,onCleanup里instance.destroy(),一起一收,生命周期闭环。
它是「反做」清单,不是终止钩子
- 别把它当成「组件要没了」的那种一次性终止信号——它更像一张反做清单:谁起了副作用,就登记怎么撤。既能在卸载时统一撤,也能在 effect 每轮重跑前撤掉上一轮,用途比「卸载钩子」宽得多。
import { onCleanup, createEffect, createSignal } from "solid-js";
function Clock() {
const [time, setTime] = createSignal(new Date());
const timer = setInterval(() => setTime(new Date()), 1000);
onCleanup(() => clearInterval(timer)); // 卸载时清定时器
return <div>{time().toLocaleTimeString()}</div>;
}
// 写进 effect:每次重跑前先清上一轮(订阅/退订成对)
createEffect(() => {
const ch = subscribe(topic());
onCleanup(() => ch.unsubscribe()); // topic 变→先退旧订阅再订新
});addEventListener、WebSocket、第三方库实例,只要在组件里建了就必须配 onCleanup,否则组件卸载后它们还在后台跑,造成内存泄漏或「幽灵回调」改到已不存在的状态。别以为组件消失了副作用会自己停——不清就不停。onCleanup 写在建立副作用的紧挨着的下一行,读起来「起一个、登记怎么收」一目了然。effect 内的清理会在每次重跑前自动触发,处理「随信号切换的订阅」特别顺——不用自己判断「上次订的是谁、要不要退」。要把状态传给深层后代,一层层 props 往下递(prop drilling)又累又脏。Context 让你在某个祖先节点提供一份值,任意后代直接取用,中间层完全不用知情。主题、当前用户、i18n 这类「全局但不算大」的状态最合适。
三步走
createContext(默认值)造一个 Context 对象。- 用
<Ctx.Provider value={...}>包住子树,value里放你要共享的东西。 - 后代任意深度
useContext(Ctx)取回那个value,无需逐层传递。
value 里放信号本身
- 想让共享的状态可变又响应,往
value里放信号或 store(连同它们的 setter),而不是当前的值快照。 - 消费端拿到信号后照常
theme()加括号读——响应性一路保持,Provider 里改了,所有用到的后代都更新。
无 Provider 走默认值
createContext(默认值)的参数,就是在找不到任何 Provider 时useContext的返回。lab 没包 Provider 时拿到构造时的默认值;包了就拿 Provider 的value。- 所以给一个合理的默认值,能让组件在没被 Provider 包裹时也不至于炸——尤其做可复用组件时。
惯用封装
- 常见做法是把「造信号 + 提供 Provider」收进一个
XxxProvider组件,再导出一个useXxx()小函数(内部就是useContext)。用的人不必知道 Context 对象长什么样,只const s = useXxx()。 - Context 更适合读多写少、跨多层的状态(主题、当前用户、语言)。要在很多不相干组件间频繁读写一份复杂状态,模块级 store 往往更直接(见 06)。
import { createContext, useContext, createSignal } from "solid-js";
const ThemeContext = createContext("light"); // 括号里是默认值
function ThemeProvider(props) {
const [theme, setTheme] = createSignal("dark");
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{props.children}
</ThemeContext.Provider>
);
}
// 任意后代取用
function Toggle() {
const ctx = useContext(ThemeContext);
// 无 Provider 时 ctx 就是默认值 "light"
return <span>当前 {ctx.theme()}</span>;
}createContext 传个合理默认值,能救急但也可能掩盖 bug:忘了包 Provider 时,useContext 悄悄返回默认值、不报错,你可能查半天。若某 Context「必须有 Provider」,可在自定义 useXxx hook 里判空并主动抛错,让缺 Provider 尽早暴露。value 里放信号 / store 及其 setter,而不是它们当下的值——放值快照就把响应性截断了。常见做法是把 { state, actions } 打包进去,后代既能读也能改。要聚焦输入框、测量元素、把节点交给第三方库,就得拿到真实 DOM 元素。Solid 的 ref 简单到不像话:声明一个普通变量,在元素上写 ref={变量},挂载后这个变量就指向真实节点——不需要 createRef 之类。
普通变量式
let el;声明空变量,<div ref={el}>绑上去。Solid 在挂载时把真实节点写进这个变量(labonMount里el.tagName拿到"INPUT")。- 组件体里此刻还是
undefined——要用它得等挂载后,通常放进onMount。
函数式 ref
- 也能传函数:
ref={(e) => ...},挂载时框架把节点作为参数e调用它。适合当场做点处理、或把节点存到别处(lab 里函数 ref 拿到"BUTTON")。 - 想把 ref 转发给子组件时也用得上:父层传函数下去,子层绑到内部元素。
别拿 ref 当状态
- ref 是「命令式逃生口」,用来做框架不管的 DOM 操作(聚焦、滚动、测量、接库)。能用响应式(信号 / JSX 绑定)表达的,就别用 ref 去手动改 DOM——那会绕开 Solid 的更新、埋下不一致。
- 读
ref永远在挂载之后(onMount/ 事件回调里),别在组件体顶层读。
转发 ref 给子组件
- Solid 里
ref就是普通 prop,没有 React 那套forwardRef。父层把变量或函数当ref传给自定义组件,子组件内部接住props.ref再绑到真实元素上,父层就拿到了里层的 DOM。 - 因为父层传下来的
ref可能是变量式也可能是函数式,子组件直接把props.ref原样绑到元素上最稳——两种形式框架都会正确处理。
两种写法怎么挑
- 只是「拿到留着后面用」(聚焦、接库、测量)→ 普通变量式最省事。想在挂载那一刻当场处理、或要把节点塞进数组 / 转发出去 → 函数式更顺手。
- 两种在同一组件里混用完全正常,绑法上没有优劣,按「什么时候需要这个节点」来选即可。
import { onMount } from "solid-js";
function AutoFocus() {
let inputEl; // 普通变量,无需 createRef
onMount(() => inputEl.focus()); // 挂载后才有值
return <input ref={inputEl} />;
}
// 函数式 ref:挂载时以节点为参数调用
function Measure() {
return (
<div ref={(e) => console.log(e.clientWidth)}>
内容
</div>
);
}ref 变量——组件只跑一次、那时 DOM 还没生成,读到的是 undefined,一调方法就报错。任何对 ref 的使用都要放到挂载之后(onMount 或用户事件回调里)。Solid 的 onCleanup、useContext、createEffect 都要挂在一个「所有者(owner)」上——就是当前正在执行的那个组件 / root。这个所有权是同步的:一旦 await 或进了 .then(),它就没了。而 onCleanup 在没有 owner 时既不报错也不生效,是本页最难查的一类静默失效。
lab 复现:清理函数到底跑没跑
- 在
createRoot里起一个异步函数,await之后调getOwner()——拿到的是 null(同一个 root 的同步位置拿到的是有效 owner)。 - 紧接着在那里注册
onCleanup,再手动dispose()掉 root:这个清理函数一次都没跑。定时器不会被清、订阅不会被退、事件监听器留在那里——这就是内存泄漏的来源。 - 把同一句用
runWithOwner(owner, () => onCleanup(…))包住,dispose()时它跑了。差别只在有没有把 owner 带过来。
正确姿势:同步时先把 owner 存下来
- 在组件体里(同步位置)
const owner = getOwner(),异步回调里再runWithOwner(owner, () => { … })。getOwner/runWithOwner都来自solid-js。 - 能避开就避开:订阅类的清理尽量写在同步位置——在
onMount里建订阅、当场onCleanup退订,就完全不必碰 owner。只有「异步拿到句柄之后才知道要清理什么」时才需要runWithOwner。 - 同一条规则也解释了另一个常见困惑:
await之后useContext会读到默认值而不是 Provider 的值——因为 Provider 关系也挂在 owner 上。
import { getOwner, runWithOwner, onCleanup } from "solid-js";
function Player() {
const owner = getOwner(); // 同步位置,此时有 owner
(async () => {
const stream = await openStream();
// ✗ 这里 getOwner() 已经是 null,清理静默丢失
onCleanup(() => stream.close());
// ✓ 把 owner 带过来,清理才注册得上
runWithOwner(owner, () => onCleanup(() => stream.close()));
})();
}onCleanup 在没有 owner 时只是把回调丢掉(开发模式下控制台可能有一句提示,容易被淹没)。症状是「组件卸载了,可 WebSocket 还连着 / 定时器还在跑 / 监听器越积越多」,而代码里明明写了清理。查这类问题先看那句 onCleanup 是不是在 await 后面。await / .then / setTimeout 之后调用的 Solid 原语,都要先问「owner 还在吗」。onCleanup、useContext、createEffect、createMemo 全在这张名单上。同步位置调用的则完全不用操心。「这两个组件怎么互相传数据」没有唯一答案,取决于它们的关系和距离。把四种手段放一起看,选起来就有谱:近的用 props,深的用 Context,散在各处的共享状态用 store,子传父用事件回调。
| 手段 | 方向 | 适用 | 要点 |
|---|---|---|---|
| props 下传 | 父 → 子 | 直接父子、层级浅 | 写 props.x,别解构(见 04) |
| 事件回调 | 子 → 父 | 子组件把动作/数据抛给父 | 父传函数 onSave 下去,子调用它 |
| Context | 祖先 → 后代 | 跨多层、避免 prop drilling | Provider 提供、useContext 取 |
| store 共享 | 任意 | 多处读写同一份复杂状态 | 模块级 createStore,谁 import 谁用(见 06) |
怎么快速定位
- 直接父子:能用 props 就用 props,最简单直白。
- 子要通知父:父把回调函数当 prop 传下去,子在合适时机调用——这就是「子传父」。
- 隔了好几层:别硬穿 props,用 Context 把中间层解放出来。
- 好几个不相干的组件共享一份状态:抽成模块级 store,各自 import,天然全局又保持细粒度。
一条主线
- 这四种其实是一个梯度:关系越近越用 props,越远越靠共享容器(Context / store)。先想清楚组件间的关系,再对号入座,别一上来就上全局状态。
一个小例子串起来
- 一个「待办列表」页:外层
App用模块级 store 存 todos(store 共享);每个<TodoItem todo={t}>靠 props 拿到自己那条;用户勾选时TodoItem调props.onToggle(t.id)把动作抛回去(事件回调);而当前主题由顶层 Provider 提供、任意深处的按钮useContext取用(Context)。四种手段在一屏里各司其职。
// 1) props 下传 + 2) 事件回调(子传父)
function Parent() {
const handleSave = (v) => console.log("子传来", v);
return <Editor title={"标题"} onSave={handleSave} />;
}
function Editor(props) {
// 下传:props.title(别解构);上抛:调用 props.onSave
return <button onClick={() => props.onSave(props.title)}>保存</button>;
}
// 3) store 共享:模块级,谁 import 谁用
export const [appState, setAppState] = createStore({ count: 0 });props.x / state.x 就地访问,否则响应性在解构那一刻就断了——这条坑对四种手段一视同仁,是从 04 一路贯穿到这里的同一规则。异步:Resource / Suspense / ErrorBoundary
把「加载中 / 出错 / 数据到了」这三态交给框架统一编排。
异步数据永远有三种状态——加载中、出错了、数据到了。手写三个信号去回合这仨既繁琐又容易漏。createResource 把一次异步请求接入响应式系统,一个资源对象就带齐了三态,还给你 mutate 和 refetch 两个把手。
返回什么
const [data, { mutate, refetch }] = createResource(fetcher)。fetcher是个返回 Promise 的函数。data既是访问器也挂着状态:data()取值、data.loading是布尔、data.error是错误对象——三态都在这一个东西上(OLAB 事实 20,lab 里)。
三态怎么读
- 加载中:请求未完成时
data.loading === true、data()为undefined。 - 数据到了:Promise resolve 后
data.loading变false、data()是结果。 - 出错了:Promise reject 后
data.error拿到错误对象(lab 里读到error.message为"boom")。
两个把手
refetch():手动再请求一次(按钮「刷新」)。mutate(v):不发请求,本地直接改资源当前值(lab 里改成"local")——常用于乐观更新,先把 UI 改了,再让请求跟上。
为什么值得用它
- 没有它,你得手动维护三个信号(
data/loading/error)、在请求前后一个个 set,还要提防竞态。createResource把这套样板收进一个对象,三态天然同步、不会互相打架。 - 它还和本章后面的
<Suspense>、<ErrorBoundary>打通——加载态能被 Suspense 接管、错误能被 ErrorBoundary 兜住,不必自己在 JSX 里写一堆条件分支。这几张卡讲的就是这条链。
import { createResource } from "solid-js";
async function fetchUser() {
const res = await fetch("/api/me");
return res.json();
}
const [user, { mutate, refetch }] = createResource(fetchUser);
user.loading; // 加载中 → true
user.error; // 出错 → 错误对象
user(); // 数据到了 → 结果(此前是 undefined)
refetch(); // 手动重新请求
mutate({ name: "本地改的" }); // 不发请求,直接改本地值data 是访问器,取值要 data() 加括号;但 data.loading / data.error 是挂在它身上的属性,不加括号。别写成 data().loading——加载中时 data() 是 undefined,再 .loading 直接报错。<Show when={!user.loading}> 或直接用 <Suspense> 兜加载态。mutate 做乐观更新——点赞先 mutate 把数字加上去、再 refetch 对齐服务端,交互立刻有反馈。createResource 的真正威力在第一个参数——源信号。给它一个信号当「源」,源一变,资源就自动重新请求,把新的源值传给 fetcher。「切换用户 id 就自动拉对应数据」这种事,你一行 effect 都不用写。
怎么接
createResource(source, fetcher):source是个访问器(信号 / memo),fetcher收到的第一个参数就是当前源值。- 源变化 → 框架自动用新值再调一次
fetcher(OLAB 事实 21,labsetId(2)后 fetcher 收到的调用序列正好是[1, 2])。
源为假值时会跳过
- 当源求值为
false/null/undefined时,createResource不会触发 fetcher——这正好用来做「依赖就绪才请求」:userId()还没有值时不发请求。 - 所以把「是否该请求」的条件塞进源信号,比在 fetcher 里写一堆
if判断干净。
心智:声明依赖,不是命令
- 你不是「监听 id 变化再手动发请求」,而是声明「这份数据由这个源决定」。剩下的重取时机交给框架——这和 02 章 effect 自动追踪是同一套思想。
竞态它替你挡了
- 快速连续切换源(id 从 1 跳到 2 再到 3),老请求可能后回来、把新数据盖掉——这就是经典的请求竞态。
createResource内部按最新一次源值对齐,过期请求的结果会被丢弃,你不用自己记「哪次是最新的」。 - 配合
<Suspense>,源变化时还能保持上一份数据显示、只在边界上转圈,切换体验更顺(详见后面 Suspense 卡)。
自动之外,手动重取仍在
- 源驱动是自动的,但需要「数据没变、就想重拉一次最新」时,解构出
refetch手动调即可。两条路不冲突:源变触发自动重取,用户点刷新触发手动重取,一份资源两种刷新入口。
import { createResource, createSignal } from "solid-js";
const [userId, setUserId] = createSignal(1);
async function fetchById(id) {
const res = await fetch("/api/users/" + id);
return res.json();
}
// 源 userId 变化 → 自动用新值重新请求
const [user] = createResource(userId, fetchById);
setUserId(2); // fetcher 再次被调用,参数为 2
// 源为 null/undefined/false 时会跳过请求userId 这个函数本身),别传 userId() 的调用结果——传值就成了一次性快照,源之后再变也不会触发 refetch。这和「effect 里要读信号才建依赖」一个道理:框架要的是能反复读的访问器,不是某一刻的值。createMemo 把它们合成一个源,任一条件变都自动重取。想「条件不满足就先别请求」,让源在那时求值为假值即可——比在 fetcher 里 if 早退优雅。页面上好几块都在异步加载,逐个写 {loading ? ... : ...} 又碎又难协调。<Suspense> 划一个边界:只要边界内还有资源在加载,就整体显示 fallback;全部就绪后,一次性切到真实内容。
怎么用
<Suspense fallback={加载占位}>包住内部会读资源的组件。内部任一createResource在加载,就显示fallback(OLAB 事实 22,lab 加载时页面是「加载中」,resolve 后变成内容且不再有「加载中」)。- 它捕获的是边界内所有资源的加载态——不用给每个组件单独判断
loading。
好在哪
- 少写模板分支:组件里直接
user().name当数据已就绪那样写,加载态交给外层 Suspense,代码干净很多。 - 协调多块:几块数据放同一个 Suspense 里,就等它们都好再一起显示,避免「东一块西一块先后蹦出来」的跳动。要各自独立加载就拆成多个 Suspense。
和三态的关系
- Suspense 接管的是「加载中」。「出错了」交给下一张的
<ErrorBoundary>,两者常套着用——外层兜错、内层兜加载。
边界该放多大
- 放太大:整页共用一个 Suspense,任一小块慢就整页卡在占位,体验差。放太小:每个字段一个,又回到「满屏转圈」的碎片感。
- 务实的粒度是按内容区块——头部一个、侧栏一个、主内容一个,各自独立加载、互不拖累。区块内部「要一起出现才不突兀」的几项,再共用一个 Suspense。
不用逐个判 loading
- 有了 Suspense,边界内的组件可以假设数据已就绪那样直接写
user().name,不必每个都套一层user.loading ? ... : ...。判断加载态的活集中到边界一处,模板一下子干净很多。
import { Suspense } from "solid-js";
// 内部组件直接把数据当就绪那样写
function Profile() {
const [user] = createResource(fetchUser);
return <h1>{user().name}</h1>; // 加载态由外层 Suspense 兜
}
// 一个边界统一兜加载;多块可放同一个 Suspense
<Suspense fallback={<p>加载中</p>}>
<Profile />
<Posts />
</Suspense>fallback 把错误藏起来,错误会往上抛。要处理失败,得在外面套一层 <ErrorBoundary>(下一张)。别指望一个 Suspense 就把加载和出错都管了。子组件渲染时抛了错,默认会让整棵树崩掉。<ErrorBoundary> 像个安全网:捕获子组件渲染期抛出的错误,改为显示你给的 fallback,还能给一个「重试」的口子,不至于白屏。
怎么用
<ErrorBoundary fallback={(err, reset) => ...}>包住可能出错的子树。- 子树里抛错时,
fallback被调用,第一个参数就是那个错误对象(OLAB 事实 23,lab 读到err.message是"渲染炸了"),第二个reset用来清除错误、重新渲染子树。
它能抓什么、抓不到什么
- 能抓:子组件渲染期间抛出的错误,也包含读取一个
error态资源时抛出的错(配 Suspense 时资源的错误会冒到这里)。 - 抓不到:事件回调里的异步错误、
setTimeout里的错——那些不在渲染流程里,得自己try/catch。它不是万能捕获。
和 Suspense 搭配
- 常见组合是外层 ErrorBoundary + 内层 Suspense:加载时 Suspense 显示占位,出错时 ErrorBoundary 显示错误 UI,数据到了显示内容——三态各归其位。
放在哪一层
- ErrorBoundary 会捕获它子树里的错误,所以放得越靠上,兜的范围越大、但错误 UI 越粗(整块换成报错)。放得越靠近出错点,越能做「只有这一小块显示重试、其余照常」的精细降级。
- 常见分层:外层放一个「兜底大网」防白屏,关键区块内再各放一个小 ErrorBoundary 做局部降级——一处崩了不连累整页。
给用户一条退路
- 好的
fallback不只是「把错误打印出来」,而是给一条可操作的退路——一句人话的提示,加一个调reset的「重试」按钮。比一片红字或白屏友好得多,也不至于让整个应用卡死在一个局部错误上。
import { ErrorBoundary, Suspense } from "solid-js";
<ErrorBoundary fallback={(err, reset) => (
<div>
<p>出错了: {err.message}</p>
<button onClick={reset}>重试</button>
</div>
)}>
<Suspense fallback={<p>加载中</p>}>
<RiskyProfile /> // 渲染期抛错 → 被上面兜住
</Suspense>
</ErrorBoundary>setTimeout、未接住的 Promise 里抛的错它管不到——这些得在原地 try/catch 自行处理。别以为包了 ErrorBoundary 就万无一失,点击回调里的异常照样会漏出去。fallback 的第二个参数 reset 是重试的关键——点「重试」调 reset(),会清掉错误状态、重新渲染子树,配合 refetch 就能让用户自己再试一次,而不是刷新整页。首屏没必要把整个应用的代码都下下来。lazy 把一个组件拆成独立 chunk,等真正要渲染它时才去下载对应代码,首屏体积因此瘦身。它天生和 <Suspense> 配合——下载那段时间显示占位。
怎么用
const Page = lazy(() => import("./Page")):传一个返回动态 import 的函数。打包器据此把./Page切成单独 chunk。- 把
<Page />放进<Suspense>;首次渲染到它时才下载那段代码,下载期间显示 Suspense 的fallback。 lazy返回的是普通组件,用法和别的组件没差(本卡按lazy的 API 契约描述;lab 里用一个立即 resolve 的模块工厂验证过「Suspense 包着能正常渲染出内容」)。
用在哪最划算
- 路由级:每个页面
lazy一下,只下载当前路由那页——最典型、收益最大的用法。 - 重而不常用的块:富文本编辑器、图表、弹窗里的复杂表单,进来不一定用到,按需加载。
- 反过来,小组件、首屏一定要用的东西别 lazy——徒增一次网络往返,得不偿失。
它和 Suspense 是天生一对
lazy组件在下载那段时间「没有内容可渲染」,正是<Suspense>兜加载态的场景——所以两者几乎总一起出现:lazy 负责切包、Suspense 负责下载期间显示占位。- 切分粒度别太碎:把整个应用拆成几十个微 chunk,反而带来一堆小请求。按路由、按重模块切几处,收益最实在。
首屏体积的直接杠杆
- 不切分时,用户打开首页就得把所有页面的代码一并下下来。
lazy把「现在还用不到的」推迟到真正访问时再下——首屏 JS 变小、可交互更快,这是它最直接实在的收益。
import { lazy, Suspense } from "solid-js";
// 拆成独立 chunk,渲染到时才下载
const Dashboard = lazy(() => import("./Dashboard"));
function App() {
return (
<Suspense fallback={<p>加载页面中</p>}>
<Dashboard /> // 首次渲染触发下载,期间显示 fallback
</Suspense>
);
}lazy 的组件必须被 <Suspense> 包着——下载那段时间它没有内容,没有 Suspense 兜就会出问题。还有:import() 的路径要能被打包器静态分析,别写成 import(变量) 这种动态拼接,否则切不出 chunk。lazy 一次,天然按访问按需加载。想让体验更顺,可在鼠标悬停链接时预取下一页的 chunk(SolidStart 的路由预加载就干这事,详见 14),点进去时代码已经到位。切换标签页、翻页、改筛选条件时,如果新数据要重新取,<Suspense> 会整块退回 fallback——用户看到的是内容闪一下变成骨架屏再变回来。transition 让这次更新「在后台准备好再切」:旧内容原地不动,同时给你一个 pending 标志去点亮进度条。
lab 对比:同一次切换,两种观感
- 裸 set:
setTab("b")之后立刻读页面文本,得到的是 "fallback"——旧内容被卸掉了。 - 包进 transition:
start(() => setTab("b"))之后读到的仍是 "A 页"(旧内容),同时pending()为 true;等新数据的 Promise resolve,文本变成 "B 页"、pending()回到 false。 - 换句话说,transition 把「fallback 闪一下」换成了「旧内容 + 一个进行中的标志」。数据没到之前,用户手上一直有可读的东西。
两个入口,选哪个
const [pending, start] = useTransition()——要在界面上显示「加载中」时用它,pending是个信号,可以直接绑到按钮的disabled或加个.loading类。必须在组件的响应式上下文里调用。startTransition(() => …)——不需要 pending 标志时的简写,任何地方都能调,返回一个 Promise,可以await到切换完成。- 两者都只对包在里面的那些 set 生效,且必须真的有 Suspense 边界在等异步;纯同步的更新包不包都一样。
它和 Suspense 的分工
<Suspense>管的是「第一次没有数据时显示什么」——首屏、进入详情页,这时除了骨架屏也没别的可显示。- transition 管的是「已经有一份旧数据时的切换」——这时退回骨架屏是倒退。经验做法:首屏交给 Suspense,之后所有由用户操作触发的重新取数都包进 transition。
import { useTransition, Suspense, createResource } from "solid-js";
function Tabs() {
const [tab, setTab] = createSignal("a");
const [data] = createResource(tab, fetchTab);
const [pending, start] = useTransition();
return (
<>
<button onClick={() => start(() => setTab("b"))}
classList={{ loading: pending() }}>B</button>
// 切换期间这里显示的仍是旧的 A 页,不闪 fallback
<Suspense fallback={<Skeleton />}>
<p>{data()}</p>
</Suspense>
</>
);
}useTransition 要在组件的响应式上下文里调用,随手放在事件回调里创建拿不到有效的 pending。表单与受控输入
受控与非受控、双向绑定的取舍、校验与提交——纯客户端表单怎么写。
受控输入就是让一个信号当输入框的「唯一真相」——value={name()} 让框里显示的永远是信号的值,onInput 里再把新值写回信号。读和写都指向同一个信号,页面上任何别的地方读这个信号都会跟着变。
接线只有两根
- 读:
value={name()}——注意信号要调用,name()带括号。 - 写:
onInput={(e) => setName(e.currentTarget.value)}——每次敲键都把最新文本写回信号。 - 因为组件只跑一次(这是 Solid 的根,见 04 章),这里的
value={name()}会被编译成对该属性的精确绑定:信号一变,只更新这个 input 的 value,不重跑组件。
要 onInput,不是 onChange
- React 把
onChange悄悄改成了「每次输入都触发」。Solid 不这么干——onChange就是浏览器原生的 change 事件,失焦或提交时才触发一次。 - 想「边打字边更新」,一律用
onInput。本页在实验台里验证过:往 input 派发 input 事件时onInput触发、onChange不触发。
label 的 id 用 createUniqueId,别手写
- 输入框要能被点标签聚焦、要能被读屏软件念对,就得有
<label for="x">+<input id="x">这对绑定。写死一个id="email"在这个框只出现一次时没问题,一旦这个组件被用了两次,页面上就有两个同 id 元素,for只会命中第一个。 createUniqueId()(来自solid-js)每次调用给一个全站唯一的字符串——lab 里连调两次拿到cl-0、cl-1。在组件体里调一次、同时用给for和id即可。- 它真正的价值在 SSR:服务端和客户端按同一顺序生成同一串 id,水合时对得上。用
Math.random()或计数器自己造,两端结果不一致,水合会出问题(13 章)。aria-describedby指向错误提示时同理。
受控换来了什么
- 值只有一处,程序改信号、界面立刻跟上(清空、预填、格式化都只是
setName(...))。 - 校验、联动、字数统计都能直接读同一个信号,不用再去 DOM 里捞。
import { createSignal } from "solid-js";
function NameField() {
const [name, setName] = createSignal("");
return (
<input
value={name()} // 读:信号要调用
onInput={(e) => setName(e.currentTarget.value)} // 写:每次输入回填
/>
);
}onInput 写成 onChange,会发现「打字时信号不动、点到别处才更新一下」——因为 Solid 的 onChange 是原生 change 事件(失焦才触发),不是 React 那套每次按键都触发。要实时更新就用 onInput。e.currentTarget.value 而不是 e.target.value:currentTarget 的类型被精确推断为这个 <input>(TS 下 target 只是宽泛的 EventTarget,见 10 章)。数字输入记得 Number(e.currentTarget.value),DOM 里拿到的永远是字符串。不是每个框都值得受控。如果这个值只在提交那一刻才需要、中途没人读,那就别接信号——让浏览器自己管着输入,提交时用一个 ref 把 input.value 读出来就行。
什么时候不受控
- 一次性读取:登录框、搜索框,只在按下按钮时要值,输入过程里没有联动。
- 不想每键重跑逻辑:受控会让每次输入都写信号、触发依赖;不受控则完全不碰响应式系统。
- 文件输入
<input type="file">只能非受控——它的值不能用代码设。
ref 就是个普通变量
- Solid 不需要
createRef:声明let el;,写ref={el},元素挂载后el就指向真实 DOM(细节见 07 章)。 - 提交时
el.value直接读当前输入。要设默认显示值用value="..."或<input value="初始">,它不会再被信号接管。
别在渲染期读 ref
- 组件体执行时元素还没挂载,此刻
el是undefined。只能在onMount、事件回调、或提交处理里读它。
受控 vs 非受控,一句话选
- 值中途要联动(禁用按钮、实时校验、字数统计)→ 受控,接信号。
- 值只在提交时读一次、过程里没人管 → 非受控,用 ref 提交时捞。
- 拿不准就先非受控,等真需要联动了再升级成受控——反过来把受控改回非受控反而费劲。
function SearchBox(props) {
let inputEl; // 普通变量,无需 createRef
function onSubmit(e) {
e.preventDefault();
props.onSearch(inputEl.value); // 提交时才读一次
}
return (
<form onSubmit={onSubmit}>
<input ref={inputEl} placeholder="搜索" />
<button>搜</button>
</form>
);
}console.log(inputEl.value) 会报 inputEl 是 undefined——组件只跑一次且此时 DOM 还没建。ref 要等到 onMount 之后才有值,读值的代码得放进事件回调或 onMount 里。Vue 的 v-model、Angular 的 [(ngModel)] 在 Solid 里没有内置对应物。官方立场是:双向绑定就是「读 + 写」两根线的语法糖,Solid 让你自己接,或者自己封一个 use: 指令来复用这段接线。
最直接:还是 value + onInput
- 九成场景直接写
value={x()}+onInput={e => setX(...)}就够了,清清楚楚,没有隐藏魔法。 - 嫌重复,就把这段抽成一个指令,在多个框上复用。
自定义 use: 指令(本页已在实验台验证)
use:model={signal}会调用你写的model(el, accessor)函数,el是 DOM、accessor()返回你传进去的那个[get, set]信号元组。- 指令里做两件事:用
createEffect把信号写进el.value(信号变→框变),再addEventListener("input")把输入写回信号(框变→信号变)。这就是一个真正的双向绑定,实验台里验过它能跑通。
指令名不能被摇掉
use:model在编译后其实是「调用名为model的函数」,但源码里看不到直接调用。如果当前模块没有别处引用到model,打包器会当它是死代码删掉,指令就失效了。把指令函数 import 进来、确保它在作用域里被「用到」。
import { createSignal, createEffect, onCleanup } from "solid-js";
// 自定义指令:el 是 DOM,accessor() 返回传入的 [get, set]
function model(el, accessor) {
const [get, set] = accessor();
createEffect(() => (el.value = get())); // 信号 → 框
const on = (e) => set(e.currentTarget.value); // 框 → 信号
el.addEventListener("input", on);
onCleanup(() => el.removeEventListener("input", on));
}
function Form() {
const text = createSignal("hi"); // 整个信号传进去
return <input use:model={text} />;
}model 在模块里被引用);二是 TypeScript 下 use:model 会报「JSX 上没有这个属性」,得给 declare module "solid-js" 的 JSX.Directives 补一条 model: [get, set] 声明,编译器才认(见 10 章)。use:;一两个框直接写 value+onInput 更好读。指令的价值是复用,不是「显得高级」。十个字段开十个信号会很啰嗦。表单本质是「一个对象」,用 createStore 一个 store 管住整个表单,改哪个字段就走路径式更新——只有那个字段对应的 DOM 会动,别的框纹丝不动。
一个 store 装下整张表
const [form, setForm] = createStore({ name: "", address: { city: "" } }):嵌套结构也能一把管住。- 读:
form.name、form.address.city——像普通对象,不加括号(store 不是信号)。 - 写:
setForm("name", 值)、setForm("address", "city", 值)——路径一层层点进去,最后给新值。store 的完整玩法见 06 章。
细粒度到字段
- 改
city只会更新绑了form.address.city的那个框,name那个框不重算。这一点本页在实验台里验过。 - 比 React 的
setForm({...form, address:{...form.address, city}})既短又准——不用手动铺展开整棵对象。
字段多了可以泛化
- 顶层扁平字段可以写一个通用回调:
onInput={e => setForm(e.currentTarget.name, e.currentTarget.value)},靠name属性对上 store 的键。
import { createStore } from "solid-js/store";
function ProfileForm() {
const [form, setForm] = createStore({
name: "",
address: { city: "" },
});
return (
<form>
<input value={form.name}
onInput={(e) => setForm("name", e.currentTarget.value)} />
<input value={form.address.city}
onInput={(e) => setForm("address", "city", e.currentTarget.value)} />
</form>
);
}const { name } = form 拿到的是那一刻的快照,之后字段变了它不更新——和解构 props 断响应是同一个坑。永远写 form.name,让读取发生在 JSX 里。unwrap(form),再 JSON.stringify 或发请求。要把服务端返回的整张表灌回 store、又保留未变字段的身份,用 setForm(reconcile(serverData))(见 06 章)。一个完整的小表单:值放一个 store、错误信息放另一个 store,提交时先跑校验、把每个字段的错误写进错误 store,全通过才真正提交。下面这段整体在实验台里跑通了。
三样东西
- 值 store:
[form, setForm],装 email、pwd。 - 错误 store:
[errors, setErrors],每个字段一条错误字符串,空串表示没错。 - 校验函数:逐字段判断,用
setErrors写结果,返回「是否全通过」。
提交流程
- 表单
onSubmit里先e.preventDefault()拦住浏览器默认跳转,再跑校验;通过才把数据交出去。 - 错误信息直接在 JSX 里读
errors.email显示——它是 store 字段,改了就只更新那条提示。
校验时机的取舍
- 提交时校验(本例)最省心、打扰最少,适合大多数表单。
- 想「边打边提示」,把单字段校验挂到
onInput或onBlur;但别一上来就飘红,通常等用户离开该框(blur)或首次提交后再开始逐键校验,体验更好。
提交按钮的收尾
- 真正提交是异步的(发请求):提交时把一个
submitting信号置真、禁用按钮防重复点,请求回来再置假。 - 服务端也可能返回错误(邮箱已注册):把它写回同一个错误 store 的对应字段,前端校验和后端校验共用一套展示位置。
import { createStore } from "solid-js/store";
function SignupForm() {
const [form, setForm] = createStore({ email: "", pwd: "" });
const [errors, setErrors] = createStore({ email: "", pwd: "" });
function validate() {
setErrors("email", form.email.includes("@") ? "" : "邮箱格式不对");
setErrors("pwd", form.pwd.length >= 6 ? "" : "至少 6 位");
return !errors.email && !errors.pwd;
}
function onSubmit(e) {
e.preventDefault();
if (validate()) submit({ ...form });
}
return (
<form onSubmit={onSubmit}>
<input value={form.email}
onInput={(e) => setForm("email", e.currentTarget.value)} />
<span>{errors.email}</span>
<input type="password" value={form.pwd}
onInput={(e) => setForm("pwd", e.currentTarget.value)} />
<span>{errors.pwd}</span>
<button>注册</button>
</form>
);
}validate() 里 return !errors.email && !errors.pwd 之所以对,是因为对 store 的 setErrors 是同步生效的、读 errors.email 立刻是新值。但别把它写成先读旧 errors 再判断的顺序——要么像这样 set 完立即读,要么直接用本地算出的布尔结果判断,别依赖「上一次渲染」的错误值。action、靠 <form action={fn}> 做渐进增强(没 JS 也能提交),并用 useSubmission 跟踪提交态——那套在 15 章讲。TypeScript 与 Solid
给 props、signal、context、resource 标类型,让编译器替你挡住反直觉的坑。
TSX 相对 JSX 只多两件事:让编译器找到 Solid 的 JSX 类型,以及把 React 那套类型名换掉。配好这两处,剩下的写法和 04 章的 JSX 规则完全一致。
tsconfig 就两行是关键
"jsx": "preserve"让tsc别自己转 JSX——转换要留给vite-plugin-solid的 babel 去做;"jsxImportSource": "solid-js"告诉它去solid-js/jsx-runtime找 JSX 命名空间,属性检查才认得class、classList这些。- 这两行是
create-vite的solid-ts模板原文(对着 create-vite 9.1.1 的template-solid-ts/tsconfig.app.json核过,同文件还带"moduleResolution": "bundler"、"types": ["vite/client"]、"verbatimModuleSyntax": true)。 - 从 React 项目迁过来最常见的错是留着
"jsx": "react-jsx"——那会让tsc按 React 运行时转换 JSX,Solid 的编译器就拿不到原始 JSX 了。
类型名对照
| React | Solid | 备注 |
|---|---|---|
React.FC<P> | Component<P> | Solid 的不含 children |
| (FC 早期自带 children) | ParentComponent<P> | 要 children 用它 |
ReactNode / ReactElement | JSX.Element | 返回值类型 |
React.CSSProperties | JSX.CSSProperties | 键是 CSS 原名,不是驼峰 |
React.MouseEvent<T>(标参数) | JSX.EventHandler<T, MouseEvent>(标整个处理器) | 标的位置不同 |
useRef<T>(null) 加 .current | let el!: HTMLInputElement 加 ref={el} | 确定赋值断言 |
TSX 会替你拦住 React 习惯——这是上 TS 的最大理由
- 本页在 strict 模式下对着 1.9.14 的
jsx.d.ts逐条跑过tsc,报错原文:className→ Property 'className' does not exist on type 'HTMLAttributes<HTMLDivElement>';htmlFor、key、dangerouslySetInnerHTML都是同一类。 - 最贴心的一条是
style={{ fontSize: "12px" }}→ 'fontSize' does not exist in type 'CSSProperties'. Did you mean to write 'font-size'?——连改法都给了。style={{ width: 20 }}的裸数字也不接受。 - 这几条在纯 JS 项目里全是静默的(见 04 章):要么被当成别名照样生效,要么当场失效但一个字不报。
五个空接口:自定义前缀要自己补声明
Directives(use:)、ExplicitProperties(prop:)、ExplicitAttributes(attr:)、ExplicitBoolAttributes(bool:)、CustomEvents(自定义事件的on:)在jsx.d.ts里都是空接口,靠你用declare module "solid-js"往里加键。- 补完声明后
use:model/attr:data-x/prop:value/bool:disabled/on:my-event全部通过;不补则报「属性不存在」。标准 DOM 事件的on:click已经被逐个枚举好,不用声明。
// tsconfig(create-vite 的 solid-ts 模板原文,节选)
{
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "solid-js",
"moduleResolution": "bundler",
"types": ["vite/client"]
}
}
// React 类型 → Solid 类型
import type { Component, ParentComponent, JSX } from "solid-js";
const Card: Component<{ title: string }> = (props) => <div>{props.title}</div>;
const Box: ParentComponent = (props) => <div>{props.children}</div>;
const s: JSX.CSSProperties = { "font-size": "12px" }; // 不是 fontSize
// 自定义前缀要先补声明,TSX 才认(通过)
declare module "solid-js" {
namespace JSX {
interface Directives { model: Signal<string> }
interface ExplicitProperties { srcObject: MediaStream }
interface CustomEvents { "my-event": CustomEvent<string> }
}
}const { title } = props 类型完全正确、编译通过,运行时照样断响应(04 章);{list().map(…)} 也是合法 TSX。TSX 拦的是「写法不对」,拦不住「时机不对」——后者只能靠规则本身和 eslint-plugin-solid 的 solid/reactivity。className、key、style 驼峰这些会被逐条点出来,改到不报错,「写法」层面的迁移就基本完成了。给组件标类型有两条路:要么给整个组件套 Component<P>(props 自动带上类型),要么直接给 props 参数标一个类型。带 children 的组件用 ParentComponent,它替你把 children 那一项补进类型里。这些类型都从 solid-js 导出,本页对着真实 .d.ts 核过。
两种写法
- 套组件类型:
const Card: Component<CardProps> = (props) => ...。Component<P>的真实定义就是(props: P) => JSX.Element,标了它props就自动是P、返回值也被约束。 - 标参数:
function Plain(props: CardProps) { ... }。更朴素,普通函数组件常用。
要不要 children,选不同类型
| 类型 | children | 用在 |
|---|---|---|
Component<P> | 不自动加 | 叶子组件、自己声明 children 的组件 |
ParentComponent<P> | 加 children?: JSX.Element | 布局、包裹类组件 |
VoidComponent<P> | children?: never | 明确不该有子节点的组件 |
FlowComponent<P, C> | children 必填 | 类 <Show> 的控制流组件 |
标了类型也别解构
- 类型只管「形状」,管不了「响应性」。
const { title } = props编译能过,但一样会断响应(见 04 章)。类型正确 ≠ 运行正确。
import type { Component, ParentComponent } from "solid-js";
type CardProps = { title: string; count: number };
// 写法一:Component<P>,props 自动是 CardProps
const Card: Component<CardProps> = (props) =>
<div>{props.title}: {props.count}</div>;
// 写法二:直接标 props 参数
function Plain(props: CardProps) {
return <p>{props.title}</p>;
}
// 要 children:ParentComponent 自动补 children?: JSX.Element
const Layout: ParentComponent<{ title: string }> = (props) =>
<section>{props.children}</section>;Component<P> 不会自动给你 children。写个布局组件用了 Component 又访问 props.children,会报「属性 children 不存在」。要 children 就换 ParentComponent,或在自己的 props 类型里显式写上 children?: JSX.Element。Component<P>,要包东西 ParentComponent<P>,明确不收子节点 VoidComponent<P>。不确定就先用 Component<P>、需要 children 时再换——换类型是安全的重构,编译器会盯着。createSignal 的类型全靠推断,但把中间产物单独传递、或想显式约束时,你得知道它拆出来的三个类型名。对着真实 .d.ts:Accessor<T> 是取值函数,Setter<T> 是设值函数,Signal<T> 是它俩组成的元组。
三个类型的真身
| 类型 | 定义(.d.ts 原文) |
|---|---|
Accessor<T> | () => T——就是个返回 T 的函数 |
Setter<T> | 可接新值或 (prev: T) => T 的重载函数 |
Signal<T> | [get: Accessor<T>, set: Setter<T>] |
什么时候要手写
- 显式泛型:初值推不出你要的类型时,
createSignal<string[]>([])把类型钉死,否则空数组会被推成never[]。 - 把访问器当参数传:函数收一个
value: Accessor<number>,比写() => number更达意。
不给初值,类型会带上 undefined
createSignal<string>()(无初值)的返回是Signal<string | undefined>——这是.d.ts里专门的一条重载。读出来是string | undefined,用之前得先判空。
import { createSignal } from "solid-js";
import type { Accessor, Setter, Signal } from "solid-js";
// 通常靠推断:count 是 Accessor<number>,setCount 是 Setter<number>
const [count, setCount] = createSignal(0);
const n: number = count();
setCount((c) => c + 1);
// 显式泛型:空数组必须钉类型,否则是 never[]
const [items, setItems] = createSignal<string[]>([]);
// 无初值 → Signal<string | undefined>
const [name, setName] = createSignal<string>();
const len = name()?.length; // name() 是 string | undefinedcreateSignal<string>() 不给初值时,别忘了类型是 string | undefined——直接 name().toUpperCase() 会报「对象可能未定义」。要么给初值 createSignal(""),要么每次用前判空。Signal<T> 元组最省事——本页表单章的自定义 use:model 指令就是收一个 Signal<string>(见 09 章)。参数标 Accessor<T> 则表示「只读,不给你 setter」。store 和 resource 的类型平时也靠推断,但把它们在模块间传递、或写工具函数时要知道名字。对着真实 .d.ts:createStore 返回 [Store<T>, SetStoreFunction<T>],createResource 的数据端是 Resource<T>。
store 的两个类型
Store<T>在.d.ts里其实就是T本身——读的时候形状和原对象一模一样,所以form.address.city类型自然正确。SetStoreFunction<T>是那个路径式 setter,重载覆盖到七层深,替你把每层的键名和值类型都对上:setForm("address", "city", ...)里第三个参数只能是string。
resource 的类型
Resource<T>是个联合类型,既能当函数调用(data()返回T | undefined),又带.loading: boolean、.error: any、.state。- 取数函数的返回类型就是
T:createResource(id, fetchUser)里fetchUser返回Promise<User>,data()就是User | undefined。
data() 永远可能 undefined
- 没加载完时
data()是undefined,所以类型是T | undefined。在<Suspense>里读一般已就绪,但类型系统不知道——包一层<Show when={data()}>或判空能同时收窄类型(见 08 章)。
import { createStore } from "solid-js/store";
import type { Store, SetStoreFunction } from "solid-js/store";
import { createResource } from "solid-js";
import type { Resource } from "solid-js";
type Form = { name: string; address: { city: string } };
const [form, setForm]: [Store<Form>, SetStoreFunction<Form>] =
createStore<Form>({ name: "", address: { city: "" } });
setForm("address", "city", "上海"); // 路径与类型都被检查
interface User { id: number; name: string }
async function fetchUser(id: number): Promise<User> { return { id, name: "u" }; }
const [user]: [Resource<User>, unknown] = createResource(userId, fetchUser) as any;
const loading: boolean = user.loading;
const u = user(); // User | undefinedStore<T> 只是可读形状,它不带 setter——想改值必须走配套的 SetStoreFunction,对 store 直接赋值 form.name = "x" 在运行时不生效——而且 Store<T> 就是 T 本身、并非深只读,TS 也不会拦这种赋值,别指望编译器替你挡。改值一律用 setForm(...)。Store<T> / SetStoreFunction<T>;组件内部日常直接 const [s, set] = createStore(...) 让它推断就好。类型名的价值在跨函数边界传递。createContext 的类型完全由「你给不给默认值」决定,这条本页用 tsc 严格模式验过。给了默认值,类型就是那个值的类型;不给,useContext 的结果就带上 undefined,逼你处理「没被 Provider 包住」的情况。
两条路,两种类型
| 写法 | useContext 返回 |
|---|---|
createContext(0)(有默认值) | number,随时能用 |
createContext<Auth>()(无默认值) | Auth | undefined,用前要判空 |
无默认值怎么收窄
- 常见做法:包一个自定义 hook,在里面判空后抛错,把
undefined挡在门外——之后组件里拿到的就是干净的Auth。 - 这样既保留了「忘了套 Provider 就报错」的保护,又让消费端不用每次判空。
tsc 不判空就报错
- 本页在严格模式下验过:先
const auth = useContext(AuthCtx)再访问auth.user会报TS18048: 'auth' is possibly 'undefined';内联写useContext(AuthCtx).user则报TS2532: Object is possibly 'undefined'。两者都是「可能未定义」的保护,正是无默认值该有的。
Provider 传的是信号,不是值
- 要跨层共享可变状态时,context 的类型通常是「信号或 store 的形状」而不是裸值——比如
createContext<{ theme: Accessor<string>; toggle: () => void }>()。 - 这样消费端
theme()读取才保持响应;把theme()的当前值塞进 context 则会断响应,类型也会退化成一个静态string(见 07 章)。
import { createContext, useContext } from "solid-js";
// 有默认值:类型确定为 number
const CountCtx = createContext(0);
const c: number = useContext(CountCtx);
// 无默认值:类型是 Auth | undefined
type Auth = { user: string };
const AuthCtx = createContext<Auth>();
// 自定义 hook:判空后抛错,收窄成 Auth
function useAuth() {
const auth = useContext(AuthCtx);
if (!auth) throw new Error("useAuth 必须在 AuthProvider 内");
return auth; // 这里已是 Auth,不含 undefined
}createContext({ user: "" } as Auth))——这样忘了套 Provider 时不会报错,而是静默拿到空用户,bug 更难查。宁可无默认值 + 自定义 hook 抛错,让问题在开发期就炸出来。undefined 提醒你套 Provider,再用自定义 hook 收窄。日常写 Solid + TS,真正会绊人的就那么几处:返回值该标什么、事件参数 e 是什么类型、ref 变量怎么标才不报「用前未赋值」。这几条本页都在 tsc 严格模式下验过。
返回值:JSX.Element
- 组件返回的类型是
JSX.Element(不是 React 的ReactNode)。多数时候靠推断,但显式标注函数返回值时用它。
事件:用 currentTarget
- 内联写
onInput={(e) => ...}时,e会被自动推断,e.currentTarget精确到这个元素(如HTMLInputElement),直接.value有类型。 - 把处理函数抽出去单独写时,标
JSX.EventHandler<HTMLInputElement, InputEvent>就能拿回同样的推断。 - 别用
e.target:它的类型只是宽泛的EventTarget,上面没有.value,要么报错要么逼你断言。
ref:用确定赋值断言
- 写
let el: HTMLInputElement;再ref={el},严格模式会抱怨「使用前未赋值」。加一个!:let el!: HTMLInputElement;,告诉编译器「它一定会被 ref 赋上」。
import type { JSX } from "solid-js";
import { createSignal } from "solid-js";
function Field(): JSX.Element {
const [v, setV] = createSignal("");
let el!: HTMLInputElement; // ! 确定赋值断言
// 抽出的处理函数:标 EventHandler 拿回推断
const onInput: JSX.EventHandler<HTMLInputElement, InputEvent> = (e) => {
setV(e.currentTarget.value); // currentTarget 是 HTMLInputElement
};
return <input ref={el} value={v()} onInput={onInput} />;
}e.target.value 报「EventTarget 上没有 value」——换成 e.currentTarget.value;二是 let el: HTMLInputElement 报「使用前未赋值」——写成 let el!: HTMLInputElement。两处都是 TS 在替你把「可能还没赋值」这件事挑明。e 上看它推出来的类型,照抄即可。Solid 的 JSX 命名空间里备好了 EventHandler、各种 HTML*Element。测试:testing-library + vitest
用 @solidjs/testing-library 渲染组件、断言细粒度更新、测异步与错误边界。
测 Solid 组件的标配是 vitest + vite-plugin-solid + jsdom,再加 @solidjs/testing-library 负责渲染。三样东西各管一段:插件负责把 JSX 编译成 Solid 的响应式代码,jsdom 提供一个假的浏览器 DOM,vitest 跑用例。本页所有例子就是在这套配置上跑通的。
四个包各管什么
| 包 | 作用 |
|---|---|
vitest | 测试运行器,跑 *.test.jsx |
vite-plugin-solid | 把 JSX 编译成 Solid 代码(必须有,否则 JSX 跑不起来) |
jsdom | Node 里的假 DOM,让组件有地方渲染 |
@solidjs/testing-library | 提供 render / fireEvent / 查询 |
关键一行:conditions 用 development
resolve.conditions: ["development", "browser"]让测试走 Solid 的开发构建——这样才能在测试里看到开发期的告警(比如把响应式用错时的提示)。少了它,很多告警和某些开发期检查不会出现。test.globals: true让describe/it/expect不用每次 import。
// vitest.config.js
import { defineConfig } from "vitest/config";
import solid from "vite-plugin-solid";
export default defineConfig({
plugins: [solid()],
// 走开发构建,才看得到开发期告警
resolve: { conditions: ["development", "browser"] },
test: {
environment: "jsdom",
globals: true,
},
});vite-plugin-solid,或误用了 React 的 @testing-library/react,JSX 要么编译不对、要么组件根本不响应。Solid 的 JSX 必须由它自己的插件编译;测试库也要用 @solidjs/testing-library 这个专门为 Solid 写的版本。npm i -D vitest jsdom vite-plugin-solid @solidjs/testing-library @testing-library/jest-dom。最后那个 jest-dom 提供 toBeInTheDocument() 之类更顺手的断言(要在 setup 里 import 一次),本页示例只用 vitest 自带的 expect 就够。测一个组件就三步:render 把它挂进 jsdom,用 getByRole / getByText 之类按「用户看到的样子」找元素,fireEvent 派发交互,再断言页面变了。注意 render 收的是一个函数 () => <C/>,不是 <C/> 本身。
为什么是 render(() => <C/>)
- Solid 的
render需要在自己的响应式根里执行组件,所以要给它一个函数、由它来调用——直接传<Counter/>是错的写法。 - 返回值里有一堆
getBy*查询和container,也可以用全局的screen来查。
按角色/文本查,别抓 class
getByRole("button")、getByText("提交")、getByLabelText("邮箱")——照用户能感知的东西找,测试更稳、也顺带逼你把无障碍属性写对。- 找不到会直接抛错,所以
getBy*本身就是一种「它存在」的断言。
fireEvent 派发交互
fireEvent.click(btn)点击、fireEvent.input(el, { target: { value: "x" } })输入。事件同步派发,Solid 在事件里的更新也同步冲刷,紧接着就能断言。
import { render, fireEvent } from "@solidjs/testing-library";
import { createSignal } from "solid-js";
import { describe, it, expect } from "vitest";
function Counter() {
const [c, setC] = createSignal(0);
return <button onClick={() => setC((n) => n + 1)}>点了 {c()} 次</button>;
}
it("点击后文本更新", () => {
const { getByRole, getByText } = render(() => <Counter />);
expect(getByText("点了 0 次")).toBeTruthy();
fireEvent.click(getByRole("button"));
expect(getByText("点了 1 次")).toBeTruthy();
});render(<Counter/>) 直接传组件元素是错的——Solid 的 render 要一个函数 () => <Counter/>,好让它在响应式根里执行。传错了轻则报错、重则组件不响应。这跟 React 测试库的写法不一样,别照搬。getByRole > getByLabelText(表单)> getByText,尽量用靠前的。getBy* 找不到即抛错,queryBy* 找不到返回 null(用来断言「某元素不存在」),findBy* 返回 Promise(等异步出现的元素)。Solid 最该被测出来、也最能体现它和 React 区别的一条:组件函数只执行一次,之后点击只更新那个文本节点、组件体不重跑。用 vi.fn() 埋在组件体里数调用次数,就能把这条断言写死。本页正是这么验证 Solid 细粒度更新的。
怎么测
- 在组件函数体第一行放一个
const runs = vi.fn()并调用runs()。 - 渲染、点两下按钮,断言
runs只被调用了 1 次,而按钮文本已经从 0 变到 2。 - 这直接证明了:更新只发生在依赖信号的那个 DOM 节点,组件体没有重新执行。
为什么这条值得单测
- 它是 Solid 一切反直觉规则的根(见 04 章):正因为只跑一次,才不能解构 props、才要用
<Show>/<For>而不是三元和map。 - 把它写成测试,等于给「有没有不小心破坏细粒度」上了一道保险——比如哪天有人把某段逻辑写成了每次更新都重跑,这个用例会红。
和 React 的直觉正好相反
- 同一个组件在 React 里,每点一次按钮组件函数就重跑一遍,
runs会数到 3;在 Solid 里永远是 1。要是你从 React 过来、下意识以为「点一下组件就重渲染」,这个用例会帮你把心智模型掰过来。 - 推论也能顺手测:把一次性初始化(读一次配置、生成一个随机 id)放组件体里,断言它只发生一次——在 Solid 里这样写是安全的,在 React 里就是 bug。
import { render, fireEvent } from "@solidjs/testing-library";
import { createSignal } from "solid-js";
import { it, expect, vi } from "vitest";
it("组件体只跑一次,点击只改文本", () => {
const runs = vi.fn();
function Counter() {
runs(); // 数组件体执行次数
const [c, setC] = createSignal(0);
return <button onClick={() => setC((n) => n + 1)}>{c()}</button>;
}
const { getByRole } = render(() => <Counter />);
const btn = getByRole("button");
fireEvent.click(btn);
fireEvent.click(btn);
expect(btn.textContent).toBe("2");
expect(runs).toHaveBeenCalledTimes(1); // 只执行一次!
});createRoot 外面单独跑响应式代码、或忘了 render 会自己建根,可能让 effect 的冲刷时机不符合预期。纯响应式(不涉及组件)的时序测试用 createRoot((dispose) => {...}) 包起来手动控制;组件测试交给 render 即可,别混用。vi.fn() 技巧能测很多细粒度行为:把它放进 createEffect 数 effect 跑了几次、放进 createMemo 数重算了几次、放进 <For> 的行组件数某一行有没有被无谓重建——这些是 Solid 性能保证的核心断言(见 12 章)。异步 UI 和错误兜底也要测。异步的关键是等——数据没到时页面显示 fallback,用 waitFor 反复重试断言直到数据到位。错误边界则是渲染一个会抛错的子组件,断言 fallback 顶上来了。下面两段都在本页实验台跑通。
测异步:waitFor
- 组件用
createResource拉数据、外面包<Suspense fallback>。渲染后先能查到 fallback(「加载中」),再用await waitFor(() => expect(...))等真实数据出现。 waitFor会在一小段时间内反复跑那个断言,直到通过或超时,专门用来跨过「异步还没 resolve」这段空窗。也可以用findByText(自带等待)。
测错误边界:ErrorBoundary
- 写一个组件在渲染期
throw new Error(...),用<ErrorBoundary fallback={(err) => ...}>包住,断言 fallback 里显示了err.message。 - 实验台里验证过:
ErrorBoundary能捕获子组件渲染期抛出的错误并渲染 fallback(见 08 章)。
异步断言别忘了 await
waitFor/findBy*都返回 Promise,测试函数要写成async并await。忘了 await,用例会在数据到达前就结束、假绿。
import { render, screen, waitFor } from "@solidjs/testing-library";
import { createResource, Suspense, ErrorBoundary } from "solid-js";
import { it, expect } from "vitest";
it("Suspense fallback 到数据到位", async () => {
function AsyncName() {
const [data] = createResource(() =>
new Promise((r) => setTimeout(() => r("Ada"), 10)));
return <Suspense fallback={<p>加载中</p>}>你好 {data()}</Suspense>;
}
render(() => <AsyncName />);
expect(screen.getByText("加载中")).toBeTruthy();
await waitFor(() =>
expect(screen.getByText("你好 Ada")).toBeTruthy());
});
it("ErrorBoundary 捕获渲染期抛错", () => {
const Boom = () => { throw new Error("炸了"); };
const { getByText } = render(() => (
<ErrorBoundary fallback={(err) => <p>出错: {err.message}</p>}>
<Boom />
</ErrorBoundary>
));
expect(getByText("出错: 炸了")).toBeTruthy();
});await waitFor(...),用例在数据到达前就结束、看起来通过其实没测到;二是被测的错误发生在事件回调里而非渲染期——ErrorBoundary 只兜渲染期的抛错,事件里的错要在回调内自己 try/catch,别指望边界接住。fetch 之类真实请求要 mock 掉(vi.fn() 返回一个假 Promise,或用 vi.mock),让测试不依赖网络、可复现。上面例子直接用一个 setTimeout 的 Promise 当假数据源,是最轻量的做法。性能与常见陷阱
Solid 默认就快,出错几乎都来自「无意中破坏了响应性」这一类固定套路。
Solid 默认就快——没有虚拟 DOM、组件函数只跑一次、更新精确到单个 DOM 节点。所以性能问题几乎从来不是「Solid 慢」,而是「你无意中把响应性弄断了」,框架于是退回到「整块重建」或「干脆不更新」。
五种最常见的破坏方式
| 套路 | 症状 | 原因 | 修法 |
|---|---|---|---|
| 解构 props | 父层传新值,子层文本不更新 | 组件只跑一次,解构那一刻取的是快照 | 改写 props.x,需要拆分用 splitProps/默认值用 mergeProps(详见 04) |
| 解构 store 顶层 | store 改了字段,界面不动 | const {n}=s 同样是快照 | 读 s.n,保留路径访问(详见 06) |
| set 同一引用的对象 | 改了对象属性再 set 回去,不触发 | signal 默认按 === 判相等,引用没变 | 建新对象 {...prev},或改用 store(详见 02) |
| 对象数组用了 Index | 列表重排后行内状态串位 | Index 按下标复用 DOM,状态跟着位置走 | 对象数组用 For,按引用搬 DOM(详见 05) |
| 原始值数组用了 For | 编辑一项,那一行被销毁重建、失焦 | For 把原始值本身当身份,值一变就是新行 | 原始值/定长数组用 Index(详见 05) |
一条主线串起全部
这五个坑没有一个是「另一个知识点」——它们全是「组件函数只跑一次」这一条的推论。信号要调用 count()、props 不能解构、列表用 For/Index 而不是 map,都从这里长出来。记住这条根,你能自己推出修法,而不是死记五条规则。
// —— 破坏响应性的五种固定套路(都是「组件只跑一次」的推论)——
// 1) 解构 props:拿到快照,父层更新它不再变
function Bad(props) { const { name } = props; }
// 2) 解构 store 顶层:同样是快照
const { count } = state;
// 3) set 同一引用的对象:=== 相等,不触发
obj().n++; setObj(obj());
// 4) 对象数组用了 Index → 重排后行内状态串位
// 5) 原始值数组用了 For → 编辑一项就销毁重建两个都渲染列表,区别只有一句:For 按引用身份 diff,Index 按下标 diff。选对了,更新只碰该动的那一格;选错了,要么多余重建、要么状态串位。
各自的主场
- For(对象数组、会增删重排):按引用 diff。数组重排(同一批对象换位置)时,For 把已存在的 DOM 行连同行内状态一起搬到新位置,行组件体不重跑。本机 lab 交换/重排同一批对象引用,行体 0 次重跑;把某一项换成全新对象,才只有那一行的行体重跑一次。
- Index(原始值数组、定长结构):按下标 diff,item 是访问器 item()。某一格的值变了,Index 只更新那一格的访问器、DOM 原地复用、行体不重跑。lab 改下标 0 的值,行体 0 次重跑,访问器发出新值。
用反的两种代价
| 数据形态 | 该用 | 用反的症状 |
|---|---|---|
| 对象数组、会重排 | For | 误用 Index:行内的非受控状态(输入框内容、行内 signal)跟着下标留在原位,数据却换了 → 内容和数据对不上(串位) |
| 原始值/定长 | Index | 误用 For:编辑某项的值,For 认为身份变了 → 销毁旧行、新建行,输入框失焦。lab For 原始值列表改一项,那一行行体多跑一次;换 Index 则 0 次 |
// 对象数组、会增删重排 → For(按引用搬 DOM 与行内状态)
<For each={rows()}>
{(row) => <input value={row.text} />}
</For>
// 原始值 / 定长 → Index(item 是访问器,值变原地更新)
<Index each={tags()}>
{(tag) => <input value={tag()} />}
</Index>createEffect 会自动追踪它读到的每个信号,任一变化就整段重跑。如果你在多个 effect(或多处 JSX)里重复算同一个派生值,这份计算就被算了很多遍。抽成一个 createMemo,无论多少处消费,每次变化只算一次。
memo 为什么省
createMemo 缓存结果,只在依赖变化时重算一次,读取 total() 走缓存。lab 一个 memo 被两个 effect 消费,源变化时内部 compute 只跑一次;同样逻辑不套 memo、直接在两个 effect 里各算一遍,源一变就算两遍——消费方越多,差距越大。
什么该抽 memo
- 昂贵的派生(过滤/排序大数组、格式化、聚合)且被多处读取,或被放进
For的 each。 - 别用普通函数冒充:写
const derived = () => expensive(list())看着像,但它没有缓存——每次调用都重算,多处读就多次算,等于回到没抽之前。memo 的价值就在「缓存 + 只在依赖变时重算」。 - 反过来,只被一处读、又便宜的表达式(a() + 1)不必 memo——memo 自身也有簿记开销。
别把副作用塞进 memo
memo 必须是同步纯函数(只算值、返回值);发请求、改 DOM、写别的信号要放 createEffect。而且 memo 是懒的——没人读它就不算,副作用会莫名不执行。
// ❌ 同一份派生在两个 effect 里各算一遍
createEffect(() => draw(expensive(list())));
createEffect(() => report(expensive(list()))); // list 变 → 算两遍
// ✅ 抽成 memo:list 变,只算一次,两处共享缓存
const derived = createMemo(() => expensive(list()));
createEffect(() => draw(derived()));
createEffect(() => report(derived()));console.log 想数「算了几次」时,如果没有任何地方读它,你会看到它一次都没算,这不是 bug 是设计。For 的「key」不是你写的 id 字段,而是数组项的引用身份(对象在内存里是不是同一个)。想让大列表更新得又快又稳,核心就一句:更新数组时,没变的项要保持原来的对象引用。
为什么引用身份要紧
For 拿新旧两个数组按引用做 diff——引用还在的项,直接复用那一行 DOM 和它的行内状态;引用变了的项,才销毁/新建。lab 换一个新的数组字面量但项还是原来那批对象,For 全部复用、行体 0 次重跑;把其中一项换成全新对象,只有那一行的行体重跑一次。
不可变更新怎么保引用
改一项时,只替换那一项、其余项原样带过来(不是整批深拷贝)。用 map 命中才 new、否则返回原对象,见右侧代码。这样 For 只会重建被改的那一行。
别整批重建
每次更新都 list().map(x => ({...x})) 把每一项都做成新对象,会让 For 认为所有行都变了、整表销毁重建——大列表卡顿常常就是这么来的。要么用「命中才 new」,要么直接上 createStore + 路径式更新(06),store 天生只动被改的字段。此外,把整段服务端数据塞回列表时,用 store 的 reconcile(06)按新快照 diff、自动保留未变项的引用,比手写「命中才 new」更省心。
// ✅ 不可变更新:只替换命中项,其余保持原引用
setList(prev =>
prev.map(item =>
item.id === id ? { ...item, done: !item.done } : item // 未命中 → 原对象
)
);
// ❌ 整批新建:每项都是新引用 → For 认为整表都变了
setList(prev => prev.map(item => ({ ...item })));For each={list().filter(...)} 这种在 each 里现过滤看着方便:项引用没变,行确实会复用,但 filter 每次都重新算一遍。列表大、过滤重时把它抽成 createMemo;引用稳定的话,For 依旧只更新真正增删的那几行。createMemo 缓存,别在 For each 里现算(见 12 前一张卡);嵌套结构直接用 createStore,路径式更新只碰被改的字段,天然满足「保引用」。createStore 很强,但不是「越用越好」。它是给嵌套对象/数组准备的;一个数字、一个布尔、一个字符串包进 store,只是徒增心智负担,还容易踩「解构断链」的坑。
signal 还是 store(回扣 06)
| 状态形态 | 选 | 为什么 |
|---|---|---|
| 单个原始值(计数、开关、输入字符串) | createSignal | 整体替换即可,读写最直接 |
| 需要整体替换的值 | createSignal | 换引用就是换值 |
| 嵌套对象/数组/表单/列表 | createStore | 路径式更新,只动被改字段,不必手写不可变展开 |
store 的隐性成本
- 读取要走 Proxy,且解构顶层照样断响应(const {n}=state 是快照,和解构 props 同理)。
- 简单值用 store,
setState("v", x)比setV(x)更绕。 - 团队读代码时要多想一层「这是 store 还是 signal」。
经验法则
90% 的简单 UI 状态用 signal,复杂结构才上 store。判据不是「会不会变复杂」,而是「现在是不是嵌套、要不要字段级更新」——真变复杂了再从 signal 迁到 store,成本很低,别提前优化。
// ✅ 简单值:signal 就够
const [count, setCount] = createSignal(0);
const [open, setOpen] = createSignal(false);
// ❌ 过度设计:把一个数字包进 store
const [s, setS] = createStore({ count: 0 });
setS("count", c => c + 1); // 只是把 setCount 写复杂了
// ✅ store 的主场:嵌套结构、字段级更新
const [form, setForm] = createStore({ user: { name: "", city: "" } });
setForm("user", "city", "上海");const { user } = state 拿到的是快照。store 不是「解构安全」的护身符:该读 state.user 还得读 state.user;要传给子组件就传整个 store 或传 getter,别解构出来传。列表里有一个「当前选中项」,每行都要判断「是不是我」——直接写 class={sel() === i ? "on" : "off"},每行都订阅了 sel,于是选中项一变,全表每一行都重算一遍。createSelector 把这件事降到只有两行重算:离开的那行和进来的那行。
lab 2000 行的对照
- 同一个 2000 行的
<For>,把 class 的计算次数插桩: - 裸
sel() === i——挂载求值 2000 次,setSel(1500)之后重求值 2000 次。 createSelector——挂载同样 2000 次,setSel(1500)之后重求值 2 次。高亮位置两者都正确。- 2000 → 2 不是「快一点」,是复杂度从 O(n) 变成 O(1):列表再长,切换选中的代价都是恒定的两行。
它凭什么做到
createSelector(源信号)返回一个isSelected(key)函数。每行调用它时,登记的不是「我订阅了 sel」,而是「我订阅了 key 这个值的选中状态」——内部按 key 分桶存着一批细粒度的订阅。- 源信号从
a变成b时,它只唤醒a桶和b桶里的订阅者,别的行根本不知道发生过变化。 - 第二个参数可以传自定义比较函数
(key, source) => boolean,用来做「区间选中」「多选包含判断」这类非等值的匹配。
什么场景值得上
- 典型:表格当前行高亮、树形结构的展开项、侧栏当前导航项、单选列表。共同特征是「n 行里只有 1 行(或少数几行)状态为真」,且这个「哪一行」会频繁变。
- 不值得:十几行的短列表——省下的重算还不够那层簿记开销。判据和 memo 一样,先看列表规模。
import { createSignal, createSelector, For } from "solid-js";
const [selected, setSelected] = createSignal(0);
const isSelected = createSelector(selected);
// ✗ 每行都订阅 selected:切换一次,2000 行全部重算
<For each={rows}>{(r) =>
<li class={selected() === r.id ? "on" : ""}>{r.name}</li>
}</For>
// ✓ 按 key 分桶订阅:切换一次,只有 2 行重算
<For each={rows}>{(r) =>
<li class={isSelected(r.id) ? "on" : ""}>{r.name}</li>
}</For>createSelector 必须建在响应式作用域里且只建一次——放在组件体顶部(组件只跑一次,天然满足)。别在 <For> 的回调里给每行各建一个,那样每行各有一套分桶,白折腾一遍还更慢。另外它只加速「读」,选中项本身仍是个普通信号,写法不变。r.done 这类),那本来就是细粒度的,不需要它。它和本章前面几张卡是互补的——<For> 保住行的身份,createMemo 缓存派生列表,createSelector 管「哪一行是当前项」。SolidStart:项目与路由
文件路由、嵌套布局、导航——全栈元框架 SolidStart 的骨架。
SolidStart 是 Solid 官方的全栈元框架(类比 React 的 Next、Vue 的 Nuxt):文件路由、SSR、Server Functions 一套齐活,底层跑在 Vinxi/Nitro 上。版本基准 SolidStart 1.x(本页据 @solidjs/start 1.3.2、@solidjs/router 1.0.0 的类型声明;router 从 0.16 走到 1.0 是稳定版号,query / createAsync / action 这几个数据 API 的签名一字未变)。
项目里几个关键位置
| 位置 | 作用 |
|---|---|
| src/routes/ | 文件即路由:这个目录下的文件自动变成页面/接口 |
| src/app.tsx | 应用根组件:用 <Router> 包住 <FileRoutes /> |
| src/entry-server.tsx | 服务端入口:SSR 时如何把应用渲染成 HTML |
| src/entry-client.tsx | 客户端入口:浏览器里如何水合(hydrate)已有 HTML |
| app.config.ts | 全站配置(部署 preset、middleware 等,详见 16) |
两个入口在忙什么
SolidStart 默认同构——同一份组件先在服务端渲染成 HTML(首屏快、利于 SEO),到浏览器再「水合」接管交互。entry-server 和 entry-client 就是这两端的启动点,脚手架已生成好,多数时候不用动。
导入路径别记混
Router 来自 @solidjs/router,而 FileRoutes 来自 @solidjs/start/router(据 .d.ts:FileRoutes 在 @solidjs/start/router 导出,不在 @solidjs/start 顶层)。两个包分工明确:路由核心在 router,Start 只做「文件 → 路由」的接线。
// src/app.tsx —— 应用根
import { Router } from "@solidjs/router";
import { FileRoutes } from "@solidjs/start/router";
import { Suspense } from "solid-js";
export default function App() {
return (
<Router root={(props) => <Suspense>{props.children}</Suspense>}>
<FileRoutes /> // 自动挂载 src/routes 下所有路由
</Router>
);
}<Router> 的 root 是包住每个路由的外壳组件(类型是 Component<RouteSectionProps>),它必须渲染 props.children 才能把当前页面显示出来;忘了写 children,页面会一片空白而不报错。root 里通常还放全站共享的导航栏和 <Suspense> 边界。npm init solid@latest 选 SolidStart 模板即可拿到上面这套目录。先把 routes/index.tsx 跑起来看到首页,再往下加路由——别一上来就纠结 entry 文件,那两个默认配置能覆盖绝大多数项目。src/routes/ 下的文件名直接决定 URL——不用手写路由表。几种命名约定就覆盖了绝大多数场景。
文件名 → 路径 约定
| 文件 | 匹配的 URL | 说明 |
|---|---|---|
| routes/index.tsx | / | index 对应目录本身 |
| routes/about.tsx | /about | 文件名即路径段 |
| routes/users/[id].tsx | /users/123 | [id] 是动态段,用 useParams().id 取 |
| routes/(marketing)/home.tsx | /home | (group) 只用于分组,不进 URL |
| routes/blog/[...slug].tsx | /blog/a/b/c | [...slug] 是 catch-all,剩余路径全归它 |
动态段怎么读
动态段的值放在 useParams() 返回的响应式对象里(来自 @solidjs/router,.d.ts 里签名是 useParams<T>(): T)。[id].tsx 里读 params.id;catch-all [...slug] 读 params.slug(是斜杠拼起来的整段)。按属性访问才响应,别解构(和解构 props 同理,04)。
(group) 常被误解
括号目录不出现在 URL 里,它只用来把一批路由归档、或给它们套一个共享布局(见下一张卡)。所以 (app)/dashboard.tsx 和 dashboard.tsx 匹配的是同一个 /dashboard——两者并存会冲突。
// src/routes/users/[id].tsx → /users/:id
import { useParams } from "@solidjs/router";
export default function User() {
const params = useParams(); // 响应式,别解构
return <h1>用户 {params.id}</h1>;
}
// src/routes/blog/[...slug].tsx → /blog/a/b/c
// useParams().slug === "a/b/c"params.id 永远是字符串(URL 里没有类型)。当成数字去做 params.id + 1 会变成字符串拼接("1" + 1 得 "11");要数字先 Number(params.id)。这条和路由本身无关,是 URL 参数的通性,但最容易在详情页出错。index 代表「目录自己」。拿不准时新建文件跑 npm run dev 直接访问最快。多个页面共享同一套导航栏/侧边栏时,用嵌套布局:一个「父」路由渲染公共外壳,再用 props.children 把子路由塞进去——切换子页面时外壳不重建。
两种做法
- 同名文件 + 同名目录:
routes/users.tsx(父布局)配routes/users/[id].tsx、routes/users/index.tsx(子页面)。父组件收到的props.children就是当前匹配的子路由。 - (group) 套壳:
routes/(app).tsx给(app)/组里所有页面套一层布局,而 URL 不受(app)影响。适合「一批页面共享布局但路径互不嵌套」。
布局组件长什么样
就是个普通 Solid 组件,渲染公共结构 + {props.children}。它对应 RouteSectionProps(.d.ts 里含 params / location / data / children),所以布局里也能直接拿到 params、location。routes/users/index.tsx 是 /users 本身的默认子页面,会作为父布局 children 的初始内容;再往深就是布局套布局,每层各渲染自己的 children,切换同层子路由时只换最内层那块。
布局只在进出这一层时挂载卸载
在父布局里 onMount 拉的数据、开的定时器,切换同层子页面时不会重跑——这正是「外壳不重建」的好处,但也意味着别把「随子页面变化的逻辑」写在布局的 onMount 里。
// src/routes/users.tsx —— users/* 的父布局
import { A } from "@solidjs/router";
export default function UsersLayout(props) {
return (
<div class="users">
<nav>
<A href="/users/1">用户 1</A>
<A href="/users/2">用户 2</A>
</nav>
{props.children} // 当前匹配的子路由渲染在这里
</div>
);
}{props.children},子页面就整个不显示,而且不报错——只看到公共外壳、内容区空白。这和 <Router root> 忘写 children 是同一个坑,排查「点了链接 URL 变了但内容没出来」时先查这里。/users 与 /users/1)→ 用同名文件父布局;一批页面共享外壳但路径平级(/dashboard、/settings 都要登录后的外壳)→ 用 (group) 套壳。路由的「读」和「走」都由 @solidjs/router 的一组 API 负责:声明式跳转用 <A>,编程式跳转用 useNavigate,读当前位置用 useLocation / useParams / useSearchParams(均已在 router 的 .d.ts 里核实)。
常用导航 API(都来自 @solidjs/router)
| API | 用途 |
|---|---|
| <A href> | 声明式链接,带 activeClass / end 等增强 |
| useNavigate() | 拿到 navigate 函数,编程式跳转 navigate("/x") |
| useLocation() | 响应式的当前位置:pathname、search、hash |
| useParams() | 当前路由的动态段参数(响应式对象) |
| useSearchParams() | [读、写] 查询字符串的元组 |
三个细节
<A>命中当前路径时自动加activeClass;end属性控制是否要「精确匹配」,否则/会对所有路径都算 active。useNavigate()返回的navigate(to, options)支持{ replace: true }(替换历史而非新增,适合登录后跳转)。useSearchParams()返回[params, setParams],params.page按属性订阅、值都是字符串;setParams({ page: 2 })会像导航一样合并进查询串。
只能在路由树内调用
这些 hook只能在 <Router> 之下调用,且要在组件的响应式上下文里读。拿到 location / params 后按属性访问才保持响应,解构出来就断了(和 props 一个道理,04)。
import { A, useNavigate, useSearchParams } from "@solidjs/router";
// 声明式:命中时自动加 activeClass;end 要求精确匹配
<A href="/" end activeClass="on">首页</A>
function SearchBox() {
const navigate = useNavigate();
const [params, setParams] = useSearchParams();
// 读:params.q 是字符串、按属性订阅;写:像导航一样合并进查询串
return (
<input
value={params.q ?? ""}
onInput={(e) => setParams({ q: e.currentTarget.value })}
/>
);
}<A href="/"> 这种根路径链接一定要加 end,否则它在任何页面都会命中 activeClass(因为所有路径都以 / 开头)——导航栏「首页」永远高亮就是这么来的。<A> 与 useNavigate 各有场景:能用 <A> 就用它(渲染成真正的 <a> 标签,可右键新标签打开、对爬虫可见);只有「提交表单后/某个条件满足才跳」这类由代码触发的跳转才用 useNavigate。SolidStart 默认同构——同一份组件先在 Node 里渲染成 HTML,再到浏览器水合。于是任何碰 window / document / localStorage 的代码,都会在服务端那一遍先炸一次。挡这件事有两个工具:细到一行用 isServer,整个组件都不能上服务端就用 clientOnly。
isServer:一个编译期常量
- 从
solid-js/web导入(不是@solidjs/start)。它是个布尔常量而不是函数,写if (isServer) return,别写isServer()。 - 关键点在于它是编译期可判定的:打包客户端产物时
isServer是字面量false,if (isServer) { … }整块会被摇掉——所以把服务端专属的重依赖包在里面,不会进客户端包。反过来if (!isServer)里的代码不会进服务端产物。 - 最常见的三处用法:读
localStorage前挡一下、注册window事件前挡一下、以及在同构的工具函数里按两端给不同实现。
clientOnly:整个组件跳过 SSR
- 来自
@solidjs/start。签名(据 .d.ts)是clientOnly(() => import("./X")),返回一个组件,接受原组件的 props 再加一个fallback。 - 用在「这个库根本不打算在服务端跑」的场合——地图、富文本编辑器、图表库、任何在模块顶层就摸
window的第三方包。服务端渲染这块时输出fallback,到浏览器再动态载入真身。 - 它和
lazy(08 章)形似但目的不同:lazy是为了拆包、两端都会渲染;clientOnly是为了不在服务端渲染。
onMount 也是一道天然的门
onMount的回调只在客户端跑——服务端渲染不经过挂载阶段。所以「只是想在挂载后摸一下 DOM」根本不需要isServer,写进onMount就够了(07 章)。- 需要
isServer的是组件体里、或模块顶层就要执行的那些语句——它们两端都会跑。
import { isServer } from "solid-js/web";
import clientOnly from "@solidjs/start";
// 一行级别:isServer 是常量,不是函数
function readTheme() {
if (isServer) return "light"; // 服务端给个默认值
return localStorage.getItem("theme") ?? "light";
}
// 组件级别:整个组件跳过 SSR
const Map = clientOnly(() => import("~/components/Map"));
<Map center={pos()} fallback={<div class="map-skeleton" />} />isServer 是个常量不是函数,写成 isServer() 会 TypeError。另外别用 typeof window !== "undefined" 代替它——那是运行时判断,打包器摸不清、摇不掉,服务端专属的依赖照样会被打进客户端包,白白撑大产物。window is not defined」时的顺序:先看报错栈指向哪个组件,再看那句代码在不在 onMount 里——不在就是它。整个第三方组件都救不回来的,直接 clientOnly 包掉,比逐行加 isServer 省事。路由不只是「显示哪个组件」,它还是数据加载的入口。SolidStart 的关键设计:从路由文件导出一个 preload,让取数在导航一开始就触发,而不是等组件渲染了才开始请求。
preload 是什么
文件路由可以额外 export const route = { preload }(.d.ts 里 RouteDefinition.preload 的类型是 RoutePreloadFunc)。它在路由被匹配、甚至组件还没渲染时就被调用,参数是 { params, location, intent }(据 RoutePreloadFuncArgs)。你在里面提前发起取数,数据和组件渲染并行进行。
为什么这样更快
如果等组件渲染到「读数据」那一行才发请求,就变成「渲染 → 发现要数据 → 等 → 再渲染」的串行等待。preload 把取数提前到导航起点,等组件真要用数据时,请求往往已经在飞、甚至回来了。配合下一章的缓存化 query,组件里的 createAsync 直接命中,不重复请求(详见 14)。
preload 是提示性预取,不是阻塞门
它不保证「数据到齐了才渲染」。组件仍要用 Suspense 处理「数据还没回来」的中间态(08)。preload 只负责「早点开始」,不负责「等到好」。完整的 SSR /预取运行时时序未在本地端到端验证,本页据 API 契约与数据流描述。
// src/routes/posts.tsx
import { createAsync } from "@solidjs/router";
import { getPosts } from "~/lib/posts"; // query 化的取数(见下一章)
// 导航一开始就预取,与组件渲染并行
export const route = {
preload() { getPosts(); },
};
export default function Posts() {
const posts = createAsync(() => getPosts()); // 命中 preload 的缓存
// ... 配 Suspense 渲染 posts()
}query 化的(带缓存 + 去重),否则 preload 发一次、组件里 createAsync 再发一次,变成请求翻倍而不是命中缓存。preload 与组件之所以能「接缝」,全靠 query 的同 key 去重——这正是下一章的主题(14)。<A> 甚至会在鼠标悬停/聚焦时触发预加载(由 <Router> 的链接预取能力驱动),所以列表页到详情页的跳转常常「秒开」。SolidStart:数据加载与 Server Functions
query + createAsync 取数、"use server" 把函数搬到服务端、路由预加载避免瀑布。
在函数(或整个文件)顶部写一行 "use server",这个函数就只在服务端执行——可以直接碰数据库、读私钥、用服务端专属的库,而你在客户端仍然像调用普通函数一样调用它,类型也完整保留。
它解决什么
前后端之间原本要手写一套 fetch + 接口 + 序列化。"use server" 让打包器把这个函数编译成一次 RPC(远程调用):客户端的调用点被替换成「发请求给服务端跑真身」,你不用写 URL、不用手动 JSON.parse,参数和返回值的 TypeScript 类型直接贯通两端。
放哪、怎么标
- 函数级:函数体第一行
"use server",只这个函数上服务端。 - 文件级:文件顶部
"use server",该文件所有导出函数都是服务端函数(适合集中放数据访问层)。
它既能取数据也能改数据,是 SolidStart 通用的服务端原语——查询用它、15 的变更也用它。
两端的边界
服务端函数体不会进客户端包,所以别在里面假设有 window / document;反过来,客户端能拿到的只有它的返回值,函数体里的私钥、数据库连接不会泄露到浏览器。参数会被序列化送到服务端,因此参数要能被结构化克隆(普通对象、FormData 等),别传函数或类实例。
// src/lib/posts.ts
import { db } from "./db";
export async function getPosts() {
"use server"; // 只在服务端运行
return db.post.findMany(); // 直接访问数据库
}
export async function createPost(title) {
"use server";
if (!title) throw new Error("标题必填");
return db.post.create({ data: { title } });
}"use server" 是给打包器看的指令字符串,不是普通语句——它必须原样写在函数体(或文件)最顶部,前面不能有其它代码。写错位置(比如放在某个 if 之后)就不生效。(运行时具体行为依赖构建,本页只描述这条契约,不臆造报错原文。)@solidjs/start 的 GET 包一层(.d.ts 里 GET(fn) 保留原函数签名)。默认的 server function 走 POST,适合「有副作用」的调用。query(fn, name)(来自 @solidjs/router)把一个取数函数包装成带缓存的读取:同一个 key 在同一次导航里只真正请求一次,多处调用共享同一份结果。
签名与 name(据 .d.ts)
签名是 query<T>(fn: T, name: string)——name 是必填的第二个参数,不是可选。这个 name 是缓存的命名空间;带参数的 query 会用参数进一步算出具体 key。返回的函数还挂了 .key / .keyFor(...),供手动失效时定位。
为什么要缓存化
路由 preload 先调一次、组件里 createAsync 再调一次、页面上两个组件都要同一份数据……如果每次都真发请求就是浪费。query 用 name + 参数做去重:这些调用命中同一份缓存,只有第一次真正打到服务端(承接 13 的 preload 接缝)。
配套操作
数据变更后要刷新,用 revalidate(key)(.d.ts 里 revalidate(key?, force?))让对应 query 失效重取;15 的 action 完成后也会自动重新验证相关 query。另外,SSR 时 query 在服务端执行、结果随页面一起送到客户端,客户端首次读同一 key 直接用这份数据、不再重发(本页据 API 契约描述,未在本地端到端验证 SSR 管线)。
// src/lib/posts.ts
import { query } from "@solidjs/router";
import { db } from "./db";
// 第二个参数 name 必填,是缓存的命名 key
export const getPosts = query(async () => {
"use server";
return db.post.findMany();
}, "posts");
// 带参数:用参数进一步算出具体缓存 key
export const getPost = query(async (id) => {
"use server";
return db.post.findUnique({ where: { id } });
}, "post");query 的第二个参数不是可有可无的——.d.ts 里它是必填 string。漏了 name,TypeScript 直接报参数缺失;就算用 JS 侥幸跑通,缓存也无法正确按 key 去重。老教程里能看到只传一个函数的 cache(fn) 写法,那是旧 API(本章「cache 已废弃」那张卡细说)。name 在整个应用里要唯一——它是这份数据的全局标识。两个不同的 query 用了同一个 name,会互相污染缓存。取名就按数据实体来("posts"、"post"、"currentUser"),别用泛泛的 "data"。组件里消费 query 化的数据,用 createAsync(() => getPosts())(来自 @solidjs/router)。它是 createResource 的包装,返回一个访问器——像信号一样 posts() 读值,加载中/出错交给 Suspense / ErrorBoundary。
签名(据 .d.ts)
createAsync<T>(fn, options?),fn 是 (prev) => Promise<T>,返回 AccessorWithLatest<T>(一个可调用的访问器,另带 .latest)。给了 options.initialValue 时返回类型收窄为非 undefined,否则首帧可能是 undefined。
为什么是「非阻塞」
SolidStart 没有 async 组件——你不能在组件体里 const data = await getPosts() 把渲染卡住。createAsync 立刻返回访问器、请求在后台跑,数据没到时 posts() 触发最近的 Suspense 显示 fallback,到了再精确更新那一块(08)。多个 createAsync 因此天然并行,不会一个等一个。
依赖要放进回调里
createAsync 要在组件的响应式上下文里创建,它读取的信号(比如 params.id)变化时会自动重新取数——所以把「依赖什么」放进那个 () => ... 回调里。别在回调外先把值取出来存成变量,那样就断了依赖、参数变了不会重取(又是「组件只跑一次」的推论)。
import { createAsync } from "@solidjs/router";
import { Suspense } from "solid-js";
import { getPosts } from "~/lib/posts";
function Posts() {
// 非阻塞:立即返回访问器,请求在后台跑
const posts = createAsync(() => getPosts());
return (
<Suspense fallback={<p>加载中</p>}>
<For each={posts()}>{(p) => <li>{p.title}</li>}</For>
</Suspense>
);
}posts() 可能是 undefined(数据还没到),直接 posts().length 会炸。要么用 <Suspense> 包起来(fallback 期间不渲染读取处),要么给 createAsync 传 { initialValue: [] } 让首帧就有空数组兜底——.d.ts 里给了 initialValue 的重载会把返回类型收窄为非 undefined。createResource(08),SolidStart 里优先 query + createAsync——因为后者带缓存、能被路由 preload 提前触发、并在 SSR 时把数据一起送到客户端。选型一句话:要不要缓存与预加载,要就 createAsync。createAsync 每次重取都换回一份全新的数组,里面每一项都是新对象——交给 <For> 就是「所有行身份全变」,整表销毁重建(12 章讲过这笔代价)。createAsyncStore 是它的 store 版本:新数据回来时按字段 diff 进旧结构,没变的项保持原来的引用。
它和 createAsync 差在哪(据 .d.ts)
- 两者都来自
@solidjs/router,签名几乎一样:createAsyncStore(fn, options?),返回同样的AccessorWithLatest<T>,用法上就是把名字换一下。 - 区别在 options 多了一个
reconcile字段(类型是ReconcileOptions)——新旧数据是调和进去的,不是整体替换。reconcile的机制见 06 章那张卡,这里是它在数据层的现成封装。 - 常用的是
reconcile: { key: "id" }:告诉它按哪个字段认「这还是同一条」。默认按id,主键叫别的名字时要显式给。
什么时候换过去
- 列表 + 会重取——轮询、action 之后的自动重验、下拉刷新。这些场景下每次都整表重建,输入框失焦、滚动位置丢失、行内展开态归零,全是这么来的。
- 单个对象、或取一次就不动的数据——用
createAsync就好,多一层 store 代理没有收益。 - 换过去之后读法要跟着变:store 是代理对象,按路径读、别解构(06 章那条规则同样适用)。
本卡的可信度
- 签名与
reconcile选项按@solidjs/router的createAsync.d.ts写;「重取后保住未变项引用」是reconcile的既有语义(06 章已),SSR 管线下的端到端行为本页未在本地验证。
import { query, createAsyncStore } from "@solidjs/router";
const getTodos = query(async () => {
"use server";
return db.todo.findMany();
}, "todos");
function TodoList() {
// 重取时按 id 调和进旧结构,没变的行不重建
const todos = createAsyncStore(() => getTodos(), {
initialValue: [],
reconcile: { key: "id" },
});
return <For each={todos()}>{(t) => <Row todo={t} />}</For>;
}reconcile 认不出主键时会退化成整体替换——列表项没有 id、或主键字段叫 uuid / _id 却没在 reconcile: { key: … } 里说明,就白换了一个 API。症状和用 createAsync 时一模一样(重取后输入框失焦、滚动跳回顶部),所以换完要真的重取一次看看。createAsyncStore,返回单个对象的用 createAsync。前者几乎总会被重取(action 完成后自动重验就是),后者多半取一次就完。改动只是换个导入名加一个 reconcile,成本极低。你在很多稍旧的教程、博客里会看到 cache(fn, name)——在当前版本里,cache 只是 query 的一个已废弃别名,新代码一律用 query。
源码怎么说
@solidjs/router 的 query.d.ts 里白纸黑字:/** @deprecated use query instead */ export declare const cache: typeof query;。也就是说 cache 和 query 指向同一个东西、签名完全一样,只是 cache 被标了 @deprecated——IDE 里会给它划删除线。
为什么改名
这是 SolidStart 数据 API 收敛过程里的更名。功能没变(缓存化取数、name 必填、同 key 去重都一样),只是名字从 cache 换成了更贴切的 query(它表达的是「一次可缓存的读」)。这正是和老教程最容易对不上的一处:看到 cache 别当成另一个 API,它就是 query。顺带一提,这些数据 API 都在 @solidjs/router 而非 @solidjs/start——query、createAsync、action 都从 router 导入,这也是老教程常记错的地方。
迁移是纯改名
把 import { cache } ... 换成 query、调用点 cache(fn, "x") 换成 query(fn, "x") 即可,参数和行为一字不用改。别因为「教程用 cache、文档用 query」就以为要重写逻辑。
// ❌ 旧写法:cache 现在是 @deprecated 别名(IDE 会划删除线)
import { cache } from "@solidjs/router";
const getPosts = cache(fetchPosts, "posts");
// ✅ 新写法:同签名、同行为,改个名而已
import { query } from "@solidjs/router";
const getPosts = query(fetchPosts, "posts");cache 和浏览器的 HTTP 缓存、或 query 命名空间上的 query.set / query.get / query.delete(.d.ts 里挂在 query 上的手动缓存操作)搞混。此处的 cache 特指那个已废弃的函数别名,它等于 query 本身,仅此而已。cache 还是 query 是个快捷信号:还在教 cache 的多半是较早版本的内容,里面别的 API(比如数据加载的组织方式)也可能已经变了,交叉验证一下当前 .d.ts 再照抄。把这一章拼起来:路由 preload 提前触发 + query 缓存去重 + createAsync 命中缓存,三者配合就能消掉最常见的性能杀手——请求瀑布(一个请求等另一个,串成一条长队)。
瀑布是怎么来的
如果数据是「组件渲染到某行才开始取」,就变成 渲染→取 A→(拿到才)取 B→…… 一层套一层地等。尤其在组件里串行 await(先 await user 再 await 它的 posts),每一步都要等上一步——这就是瀑布。
三段接力怎么拆掉瀑布
- 路由文件
export const route = { preload }:导航一开始就调 query 化的取数(13)。 query按 name + 参数缓存去重:preload 发起的请求和组件里createAsync要的是同一份,命中缓存、不重发。- 组件里多个
createAsync各自独立、并行取数,谁先回来先渲染谁,没有互相等待。
关键前提:取数函数被 query 化了
preload 和 createAsync 靠同一个 name/参数算出同一个 key 才能命中缓存。如果 preload 里调的是没 query 化的裸函数,就成了「预取一份、组件再取一份」的请求翻倍,比不预取还糟。(完整的 SSR 流式与预取时序未在本地端到端验证,本页据 API 契约与数据流描述。)
// routes/user/[id].tsx —— 预加载 + 并行取数,无瀑布
import { query, createAsync } from "@solidjs/router";
const getUser = query((id) => fetchUser(id), "user");
const getPosts = query((id) => fetchPosts(id), "userPosts");
// 导航即预取;两个 query 并行
export const route = {
preload({ params }) {
getUser(params.id);
getPosts(params.id);
},
};
export default function Page(props) {
const user = createAsync(() => getUser(props.params.id)); // 命中缓存
const posts = createAsync(() => getPosts(props.params.id)); // 与 user 并行
}SolidStart:Actions 与表单变更
action 做数据变更、useSubmission 跟踪状态、表单渐进增强。
读数据用 14 的 query/createAsync,写数据(新建、删除、点赞这类会改后端的操作)在 SolidStart 里统一走 action。它来自 @solidjs/router,把一个「会产生副作用」的函数包成一个既能接到 <form> 上、又能命令式调用的东西。
两种写法(重载)
第二参可以是一个字符串名字,也可以是选项对象,二选一:
| 写法 | 签名 | 什么时候用 |
|---|---|---|
| 只给名字 | action(fn, name?) | 绝大多数场景,名字用来跨请求识别这个 action |
| 给选项对象 | action(fn, { name, onComplete }) | 需要在提交完成后拿到结果做点事(onComplete 收到一个 Submission) |
包起来的函数长什么样
- 被包的函数通常自己带
"use server",于是这次「写」直接在服务端跑,能碰数据库和私钥(server function 的机制见 14)。 - 返回的
Action有个.url字段,还带一个.with(...)方法用来预绑参数——渐进增强传参就靠它,最后一张卡细讲。
变更后会自动重验
- action 跑完,SolidStart 默认会重新验证(revalidate)相关的
query,页面上依赖那份数据的地方会自己更新,你不用手动去刷新列表。 - 想精确控制刷新范围时,服务端函数里可以返回
reload/redirect/json(都来自@solidjs/router)来指定后续行为。
import { action } from "@solidjs/router";
// 写法一:第二参是名字
const addTodo = action(async (formData: FormData) => {
"use server";
await db.todo.create({ data: { text: formData.get("text") } });
}, "addTodo");
// 写法二:第二参是选项对象,可挂 onComplete
const removeTodo = action(async (id: number) => {
"use server";
await db.todo.delete({ where: { id } });
}, { name: "removeTodo", onComplete: (s) => console.log(s.result) });action 只是「声明」一个变更操作,本身不发请求;真正触发是把它接到 <form action={...}>、或用 useAction 拿到可调用版本后再调。光 const a = action(fn) 放着,什么都不会发生。表单接上 action 后,你会想知道「正在提交吗」「成功了吗」「报错了吗」——这三个状态由 useSubmission 和 useSubmissions 提供,都来自 @solidjs/router。
一个还是一批
| Hook | 返回 | 用途 |
|---|---|---|
useSubmission(action, filter?) | 最近一次提交(Submission,还没提交过时是 SubmissionStub) | 单个表单,只关心最新这一次 |
useSubmissions(action, filter?) | 一个数组,外带 .pending 汇总 | 可能并发多次提交(列表里每行一个删除按钮),要全部盯住 |
Submission 上有什么
pending:是否进行中——拿去禁用按钮、显示 loading。result:成功后的返回值;error:抛出的错误。input:这次提交的入参(表单场景就是[FormData]),乐观更新时从这里读用户刚填的值。- 方法
clear()清掉这条记录、retry()重试。
filter:只看关心的那些
- 第二参
filter是一个(input) => boolean,同一个 action 在多处使用时,用它按入参筛出你这一处关心的提交,避免别处的提交也把你的按钮点亮。 - 比如一个删除 action 被列表里每一行复用,某一行的按钮只想反映「删自己这条」的状态,就用
filter比对input里的 id。
成功之后
- 提交成功后
result有值、pending变回假;这条记录会一直留着直到你clear()或下一次提交覆盖它,方便你展示「上次的结果」。 - 失败时
error有值,retry()可以用同样的入参再试一次,不必让用户重填表单。
import { useSubmission } from "@solidjs/router";
function NewTodo() {
const submission = useSubmission(addTodo);
return (
<form action={addTodo} method="post">
<input name="text" />
<button disabled={submission.pending}>
{submission.pending ? "提交中…" : "添加"}
</button>
<Show when={submission.error}>
<p class="err">{submission.error.message}</p>
</Show>
</form>
);
}useSubmission 返回的是 SubmissionStub——它的 pending/result/error 都是 undefined,不是 false。当假值判断(禁用按钮、<Show>)没问题,但别写 submission.pending === false 这种严格比较,初始态会不符合预期。useSubmissions:把里面 pending 的那些提交,从各自的 input 读出用户刚填的内容,临时合并进当前列表立刻显示;服务端确认后自动重验会用真实数据替换,出错则那条 error 有值,回滚即可。不是所有变更都来自 <form>。一个「加入收藏」的图标按钮、一次拖拽排序结束后的保存,你想在事件回调里直接 await 一下、拿到返回值再决定下一步——这时用 useAction。
它做的事
useAction(myAction)返回一个普通函数,签名和你当初传给action的那个一致,调用它返回Promise,能await到结果、也能try/catch到错误。- 和直接把 action 接到表单相比,区别只在触发方式:这里是你手动调,表单那边是浏览器提交时自动调。两者共享同一个 action,状态照样能被
useSubmission跟踪。
什么时候选它
- 没有真实表单、或参数不是
FormData而是普通值(一个 id、一个对象)。 - 提交后要在同一段逻辑里接着用返回值(比如拿到新建记录的 id 再跳转,配合 13 的
useNavigate)。 - 一次操作要连着做几件事(先删除、成功后再刷新某个筛选、再弹提示),命令式的
await写起来比事件链清晰。
form action vs useAction 怎么选
- <form action={a}>(声明式)优无 JS 也能提交,渐进增强的默认选择短入参走 FormData,想拿返回值接着处理不如命令式直接为何它把提交交给浏览器原生行为兜底,脚本只是增强层。
- useAction(命令式)优普通值传参、能 await 返回值与错误、逻辑连贯短依赖 JS,无脚本环境直接不可用为何它就是把 action 变回一个普通异步函数,好处坏处都来自这一点。
import { useAction } from "@solidjs/router";
function TodoRow(props) {
// 拿到可直接调用的版本
const remove = useAction(removeTodo);
const onDelete = async () => {
try {
await remove(props.id); // 传普通值,不是 FormData
} catch (e) {
console.error("删除失败", e);
}
};
return <button onClick={onDelete}>删除</button>;
}useAction 走的是纯 JS 路径,没有渐进增强——脚本没加载时这个按钮就是死的。如果这个操作在无 JS 环境下也必须能用(SEO 页、极端弱网),别用 useAction,改回下一张卡讲的 <form action={...}>。pending/error,照旧对同一个 removeTodo 用 useSubmission/useSubmissions 即可,两者说的是同一批提交。Solid 的 Action 类型里藏了一个 JSX.SerializableAttributeValue,意思是它能被直接写进 <form action={...}>。这一步是渐进增强的地基:脚本还没到位时,表单就是一个普通的原生 <form>,浏览器照常 POST;脚本到位后,客户端接管提交,走细粒度更新 + 自动重验,页面不整刷。
对的接法 vs 错的接法
| 写法 | 结果 |
|---|---|
<form action={addTodo}> | 对。属性拿到的是 action 本身,带着序列化信息,无 JS 也能提交 |
<form action={() => addTodo()}> | 错。包进箭头函数后就是个普通客户端回调,丢掉了序列化信息,渐进增强失效 |
要传额外参数怎么办
- 表单本身会把字段打包成
FormData传给 action,这是首选——需要的值放进<input name="…">(隐藏字段也行)。 - 要在表单之外预绑一个固定参数(比如当前行的 id),用 action 的
.with(id):它返回一个新的、仍可序列化的 action,渐进增强不破。
别把 action 包进客户端箭头函数
- 这是从 React 那套
onSubmit={e => ...}习惯带过来的最常见错误。Solid 这里,action属性接的是 action 值,不是事件回调。 - 一旦你写成
action={() => myAction(x)},无 JS 场景直接失灵,有 JS 时也可能绕过了 action 的重验链路。要传参就用.with或FormData。
// ✅ 直接接 action:无 JS 也能提交,字段走 FormData
<form action={addTodo} method="post">
<input name="text" placeholder="要做什么" />
<button>添加</button>
</form>
// ✅ 预绑一个固定参数(当前行 id),仍可序列化
<form action={removeTodo.with(props.id)} method="post">
<button>删除</button>
</form>
// ❌ 包进箭头函数:丢掉序列化信息,渐进增强失效
// <form action={() => removeTodo(props.id)}>…</form>action={() => myAction(arg)} 这种箭头包裹来传参——这正是 React 表单直觉在 Solid 里踩的坑。它会破坏渐进增强、也可能绕开自动重验。传参一律走 FormData(放 <input>)或 myAction.with(arg)。SolidStart:API 路由、中间件与部署
API 端点、middleware 与 event.locals、app.config.ts 与部署 preset。
组件内部取数用 server function(见 14),但有时你要的是一个对外的 HTTP 端点——给第三方 webhook 回调、给移动端当接口、下载文件。SolidStart 里这靠 API 路由:在 routes/ 下的文件里,导出以 HTTP 方法命名的函数即可。
约定
- 文件放
routes/api/(路径就是 URL,routes/api/posts.ts→/api/posts),惯例上 API 都归到api/下,和页面路由分开。 - 导出的是大写的方法名函数:
GET、POST、PUT、PATCH、DELETE——不是default导出的组件。一个文件可以同时导出多个方法。
处理函数收到 APIEvent
- 类型是
APIEvent(来自@solidjs/start/server),它extends FetchEvent,在其基础上多了params(动态路由段,如routes/api/posts/[id].ts里的event.params.id)。 - 从
FetchEvent继承来的字段有request(标准Request)、response、locals、nativeEvent——下面几张卡会用到locals。
返回什么
- 可以直接返回一个标准
Response,或用@solidjs/router的json(data, init?)快速返回 JSON,redirect/reload同样能用。
// routes/api/posts.ts → GET /api/posts
import { json } from "@solidjs/router";
import type { APIEvent } from "@solidjs/start/server";
export async function GET(event: APIEvent) {
const posts = await db.post.findMany();
return json(posts);
}
export async function POST(event: APIEvent) {
const body = await event.request.json(); // 标准 Request
const created = await db.post.create({ data: body });
return json(created, { status: 201 });
}export default function——那会被当成页面组件。API 路由认的是具名的大写方法函数。反过来,页面组件文件里也别去导出 GET/POST,两种角色不要混在同一文件。query/action(14、15)更省事、还带类型和缓存。有些事得在每个请求碰到路由、server function、API 之前统一做:验 session、记日志、按条件重定向。这就是 middleware,用 @solidjs/start/middleware 的 createMiddleware 定义。
两个钩子
| 钩子 | 时机 | 签名 |
|---|---|---|
onRequest | 请求进入、处理之前 | (event: FetchEvent) => Response | void | Promise |
onBeforeResponse | 响应发出之前 | (event, response) => …,可读改将要发出的响应 |
两个字段都接一个函数或函数数组,数组按顺序跑,方便把鉴权、日志拆成几段。
onRequest 能做两件关键事
- 往
event.locals上挂东西(下一张卡专讲),供后续 server function/API 读取。 - 直接
return一个Response来提前短路——比如没登录就返回一个redirect,请求根本到不了受保护的路由。
注册
- 定义好之后要在
app.config.ts的middleware字段登记,值是指向入口文件的字符串路径(不是把对象 import 进来传值)。 - 入口文件用
export default createMiddleware({...})默认导出,SolidStart 按你给的路径去找这个默认导出。
用数组拆成几段
onRequest传数组时,里面的函数按顺序依次跑,前一个往locals挂的东西后一个能读到——适合把「解析 session、鉴权、日志」拆成独立小函数,各管一件事。- 任意一个函数
return了Response,就提前结束、后面的不再跑,请求也到不了路由。
// src/middleware.ts
import { createMiddleware } from "@solidjs/start/middleware";
import { redirect } from "@solidjs/router";
export default createMiddleware({
onRequest: async (event) => {
const user = await getUserFromSession(event.request);
event.locals.user = user; // 挂到 locals,后续可读
const url = new URL(event.request.url);
if (!user && url.pathname.startsWith("/admin")) {
return redirect("/login"); // 提前短路
}
},
});
// app.config.ts —— 用字符串路径注册
import { defineConfig } from "@solidjs/start/config";
export default defineConfig({ middleware: "./src/middleware.ts" });app.config.ts 的 middleware 要的是文件路径字符串,不是 import 进来的中间件对象。写成 middleware: myMiddleware 是不对的,得写 middleware: "./src/middleware.ts"。另外 middleware 只跑在服务端,别在里面碰浏览器 API。event.locals 里已经准备好的东西,不必各自重复解析 cookie、验 session。event.locals 是每个请求专属的一个容器:在 middleware 里把当前用户挂上去,到了 server function、API 路由再读出来。它是把「这个请求是谁发的」在服务端各处传递的标准通道。
怎么给它加类型
FetchEvent.locals的类型是App.RequestEventLocals——一个全局命名空间下的接口,留给你用模块增强去填。- 在一个
.d.ts(比如src/global.d.ts)里写declare global { namespace App { interface RequestEventLocals { … } } },把你要挂的字段声明进去,全站的event.locals就都有类型了。
一条链路
- middleware 的
onRequest里event.locals.user = …挂上; - API 路由的处理函数、以及带
"use server"的 server function 里读event.locals.user(server function 里通过下一张卡的getRequestEvent拿到event)。
授权只信服务端验过的 locals
event.locals里的用户是 middleware 在服务端校验 session 得来的,可信。- 反过来,
params、URL、请求体这些都能被客户端随意伪造,绝不能拿它们直接判定权限。
// src/global.d.ts —— 模块增强,给 locals 声明类型
export {};
declare global {
namespace App {
interface RequestEventLocals {
user?: { id: string; role: string };
}
}
}
// routes/api/me.ts —— 从 locals 读,不信 URL/body
import type { APIEvent } from "@solidjs/start/server";
import { json } from "@solidjs/router";
export function GET(event: APIEvent) {
const user = event.locals.user; // 已带类型
if (!user) return new Response("Unauthorized", { status: 401 });
return json(user);
}declare module "@solidjs/start/server" { interface RequestEventLocals {…} } 去增强,和当前类型对不上:locals 的类型是 App.RequestEventLocals,要增强的是全局 App 命名空间,不是那个模块。用错地方类型不会生效。App 命名空间的模块增强(declare global { namespace App { … } })——这是官方约定的挂点,写一次全站生效。别到处 as any 硬转 event.locals。server function(带 "use server")里没有参数直接给你 event,但你常常要读请求头、cookie、或者 middleware 挂在 locals 上的用户。这时用 getRequestEvent()。
它在哪
- 注意:
getRequestEvent来自solid-js/web,不是@solidjs/start——这是最容易 import 错的一处。 - 返回类型是
RequestEvent | undefined。在 SolidStart 里,这个RequestEvent被扩展成了带request/locals/nativeEvent等字段(即FetchEvent那套)。
典型用法
- 在 server function 顶部拿到
event,读event.request.headers里的 cookie、UA,或读event.locals.user做鉴权。 - 因为返回可能是
undefined(不在请求上下文里时),用之前先判空,别直接解构。
它和 API 路由的 event 什么关系
- API 路由的处理函数是直接收到
APIEvent参数的(上一类卡片那样GET(event));server function 没有这个参数,才需要getRequestEvent()主动去取。 - 两者拿到的是同一套请求上下文——
request、locals、nativeEvent都在,middleware 挂在locals上的东西两边都读得到。差别只是「参数给你」还是「你自己去拿」。
import { getRequestEvent } from "solid-js/web";
async function getMyProfile() {
"use server";
const event = getRequestEvent();
if (!event) throw new Error("不在请求上下文中");
// 读 middleware 挂上的用户
const user = event.locals.user;
if (!user) throw new Error("未登录");
// 也能直接读原始请求头 / cookie
const cookie = event.request.headers.get("cookie");
return db.user.findUnique({ where: { id: user.id } });
}solid-js/web 导入,不是 @solidjs/start——写错来源会直接找不到。另外它返回 RequestEvent | undefined,在没有请求上下文的地方调用会是 undefined,务必先判空再用,别上来就 getRequestEvent().locals。getRequestEvent 当成 server function 与 middleware 之间的取货口:middleware 在 locals 上放好东西,server function 用 getRequestEvent()?.locals 取,就不必把用户信息一层层当参数传下来。做一个「找不到该文章」的页面很容易,但它默认仍然返回 200——对浏览器、爬虫、监控和 CDN 来说,这个页面「正常」。要让 SSR 那一遍真的写出 404,用 @solidjs/start 的 <HttpStatusCode>;要加响应头则用 <HttpHeader>。
它们长什么样(据 .d.ts)
HttpStatusCode收{ code: number; text?: string };HttpHeader收{ name, value, append? }。两者的返回类型都是null——它们不渲染任何东西,只是「在渲染的过程中顺便把这件事告诉服务端」。- 因为不渲染,放在组件树的哪个位置都行,跟着那段条件渲染一起写最直观:
<Show when={!post()}><HttpStatusCode code={404} />…</Show>。 - 客户端导航到这个页面时它们是空操作——那时候已经没有 HTTP 响应可设了。这不是 bug:状态码只对「直接请求这个 URL」的那一次访问有意义。
什么时候该记得用
- 404:动态路由查不到记录、catch-all 路由兜底页。不设的话搜索引擎会把一堆「找不到」页当正常内容收录。
- 401 / 403:鉴权失败但你选择渲染一个提示页而不是重定向。
- 500:
<ErrorBoundary>(08 章)的 fallback 里带一个 500,让监控能统计到服务端渲染出错。 - 响应头:
<HttpHeader name="Cache-Control" value="…" />给某个页面单独设缓存策略。
和 redirect 怎么分
- 要换个地址(未登录跳登录页)用
redirect()(@solidjs/router),或在 middleware 里直接 return 一个 Response 提前短路(本章前面几张卡)——这条路根本不渲染当前页。 - 要留在这个地址、渲染一个页面、但状态码不是 200,才用
HttpStatusCode。404 页通常属于后者:URL 保持原样,用户刷新还是这个 404。
本卡的可信度
- 组件签名与「返回 null」按
@solidjs/start1.3.2 的类型声明写;实际写进 HTTP 响应的行为依赖 SSR 管线,本页未在本地端到端验证。
import { HttpStatusCode, HttpHeader } from "@solidjs/start";
import { createAsync } from "@solidjs/router";
export default function Post(props) {
const post = createAsync(() => getPost(props.params.id));
return (
<Show when={post()} fallback={
<>
<HttpStatusCode code={404} /> // 渲染 null,只写状态码
<h1>找不到这篇文章</h1>
</>
}>
{(p) => <article>{p().title}</article>}
</Show>
);
}curl -I https://站点/不存在的路径 看第一行是不是 HTTP/1.1 404。这一步经常被漏掉——页面上写着「找不到」而响应是 200,是上线后才被 SEO 报表发现的典型问题。整个 SolidStart 应用的配置集中在项目根的 app.config.ts,用 @solidjs/start/config 的 defineConfig 写。前面的 middleware 注册就在这里,部署目标也在这里。
常用字段
| 字段 | 作用 |
|---|---|
server.preset | 部署目标预设(底层是 Nitro),换一个词就换一个平台 |
middleware | 中间件入口文件路径 |
ssr | 是否服务端渲染 |
部署:换 preset 就换平台
- SolidStart 构建产物由 Nitro 生成,切换部署平台通常只是改
server.preset:"node"、"vercel"、"netlify"、"cloudflare"、静态导出等,业务代码基本不动。 - 开发/构建/预览走脚手架给的脚本(
npm run dev/build/start)。
为什么切平台这么轻
- SolidStart 把「怎么跑」这层交给了 Nitro:你的业务代码只面对标准的
Request/Response和event,具体是 Node 进程、Vercel 函数还是 Cloudflare Worker,由 preset 在构建时适配。 - 所以换平台一般不动业务代码,改的是 preset 这一处;middleware、server function、API 路由的写法都不变。
关于本卡的可信度
- 上面的字段与
preset的存在是按官方约定和类型声明写的;各平台的实际部署行为本页未逐一。真要上线,以对应平台的官方适配文档为准。
// app.config.ts
import { defineConfig } from "@solidjs/start/config";
export default defineConfig({
ssr: true,
middleware: "./src/middleware.ts",
server: {
preset: "node", // 换成 "vercel" / "netlify" / "cloudflare" 等即可切平台
},
});
// 开发 / 构建 / 预览
// npm run dev
// npm run build
// npm run startapp.config.ts 和 Vite 的 vite.config.ts 搞混——SolidStart 的配置入口是前者,Vite 相关选项要通过 defineConfig 的 vite 字段透传,直接放一个独立 vite.config.ts 不会被 SolidStart 读到。preset: "node" 构建出一个标准 Node 服务是最省心的起点;等要上 Serverless/边缘平台,再换对应 preset,配置层的改动通常就这一行。从这里到精通:路线图
接下来动手做什么、读什么、怎么自测。
知识点铺完了,从「看懂」到「会写」之间隔着的是几个亲手做完的项目。Solid 的反直觉不是靠读消化的,是靠踩过一次「解构 props 怎么不更新了」记住的。
动手项目(难度递进)
- ① Todo 应用:
createSignal+<For>+ 受控输入,支持增删改、过滤、localStorage持久化。把 02 的信号和 05 的列表跑通。 - ② 多页数据站:
@solidjs/router+createResource,做一个带路由、搜索、详情页的电影/图书浏览站,用<Suspense>/<ErrorBoundary>处理加载与错误(08、13)。 - ③ SolidStart 全栈项目:文件路由 + server function +
action+ middleware 鉴权,做一个含 SSR、表单提交、登录的博客或留言板(14、15、16),最后按 preset 部署上线。 - ④ 造轮子:亲手写一个几十行的迷你信号库(signal/effect/memo,含依赖收集与清理),对照 Solid 源码,能讲清一次
set如何精确触达 DOM。
官方资料
- 入门:solidjs.com 的交互式 Tutorial,边写边过信号、控制流、Store。
- 参考:docs.solidjs.com 官方文档,SolidStart 部分在 docs.solidjs.com 的 solid-start 章节。
- 玩:官方 Playground 可以直接贴代码看编译产物,验证「组件只跑一次」这类心智特别直观。
- 原理:Ryan Carniato(作者)的博文与直播,是细粒度响应式的第一手资料;社区看 Solid Discord 和 solid-primitives 仓库。
把本页当索引
- 写项目时忘了某个 API 的确切用法,回到对应章的速查卡查一眼比翻文档快:响应原语看 02 和 03,状态结构看 06,全栈看 13 起的几章。
// 一条最小的全栈闭环,串起后面几章要做的事:
// 1) 读:query 缓存化取数 + createAsync 非阻塞消费
const getTodos = query(async () => {
"use server"; return db.todo.findMany();
}, "todos");
// 2) 写:action 变更,接到渐进增强的 form
const addTodo = action(async (fd: FormData) => {
"use server"; await db.todo.create({ data: { text: fd.get("text") } });
}, "addTodo");
// 3) 用:读自动更新、写自动重验——这就是你要做的 Todo 项目的核心await 组件」「为什么不能解构 props」全成了玄学。顺序是先吃透信号与控制流,再上元框架——地基不牢,上层全是似懂非懂。下面这些问题,能用自己的话讲清楚、并说出「为什么」,这一页就算毕业了。讲不清的那条,回对应章再过一遍——每一条背后都是同一根主线:组件函数只跑一次。
响应式心智(02、04)
- 为什么组件函数只执行一次?这一次里到底发生了什么,之后靠什么更新 DOM?
- 为什么不能解构
props/不能提前把props.x存成变量?解构那一刻丢了什么? - 信号为什么读取要加括号
count()?把count(不调用)传下去会怎样?
控制流与状态(05、06)
<For>和<Index>差别在哪?什么数据该用哪个,选错会出什么问题?- 为什么用
<Show>/<For>而不是三元和.map()? - 信号里放对象、直接改属性再
set回去为什么不触发?createStore凭什么能细粒度更新?
异步与全栈(08、14、15)
query和createAsync是什么关系?为什么 SolidStart 没有 async 组件,这样设计避免了什么?- 把
action包进() => myAction()会破坏什么?正确传参怎么写? event.locals里的用户为什么可信,而 URL/请求体为什么不可信?
// 终极自测:下面这段「从 React 直译」的代码,你能指出每处响应性断裂吗?
function Profile(props) {
const { name } = props; // ← 断了:解构拿到快照
const upper = name.toUpperCase(); // ← 只算一次,name 变了不更新
return (
<div>
{props.online ? <Badge /> : null} // ← 该用 <Show>
{props.tags.map(t => <li>{t}</li>)} // ← 该用 <For>
<p>{upper}</p>
</div>
);
}
// 能逐处说清「为什么这在 React 里没问题、在 Solid 里错」——就毕业了name 换成 props.name、派生值用 createMemo、条件换 <Show>、列表换 <For>。改完再问自己每一处「Solid 为什么要这样」,答得上来才是真懂,不是背下来。