Solid.js 与 SolidStart 全面速查手册

速查总览:这一页怎么查

本页是速查手册:每章第一张卡把该章的 API 与语法一次列全,后面的卡再展开语义与坑。这一章给三张索引——按模块列出的导出速查与章节地图、Solid 的心智模型(三条规则的由来)、四大框架的横向对照。

Solid 的 API 面不大,一张表能列完。先按模块定位导出,再按最右列翻到对应章;每章第一张卡都是该章的速查表。

solid-js:响应式与组件

导出作用章
createSignal最小状态单元,返回 [取值函数, setter]02
createEffect副作用,自动追踪读到的信号02
createMemo派生值,缓存且急切求值02
createResource异步数据的三态封装08
createSelector选中态:把 O(n) 重算降成两处12
createDeferred低优先级的延迟派生值12
createContext / useContext跨层级共享07
createRoot手动建一个所有权作用域07
createUniqueIdSSR 安全的唯一 id09
createComputed / createRenderEffect更早时机的副作用,日常用不到03
batch多次 set 合并成一次冲刷03
untrack读信号但不建立依赖03
on显式指定依赖,defer 跳过首次03
onMount / onCleanup挂载后跑一次 / 清理07
getOwner / runWithOwnerawait 之后恢复所有权07
catchError以函数形式捕获错误08
startTransition / useTransition切换时不回落到 fallback08
mergeProps / splitProps给默认值 / 拆分转发,且保住响应性04
children解析一次 children 并缓存04
lazy代码分割,配 Suspense08
mapArray / indexArrayFor / Index 背后的原语05
from / observable与外部数据源、Observable 互通12
Show For Index Switch Match控制流组件05
Suspense ErrorBoundary加载与错误边界08

solid-js/store 与 solid-js/web

导出作用章
createStore嵌套结构的细粒度响应,返回 [只读 Proxy, setter]06
produce在 setter 里用可变写法改嵌套结构06
reconcile把整份新数据 diff 进 store,保住行的身份06
unwrap拿到 store 背后的原始对象06
createMutable / modifyMutable可变风格的 store,官方不推荐日常使用06
render / hydrate挂载到 DOM / 接管 SSR 产物01
Dynamic组件或标签名由变量决定05
Portal渲染到 DOM 树的别处05
isServer编译期可剔除的环境判断13
getRequestEvent在服务端拿当前请求16
renderToString / renderToStreamSSR 入口(元框架已封装)13

@solidjs/router 与 @solidjs/start

导出作用章
Router / Route / FileRoutes路由骨架与文件路由13
A / Navigate / useNavigate链接与命令式跳转13
useParams / useLocation / useSearchParams读路径参数、位置与查询串13
useMatch / useIsRouting / useBeforeLeave匹配判断、导航中标志、离开拦截13
query可缓存、可去重的读取(旧名 cache 已废弃)14
createAsync / createAsyncStore把 query 接进组件14
action / useAction / useSubmission / useSubmissions数据变更与提交状态15
redirect / reload / revalidate服务端跳转与缓存重验15
"use server"把函数编译成 RPC,只在服务端执行14
clientOnly只在客户端加载的组件13
createMiddleware / event.locals请求钩子与请求级上下文16
HttpStatusCode / HttpHeaderSSR 下设状态码与响应头16

章节地图

章查什么
01建项目、命令、报错与告警
02–03响应式三件套与追踪控制
04组件、JSX 差异、props 处理
05控制流组件
06Store 与嵌套状态
07生命周期、Context、ref、所有权
08异步、Suspense、错误边界
09表单
10TypeScript 类型
11测试
12性能与破坏响应性的固定套路
13–16SolidStart:路由 / 取数 / 变更 / 部署
17版本、生态与资料索引
Solid 的 API 名字和 React Hooks 很像,语义却常常相反。createEffect 没有依赖数组、createMemo 不是性能优化而是派生状态的正规写法、组件函数只执行一次。带着 React 的直觉逐条对号入座,是这一页最容易踩的坑——每章速查表里凡是与 React 语义不同的地方都标了出来。
只记得「想干什么」时,从章节地图进;已经知道 API 名字时,用页内搜索直接搜导出名——它们都写在上面的表里和卡片标题上。

另一条省时间的规则:看到 props.x / store.a.b 这类「点出来的读取」,就该想到它是响应式的读取点,把它挪出 JSX 或提前解构,就是断掉响应的最常见方式(12 章列了整张症状表)。

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 的 JSX 和 React 的只是长得像:整段搬过来通常「能跑」,但更新会静默失效——因为你解构了 props,或在三元里读了信号。
从 React 转过来先记一条:凡是「每次渲染都会重来」的直觉全部作废。组件体只跑一次,要重跑的东西必须自己放进 effect 或 memo。

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>;
}
别因为 JSX 长得像就把 Solid 当「更快的 React」。两者形似而规则不同——直接把 React 组件粘过来,解构 props、用 useEffect 思维写 createEffect、用三元代替 <Show>,都会踩响应性的坑。
没有「最好的框架」,只有匹配团队和场景的。性能敏感的交互密集应用、认同信号模型又喜欢 JSX 的团队,Solid 是强选项;要靠海量第三方库、又好招人,React 更稳。

环境、命令与三条硬规则

建项目与运行的命令表、Solid 与 React 的逐条写法翻译、以及新手最常撞的两类静默失效。三条硬规则(读要调用、别解构 props、读取点留在 JSX 里)是这一页后面所有内容的前提。

两条路:裸 Vite + Solid 面积最小,适合先把语法吃透;SolidStart 一步到位带文件路由、SSR 与 Server Functions(13–16 章)。

命令

任务命令
建纯前端项目npm create vite@latest my-app -- --template solid-ts
建全栈项目npm create solid@latest
开发服务器npm run dev(Vite,默认 5173 端口)
构建 / 预览npm run build / npm run preview
类型检查npx tsc --noEmit
加路由(裸 Vite)npm i @solidjs/router
调试响应式图装 solid-devtools(浏览器扩展 + npm 包,仅开发期)

模板名去掉 -ts 就是 JS 版,但建议直接上 TS——props 与信号的类型提示能挡掉不少响应性的坑(10 章)。

项目结构与关键文件

文件 / 目录作用属于
src/index.tsxrender(() => <App />, root) 挂载入口裸 Vite
vite.config.ts必须启用 vite-plugin-solid(编译 JSX 的关键)两者
src/app.tsx应用外壳,通常放 Router 与全局 ProviderSolidStart
src/routes/文件路由目录,一个文件一条路由SolidStart
src/entry-client.tsx / entry-server.tsx客户端水合与服务端渲染入口,一般不用改SolidStart
app.config.tsSolidStart 配置与部署 preset(16 章)SolidStart

JSX 在这里不是虚拟 DOM

编译器把 JSX 变成一次性克隆的模板 + 少量精确的更新语句:静态部分只 cloneNode 一次,动态部分被包成一个个订阅点。没有 diff、没有虚拟 DOM、组件函数只执行一次——这是理解后面一切行为的前提,也是为什么必须用 Show / For 而不是三元和 map(05 章)。

// 挂载:render 的第一个参数是函数,不是元素
import { render } from "solid-js/web";
render(() => <App />, document.getElementById("root")!);

// 一个最小组件:三条规则都在这七行里
function Counter() {
  console.log("只会打印一次");              // 组件函数整个生命周期只跑一次
  const [count, setCount] = createSignal(0);
  return <button onClick={() => setCount((c) => c + 1)}>
    点击 {count()} 次                       // 读信号要加括号,且读取点留在 JSX 里
  </button>;
}
没配好 vite-plugin-solid 时,JSX 会被当成 React 的 JSX 编译。症状是各种匪夷所思的报错(React is not defined、组件反复执行、事件不触发),而不是一句清楚的「插件没装」。从别的模板复制配置、或在 monorepo 里共用 Vite 配置时最容易出这个问题——先确认 vite.config.ts 里有这个插件,以及 tsconfig 的 jsxImportSource 指向 solid-js。
先用裸 Vite 把 02–12 章吃透,再上 SolidStart。同构、Server Functions、水合这些概念一起涌上来时,很难分清「这是 Solid 的规则」还是「这是 SSR 的规则」——而 13–16 章的一切都建立在前面那套响应式模型之上。

Solid 的坑九成来自同一件事:把一个响应式读取点,提前读成了普通值。三条硬规则说的都是这件事的不同侧面。

三条硬规则

规则正确错误(多数不报错,只是不更新)
读信号要调用{count()}{count}——把函数本身塞进了 JSX
别解构 propsprops.name / splitPropsconst { name } = props——当场读死,此后不再更新
读取点留在 JSX / effect 里<p>{user().name}</p>组件体顶部 const n = user().name 再用 {n}

判断方法只有一句:这个值是在「每次要用时」被读出来的,还是在「组件初始化那一次」被读出来的?后者就断了。

从 React 逐条翻译

ReactSolid差别
useStatecreateSignal读要加括号;没有闭包过期问题
useEffect(fn, [a, b])createEffect(fn)没有依赖数组,读到什么就追踪什么
useEffect(fn, [])onMount(fn)只在客户端跑一次
effect 里 return () => …onCleanup(fn)可写在任意作用域里
useMemocreateMemo不是性能优化,是派生状态的正规写法
useCallback / memo()不需要组件只跑一次,没有重渲染要挡
useRef(存 DOM)ref={(el) => …} 或 let el; …ref={el}就是个普通变量
useRef(存可变值)普通 let 变量同上
useContextuseContext用法几乎一致(07 章)
classNameclass(classList 接对象)用的是 DOM 原名
style={{ fontSize: 14 }}style={{ "font-size": "14px" }}CSS 属性名 + 带单位
{cond && <A />}<Show when={cond}>三元与 && 会整块重建
list.map(…)<For each={list}>按引用复用行,无需 key
onChangeonInput原生语义,逐字符触发
dangerouslySetInnerHTMLinnerHTML同样只能用于可信内容
React.lazy / Suspenselazy / Suspense用法基本一致(08 章)
错误边界(class 组件)<ErrorBoundary>函数式,fallback 拿到 (err, reset)
状态更新是异步批处理同步生效set 完立刻读得到新值;批处理用 batch
// 断响应的三种典型写法,与它们的修法
const { name } = props;                 // ❌ 解构:从此定格在初值
const [local, others] = splitProps(props, ["name"]);   // ✅ 需要拆分时用它

const n = user().name;                  // ❌ 组件体里读死
<p>{user().name}</p>                  // ✅ 读取点留在 JSX 里

<div>{count}</div>                    // ❌ 显示的是函数本身
<div>{count()}</div>                  // ✅

// 传值给子组件时也别提前读:传函数或整个 props 才保住响应
<Child value={count()} />              // ✅ 这里是对的:JSX 属性本身就是懒读的
const v = count(); <Child value={v} />    // ❌ 提前读成了快照
带着 React 的直觉写 Solid,最容易在「组件只跑一次」上翻车。组件体里的代码不会随状态变化重跑:在那里做的条件判断、计算、日志,全部停留在初始值上。凡是要随状态变的东西,必须待在会被重新求值的地方——JSX 表达式、createMemo、createEffect、控制流组件的回调。
JSX 属性里的表达式是懒求值的,组件体里的语句不是。这解释了一个看似矛盾的现象:<Child value={count()} /> 明明「读了」却仍然响应——因为编译器把它包成了一个 getter,子组件读 props.value 时才真正求值。而写在组件体里的 const v = count() 就是实打实地读了一次。

Solid 里最常撞的墙,一半根本不报错,只是「静默不更新」;另一半的报错文案又很陌生。这里只列在实验台真见过的两类。

坑一:解构 props,静默不更新(不报错)

  • 先装 solid-devtools(浏览器扩展 + solid-devtools npm 包,开发期工具,正式构建不该包含):它把看不见的响应式图显示出来——组件树、有哪些信号和 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> 条件渲染
别凭「控制台没报错」就以为没问题。解构 props、给信号 set 同一个对象引用,这类静默失效是 Solid 最费时间的一类问题——它们不抛异常,只是安静地不更新。
把这条排查口诀记牢——「不更新,先查解构和括号」。Solid 里绝大多数「代码看着对却没反应」,都是这两件事之一。

响应式核心:signal / effect / memo

三件套的完整签名与选项、各自的求值时机、以及「set 了没反应」的相等性规则——这是 Solid 一切的地基。

三件套的分工是固定的:signal 是源(唯一能写的东西)、memo 是派生(能读、有缓存)、effect 是出口(往外部世界写)。

签名

API签名返回
createSignalcreateSignal(value, { equals, name })[取值函数, setter]
setter 的两种写法set(v) / set((prev) => next)返回新值
存函数时必须写 set(() => fn)否则会被当成更新函数调用
createEffectcreateEffect(fn, initialValue)无;fn 的返回值传给下一次
createMemocreateMemo(fn, initialValue, { equals })取值函数
关闭相等性判断{ equals: false }每次 set 都通知
自定义比较{ equals: (a, b) => a.id === b.id }—

时机与特性

何时执行有无缓存能否读别的信号典型用途
createSignal———状态源
createMemo急切:依赖变了立刻算有能派生值、昂贵计算、稳定引用
createEffect渲染后的微任务里无能DOM 操作、日志、订阅外部
createRenderEffect渲染期间,早于 effect无能极少数需要更早写 DOM 的场合(03 章)

选哪个

需求用
一个可读可写的状态createSignal
由别的状态算出来的值createMemo(别用 effect + signal 手动同步)
只在一处用、计算便宜的派生直接写个箭头函数 () => a() * 2,不必 memo
要把值写到 Solid 之外(DOM、localStorage、第三方库)createEffect
嵌套对象、只想更新其中一个字段createStore(06 章)
依赖太多、只想盯其中一个on(source, fn)(03 章)

为什么「set 了没反应」

默认用 === 判断新旧值:同一个对象引用再 set 一次,什么都不会发生。改数组和对象要么换新引用(setList([…list(), x])),要么用 store,要么显式 { equals: false }。

// 派生值:用 memo,而不是 effect + 第二个 signal
const [items, setItems] = createSignal([]);
const total = createMemo(() => items().reduce((s, i) => s + i.price, 0));

// ❌ 反面写法:多一份状态、多一次冲刷、还可能读到旧值
const [total2, setTotal2] = createSignal(0);
createEffect(() => setTotal2(items().length));

// 同引用不触发:push 之后 UI 纹丝不动
setItems((arr) => { arr.push(x); return arr; });   // ❌
setItems((arr) => [...arr, x]);                     // ✅ 新引用

// 存函数必须包一层,否则 setter 会把它当更新函数
const [fn, setFn] = createSignal(() => defaultHandler);
setFn(() => newHandler);
在 effect 里 set 自己依赖的信号,会造成无限循环。Solid 不像 React 有依赖数组挡一挡——它追踪的是运行时真的读到的东西,读了又写就自我触发。要基于旧值更新,用 set((prev) => …);要「只在 A 变时做事、但要读 B」,用 on(A, fn) 或把 B 的读取包进 untrack(03 章)。
凡是「能由别的状态算出来的」都别单独存一份。Solid 里 createMemo 的开销极低,用它表达派生关系既省掉手动同步,也不会出现两份状态不一致的中间态。真正需要 createEffect 的只有一类:把值送到响应式系统之外。

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());
});
别在 effect 里 setXxx 去写自己依赖的信号,容易绕成循环或反复触发。要「从 A 算出 B」,用 createMemo;只在个别场景才用 on(...) 显式指定依赖(详见 03 章)。
effect 的清理写在它里面——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() 得到新值
别在 memo 里做副作用(发请求、改 DOM、setXxx)。memo 该是同步纯函数,只返回一个值;有副作用会让它的执行时机变得难以预期。副作用请放 createEffect。
什么时候该上 memo——计算贵(大列表过滤、排序),或派生值被多处读取时最划算。轻量表达式(如 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()));  // 出口(无值、终点)
别用「effect 加一个额外 signal」来模拟 memo(在 effect 里算完 setResult(...))。这样多一次冲刷、时序更绕,还容易漏依赖。能用 memo 表达的派生,就用 memo。
一个判断窍门——问「这东西有没有值、值要不要给别人用」。要 → memo;不要、只是去做副作用 → effect;它本身就是可写的源头 → signal。

「我明明 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 });
这是 Solid 的头号静默坑——对信号里的对象/数组直接 push、改属性再 set,不报错也不更新。规则是:对象/数组要么每次换新引用,要么直接上 createStore。
原始值(数字、字符串、布尔)不用操心这条——它们按值比较,set 一个不同的数就会触发。相等性坑只在对象和数组上出现,因为比的是引用。

高级响应式:batch / untrack / on / 相等性

控制追踪与冲刷的四个工具(batch / untrack / on / 相等性选项)、四种 effect 变体的执行时机对照,以及所有权与 createRoot。

默认规则是「读到即订阅、写完即冲刷」。下面这些 API 就是在这两句话上开口子。

控制追踪与冲刷

API作用什么时候需要
batch(fn)把 fn 里的多次 set 合并成一次通知连续更新多个信号;事件回调与 store setter 已自动批处理
untrack(fn)读信号但不建立依赖effect 里要读一个「不想因它重跑」的值
on(deps, fn, { defer })只追踪显式列出的依赖;defer: true 跳过首次执行依赖太杂、或要模仿 React 的依赖数组
{ equals }自定义「算不算变了」对象比较、或用 false 强制每次都通知
createRoot(dispose => …)建一个独立的所有权作用域在组件外用响应式,必须自己负责 dispose
getOwner / runWithOwner拿到 / 恢复当前所有者await 之后还要注册 onCleanup(07 章)

四种「副作用」的时机

API时机日常用不用
createComputed最早,依赖变化时同步执行基本不用——想写派生值请用 memo
createRenderEffect渲染期间,DOM 更新之前极少;框架内部用它接 DOM
createEffect渲染完成后的微任务默认选它
onMount首次渲染后执行一次,仅客户端要拿真实 DOM 尺寸、初始化第三方库

已经自动批处理的场合

事件处理器内部、createStore 的 setter、Suspense 与 transition 的过渡——这些地方不必手写 batch。真正需要它的是「在异步回调或普通函数里连续更新多个信号」,尤其是几个信号之间存在瞬时不一致的时候。

// batch:三次 set 只触发一次冲刷
batch(() => {
  setFirst("Ada");
  setLast("Lovelace");
  setAge(36);
});

// on + defer:只在 userId 变时重取,且跳过首次
createEffect(on(userId, (id) => refetch(id), { defer: true }));

// untrack:想读当前值,但不因它重跑
createEffect(() => {
  const q = query();                // 追踪:q 变了要重跑
  const p = untrack(() => page());   // 不追踪:只是顺手读一下
  search(q, p);
});

// 组件外用响应式:自己开 root,自己 dispose
const dispose = createRoot((d) => {
  createEffect(() => console.log(count()));
  return d;
});
createRoot 建出来的作用域不会自动回收。在组件之外(工具模块、全局 store、测试)创建 effect 时,如果不保存并调用 dispose,这些 effect 会一直挂在信号上——页面切换后仍在跑,既泄漏内存也可能对着已卸载的 DOM 操作。组件内部则相反:所有权由组件树管理,卸载时自动清理,不需要也不应该自己开 root。
on 是从 React 迁过来时最顺手的工具:它把「隐式追踪」变回「显式依赖」,语义接近 useEffect(fn, [deps]),还多了个 defer 来跳过首次执行。不过别把它当默认写法——自动追踪本来就更不容易漏依赖。

默认情况下你每调用一次 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 内始终是最新的——想在改之前留住旧值,得自己先用变量存下来。
Solid 不像 React 那样在事件处理里自动合并更新,所以有个简单心法:凡是连改多个信号又只想冲刷一次,就手动包一层 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 重跑,此时才带上最新的 b
别用 untrack 去「优化掉」你其实需要的依赖。漏订阅导致的 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

JSX 在 Solid 里的全部差异一张表(属性名、样式、事件、四个命名空间前缀、指令),加 props 三件工具 mergeProps / splitProps / children。

Solid 的 JSX 编译成真实 DOM 操作,所以属性名基本沿用 DOM 原名(class 而不是 className)。下表把所有需要单独记的写法列全。

属性与内容

写法作用提醒
class="a b"类名不是 className
classList={{ active: isOn() }}按条件切类值为真才加,自动追踪
style={{ "font-size": "14px" }}行内样式CSS 属性名 + 单位,不是驼峰
style={{ "--c": color() }}CSS 变量主题切换常用
ref={el} / ref={(e) => …}拿真实 DOM普通变量即可(07 章)
innerHTML={html()}插入 HTML只用于可信内容
textContent={s()}纯文本比 innerHTML 安全
{…props}展开 props保持响应,编译器会逐个建订阅
<>…</>Fragment与 React 相同
{/* 注释 */}注释与 React 相同

事件

写法行为
onClick={fn}常见事件走委托:挂在根节点上,性能更好
onClick={[fn, data]}数组形式:fn(data, event),省一个闭包
on:click={fn}原生 addEventListener,不走委托
on:click={{ handleEvent, once: true }}带选项的原生监听
oninput(全小写)直接赋值到属性,也不走委托
自定义事件必须用 on:——委托只认内置事件名

命名空间前缀

前缀含义什么时候用
attr:强制走 setAttribute自定义元素、非标准属性
prop:强制写 DOM 属性给 Web Component 传对象 / 数组
bool:布尔属性:真则加、假则删自定义元素上的开关属性
use:指令:use:clickOutside={arg}复用 DOM 行为

use: 的两个硬要求:指令函数签名是 (el, accessor) => void;指令名必须在当前文件里被静态引用,否则会被打包器摇掉(TS 项目还要做模块增强声明,10 章)。

props 三件工具

API作用要点
mergeProps(defaults, props)给默认值保持响应;别用 props.x ?? 默认 之外的解构写法
splitProps(props, ["a"], ["b"])拆成几组返回 [组1, 组2, …, 其余],转发时用
children(() => props.children)解析一次并缓存要多次读 children,或要拿到真实节点做操作时必须用它
// 默认值与拆分转发:两个都保住响应性
function Button(props) {
  const merged = mergeProps({ size: "md" }, props);
  const [local, rest] = splitProps(merged, ["size", "children"]);
  return <button class={"btn-" + local.size} {...rest}>{local.children}</button>;
}

// children():解析一次,之后随便读几次都不会重复创建节点
function List(props) {
  const items = children(() => props.children);
  createEffect(() => console.log(items.toArray().length));
  return <ul>{items()}</ul>;
}

// 给 Web Component 传对象要用 prop:,否则会被转成字符串
<my-chart prop:data={data()} bool:loading={busy()} />

// 指令:签名固定为 (el, accessor)
function clickOutside(el, accessor) {
  const onClick = (e) => !el.contains(e.target) && accessor()?.();
  document.addEventListener("click", onClick);
  onCleanup(() => document.removeEventListener("click", onClick));
}
解构 props 是 Solid 最高频的 bug,而且不报错。function C({ name }) 在组件初始化时就把值读死了,之后父组件怎么更新都不会反映出来——页面安静地停在初值上。要么一路写 props.name,要么用 splitProps。同样的道理适用于 store(06 章):const { user } = state 一样断响应。
组件之间传值时,把「读」推迟到最后一刻。传 value={count()} 是对的(JSX 属性本身是懒求值的),但传 value={count}(传函数)在需要子组件自行决定何时读的场景更灵活。真正错误的只有一种:在组件体里先 const v = count() 再传 v。

这是理解 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 章),别指望函数体自己重跑。
把组件函数想成「布线图」而不是「渲染函数」:它只在装配时跑一次,把哪根信号连到哪个节点接好,之后就退居幕后。带着这个画面看 Solid,很多「怪规则」立刻变得顺理成章。

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() 接成响应式绑定,变了只改这里
别在 Solid 项目里塞一个只认 React 的 JSX 配置(比如手动配 @vitejs/plugin-react,或让 esbuild 处理 JSX)。JSX 会被当 React 编译,信号接不进响应式图,页面「渲染一次就再也不更新」。Solid 的 JSX 必须走 vite-plugin-solid。
记住一条——Solid 的「快」不是运行时优化出来的,是编译期就把动态和静态分开了。静态 DOM 只建一次,只有动态绑定参与更新,这也是它不需要虚拟 DOM 的原因。

Solid 的 JSX 和 React 的长得一样、编译器不一样,所以差异不在语法形状上,而在「同一段写法编译成什么」。按后果分三类记最省事:照抄能用、照抄不存在、照抄不报错但行为不同——第三类才是真会咬人的。

逐条对照

ReactSolid照抄的后果
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 属性
dangerouslySetInnerHTMLinnerHTML={html()}这个属性在 Solid 里不存在
useRef() 加 .currentlet el; 加 ref={el}useRef 不存在(见 07 章)
{list.map(…)}<For each={list()}>能渲染,但一变更整段重建(见 05 章)
useStatecreateSignal,读要加括号见 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。
迁移时最省事的自检是把项目写成 TSX——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)      ❌ 传进去的是快照
最隐蔽的是它不报错、不告警——界面只是安静地停在旧值上,你会怀疑是父组件没传对、是信号没更新,查很久才想到是解构。ESLint 有 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() 助手拿缓存后的结果。
判断标准很简单:只是把 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,假值删掉
TSX 下这几个前缀不声明就报错:attr: / prop: / bool: / use: 在 jsx.d.ts 里对应的是四个空接口,得自己写 declare module "solid-js" 往里加键(见 10 章)。唯一开箱即用的是标准 DOM 事件的 on:xxx——那些已经被逐个枚举好了。
优先用普通写法。这组前缀是逃生口——只有默认处理不对时才动它:自定义元素把对象转成了 [object Object]、事件被祖先截停、布尔属性该删却留着。日常 class / style / onClick 就够。

控制流:Show / For / Index / Switch

六个控制流组件的完整属性表与选型(Show / For / Index / Switch / Dynamic / Portal),以及为什么三元和 map 会破坏细粒度更新。

因为组件只跑一次,条件与列表必须交给控制流组件——只有它们能在数据变化时精确地增删那一小块 DOM。

组件与属性

组件属性children
<Show>when、fallback、keyed元素,或回调;非 keyed 时回调收到访问器,keyed 时收到值
<For>each、fallback(item, index) => …,item 是值、index 是信号
<Index>each、fallback(item, index) => …,item 是访问器、index 是数字
<Switch> / <Match>fallback / when从上到下第一个为真的 Match 生效
<Dynamic>component + 其余透传组件或标签名由变量决定
<Portal>mount、useShadow、isSVG默认挂到 body
<ErrorBoundary> / <Suspense>见 08 章—

For 还是 Index

ForIndex
按什么 diff元素引用下标
item 是值(不会变)访问器(要写 item())
index 是信号(要写 index())数字
适合对象列表、会重排 / 增删原始值列表(字符串、数字)、长度固定的表单行
选错的代价用在原始值列表上:值一改就重建整行 DOM用在会重排的对象列表上:所有行的内容被就地改写,输入框状态串位

为什么不能用三元和 map

写法后果
{cond() ? <A /> : <B />}能工作,但条件一变就整块重建;内部状态、DOM 焦点全丢
{list().map((x) => <Row … />)}数组任何变化都重建全部行,没有复用
<Show when={cond()}>只在真假切换时增删那一块
<For each={list()}>按引用比对,只动变化的行

例外:静态的、不会变的条件(比如只依赖 props 里某个常量)用三元没有问题,因为它本来就只求值一次。

// Show 的回调形式:非 keyed 时参数是访问器,顺带完成类型收窄
<Show when={user()} fallback={<Login />}>
  {(u) => <p>{u().name}</p>}
</Show>

// For:item 是值,index 是信号
<For each={todos()} fallback={<p>空</p>}>
  {(todo, i) => <li>{i() + 1}. {todo.text}</li>}
</For>

// Index:item 是访问器,index 是数字 —— 正好反过来
<Index each={tags()}>
  {(tag, i) => <span>{i}: {tag()}</span>}
</Index>

// Dynamic:标签名或组件由数据决定
<Dynamic component={map[kind()]} value={v()} />
Portal 的内容虽然渲染到了别处,事件仍沿着组件树冒泡。这与 DOM 直觉相反:弹层挂在 body 下,点击事件却会冒泡回逻辑上的父组件——写「点击外部关闭」时如果只判断 DOM 包含关系,就会出现「点弹层里的按钮反而关掉了弹层」。判断时用组件树的语义,或在弹层内 stopPropagation。
记忆口诀:For 的 item 是值、index 是函数;Index 正好反过来。这不是随意设计——For 复用的是「同一个对象所在的行」,所以行的位置会变(index 得是信号)而对象本身不变;Index 复用的是「第 n 行」,位置固定(index 是数字)而内容会变(item 得是访问器)。

在 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>。
带着一个问题看 JSX:这段「渲染什么」需不需要随信号变?需要,就用控制流组件;不需要(纯静态结构),随便怎么写都行。这个判断能帮你快速决定该不该上 <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>
别忘了 Portal 的内容不在挂载容器里——写测试时 container.querySelector(".modal") 会查不到(返回 null),要去 document.body 或用 testing-library 的 screen.* 查(11 章)。同理,靠「后代选择器」写的样式(.card .modal { … })会整条失效,因为 .modal 已经不是 .card 的后代了。
判断「该不该上 Portal」的信号很直白——弹窗被切掉一半、或者 z-index 调到 9999 还是被盖住。这两种症状都是祖先的 overflow / 层叠上下文造成的,改 CSS 治不了根,把 DOM 挪出去才治得了。

Store:嵌套状态的细粒度响应

createStore 的路径式 setter 全部写法、produce / reconcile / unwrap 各自的适用场合,以及 signal 与 store 的选型判据。

store 是一层只读 Proxy:读的时候按访问到的路径建立细粒度依赖,写的时候只能走 setter。setter 的表达力全在「路径」这一段。

setter 路径写法

写法含义
setState({ a: 1 })顶层浅合并(不是替换)
setState("a", 1)设一个字段
setState("user", "name", "Ada")按路径深入
setState("count", (c) => c + 1)函数式更新
setState("list", 0, "done", true)数组按下标
setState("list", [0, 2], "done", true)一组下标批量改
setState("list", { from: 0, to: 5 }, "x", 1)下标区间
setState("list", (item) => item.done, "x", 1)按条件筛选再改
setState("list", list.length, item)追加一项
setState("list", (l) => [...l, item])整体换新数组
setState("a", undefined)把字段设为 undefined(不会删除键)

三个配套函数

API作用什么时候用
produce((s) => { … })在草稿上用可变写法改,改完统一提交连续改多处、要 push/splice/delete 时
reconcile(newData, { key: "id", merge: false })把整份新数据diff 进 store服务端数据回来时,保住每行的身份不整体重建
unwrap(store)拿到 Proxy 背后的原始对象要传给第三方库、或做序列化时
createMutable / modifyMutable可变风格的 store官方不推荐日常使用,读旧代码时会遇到

signal 还是 store

数据形态选理由
数字、字符串、布尔createSignalstore 是纯粹的额外开销
短数组、整体替换的对象createSignal换引用就够了
嵌套两层以上、只改其中一个字段createStore只有读到那个字段的地方会更新
大列表,行内有可编辑状态createStore + reconcile重取数据时不重建整张表
整个表单对象createStore路径式更新,字段多也不用建一堆信号(09 章)
// 路径 + 条件筛选:把所有未完成项标成完成
setState("todos", (t) => !t.done, "done", true);

// produce:可变写法,适合连续改多处或数组增删
setState(
  produce((s) => {
    s.todos.push({ id: 4, text: "新任务", done: false });
    s.meta.count++;
    delete s.draft;
  })
);

// reconcile:重取数据时保住行的身份,输入框不会失焦
const fresh = await fetchTodos();
setState("todos", reconcile(fresh, { key: "id" }));

// ❌ 直接整体覆盖:所有行的 DOM 全部重建
setState("todos", fresh);

// 传给第三方库前先 unwrap,否则对方拿到的是 Proxy
chart.setData(unwrap(state.series));
解构 store 和解构 props 一样会断响应。const { user } = state 取出的是当时那个值,之后 setState("user", …) 不会让它更新。

另一个容易忽略的点:顶层 setState(obj) 是浅合并而不是替换——传一个只有两个键的对象进去,其余的键会原样保留。想真正替换整份状态,用 reconcile。
「服务端数据回来了」这一步几乎总该用 reconcile。直接赋新数组会让 Solid 认为整个列表换了,于是重建每一行——正在编辑的输入框失焦、展开状态复位、滚动位置跳走。reconcile 按 key 比对,只改真正变了的字段。

信号适合单个值,可一旦状态是嵌套对象或数组,用信号就得每次整体建新对象再替换。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 只用来「只读地」把数据交出去。
把「拉数据」和「塞回 store」分开想:createResource 负责取,reconcile 负责把结果平滑合并进 store 而不是粗暴替换。列表页刷新时用它,能避免整列 DOM 重建导致的闪烁和滚动位置丢失。

两个都是状态原语,界线其实很清爽:单个会整体替换的值用 createSignal,嵌套、要按字段局部更新的结构用 createStore。经验值是九成的简单 UI 状态用信号,复杂结构才上 store。

一句话判据

  • 问自己:这份状态会不会只改其中一个字段?会 → store(细粒度到字段)。总是整体换 → signal。
  • 再问:读取时要加括号吗?signal 要 count(),store 不要 state.x——这也是两者最直观的区别。
维度createSignalcreateStore
适合的数据原始值 · 需整体替换的对象嵌套对象 / 数组
读取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 在属性级拦截读写,从根上没有这个问题。
别为了「统一」硬把所有状态塞进一个大 store,也别用一堆信号硬拼出嵌套结构。按数据形状选:扁平单值走信号,嵌套结构走 store,两者在同一个组件里混用完全正常。

生命周期、Context 与 ref

生命周期只有两个钩子、Context 的完整用法、ref 的三种写法,以及「await 之后所有权就没了」这个静默失效的原理与修法。

Solid 没有 React 那一串生命周期方法——组件只跑一次,所以只需要两个钩子:挂载后和清理时。

生命周期

API时机用途
onMount(fn)首次渲染完成后,仅客户端量 DOM 尺寸、初始化第三方库、发首个请求
onCleanup(fn)组件卸载时,以及所在 effect 重跑之前解绑监听、清定时器、断开连接
写在哪组件体、effect 内部、甚至自定义 hook 里按所有权就近注册即可
没有的东西没有 onUpdate要「值变了做事」用 createEffect

Context

步骤写法
创建const Ctx = createContext(defaultValue)
提供<Ctx.Provider value={store}>…</Ctx.Provider>
消费const v = useContext(Ctx)
没有默认值时类型是 T | undefined,包一个自定义 hook 断言更好用(10 章)
放什么进去信号 / store / 函数——放裸值就失去响应性
性能没有「Context 变化导致整棵子树重渲染」的问题,细粒度依赖照常工作

ref 的三种写法

写法说明
let el; …<div ref={el}>最常见;编译器会替你赋值,但只有 onMount 之后才能用
ref={(e) => …}回调形式,节点创建时立刻拿到
props.ref转发给子组件:子组件里写 ref={props.ref}

所有权:await 之后就没了

Solid 用所有权树决定「谁负责清理谁」。onCleanup 注册到当前所有者上,而 await 之后同步上下文已经结束,当前所有者变成 null——此时注册的清理函数永远不会被调用,而且不报错。修法是先 getOwner() 存下来,异步回调里用 runWithOwner(owner, () => …) 包住。

// ref:变量形式要等 onMount 之后才有值
let canvas;
onMount(() => {
  const ctx = canvas.getContext("2d");
  const chart = new Chart(ctx);
  onCleanup(() => chart.destroy());     // 就近注册清理
});
<canvas ref={canvas} />

// Context:放信号或 store,别放裸值
const ThemeCtx = createContext();
function ThemeProvider(props) {
  const [theme, setTheme] = createSignal("dark");
  return <ThemeCtx.Provider value={[theme, setTheme]}>{props.children}</ThemeCtx.Provider>;
}

// await 之后要恢复所有权,否则 onCleanup 静默失效
const owner = getOwner();
const data = await load();
runWithOwner(owner, () => {
  const t = setInterval(tick, 1000);
  onCleanup(() => clearInterval(t));
});
「异步之后注册的清理没生效」是这一章最隐蔽的坑,因为它完全不报错。典型场景:onMount 里 await 一个请求,然后建定时器 / 订阅并 onCleanup——组件卸载后定时器照跑,控制台干干净净。判断方法很简单:只要清理注册在 await 之后,就必须先 getOwner 再 runWithOwner。
onCleanup 可以写在任何有所有者的地方,就近放最省心。在 effect 里注册的清理会在该 effect 每次重跑之前先执行一遍——这正好是「换了 id 就先断开旧连接、再连新的」的标准形态,不必自己记录上一次的句柄。

组件函数只跑一次、用来搭响应式图,这时候真实 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 尽早暴露。
Provider 的 value 里放信号 / store 及其 setter,而不是它们当下的值——放值快照就把响应性截断了。常见做法是把 { state, actions } 打包进去,后代既能读也能改。

要聚焦输入框、测量元素、把节点交给第三方库,就得拿到真实 DOM 元素。Solid 的 ref 简单到不像话:声明一个普通变量,在元素上写 ref={变量},挂载后这个变量就指向真实节点——不需要 createRef 之类。

普通变量式

  • let el; 声明空变量,<div ref={el}> 绑上去。Solid 在挂载时把真实节点写进这个变量(lab onMount 里 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 drillingProvider 提供、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、store),子组件里就别解构,写 props.x / state.x 就地访问,否则响应性在解构那一刻就断了——这条坑对四种手段一视同仁,是从 04 一路贯穿到这里的同一规则。
先判断组件关系再选手段:直接父子走 props / 回调,隔层走 Context,散落各处的共享状态抽成模块级 store。别一遇到传值就上全局状态——近距离的 props 往往最清楚、最好维护。

异步:Resource / Suspense / ErrorBoundary

createResource 的完整返回形态与状态机、Suspense / ErrorBoundary / lazy / transition 的属性表,以及三态编排的固定写法。

异步的三态(加载中 / 出错 / 有数据)在 Solid 里由 createResource 建模、由 Suspense 与 ErrorBoundary 统一编排,组件里不必再写一堆 if。

createResource

写法含义
createResource(fetcher)只取一次
createResource(source, fetcher)source 变化时自动重取;source 为 false / null / undefined 时不取
fetcher(sourceValue, { value, refetching })第二个参数带上一次的值与重取标志
返回 [data, { mutate, refetch }]mutate 本地改值(乐观更新),refetch 手动重取
data()取值;未就绪时返回 undefined
data.loading是否在请求中
data.error失败时的错误对象
data.latest重取期间仍返回上一份数据(避免闪烁)
data.stateunresolved / pending / ready / refreshing / errored
{ initialValue }给初值,此时首帧就是 ready

编排组件

组件 / API属性说明
<Suspense>fallback包住读了 resource 的地方;只有在 Suspense 里读才会触发挂起
<ErrorBoundary>fallback={(err, reset) => …}捕获渲染期抛出的错误;reset 可重试
lazy(() => import("./X"))—代码分割,配 Suspense 使用
useTransition()返回 [pending, start]切换期间旧内容留在屏上,不回落 fallback
startTransition(fn)—同上的函数式写法
catchError(fn, handler)—不用组件包裹的错误捕获

三个常见错法

写法问题
createResource(async () => await fetch(url()))依赖写在了 fetcher 内部:url 变了不会重取。要写成 createResource(url, fetcher)
在 Suspense 外读 data()拿到 undefined 而不是挂起,容易在渲染时炸
用 data.loading 手写三个分支可以,但把 Suspense 的统一编排丢掉了;两种风格别混用
// source + fetcher:id 变了自动重取,id 为空时不取
const [user, { mutate, refetch }] = createResource(
  userId,
  (id) => fetch("/api/users/" + id).then((r) => r.json())
);

// 三态交给框架编排,组件里只写「有数据」的那一支
<ErrorBoundary fallback={(err, reset) => <button onClick={reset}>{err.message} 重试</button>}>
  <Suspense fallback={<Spinner />}>
    <p>{user()?.name}</p>
  </Suspense>
</ErrorBoundary>

// 乐观更新:先 mutate 本地,失败再回滚
const prev = user();
mutate({ ...prev, name: newName });
try { await save(newName); } catch { mutate(prev); }

// transition:切页时旧内容不闪走
const [pending, start] = useTransition();
start(() => setTab("profile"));
ErrorBoundary 只捕获渲染期间抛出的错误。事件处理器里、setTimeout 回调里、以及没有被 resource 包住的 await 中抛出的错误,它一概接不住——这和 React 的错误边界是同一套限制。事件里的错误要自己 try/catch,异步取数则交给 createResource,它会把失败转成 data.error 并在渲染时抛出,这才轮得到边界接管。
把「依赖」交给 source 参数,而不是写在 fetcher 里。createResource(source, fetcher) 的两个额外好处:source 为空值时自动不发请求(省掉一层 if),以及切换 source 时框架知道该丢弃哪次结果——手写 createEffect + fetch 则要自己处理竞态。

异步数据永远有三种状态——加载中、出错了、数据到了。手写三个信号去回合这仨既繁琐又容易漏。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 直接报错。
在 JSX 里判三态最省事的写法是配 <Show when={!user.loading}> 或直接用 <Suspense> 兜加载态。mutate 做乐观更新——点赞先 mutate 把数字加上去、再 refetch 对齐服务端,交互立刻有反馈。

createResource 的真正威力在第一个参数——源信号。给它一个信号当「源」,源一变,资源就自动重新请求,把新的源值传给 fetcher。「切换用户 id 就自动拉对应数据」这种事,你一行 effect 都不用写。

怎么接

  • createResource(source, fetcher):source 是个访问器(信号 / memo),fetcher 收到的第一个参数就是当前源值。
  • 源变化 → 框架自动用新值再调一次 fetcher(OLAB 事实 21,lab setId(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>
Suspense 只兜加载态,不兜错误——fetcher reject 时,Suspense 不会显示 fallback 把错误藏起来,错误会往上抛。要处理失败,得在外面套一层 <ErrorBoundary>(下一张)。别指望一个 Suspense 就把加载和出错都管了。
把「一起出现才不突兀」的几块放同一个 Suspense(比如头像 + 用户名 + 简介),让它们同进同出;把「各自独立、快的先出」的块拆成各自的 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>
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>
    </>
  );
}
别指望 transition 能让「没有旧内容可显示」的情况变好看——首屏、或者第一次进这个页面时,本来就没有旧内容,该显示 fallback 还是显示 fallback。它只在「屏幕上已经有一份能看的东西」时才有意义。另外 useTransition 要在组件的响应式上下文里调用,随手放在事件回调里创建拿不到有效的 pending。
一条好用的默认策略:首屏用 Suspense,用户点出来的每一次重新取数都包一层 transition。这样「第一次等」有骨架屏、「换一次」不闪屏,两种体验各归各位。SolidStart 里路由跳转默认就走 transition,所以页面切换不会整屏闪——这也是 13 章 preload 感觉「顺」的一部分原因。

表单与受控输入

表单的四种写法对照(受控 / 非受控 / store 多字段 / 原生提交)、输入事件与绑定属性表,以及校验与提交的固定结构。

Solid 没有 v-model,双向绑定要么手写 value + onInput,要么自己写一个 use: 指令。控件不同,绑定的属性也不同。

各类控件怎么绑

控件绑定读值
文本 / textareavalue={s()} + onInpute.currentTarget.value
数字同上e.currentTarget.valueAsNumber
复选框checked={b()} + onChangee.currentTarget.checked
单选组checked={v() === "a"} + onChange同上
下拉框value={v()} + onChangee.currentTarget.value
多选下拉multiple[…e.currentTarget.selectedOptions].map((o) => o.value)
文件不绑 valuee.currentTarget.files
日期value={iso()}e.currentTarget.valueAsDate

用 onInput 而不是 onChange:前者逐字符触发,后者要等失焦——这与 React 的命名习惯相反,React 的 onChange 实际就是原生的 input。

四种写法

写法状态在哪适合
受控(signal)信号是唯一真相需要即时校验、联动、格式化
非受控(ref)DOM 自己字段少、提交时一次性读
store 多字段一个 store 对象字段多的表单,路径式更新省掉一堆信号
原生提交(FormData)DOM 自己SolidStart 的 action 表单,无 JS 也能提交(15 章)

校验与提交的固定结构

环节做法
提交拦截onSubmit={(e) => { e.preventDefault(); … }}
校验时机提交时全量校验;失焦(onBlur)后再逐字段实时校验,体验最好
错误存放存进 store 的 errors 字段,按字段名索引
禁用按钮disabled={submitting() || !valid()}
校验库zod / valibot 校验完顺带产出类型,比手写判断可靠
浏览器原生校验required / pattern / type="email" 先挡一层,零成本
// 多字段表单:一个 store + 一个通用 onInput
const [form, setForm] = createStore({ name: "", email: "", errors: {} });
const bind = (k) => ({
  value: form[k],
  onInput: (e) => setForm(k, e.currentTarget.value),
});
<input {...bind("name")} />
<input {...bind("email")} type="email" />

// 提交:先 preventDefault,再校验,再发请求
const onSubmit = async (e) => {
  e.preventDefault();
  const r = schema.safeParse({ name: form.name, email: form.email });
  if (!r.success) return setForm("errors", fieldErrors(r.error));
  await save(r.data);
};

// 自己实现「双向绑定」:一个 use: 指令
function model(el, accessor) {
  const [get, set] = accessor();
  createRenderEffect(() => (el.value = get()));
  el.addEventListener("input", (e) => set(e.target.value));
}
<input use:model={[text, setText]} />
用了 use: 指令却发现毫无反应,多半是被打包器摇掉了。编译器只在当前文件里能静态看到该标识符时才保留它,从别处 import { model } 但只在 JSX 里用到时,压缩阶段会认为它没被使用而删除。解法是保证有一次显式引用(TS 项目里再加一句模块增强给 use: 声明类型,见 10 章)。
字段超过三四个就直接上 store。一个 createStore 加一个通用的 bind(key) 辅助函数,比十个 createSignal 干净得多,而且路径式更新天然只让那一个字段的 DOM 变化——细粒度的好处在表单上体现得最直接。

受控输入就是让一个信号当输入框的「唯一真相」——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} />;
}
两个坑:一是指令名被 tree-shake(上面警告块说的,保证 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>
  );
}
别解构 store:const { name } = form 拿到的是那一刻的快照,之后字段变了它不更新——和解构 props 断响应是同一个坑。永远写 form.name,让读取发生在 JSX 里。
提交时想拿「纯对象」(去掉 store 的 Proxy 外壳)用 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 完立即读,要么直接用本地算出的布尔结果判断,别依赖「上一次渲染」的错误值。
这里讲的是组件内的纯客户端表单。如果用 SolidStart,还能把表单交给服务端 action、靠 <form action={fn}> 做渐进增强(没 JS 也能提交),并用 useSubmission 跟踪提交态——那套在 15 章讲。

TypeScript 与 Solid

Solid 全部类型工具一张表(Accessor / Setter / Component 家族 / Store / Resource)、TSX 配置项,以及与 React 类型的对照。

Solid 导出的类型不多,但对应关系要记准——尤其是组件类型的四个变体和「信号的类型是函数」这件事。

响应式相关

类型含义
Accessor<T>() => T,信号 / memo 的取值函数
Setter<T>写入函数,支持值与更新函数两种调用
Signal<T>[Accessor<T>, Setter<T>] 的元组
Resource<T>Accessor<T | undefined> 加上 loading / error / state / latest
InitializedResource<T>给了 initialValue 的版本,取值不再含 undefined
Store<T> / SetStoreFunction<T>store 与它的路径式 setter

组件相关

类型用途
Component<P>不接收 children 的组件
ParentComponent<P>children 可选(最常用)
FlowComponent<P, C>children 必填,如控制流组件
VoidComponent<P>禁止 children
JSX.ElementJSX 的返回类型(不是 React 的 ReactNode)
ComponentProps<"button">取某个原生标签或组件的 props 类型
JSX.IntrinsicElements所有原生标签的属性表
事件类型JSX.EventHandler<HTMLInputElement, InputEvent>,或直接标注 e.currentTarget

配置与对照

项值 / 说明
"jsx": "preserve"交给 vite-plugin-solid 编译
"jsxImportSource": "solid-js"必填,否则会按 React 的 JSX 类型检查
React 的 FC<P>对应 Component<P>,但 Solid 的 props 不带隐式 children
React 的 useState 返回值对应 Signal<T>,注意读值要调用
use: 指令的类型要在模块里增强 JSX.Directives 才不报错
Context 的类型没给默认值时是 T | undefined,包一层 hook 里断言更好用
// props 类型:children 用 ParentComponent,或显式写 JSX.Element
const Card: ParentComponent<{ title: string }> = (props) => (
  <section><h3>{props.title}</h3>{props.children}</section>
);

// 扩展原生标签的属性:拿 ComponentProps 再加自己的字段
type BtnProps = ComponentProps<"button"> & { variant?: "primary" | "ghost" };

// Context:包一个 hook,把 undefined 挡在外面
const Ctx = createContext<Signal<string>>();
export function useTheme() {
  const v = useContext(Ctx);
  if (!v) throw new Error("useTheme 必须在 ThemeProvider 内使用");
  return v;
}

// 给 use: 指令声明类型,否则 TSX 会报「属性不存在」
declare module "solid-js" {
  namespace JSX {
    interface Directives { model: [Accessor<string>, Setter<string>] }
  }
}
jsxImportSource 配错时,报错信息完全指不到真正的原因。缺了这一行,TS 会按 React 的 JSX 类型检查 Solid 组件,于是 class 报「应为 className」、ref 类型对不上、JSX.Element 与 ReactNode 互相不兼容——一连串看似无关的错误,根因只有一个。从 React 项目复制 tsconfig 时最容易漏。
让类型替你挡住「解构 props」。把 props 参数直接标注成 P 而不是解构写法,编辑器在你输入 props. 时就会列出全部字段——这既是补全,也是提醒:这些值必须每次用时才读。

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 了。

类型名对照

ReactSolid备注
React.FC<P>Component<P>Solid 的不含 children
(FC 早期自带 children)ParentComponent<P>要 children 用它
ReactNode / ReactElementJSX.Element返回值类型
React.CSSPropertiesJSX.CSSProperties键是 CSS 原名,不是驼峰
React.MouseEvent<T>(标参数)JSX.EventHandler<T, MouseEvent>(标整个处理器)标的位置不同
useRef<T>(null) 加 .currentlet 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> }
  }
}
别指望 TS 能拦住响应性问题。const { title } = props 类型完全正确、编译通过,运行时照样断响应(04 章);{list().map(…)} 也是合法 TSX。TSX 拦的是「写法不对」,拦不住「时机不对」——后者只能靠规则本身和 eslint-plugin-solid 的 solid/reactivity。
迁移一个 React 组件时,把类型报错当清单用——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 | undefined
createSignal<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 | undefined
Store<T> 只是可读形状,它不带 setter——想改值必须走配套的 SetStoreFunction,对 store 直接赋值 form.name = "x" 在运行时不生效——而且 Store<T> 就是 T 本身、并非深只读,TS 也不会拦这种赋值,别指望编译器替你挡。改值一律用 setForm(...)。
写工具函数(比如「接收任意表单 store 并做校验」)时才需要显式写 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 抛错,让问题在开发期就炸出来。
给不给默认值是个设计选择:全局主题、计数器这类「有个合理缺省」的用带默认值版,随取随用;而「登录用户」「必须由 Provider 注入」的东西故意不给默认值,让类型里的 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 在替你把「可能还没赋值」这件事挑明。
拿不准某个事件/元素的类型名时,别硬记——把处理函数内联写进 JSX,让编译器推断,再把鼠标悬到 e 上看它推出来的类型,照抄即可。Solid 的 JSX 命名空间里备好了 EventHandler、各种 HTML*Element。

测试:testing-library + vitest

测试环境的三件配置、渲染与查询 API 对照、以及 Solid 特有的两类断言:组件体只跑一次、异步与错误边界。

工具链是 vitest + @solidjs/testing-library。配置有三处容易出错,API 面则与 React Testing Library 高度一致。

环境配置

要点配置
编译 JSXvitest 配置里必须启用 vite-plugin-solid
解析条件resolve.conditions 加 "browser"(漏了会加载服务端构建,渲染不出 DOM)
DOM 环境environment: "jsdom" 或 happy-dom
依赖vitest、jsdom、@solidjs/testing-library、@testing-library/jest-dom
清理testing-library 会自动 cleanup,无需手写

API

做什么写法
渲染render(() => <C prop={1} />)——传函数,不是元素
查询getByRole / getByText / getByLabelText(按可访问性名查,不按 class)
可能不存在queryBy… 返回 null,用来断言「不存在」
异步出现await findBy… 或 await waitFor(() => …)
触发事件fireEvent.click(el) / fireEvent.input(el, { target: { value: "x" } })
更贴近真人@testing-library/user-event
测纯响应式createRoot((dispose) => { … dispose(); }),不必渲染组件
断言 DOMexpect(el).toBeInTheDocument()(来自 jest-dom)

Solid 特有的两条断言

断言怎么测
组件体只执行一次在组件体里放一个 vi.fn(),多次交互后断言 toHaveBeenCalledTimes(1)
更新是细粒度的取到某个 DOM 节点的引用,交互后断言它还是同一个节点(没有被重建)
// vitest 配置:三处缺一不可
export default defineConfig({
  plugins: [solid()],
  resolve: { conditions: ["browser"] },
  test: { environment: "jsdom", globals: true },
});

// 渲染 + 交互 + 断言
test("计数器累加", async () => {
  const { getByRole } = render(() => <Counter />);
  const btn = getByRole("button");
  fireEvent.click(btn);
  expect(btn).toHaveTextContent("1");
});

// 断言「组件只跑一次」——Solid 最值得单独测的一条
const spy = vi.fn();
function C() { spy(); return <button onClick={() => setN(n() + 1)}>{n()}</button>; }
fireEvent.click(btn);
expect(spy).toHaveBeenCalledTimes(1);

// 测纯响应式逻辑:不用渲染,但要自己 dispose
createRoot((dispose) => {
  const [n, setN] = createSignal(0);
  const double = createMemo(() => n() * 2);
  setN(2);
  expect(double()).toBe(4);
  dispose();
});
漏配 resolve.conditions: ["browser"] 会让测试加载 Solid 的服务端构建。症状很迷惑:组件「渲染」了但查不到任何 DOM 节点、事件毫无反应,报错也指不到配置上。从 React 项目搬 vitest 配置时几乎必踩——这一行是 Solid 特有的。
把响应式逻辑抽成不依赖组件的函数,测起来最省事。「一个 store 加几个操作函数」的形态可以直接在 createRoot 里测,不需要 jsdom、不需要渲染、跑得也快;组件测试则专注于「点了之后界面变成什么样」。

测 Solid 组件的标配是 vitest + vite-plugin-solid + jsdom,再加 @solidjs/testing-library 负责渲染。三样东西各管一段:插件负责把 JSX 编译成 Solid 的响应式代码,jsdom 提供一个假的浏览器 DOM,vitest 跑用例。本页所有例子就是在这套配置上跑通的。

四个包各管什么

包作用
vitest测试运行器,跑 *.test.jsx
vite-plugin-solid把 JSX 编译成 Solid 代码(必须有,否则 JSX 跑不起来)
jsdomNode 里的假 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 当假数据源,是最轻量的做法。

性能与常见陷阱

第一张卡是症状 → 原因 → 修法的对照表,涵盖破坏响应性的全部固定套路;后面几张展开 For / Index 选型、该 memo 而没 memo、大列表与 createSelector。

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 → 编辑一项就销毁重建
这些坑在开发时往往「看着对、跑起来错」——因为初次渲染的结果是对的,只有到了第二次更新才暴露。别只截首屏就以为通了,一定要点一下、改一改数据再看。
定位「界面该动没动」的第一反应——把 setter 之后的值 log 出来确认数据真的变了。数据变了界面没动,十有八九是「引用没变」(第 3 类)或「解构断链」(第 1、2 类),而不是渲染 bug。

两个都渲染列表,区别只有一句: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>
最隐蔽的错误是非受控输入 + 用反:列表首屏看起来一切正常,直到用户在输入框里打了字、然后列表重排或某项被编辑,内容才错位或丢失。只截首屏永远发现不了,必须真的交互一遍。
一句口诀:「对象用 For、值用 Index」。再补一条判据——问自己「这一项有没有稳定的身份(id /对象引用)」:有就是 For,没有(就是个裸数字或字符串)就是 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()));
memo 是懒计算——依赖变了只标记为「脏」,下次被读取时才真算。所以在 memo 里写 console.log 想数「算了几次」时,如果没有任何地方读它,你会看到它一次都没算,这不是 bug 是设计。
判断「要不要 memo」的两个信号:① 这个派生被读了不止一次(多个 effect/多处 JSX/塞进 For each);② 它算起来不便宜(遍历、排序、格式化)。两条都中就抽 memo,只中一条通常不值当。

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", "上海");
就算用了 store,解构它的顶层属性一样断响应——const { user } = state 拿到的是快照。store 不是「解构安全」的护身符:该读 state.user 还得读 state.user;要传给子组件就传整个 store 或传 getter,别解构出来传。
一个务实的迁移路径:先全用 signal,等某个 signal 里塞的对象开始需要「只改一个字段又不想整体展开」时,再把它换成 store。反过来把简单值硬塞进 store,是 Solid 最常见的过度设计。

列表里有一个「当前选中项」,每行都要判断「是不是我」——直接写 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> 的回调里给每行各建一个,那样每行各有一套分桶,白折腾一遍还更慢。另外它只加速「读」,选中项本身仍是个普通信号,写法不变。
识别这个模式的口诀:「n 个里选 1 个」就该上 createSelector。反过来,如果每行判断的是自己独有的数据(r.done 这类),那本来就是细粒度的,不需要它。它和本章前面几张卡是互补的——<For> 保住行的身份,createMemo 缓存派生列表,createSelector 管「哪一行是当前项」。

SolidStart:项目与路由

文件路由的命名规则全表、路由 API 与各个 hook 的返回值、SSR 下的环境判断,以及 preload 与取数的接缝。

SolidStart 的路由由 src/routes/ 下的文件名决定。下表把命名规则和对应 URL 一次列全。

文件名 → 路由

文件匹配说明
routes/index.tsx/目录的默认页
routes/about.tsx/about—
routes/users/[id].tsx/users/1useParams().id
routes/[...rest].tsx任意剩余路径404 页常用
routes/(marketing)/price.tsx/price括号分组不进 URL,用来套不同布局
routes/users.tsx + routes/users/…嵌套布局父文件渲染 props.children
routes/api/hello.ts/api/hello导出 HTTP 方法函数(16 章)
routes/[id]/[tab].tsx多段参数都在 useParams() 里

路由 API

API返回 / 作用
<A href="/x">客户端导航;activeClass / end 控制高亮
useNavigate()navigate("/x", { replace, state })
useParams()路径参数对象(响应式)
useSearchParams()[params, setParams],改查询串不刷新页面
useLocation()pathname / search / hash / state
useMatch(() => "/x/*")当前是否匹配某模式
useIsRouting()导航进行中标志,做进度条
useBeforeLeave(fn)离开前拦截(未保存提示)
<Navigate href="/login" />渲染即跳转
route.preload导航开始时就触发取数,避免瀑布(14 章)

SSR 下的环境判断

写法作用
isServer(solid-js/web)编译期常量,另一侧的分支会被整段剔除
clientOnly(() => import("./Map"))整个组件只在客户端加载,适合依赖 window 的第三方库
onMount只在客户端执行,最简单的兜底
typeof window !== "undefined"能用,但不会被编译期剔除,代码仍会打进产物
// 应用外壳:Router + FileRoutes,root 传布局组件
<Router root={(props) => <Layout><Suspense>{props.children}</Suspense></Layout>}>
  <FileRoutes />
</Router>

// 参数是响应式的:不要解构,直接在用的地方读
const params = useParams();
const [user] = createResource(() => params.id, fetchUser);

// 查询串当状态用:分页、筛选、排序都适合放这儿
const [sp, setSp] = useSearchParams();
setSp({ page: String(page() + 1) });

// 浏览器专属库:clientOnly 包一层,服务端不会加载
const Map = clientOnly(() => import("./Map"));
useParams() 的返回值是响应式对象,解构就断。const { id } = useParams() 之后,从 /users/1 导航到 /users/2 时组件不会重新取数——因为 id 早已定格。同一个陷阱在 props 和 store 上出现过(04 / 06 章),这里只是换了个来源:凡是框架给的响应式对象,一律不解构。
能放进 URL 的状态就别放进信号。分页、筛选条件、当前标签页放进查询串后,刷新不丢、能分享、浏览器前进后退天然可用——useSearchParams 让它和普通状态一样好写。

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 参数的通性,但最容易在详情页出错。
想确认某个文件会映射到哪条路径,记两条就够:文件名/目录名 = 路径段、方括号 = 变量、圆括号 = 只分组不进 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 变了但内容没出来」时先查这里。
分不清「该用同名文件还是 (group)」时按 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" 代替它——那是运行时判断,打包器摸不清、摇不掉,服务端专属的依赖照样会被打进客户端包,白白撑大产物。
排查「本地 dev 好好的,一 build 就报 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()
}
preload 里调用的取数函数必须是 query 化的(带缓存 + 去重),否则 preload 发一次、组件里 createAsync 再发一次,变成请求翻倍而不是命中缓存。preload 与组件之所以能「接缝」,全靠 query 的同 key 去重——这正是下一章的主题(14)。
把 preload 当成「路由级的提前量」:用户还在点链接、页面还没切过去,取数就已经开始。<A> 甚至会在鼠标悬停/聚焦时触发预加载(由 <Router> 的链接预取能力驱动),所以列表页到详情页的跳转常常「秒开」。

SolidStart:数据加载与 Server Functions

取数四件套:"use server" 的规则、query 的缓存与去重、createAsync / createAsyncStore 的区别,以及用 preload 消除请求瀑布。

SolidStart 的取数分三层:"use server" 决定代码跑在哪、query 负责缓存与去重、createAsync 把它接进组件。

API

API作用要点
"use server"函数体只在服务端执行,客户端调用编译成 RPC写在函数体第一行或文件顶部;参数与返回值要可序列化
query(fn, "key")包一层缓存:同 key + 同参数在一次导航内只发一次第二个参数必填且全局唯一
createAsync(() => getUser(id()))在组件里读取,配 Suspense 挂起返回访问器;不要在里面写副作用
createAsyncStore(…)同上但结果进 store,内建 reconcile列表数据用它:重取时不重建每一行
revalidate(key)手动让缓存失效并重取配 query.keyFor(args) 精确到参数
redirect(path) / reload()在服务端函数里跳转 / 触发重验用 throw 抛出
route.preload导航一开始就触发取数与 query 的缓存命中,组件里再调不会重复请求
cache(fn, key)已废弃,是 query 的旧名旧教程里大量出现,新代码一律用 query

"use server" 的边界

规则说明
参数与返回值要能被序列化:函数、类实例、DOM 节点传不过去
闭包变量捕获的外部变量也要可序列化
密钥与数据库放心用——这段代码不会进客户端产物
抛出的错误会被序列化回客户端,别把内部细节写进 message
拿请求上下文getRequestEvent()(16 章),可读 cookie 与 header
调试网络面板里是一个 POST 请求;日志打在服务端终端而不是浏览器

请求瀑布怎么消除

形态后果
组件挂载后才发请求导航 → 渲染 → 请求,三段串行
父组件取完数据子组件才开始取典型瀑布,层数越深越慢
preload + query导航刚开始就并行发起,渲染时缓存已命中
// 一个完整的取数链路:server function → query → createAsync
const getUser = query(async (id: string) => {
  "use server";
  return db.user.findUnique({ where: { id } });   // 直接查库,不会打进前端
}, "user");

export const route = {
  preload: ({ params }) => getUser(params.id),      // 导航即取数
};

export default function UserPage() {
  const params = useParams();
  const user = createAsync(() => getUser(params.id));   // 命中缓存,不重复请求
  return <Suspense fallback={"加载中"}><h1>{user()?.name}</h1></Suspense>;
}

// 列表用 createAsyncStore:重取时保住每行的身份
const todos = createAsyncStore(() => getTodos());
"use server" 里的代码不会进客户端产物,但参数会经网络传过去。这意味着两件事:一是别把敏感数据当参数从客户端传上来再「校验」,服务端要自己从会话里取;二是任何来自参数的值都属于用户输入,该做的鉴权与校验一样都不能省——它看起来像普通函数调用,实际上是一个公开的 HTTP 端点。
query 的 key 要当成全局标识来起。它既是去重的依据,也是 revalidate 的抓手——revalidate(getUser.keyFor(id)) 只失效那一条,而 revalidate(getUser.key) 失效整组。key 起得含糊,缓存就会在意想不到的地方互相打架。

在函数(或整个文件)顶部写一行 "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 之后)就不生效。(运行时具体行为依赖构建,本页只描述这条契约,不臆造报错原文。)
想让某个 server function 走 GET 语义(可被浏览器/CDN 缓存、幂等读取),用 @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。
纯客户端 Solid 用 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 时一模一样(重取后输入框失焦、滚动跳回顶部),所以换完要真的重取一次看看。
一条选型口诀:返回数组的 query 用 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 本身,仅此而已。
判断一份 SolidStart 资料新不新,看它用 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 并行
}
最隐蔽的瀑布来自「用前一个请求的结果当后一个的参数」——只要 B 真的需要 A 的结果,它就必须等 A,这种数据依赖的瀑布消不掉,preload 也救不了。能并行的前提是两个请求的参数都来自 URL / params 这类一开始就知道的值;发现自己在 await A 拿 id 再取 B,先想想 B 的参数能不能直接从路由拿到。
检验「有没有瀑布」的土办法:开浏览器网络面板看请求的时间条是否错开成阶梯。理想是同一时刻并排开始(并行);如果一条接一条像楼梯,就是串行等待,把「后面那个」的依赖提到 preload 或拆成独立 createAsync。

SolidStart:Actions 与表单变更

变更侧的四个 API:action 的定义与自动重验、useSubmission 系列能读到什么、命令式调用与渐进增强表单的写法。

读走 query,写走 action。action 跑完会自动重验本次导航涉及的 query 缓存,所以多数情况下不必手动刷新数据。

API

API作用要点
action(fn, "name")定义一次数据变更name 用于表单序列化与去重;也可传选项对象
表单接法<form action={myAction} method="post">无 JS 也能提交(渐进增强)
myAction.with(extra)预置额外参数表单里传不了的值(如 id)用它
useAction(myAction)拿到可 await 的函数非表单场景:按钮、拖拽、快捷键
useSubmission(myAction)跟踪最近一次提交pending / input / result / error / clear()
useSubmissions(myAction)跟踪全部并发提交可迭代,适合列表里多行同时操作
第二参 filter只跟踪符合条件的提交如「只看这一行的删除」
throw redirect("/x")成功后跳转在 action 内部抛出
throw reload() / revalidate(key)手动触发重验默认已自动重验,特殊情况才用

三种提交形态

形态写法适合
原生表单<form action={a}> + FormData创建 / 编辑页,要渐进增强
命令式const run = useAction(a); await run(arg)按钮、菜单项、非表单交互
乐观更新useSubmission().input 里读待提交的值,先画出来点赞、勾选这类要求即时反馈的操作

action 里能拿到什么

参数是 FormData(表单提交时)或你自己传的值(useAction 时)。action 默认就是 server function——里面可以直接查库;同样地,它也是一个公开端点,鉴权与校验一样不能省(14 章)。

// 定义:从 FormData 取值,成功后跳转
const addTodo = action(async (form: FormData) => {
  "use server";
  const text = String(form.get("text") ?? "").trim();
  if (!text) return new Error("内容不能为空");   // 返回错误,页面可读
  await db.todo.create({ data: { text } });
  throw redirect("/todos");
}, "addTodo");

// 表单:不写一行 JS 也能工作,有 JS 时自动变成局部更新
const sub = useSubmission(addTodo);
<form action={addTodo} method="post">
  <input name="text" />
  <button disabled={sub.pending}>{sub.pending ? "提交中" : "添加"}</button>
  <Show when={sub.result instanceof Error}><p>{sub.result.message}</p></Show>
</form>

// 乐观更新:从 input 里读待提交的值,先画出来
<Show when={sub.pending}>
  <li class="pending">{String(sub.input?.[0]?.get("text"))}</li>
</Show>

// 非表单场景:拿到函数直接调
const remove = useAction(deleteTodo);
<button onClick={() => remove(todo.id)}>删除</button>
表单字段忘了写 name,FormData 里就什么都没有。受控组件写惯了容易只给 value 和 onInput——但原生提交读的是 name,没有它这个字段根本不会被序列化,而且不报错,只是值为 null。渐进增强的前提是这个表单在没有 JS 时也是一个合法的 HTML 表单。
让 action return 错误、throw 跳转。返回值会出现在 useSubmission().result 里,页面可以直接显示校验错误而不必自己传状态;redirect 则必须 throw 才能中断后续逻辑。这个「返回错、抛出跳转」的分工记熟了,表单代码会短很多。

读数据用 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 都显式给一个稳定的名字——渐进增强的表单在无 JS 时靠它把 POST 请求对回正确的 action,多个 action 并存时也靠它区分。

表单接上 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)。
判断标准很简单:把网络调成禁用 JS,表单还能提交、数据还能写进去,就说明你接对了。做到这一点,弱网、脚本加载失败、爬虫都能用你的表单,JS 到位后再无缝升级成即时更新。

SolidStart:API 路由、中间件与部署

服务端那一侧:API 路由的导出约定、middleware 与 event.locals、SSR 下设状态码与响应头,以及 app.config.ts 的部署 preset。

这一章都是请求生命周期上的东西:进来先过 middleware,再落到页面或 API 路由,最后由 Nitro 按 preset 打包成目标平台的产物。

API 路由

写法说明
文件位置src/routes/api/*.ts(与页面共用路由规则)
导出export function GET(event: APIEvent) {},同理 POST / PUT / PATCH / DELETE
参数event.request / event.params / event.locals / event.nativeEvent
返回普通对象(自动转 JSON)或 Response
什么时候用给外部调用(webhook、第三方、移动端);自家页面取数用 server function 更省事

中间件与请求上下文

API作用
createMiddleware({ onRequest, onBeforeResponse })请求进来 / 响应发出前的钩子
注册app.config.ts 里 middleware: "./src/middleware.ts"
event.locals请求级的共享数据:中间件里挂用户信息,server function 与 API 路由里读
类型用模块增强给 RequestEventLocals 加字段
getRequestEvent()在 server function 里拿当前请求(读 cookie / header);客户端调用时为 undefined
典型用途鉴权、日志、地区重定向、统一响应头

SSR 下的响应控制与部署

写法作用
<HttpStatusCode code={404} />让 404 页真的返回 404(对 SEO 与爬虫很重要)
<HttpHeader name="Cache-Control" value="…" />组件式声明响应头
两者的限制只在服务端渲染时生效,客户端导航时不会改响应
app.config.ts 的 server.presetnode-server / vercel / netlify / cloudflare-pages / deno 等,一处切平台
ssr: false整站变纯客户端渲染
环境变量要暴露给浏览器的必须以 VITE_ 开头,其余只在服务端可见
// API 路由:导出方法名即可
export async function GET({ params, request }: APIEvent) {
  const data = await db.post.findMany();
  return json(data, { headers: { "Cache-Control": "max-age=60" } });
}

// 中间件:把用户挂到 locals 上,后面各处直接读
export default createMiddleware({
  onRequest: [async (event) => {
    event.locals.user = await getUserFromCookie(event.request);
  }],
});

// server function 里读请求上下文
const me = query(async () => {
  "use server";
  const event = getRequestEvent();
  if (!event?.locals.user) throw redirect("/login");
  return event.locals.user;
}, "me");

// 404 页要真的返回 404
<HttpStatusCode code={404} />
getRequestEvent() 在客户端调用时返回 undefined。同一个函数既可能在服务端首屏渲染时执行、也可能在客户端导航后执行——直接 getRequestEvent().locals 会在后一种情况下抛错。凡是用它,都要先判空;需要在两侧都能拿到的数据,应该经由 server function 的返回值传下来。
鉴权放在中间件里,业务代码只读 locals。每个 server function 各写一遍「取 cookie、验签、查用户」不仅重复,还迟早会漏掉一处。中间件统一挂好之后,漏写的地方读到的是空值而不是「跳过了检查」。

组件内部取数用 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 });
}
别把 API 路由文件写成 export default function——那会被当成页面组件。API 路由认的是具名的大写方法函数。反过来,页面组件文件里也别去导出 GET/POST,两种角色不要混在同一文件。
API 路由是给「HTTP 边界外」的调用方用的——webhook、别的服务、命令行。如果只是你自己的组件要读写数据,别绕一圈发 fetch,直接用 server function + 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。
把「每个请求都要跑的前置逻辑」收敛到 middleware,后面的 server function、API、action 就能默认信任 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/start 1.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>
  );
}
这两个组件只在服务端首次渲染那一遍生效。在浏览器里点链接过来(客户端导航)时它们什么都不做,所以「点进 404 页,curl 却要重新访问才能复现」是正常的。另外它们要在渲染过程中被执行到——写在一个永远不会渲染的分支里自然不会生效。
验证很简单,别靠肉眼看页面: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 start
别把 app.config.ts 和 Vite 的 vite.config.ts 搞混——SolidStart 的配置入口是前者,Vite 相关选项要通过 defineConfig 的 vite 字段透传,直接放一个独立 vite.config.ts 不会被 SolidStart 读到。
如果你只想先跑起来、部署到自己的服务器,preset: "node" 构建出一个标准 Node 服务是最省心的起点;等要上 Serverless/边缘平台,再换对应 preset,配置层的改动通常就这一行。

版本、生态与资料索引

本页查不到时去哪儿查:各个包的版本现状与迁移提示、生态里各类问题的常用方案,以及一份能自查掌握程度的检查点清单。

Solid 的包不多,但核心库、路由、元框架三者各自版本独立,读教程时先确认它写的是哪一代 API。

包与版本现状

包状态要留意的
solid-js1.x 稳定本页全部内容基于它;2.x 正在开发,主要变化在异步原语与内部实现
@solidjs/router1.x从 0.x 升上来时,数据 API 变化最大:cache → query、createRouteData → createAsync
@solidjs/start1.x早期教程里的 routeData / server$ 写法已废弃,现在是 "use server" + query + action
solid-js/store随核心发布createMutable 一直存在但官方不推荐
vite-plugin-solid随 Vite 版本更新JSX 编译的关键,配置里漏了它一切都不对

看到旧 API 怎么办

旧写法现在写
cache(fn, "key")query(fn, "key")(同一个东西,旧名已标废弃)
createRouteData / routeDataquery + createAsync + route.preload
server$函数体第一行写 "use server"
<Routes> 手写路由表<Router> + <FileRoutes />
useRouteData()直接调 query 函数,缓存会命中

生态:按问题查

要解决的问题方案
常用工具型原语(防抖、媒体查询、本地存储…)solid-primitives——官方维护,先在这里找再自己写
无样式的可访问组件(下拉、对话框、日历)Kobalte、Ark UI
带样式的组件库solid-ui(Kobalte + Tailwind)、SUID
服务端数据缓存TanStack Query 的 Solid 适配
表格 / 虚拟滚动TanStack Table / Virtual
动画Motion One 的 Solid 绑定、原生 View Transitions
表单校验zod / valibot + 自己的 store(09 章),或 modular-forms
调试响应式图solid-devtools(扩展 + npm 包)

资料入口

入口用途
官方文档 docs.solidjs.comAPI 参考与教程,改版后组织清晰
Playground能看到编译产物——不确定 JSX 变成了什么就贴进去看
solid-primitives 站点每个原语都有说明与在线示例
GitHub Discussions / Discord核心成员活跃,响应式设计问题在这里问最快
SolidStart 文档13–16 章对应的官方章节
网上的 Solid 教程有相当一部分停留在旧 API 上。尤其是 SolidStart 相关的文章:server$、createRouteData、routeData 这套写法在今天的版本里已经不存在或已废弃,照抄会得到「找不到导出」这类报错。判断办法很简单——看它有没有用 "use server" 与 query,没有就说明是旧文。
不确定某段 JSX 到底被编译成了什么,就贴进官方 Playground 看输出。它会显示模板克隆、订阅点、事件委托的完整产物——「为什么这里没更新」「为什么这个函数跑了两次」之类的问题,看一眼编译结果往往就明白了,比读文档快。

下面这些问题,能用自己的话讲清楚、并说出「为什么」,这一页就算毕业了。讲不清的那条,回对应章再过一遍——每一条背后都是同一根主线:组件函数只跑一次。

响应式心智(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 里错」——就毕业了
自测最容易骗自己的地方:把答案背下来当成懂了。判断标准不是能复述「不能解构 props」,而是遇到一段没见过的坏代码,能当场指出哪断了、为什么断、怎么改——讲不出「为什么」,就是还没到。
把上面那段代码真的改对一遍:name 换成 props.name、派生值用 createMemo、条件换 <Show>、列表换 <For>。改完再问自己每一处「Solid 为什么要这样」,答得上来才是真懂,不是背下来。