Solid.js 与 SolidStart 完整知识体系交互讲解

全景:Solid 的定位与现状

钻进 API 前先答三问:它的心智模型是什么、生态到哪了、四大框架怎么选。

Solid 的立身之本是细粒度响应式:组件函数只跑一次,把数据和 DOM 的依赖关系布好线,之后状态一变就只改那一个节点——更新链上没有组件函数

一句话心智模型

  • 组件函数只执行一次。JSX 长得和 React 一样,语义却相反:React 里它是「每次更新都重跑的渲染函数」,Solid 里它是「一次性的布线说明书」;
  • 真正的更新单元是信号(createSignal),不是组件。版本基准 Solid 1.x,全栈元框架是 SolidStart它的卖点从来不是生态,是运行时开销

两步机制,推出三条反直觉规则

  • 编译期把静态结构变成一个 HTML 模板字符串(运行时 clone 一次就完事),动态位置只留下一句 insert运行时insert 判断参数是不是函数,是就用 createRenderEffect 包一层——这一句是整个框架的枢纽
  • 于是:读信号要调用(「更新」的实现就是重新调用那个函数)、props 不能解构(解构等于当场取值,读取时机丢了)、条件要用 <Show>(三元在布线时就把分支定死了)。
import { createSignal } from "solid-js";

// 组件函数只跑一次;点击只更新那个文本节点
function Counter() {
  const [count, setCount] = createSignal(0);
  return (
    <button onClick={() => setCount(c => c + 1)}>
      点击 {count()} 次
    </button>
  );
}
Solid 的 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 更稳。

这一页从心智模型讲到全栈,建议按下面的顺序读——先把「组件只跑一次」立住,剩下的都是推论。别一上来就冲 SolidStart。

第一步:立心智(必读)

  • 01 上手——把项目跑起来,逐行拆第一个组件,看清 JSX 被编译成了什么。
  • 02 响应式核心——createSignal / createMemo / createEffect 三件套,这是全书地基。

第二步:写出真实组件

  • 04 组件与 props05 控制流<Show>/<For>)、06 Store(嵌套状态)、07 生命周期与 Context
  • 进阶细节看 03 高级响应式batch/untrack)、08 异步资源09 表单。工程化看 101112

第三步:全栈 SolidStart(可后读)

  • 纯客户端 Solid 学扎实了,再进 13 起步的 SolidStart 部分——文件路由、14 数据加载15 变更16 API 路由。最后用 17 规划动手项目。
// 一条主线,四个推论
// 「组件只跑一次」
//   ├─ 读信号要调用      → count()
//   ├─ 不能解构 props    → 写 props.name
//   ├─ 条件/列表用组件   → <Show> / <For>
//   └─ 副作用进 effect   → 别在组件体里裸跑
别跳过 02 直接冲 SolidStart。信号和「只跑一次」没吃透,到了 createAsyncaction 那些全栈原语会处处别扭——它们全建立在响应式地基上。
每一章都能独立读,卡片也自成一体。真读不下去,就先跑 01 的计数器亲手点一下——「只跑一次」会比任何文字都直观。

上手:装环境,跑通第一个组件

从零建项目、看懂 JSX 怎么被编译、装 devtools、读懂最常见的几个报错与告警。

上手 Solid 有两条路——想学语法、做纯前端,用裸 Vite + Solid 模板;想做一个带服务端的站,直接上 SolidStart。别一上来就背全栈,先把语法跑通。

两个脚手架命令

  • 裸 Vite + Solidnpm create vite@latest my-app -- --template solid-ts,然后 npm installnpm run dev
  • SolidStartnpm create solid@latest,自带文件路由、SSR 与 Server Functions;
  • 模板名去掉 -ts 就是 JS 版,但学 Solid 建议直接上 TS——props 和信号的类型提示能帮你少踩不少响应性的坑。

怎么选

  • 裸 Vite 面积最小,适合先把信号和控制流吃透,代价是没有路由与 SSR;
  • SolidStart 一步到位,但同构、服务端函数、水合这些概念会一次涌上来——建议语法通了再上。
# 学语法:裸 Vite + Solid(TS 模板)
npm create vite@latest my-app -- --template solid-ts
cd my-app
npm install
npm run dev

# 做站:SolidStart
npm create solid@latest
模板名别记混——是 solid-ts(或 solid),不是 solidjs,写错会因为找不到模板报错。想做全栈也别用 Vite 模板硬拼路由和 SSR,那是在重造 SolidStart。
版本基准:本页基于 solid-js 1.9.x、@solidjs/router 0.16.x、@solidjs/start 1.3.x别写「装最新版」这种话——写清楚基准,读者才知道对不上时该怀疑什么。

下面这个计数器只有十行,却藏着三条最关键的规则

逐行讲

  • function Counter()——这个函数整个生命周期只执行一次。里面写一句 console.log,从头到尾只打印一次,点按钮也不会再打印。
  • const [count, setCount] = createSignal(0)——创建一个信号,返回 [读取函数、写入函数]。注意 count 是个函数,不是值。
  • {count()}——读信号必须调用,加括号。写成 {count} 只会把函数本身塞进 JSX。
  • onClick={() => setCount(c => c + 1)}——点击时更新信号,Solid 顺着依赖只更新那个显示 count() 的文本节点,组件函数不重跑。

点一下,到底动了什么

  • 点击触发 setCount,Solid 顺着这个信号的订阅者,找到唯一读了 count() 的地方——那个文本节点,只改它的文字。按钮元素、周围的「点击」「次」这些静态文字一个都不动,更不会重跑组件函数。这就是「细粒度」三个字的字面意思——点两次,文本从 0 次 变到 2 次,而组件函数体只被调用一次
import { createSignal } from "solid-js";

function Counter() {
  console.log("只打印一次");            // 组件函数只执行一次
  const [count, setCount] = createSignal(0);   // [读取函数, 写入函数]
  return (
    <button onClick={() => setCount(c => c + 1)}>
      点击 {count()} 次      {/* 读信号要加括号 */}
    </button>
  );
}
「漏括号」的真实形状和常见说法不一样:在 JSX 插入位置写 {count},编译器把它当成一个普通值插进去——此后永不更新,而且不报错
记住这组对应关系——信号是函数createSignal 给你一对 [getter, setter]读用 count(),写用 setCount(v)

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

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

  • 先装 solid-devtools(浏览器扩展 + solid-devtools npm 包,开发期工具,正式构建不该包含):它把看不见的响应式图显示出来——组件树、有哪些信号和 memo、它们连着哪些副作用。遇到「点了没反应」的固定流程:① 组件树里找到那个组件;② 看它读的信号有没有连到你改的那个;③ 没连上,十有八九是下面这两类静默错误。
  • 没有任何报错,页面只是永远停在第一次的值。正确写法是一路用 props.name,或用 splitProps

坑二:在服务端调用了浏览器专属 API

  • SolidStart 会先在服务端跑一遍组件,此时 window / document / localStorage 都不存在,报 ReferenceErrornot 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

三件套搭起从数据到 DOM 的依赖图——这是 Solid 一切的地基。

createSignal 是 Solid 响应式的最小单元。一句话——它给你一对 [读取函数、写入函数]读要调用,写可以直接给值、也可以基于旧值。

读要调用,写有两种

  • const [count, setCount] = createSignal(0)——count访问器函数,读值写 count(),不是 count
  • 写有两式——setCount(5) 直接设值;setCount(c => c + 1) 基于旧值更新。连续更新、或不确定当前值时,用函数式更稳。

为什么读是函数调用

  • 正因为读是一次函数调用,Solid 才能在你读它的那一刻「记一笔」——谁读了我,谁就订阅了我。这套自动依赖收集是整个响应式的核心。写成裸变量就没有这个「读」的时机,也就无从追踪。

能装什么、初值怎么给

  • 信号能装任何值——数字、字符串、布尔、对象、数组,甚至函数(存函数要包一层,见下方 tip)。createSignal() 不给初值时,初值是 undefined
  • 在响应式作用域外面读信号完全没问题(比如在事件处理函数里读一下当前值),只是那次读不建立依赖——依赖只在 effect、memo、JSX 这些追踪上下文里才会被收集。

lab 里跑过

  • 实验台里 setCount(5)count()5,再 setCount(c => c + 1) 后是 6——函数式更新拿到的就是当前最新值。
import { createSignal } from "solid-js";

const [count, setCount] = createSignal(0);

count();                  // 读:必须加括号
setCount(5);             // 写:直接设值
setCount(c => c + 1);    // 写:基于旧值(连续更新更稳)
别把 count(函数)当值用。count + 1 是「函数加一」,得到的是一串拼上了函数源码的字符串(不是数字,也不是 NaN);value={count} 这类属性绑定会把函数源码写进属性且永不更新。唯一的例外是 JSX 的插入位置——那里 {count}{count()} 编译成同一句,恰好能用(见 01 章)。别指望这份运气,读信号永远加括号
想把函数存进信号要绕一下——setCount(fn) 会把 fn 当「基于旧值的更新函数」执行掉。真要存函数,写 setCount(() => fn)。日常存数字、字符串、对象没这问题。

createEffect 是响应式的「出口」——把信号的变化接到副作用上(打日志、发请求、手动碰 DOM)。它自动追踪函数体里读到的信号,任何一个变了就重跑,不用手写依赖数组。

自动、动态的依赖

  • effect 体里读到哪个信号,就订阅哪个,无需 React 那样的依赖数组。而且依赖是动态的——每次重跑只订阅这一次实际读到的信号。if (a()) b()a() 为假时,这轮就不订阅 b

时序:初次运行没那么「立刻」

  • 在一个响应式作用域(createRoot 或组件的 render)里创建的 effect,初次运行会被延后,等所属作用域的同步代码跑完才统一冲刷——不是立刻、也不是微任务
  • 实验台复现——在 createRoot(d => {...}) 的函数体内部读 effect 写的变量,还是 undefined;等 createRoot 返回之后再读,就已经是最新值了。

创建时先跑一次

  • effect 创建时会立刻跑一次(在同步作用域结束时冲刷),目的是拿到初值、把依赖收集上——之后才靠依赖变化驱动重跑。这点和 React 的 useEffect 不同——它不需要依赖数组,也没有「只在挂载后跑」的空数组写法,依赖完全由「这一次读了谁」自动决定。

什么该进 effect

  • 只放副作用——日志、订阅、手动操作 DOM、和外部系统同步。派生一个值别用 effect,那是 createMemo 的活(effect 没有返回值,产物也不能被别人追踪)。
import { createSignal, createEffect } from "solid-js";

const [name, setName] = createSignal("Alice");
const [age, setAge] = createSignal(25);

createEffect(() => {
  console.log(`${name()} is ${age()}`);   // 读了 name、age → 订阅两者
});

setName("Bob");   // name 变 → effect 重跑

// 动态依赖:showName 为 false 这轮就不订阅 name
createEffect(() => {
  if (showName()) console.log(name());
});
别在 effect 里 setXxx 去写自己依赖的信号,容易绕成循环或反复触发。要「从 A 算出 B」,用 createMemo;只在个别场景才用 on(...) 显式指定依赖(详见 03 章)。
effect 的清理写在它里面——onCleanup(() => ...) 会在下次重跑前、以及销毁时先执行,天然适合「订阅/退订」成对操作(详见 07 章)。

createMemo 声明一个派生值——从别的信号算出来、结果被缓存,依赖不变时重复读也不重算。读法和信号一样,加括号 total()

缓存、依赖变即算、纯

  • 缓存——只要依赖没变,多次读 total() 只计算一次,后续直接给缓存值。
  • 只在依赖变时重算——priceqty 变了,才重新算一次。
  • 必须是纯函数——只做计算、同步返回一个值,别在里面发请求或改 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——priceqty 是用户能改的源。
  • 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 / 相等性

控制何时追踪、何时冲刷、何时算相等——把细粒度响应用对的关键。

默认情况下你每调用一次 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())bsetB(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) 时能拿到 prev2。适合「初始值不用管,只在用户改动时才响应」的场景。

多个源

  • 第一个参数传数组 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 有一组「同步执行」的响应式原语——createComputedcreateRenderEffect。它们和 createEffect 长得一样,区别在什么时候跑。日常几乎用不到,主要是给库作者的。

时序差在哪

  • createEffect 的回调在初始那轮同步执行里被延迟,等所属作用域(render / createRoot)同步跑完才冲刷。lab 里在 createRoot内部读它的产物是 undefined
  • createComputedcreateRenderEffect同步执行: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.5floor 没变,memo 判定「不变」,下游 effect 不跑;变到 1.2 才跑。用它可以把「无意义的小抖动」挡在下游之外。
import { createSignal, createMemo } from "solid-js";

// 默认 ===:改属性后传回同一引用,不触发
const [o, setO] = createSignal({ n: 0 });
o().n = 1;
setO(o());          // 引用没变 → 下游不动
setO(p => ({ ...p, n: p.n + 1 }));   // 新引用 → 触发

// equals:false —— 每次 set 都通知
const [t, setT] = createSignal(0, { equals: false });

// memo 自定义 equals:floor 相同就当没变,挡住小抖动
const level = createMemo(() => raw(), undefined, {
  equals: (a, b) => Math.floor(a) === Math.floor(b),
});
自定义 equals 的返回值别搞反:返回 true 表示「两者相等、通知」,返回 false 才触发更新。写反了会得到「要么永远不更新、要么永远在更新」的诡异现象。
equals: false 是「我就是要每次都刷新」的开关,处理「值可能相同但仍需重新触发」(比如重播同一段动画、重发同一条指令)时很实用。

组件与 JSX、props

和 React 同形不同义的 JSX:从「组件只跑一次」到 class / style / key 的逐条差异。

这是理解 Solid 的总开关:组件函数只执行一次,用来搭好「信号 → DOM」的依赖图;此后信号更新,只会去碰真正用到它的那几个节点,组件体不再重跑。Solid 里几乎所有反直觉的规则,都是这一条的推论。

lab 验证

  • 一个 Counter 在函数体里 runs++,点两次按钮把数字从 0 加到 2 之后,runs 仍然是 1——更新只改了那个文本节点,函数一次都没重跑。
  • 所以你可以放心在组件体里做「只该做一次」的事(建信号、订阅、算初值),不用像 React 那样担心它每次渲染都重来。

和 React 到底差在哪

维度React 函数组件Solid 组件
函数执行次数每次更新都重跑只跑一次
更新粒度重渲染整棵子树再 diff只更新绑定到变化信号的节点
读状态count(值)count()(调用 getter)
要不要 memo / 依赖数组要,防重渲染不要,天生细粒度

由此推出的三条铁律

  • 读信号要调用count(),不是 count
  • props 别解构(下一张卡)——解构那一刻就成了快照。
  • 条件和列表用 <Show> / <For> 而不是三元和 map(见 05 章)——因为组件体不会再跑第二次去重算它们。
import { createSignal } from "solid-js";

let runs = 0;
function Counter() {
  runs++;                       // 点多少次按钮,这里始终只 +1 到 1
  const [c, setC] = createSignal(0);
  return (
    <button onClick={() => setC(c() + 1)}>
      {c()}                     {/* 只有这个文本节点会更新 */}
    </button>
  );
}
别把「每次更新都要重算」的逻辑直接写在组件体里——它只跑一次,不会重算。需要随信号变化的派生值放 createMemo,需要随信号变化的副作用放 createEffect(见 02 章),别指望函数体自己重跑。
把组件函数想成「布线图」而不是「渲染函数」:它只在装配时跑一次,把哪根信号连到哪个节点接好,之后就退居幕后。带着这个画面看 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 个事件的白名单生效,名单外的(onFocusonChange 等)编译器会自动退回真正的 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 的构造器是 HTMLDivElementnode instanceof HTMLElementtruenode.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>'
别把「能跑」当成「写对了」。classNamehtmlFor 在运行时确实被当成 class / for 处理——两个都写会合并class="a b",一路不报错。直到有人打开 TSX 或 lint,才发现全项目都要改。新代码一律写 class / for
迁移时最省事的自检是把项目写成 TSX——classNamehtmlForkeydangerouslySetInnerHTMLstyle 驼峰这几条 React 习惯,TypeScript 全部当场报错(fontSize 那条还直接建议你改成 font-size)。纯 JS 项目里它们要么静默生效、要么静默失效,只能靠肉眼(见 10 章)。

这是全页最该记牢的一个坑props 是个带 getter 的响应式代理对象,靠「在响应式上下文里读 props.x」来保持实时。一旦解构,你就把当下的值抠出来存成了普通变量——从此和父组件断了联系。

lab 复现

  • 两个组件收同一个会变的 vBrokenconst { v } = propsLive 里直接 props.v。父层把 v"a" 改成 "b" 后——Broken 还显示 aLive 已经变成 b
  • 原因就是「组件只跑一次」:解构发生在那唯一一次执行里,之后再没机会重新解构,值就冻在了 "a"

连默认值也别用解构写

  • React 习惯的 function C({ size = "m" }) 在 Solid 里同样断响应。给默认值请用 mergeProps,拆分请用 splitProps(下一张卡),它们专门设计成保持响应。

怎么写才对

  • 在 JSX、createMemocreateEffect 这些响应式上下文里读 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 给了两个专用助手:mergePropssplitProps,它们做这两件事的同时全程保持响应

mergeProps:合并 / 默认值

  • mergeProps(默认对象, props) 返回一个合并后的响应式对象:props 里给了就用 props 的,没给就用默认的。
  • lab 验证:ButtonmergeProps({ 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>;父层改 labelplaceholder,两处都实时更新。这正是「用了自己关心的、其余原样透传」的标准写法。
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() }},点击把 activefalse 翻到 true——active 类被加上,静态的 tag 类始终保留,再点一下 active 又移除。切换精准,不影响其它类。

style:对象写内联样式

  • style={{ color: … }} 用对象,属性名用 CSS 原名("background-color" 这类连字符名要加引号或写成字符串键)。
  • lab 验证:style={{ color: on() ? "green" : "gray" }},信号从 falsetrue 时,元素颜色实时从 graygreen

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 个事件的白名单生效(源码里的 DelegatedEventsclick / input / keydown / keyup / pointer* / touch* / focusin / focusout 这类)。名单外的比如 onFocusonChangeonSubmitonMouseEnter,编译器自动退回真正的 addEventListener——这件事不用你操心。
  • onclick 全小写和 onClick 的编译产物完全一样,大小写不敏感(React 里必须精确写 onClick)。

那什么时候真的需要 on:

  • 出来最实在的一条:祖先上有一个原生监听器调用了 e.stopPropagation(),子元素的 onClick 就一次都不触发——委托的监听器在根节点上,事件被半路截停就到不了根。同一个位置换成 on:click 照常触发。和第三方库、非 Solid 管理的那片 DOM 混用时最容易撞上。
  • 另外两种场合:自定义事件(名字不在标准表里),以及需要给 addEventListener 传选项。

处理器还能收一个数组

  • onClick={[handler, data]} 编译成 el.$$click = handlerel.$$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

为什么不用三元和 map——用控制流组件才不破坏细粒度更新。

在 React 里你习惯用 if、三元、&&.map() 来做条件和列表——因为组件每次更新都会重跑,这些 JS 表达式也跟着重算。Solid 的组件只跑一次,这些表达式也就只在「布线那一刻」算一次,之后不会再自动重算。所以 Solid 给了一套控制流组件<Show> / <For> / <Index> / <Switch> / <Dynamic>

根子上是同一条主线

  • 「组件只跑一次」意味着:凡是需要随信号变化而重新决定「渲染什么」的地方,都得交给一个自身是响应式的东西去管——控制流组件就是干这个的。
  • 它们内部把「条件 / 列表」接进了响应式系统,信号一变,它们就精确地增删 / 复用对应的 DOM,而不必重跑整个组件。

裸三元 / map 的代价

  • {cond() ? <A/> : <B/>} 写在 JSX 里也能跑,但每次 cond 变化,Solid 只能整块替换那段结果,里面的组件被销毁重建、内部状态丢失。
  • <For> / <Index> 则会按策略复用已有节点,只动该动的行,性能和状态保持都更好。

一句话记牢

  • 条件 → <Show> / <Switch>;列表 → <For> / <Index>;动态标签 → <Dynamic>把 JS 控制流留给普通逻辑,把「渲染什么」交给控制流组件。
import { Show, For, Switch, Match } from "solid-js";

// ❌ 裸三元:cond 变化时整块替换,内部组件被重建
// {cond() ? <Heavy /> : <Empty />}

// ✅ 控制流组件:精确增删 / 复用,保住内部状态
<Show when={cond()} fallback={<Empty />}>
  <Heavy />
</Show>
别以为「反正只跑一次,裸 map 出来的列表就固定了」——真相更糟:数组一变,那段 map 结果会被整体重建,每一行的 DOM 和内部状态全丢。列表务必用 <For> / <Index>
带着一个问题看 JSX:这段「渲染什么」需不需要随信号变?需要,就用控制流组件;不需要(纯静态结构),随便怎么写都行。这个判断能帮你快速决定该不该上 <Show> / <For>

<Show when={…} fallback={…}> 是最基础的条件渲染:when 为真渲染子节点,为假渲染 fallback(不给就渲染空)。它是响应式的——when 一变就切换,不用组件重跑。

基本用法

  • lab 验证:whenfalsetrue,内容从 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() > 5n() > 0 顺序排。n0 时都不满足,走 fallback;n=3 命中第二条显示 smalln=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()} 在两个组件间切换,内容随之从 AB

它替你省掉什么

  • 本来要写一长串 <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)

  • mountNode):换落点。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:嵌套状态的细粒度响应

signal 管简单值,store 管嵌套结构:只读 Proxy、路径式更新、produce 与 reconcile。

信号适合单个值,可一旦状态是嵌套对象或数组,用信号就得每次整体建新对象再替换。createStore 就是为这种结构生的——它返回一个只读 Proxy 和一个路径式 setter,让你「只改一个字段」而完全不惊动其它节点。

读像对象,写走 setter

  • createStore(初值) 返回 [state, setState]。读取不加括号——state.user.name 就是普通属性访问,Proxy 会在读取的那一处悄悄登记依赖。这一点和信号要写 count() 正相反,别混。
  • state只读的:直接写 state.user.name = "Bob" 不生效——值不变、也不抛异常,但开发构建会打印告警 Cannot mutate a Store directly 提醒你走 setState。这一读一写的不对称,正是它能做到细粒度的关键。

路径式更新的三种末段

  • setState("user", "name", "Bob"):前面几段是路径,最后一个参数是新值
  • 末段传函数基于旧值算:setState("user", "age", a => a + 1)
  • 路径中间放谓词函数按条件定位数组项:setState("todos", t => t.id === 1, "done", true) 只改命中的那一项。

细粒度到字段

  • a.ba.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 拉回列表,createEffectsetState("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 内部。要修改数据永远走 setStateunwrap 只用来「只读地」把数据交出去。
把「拉数据」和「塞回 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

onMount / onCleanup 挂在哪、跨层级共享用 Context、拿真实 DOM 用 ref。

组件函数只跑一次、用来搭响应式图,这时候真实 DOM 还没生成。想在「元素已经在页面上」之后做点事——测量尺寸、聚焦、接第三方库——就用 onMount。它在组件首次挂载到真实 DOM 后执行一次,且只在客户端跑

它解决什么

  • 组件体里直接摸 DOM 是空的(还没挂载)。onMount(fn)fn 推迟到挂载完成后,此时 ref 已指向真实节点(lab onMount 里读 el.tagName 拿到 "DIV")。
  • 只跑一次、不追踪任何信号——它就是「初始化钩子」,类比 React 的 useEffect(fn, [])。lab 里被调用次数正好 1。

为什么强调「只在客户端」

  • SSR 时组件在服务端渲染成 HTML 字符串,那里没有 DOM。onMount 不会在服务端执行,只在浏览器水合后跑——所以摸 window/document、启动定时器这类只该发生在浏览器的事,放这里最稳。
  • 反过来,纯数据的初始化(不碰 DOM)用 createEffect 或直接写在组件体里即可,不必都塞进 onMount。

和 createEffect 的分工

  • onMount = 只跑一次、不追踪依赖的 effect。要「随某个信号变化反复做事」是 createEffect 的活,别指望 onMount 会再跑第二次。
  • 顺序上,onMount 的回调在挂载后触发,此时 ref 已就位、子组件也已渲染,适合做「读真实布局」这类必须等 DOM 齐了的事。

典型用途

  • 需要真实节点的第三方库——图表、地图、富文本编辑器,都在这里 new 出实例、挂到 ref 指的元素上。
  • 读初始布局:el.clientWidth、滚动位置、聚焦第一个输入框。
  • 启动只该在浏览器发生的东西:window 监听、requestAnimationFrame 循环——记得配 onCleanup 收尾。
import { onMount, createSignal } from "solid-js";

function Chart() {
  let box;   // ref 变量

  onMount(() => {
    // 到这里 DOM 已存在:可以测量、聚焦、接第三方库
    console.log(box.clientWidth);
  });

  return <div ref={box}>图表容器</div>;
}
别指望 onMount 会像 React 的 effect 那样「依赖变了再跑一次」——它只跑一次、永不重跑。想响应某个信号变化做事,那是 createEffect 的活。也别在组件体里直接读 ref(那时还没挂载,是 undefined),要读就进 onMount
onMount 本质就是「首次运行、不追踪依赖」的 createEffect。要接入图表库、地图、编辑器这类需要真实 DOM 节点的第三方库,几乎都在这里初始化,再配 onCleanup 在卸载时销毁。

onCleanup(fn) 登记一个清理函数。它不止在组件卸载时跑——写在任何响应式作用域(尤其 createEffect)里,它会在该作用域每次重跑之前先清理上一轮。定时器、事件监听、订阅,全靠它收尾。

两个触发时机

  • 组件卸载:组件从 DOM 移除时,其内所有 onCleanup 依次执行——清 setInterval、解绑监听就靠这个。
  • 作用域重跑前:把 onCleanup 写进 createEffect,effect 每次因依赖变化重跑之前,会先跑上一轮登记的清理。lab 信号从 0→1→2,清理依次收到 0、1,dispose 时再收到 2。

为什么这个设计很顺手

  • 「订阅 / 取消订阅」天然成对:在 effect 里根据当前信号建立订阅,再 onCleanup 取消——切换目标时,框架自动先退订旧的、再订新的,不用你手写 diff。
  • 清理逻辑就近写在建立副作用的地方,读代码时一眼看到「起」和「收」,不会各写一处对不上。

注意作用域

  • onCleanup 必须在某个响应式作用域内调用(组件体、effect、createRoot 里)才有效;脱离作用域调用不会有归属。
  • 它登记的是「反做」动作——里面别再建立新的持久副作用,容易套娃。

一次注册多个清理

  • 同一个作用域里可以多次 onCleanup,各自登记、按注册顺序执行——不用把所有清理硬塞进一个函数。谁起的副作用,就紧跟着登记谁的清理,读起来最清楚。
  • 它和 onMount 常成对出现:onMountnew 出第三方实例,onCleanupinstance.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(默认值) 的参数,就是在找不到任何 ProvideruseContext 的返回。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 onMountel.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 的 onCleanupuseContextcreateEffect 都要挂在一个「所有者(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 还在吗」onCleanupuseContextcreateEffectcreateMemo 全在这张名单上。同步位置调用的则完全不用操心。

「这两个组件怎么互相传数据」没有唯一答案,取决于它们的关系距离。把四种手段放一起看,选起来就有谱:近的用 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 拿到自己那条;用户勾选时 TodoItemprops.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 把一次异步请求接入响应式系统,一个资源对象就带齐了三态,还给你 mutaterefetch 两个把手。

返回什么

  • const [data, { mutate, refetch }] = createResource(fetcher)fetcher 是个返回 Promise 的函数。
  • data 既是访问器也挂着状态data() 取值、data.loading 是布尔、data.error 是错误对象——三态都在这一个东西上(OLAB 事实 20,lab 里)。

三态怎么读

  • 加载中:请求未完成时 data.loading === truedata()undefined
  • 数据到了:Promise resolve 后 data.loadingfalsedata() 是结果。
  • 出错了: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 对比:同一次切换,两种观感

  • 裸 setsetTab("b") 之后立刻读页面文本,得到的是 "fallback"——旧内容被卸掉了。
  • 包进 transitionstart(() => 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 感觉「顺」的一部分原因。

表单与受控输入

受控与非受控、双向绑定的取舍、校验与提交——纯客户端表单怎么写。

受控输入就是让一个信号当输入框的「唯一真相」——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-0cl-1。在组件体里调一次、同时用给 forid 即可。
  • 它真正的价值在 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.valuecurrentTarget 的类型被精确推断为这个 <input>(TS 下 target 只是宽泛的 EventTarget,见 10 章)。数字输入记得 Number(e.currentTarget.value),DOM 里拿到的永远是字符串。

不是每个框都值得受控。如果这个值只在提交那一刻才需要、中途没人读,那就别接信号——让浏览器自己管着输入,提交时用一个 refinput.value 读出来就行。

什么时候不受控

  • 一次性读取:登录框、搜索框,只在按下按钮时要值,输入过程里没有联动。
  • 不想每键重跑逻辑:受控会让每次输入都写信号、触发依赖;不受控则完全不碰响应式系统。
  • 文件输入 <input type="file"> 只能非受控——它的值不能用代码设。

ref 就是个普通变量

  • Solid 不需要 createRef:声明 let el;,写 ref={el},元素挂载后 el 就指向真实 DOM(细节见 07 章)。
  • 提交时 el.value 直接读当前输入。要设默认显示值用 value="..."<input value="初始">,它不会再被信号接管。

别在渲染期读 ref

  • 组件体执行时元素还没挂载,此刻 elundefined。只能在 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) 会报 inputElundefined——组件只跑一次且此时 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.nameform.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 字段,改了就只更新那条提示。

校验时机的取舍

  • 提交时校验(本例)最省心、打扰最少,适合大多数表单。
  • 想「边打边提示」,把单字段校验挂到 onInputonBlur;但别一上来就飘红,通常等用户离开该框(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

给 props、signal、context、resource 标类型,让编译器替你挡住反直觉的坑。

TSX 相对 JSX 只多两件事:让编译器找到 Solid 的 JSX 类型,以及把 React 那套类型名换掉。配好这两处,剩下的写法和 04 章的 JSX 规则完全一致。

tsconfig 就两行是关键

  • "jsx": "preserve"tsc 别自己转 JSX——转换要留给 vite-plugin-solid 的 babel 去做;"jsxImportSource": "solid-js" 告诉它去 solid-js/jsx-runtime 找 JSX 命名空间,属性检查才认得 classclassList 这些。
  • 这两行是 create-vitesolid-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!: HTMLInputElementref={el}确定赋值断言

TSX 会替你拦住 React 习惯——这是上 TS 的最大理由

  • 本页在 strict 模式下对着 1.9.14 的 jsx.d.ts 逐条跑过 tsc,报错原文:classNameProperty 'className' does not exist on type 'HTMLAttributes<HTMLDivElement>'htmlForkeydangerouslySetInnerHTML 都是同一类。
  • 最贴心的一条是 style={{ fontSize: "12px" }}'fontSize' does not exist in type 'CSSProperties'. Did you mean to write 'font-size'?——连改法都给了。style={{ width: 20 }} 的裸数字也不接受。
  • 这几条在纯 JS 项目里全是静默的(见 04 章):要么被当成别名照样生效,要么当场失效但一个字不报。

五个空接口:自定义前缀要自己补声明

  • Directivesuse:)、ExplicitPropertiesprop:)、ExplicitAttributesattr:)、ExplicitBoolAttributesbool:)、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-solidsolid/reactivity
迁移一个 React 组件时,把类型报错当清单用——classNamekeystyle 驼峰这些会被逐条点出来,改到不报错,「写法」层面的迁移就基本完成了。

给组件标类型有两条路:要么给整个组件套 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.tsAccessor<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.tscreateStore 返回 [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
  • 取数函数的返回类型就是 TcreateResource(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

用 @solidjs/testing-library 渲染组件、断言细粒度更新、测异步与错误边界。

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

四个包各管什么

作用
vitest测试运行器,跑 *.test.jsx
vite-plugin-solid把 JSX 编译成 Solid 代码(必须有,否则 JSX 跑不起来)
jsdomNode 里的假 DOM,让组件有地方渲染
@solidjs/testing-library提供 render / fireEvent / 查询

关键一行:conditions 用 development

  • resolve.conditions: ["development", "browser"] 让测试走 Solid 的开发构建——这样才能在测试里看到开发期的告警(比如把响应式用错时的提示)。少了它,很多告警和某些开发期检查不会出现。
  • test.globals: truedescribe / 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,测试函数要写成 asyncawait。忘了 await,用例会在数据到达前就结束、假绿。
import { render, screen, waitFor } from "@solidjs/testing-library";
import { createResource, Suspense, ErrorBoundary } from "solid-js";
import { it, expect } from "vitest";

it("Suspense fallback 到数据到位", async () => {
  function AsyncName() {
    const [data] = createResource(() =>
      new Promise((r) => setTimeout(() => r("Ada"), 10)));
    return <Suspense fallback={<p>加载中</p>}>你好 {data()}</Suspense>;
  }
  render(() => <AsyncName />);
  expect(screen.getByText("加载中")).toBeTruthy();
  await waitFor(() =>
    expect(screen.getByText("你好 Ada")).toBeTruthy());
});

it("ErrorBoundary 捕获渲染期抛错", () => {
  const Boom = () => { throw new Error("炸了"); };
  const { getByText } = render(() => (
    <ErrorBoundary fallback={(err) => <p>出错: {err.message}</p>}>
      <Boom />
    </ErrorBoundary>
  ));
  expect(getByText("出错: 炸了")).toBeTruthy();
});
两个假绿陷阱:一是忘了 await waitFor(...),用例在数据到达前就结束、看起来通过其实没测到;二是被测的错误发生在事件回调里而非渲染期——ErrorBoundary 只兜渲染期的抛错,事件里的错要在回调内自己 try/catch,别指望边界接住。
测异步数据时,fetch 之类真实请求要 mock 掉(vi.fn() 返回一个假 Promise,或用 vi.mock),让测试不依赖网络、可复现。上面例子直接用一个 setTimeout 的 Promise 当假数据源,是最轻量的做法。

性能与常见陷阱

Solid 默认就快,出错几乎都来自「无意中破坏了响应性」这一类固定套路。

Solid 默认就快——没有虚拟 DOM、组件函数只跑一次、更新精确到单个 DOM 节点。所以性能问题几乎从来不是「Solid 慢」,而是「你无意中把响应性弄断了」,框架于是退回到「整块重建」或「干脆不更新」。

五种最常见的破坏方式

套路症状原因修法
解构 props父层传新值,子层文本不更新组件只跑一次,解构那一刻取的是快照改写 props.x,需要拆分用 splitProps/默认值用 mergeProps(详见 04)
解构 store 顶层store 改了字段,界面不动const {n}=s 同样是快照读 s.n,保留路径访问(详见 06)
set 同一引用的对象改了对象属性再 set 回去,不触发signal 默认按 === 判相等,引用没变建新对象 {...prev},或改用 store(详见 02)
对象数组用了 Index列表重排后行内状态串位Index 按下标复用 DOM,状态跟着位置走对象数组用 For,按引用搬 DOM(详见 05)
原始值数组用了 For编辑一项,那一行被销毁重建、失焦For 把原始值本身当身份,值一变就是新行原始值/定长数组用 Index(详见 05)

一条主线串起全部

这五个坑没有一个是「另一个知识点」——它们全是「组件函数只跑一次」这一条的推论。信号要调用 count()、props 不能解构、列表用 For/Index 而不是 map,都从这里长出来。记住这条根,你能自己推出修法,而不是死记五条规则。

// —— 破坏响应性的五种固定套路(都是「组件只跑一次」的推论)——
// 1) 解构 props:拿到快照,父层更新它不再变
function Bad(props) { const { name } = props; }
// 2) 解构 store 顶层:同样是快照
const { count } = state;
// 3) set 同一引用的对象:=== 相等,不触发
obj().n++; setObj(obj());
// 4) 对象数组用了 Index → 重排后行内状态串位
// 5) 原始值数组用了 For → 编辑一项就销毁重建
这些坑在开发时往往「看着对、跑起来错」——因为初次渲染的结果是对的,只有到了第二次更新才暴露。别只截首屏就以为通了,一定要点一下、改一改数据再看。
定位「界面该动没动」的第一反应——把 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:项目与路由

文件路由、嵌套布局、导航——全栈元框架 SolidStart 的骨架。

SolidStart 是 Solid 官方的全栈元框架(类比 React 的 Next、Vue 的 Nuxt):文件路由、SSR、Server Functions 一套齐活,底层跑在 Vinxi/Nitro 上。版本基准 SolidStart 1.x(本页据 @solidjs/start 1.3.2、@solidjs/router 1.0.0 的类型声明;router 从 0.16 走到 1.0 是稳定版号,query / createAsync / action 这几个数据 API 的签名一字未变)。

项目里几个关键位置

位置作用
src/routes/文件即路由:这个目录下的文件自动变成页面/接口
src/app.tsx应用根组件:用 <Router> 包住 <FileRoutes />
src/entry-server.tsx服务端入口:SSR 时如何把应用渲染成 HTML
src/entry-client.tsx客户端入口:浏览器里如何水合(hydrate)已有 HTML
app.config.ts全站配置(部署 preset、middleware 等,详见 16)

两个入口在忙什么

SolidStart 默认同构——同一份组件先在服务端渲染成 HTML(首屏快、利于 SEO),到浏览器再「水合」接管交互。entry-server 和 entry-client 就是这两端的启动点,脚手架已生成好,多数时候不用动。

导入路径别记混

Router 来自 @solidjs/router,而 FileRoutes 来自 @solidjs/start/router(据 .d.ts:FileRoutes@solidjs/start/router 导出,不在 @solidjs/start 顶层)。两个包分工明确:路由核心在 router,Start 只做「文件 → 路由」的接线。

// src/app.tsx —— 应用根
import { Router } from "@solidjs/router";
import { FileRoutes } from "@solidjs/start/router";
import { Suspense } from "solid-js";

export default function App() {
  return (
    <Router root={(props) => <Suspense>{props.children}</Suspense>}>
      <FileRoutes />   // 自动挂载 src/routes 下所有路由
    </Router>
  );
}
<Router>root包住每个路由的外壳组件(类型是 Component<RouteSectionProps>),它必须渲染 props.children 才能把当前页面显示出来;忘了写 children,页面会一片空白而不报错。root 里通常还放全站共享的导航栏和 <Suspense> 边界。
脚手架:npm init solid@latest 选 SolidStart 模板即可拿到上面这套目录。先把 routes/index.tsx 跑起来看到首页,再往下加路由——别一上来就纠结 entry 文件,那两个默认配置能覆盖绝大多数项目。

src/routes/ 下的文件名直接决定 URL——不用手写路由表。几种命名约定就覆盖了绝大多数场景。

文件名 → 路径 约定

文件匹配的 URL说明
routes/index.tsx/index 对应目录本身
routes/about.tsx/about文件名即路径段
routes/users/[id].tsx/users/123[id] 是动态段,用 useParams().id 取
routes/(marketing)/home.tsx/home(group) 只用于分组,不进 URL
routes/blog/[...slug].tsx/blog/a/b/c[...slug] 是 catch-all,剩余路径全归它

动态段怎么读

动态段的值放在 useParams() 返回的响应式对象里(来自 @solidjs/router,.d.ts 里签名是 useParams<T>(): T)。[id].tsx 里读 params.id;catch-all [...slug]params.slug(是斜杠拼起来的整段)。按属性访问才响应,别解构(和解构 props 同理,04)。

(group) 常被误解

括号目录不出现在 URL 里,它只用来把一批路由归档、或给它们套一个共享布局(见下一张卡)。所以 (app)/dashboard.tsxdashboard.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 参数的通性,但最容易在详情页出错。
想确认某个文件会映射到哪条路径,记两条就够:文件名/目录名 = 路径段方括号 = 变量、圆括号 = 只分组不进 URLindex 代表「目录自己」。拿不准时新建文件跑 npm run dev 直接访问最快。

多个页面共享同一套导航栏/侧边栏时,用嵌套布局:一个「父」路由渲染公共外壳,再用 props.children 把子路由塞进去——切换子页面时外壳不重建。

两种做法

  • 同名文件 + 同名目录routes/users.tsx(父布局)配 routes/users/[id].tsxroutes/users/index.tsx(子页面)。父组件收到的 props.children 就是当前匹配的子路由。
  • (group) 套壳routes/(app).tsx(app)/ 组里所有页面套一层布局,而 URL 不受 (app) 影响。适合「一批页面共享布局但路径互不嵌套」。

布局组件长什么样

就是个普通 Solid 组件,渲染公共结构 + {props.children}。它对应 RouteSectionProps(.d.ts 里含 params / location / data / children),所以布局里也能直接拿到 paramslocationroutes/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> 命中当前路径时自动加 activeClassend 属性控制是否要「精确匹配」,否则 / 会对所有路径都算 active。
  • useNavigate() 返回的 navigate(to, options) 支持 { replace: true }(替换历史而非新增,适合登录后跳转)。
  • useSearchParams() 返回 [params, setParams]params.page 按属性订阅、值都是字符串;setParams({ page: 2 }) 会像导航一样合并进查询串。

只能在路由树内调用

这些 hook只能在 <Router> 之下调用,且要在组件的响应式上下文里读。拿到 locationparams按属性访问才保持响应,解构出来就断了(和 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 是字面量 falseif (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

query + createAsync 取数、"use server" 把函数搬到服务端、路由预加载避免瀑布。

在函数(或整个文件)顶部写一行 "use server",这个函数就只在服务端执行——可以直接碰数据库、读私钥、用服务端专属的库,而你在客户端仍然像调用普通函数一样调用它,类型也完整保留。

它解决什么

前后端之间原本要手写一套 fetch + 接口 + 序列化。"use server" 让打包器把这个函数编译成一次 RPC(远程调用):客户端的调用点被替换成「发请求给服务端跑真身」,你不用写 URL、不用手动 JSON.parse,参数和返回值的 TypeScript 类型直接贯通两端

放哪、怎么标

  • 函数级:函数体第一行 "use server",只这个函数上服务端。
  • 文件级:文件顶部 "use server",该文件所有导出函数都是服务端函数(适合集中放数据访问层)。

它既能取数据也能改数据,是 SolidStart 通用的服务端原语——查询用它、15 的变更也用它。

两端的边界

服务端函数体不会进客户端包,所以别在里面假设有 windowdocument;反过来,客户端能拿到的只有它的返回值,函数体里的私钥、数据库连接不会泄露到浏览器。参数会被序列化送到服务端,因此参数要能被结构化克隆(普通对象、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/startGET 包一层(.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() 读值,加载中/出错交给 SuspenseErrorBoundary

签名(据 .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/routercreateAsync.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;。也就是说 cachequery 指向同一个东西、签名完全一样,只是 cache 被标了 @deprecated——IDE 里会给它划删除线。

为什么改名

这是 SolidStart 数据 API 收敛过程里的更名。功能没变(缓存化取数、name 必填、同 key 去重都一样),只是名字从 cache 换成了更贴切的 query(它表达的是「一次可缓存的读」)。这正是和老教程最容易对不上的一处:看到 cache 别当成另一个 API,它就是 query。顺带一提,这些数据 API 都在 @solidjs/router 而非 @solidjs/start——querycreateAsyncaction 都从 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 与表单变更

action 做数据变更、useSubmission 跟踪状态、表单渐进增强。

读数据用 14 的 querycreateAsync,写数据(新建、删除、点赞这类会改后端的操作)在 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,页面上依赖那份数据的地方会自己更新,你不用手动去刷新列表。
  • 想精确控制刷新范围时,服务端函数里可以返回 reloadredirectjson(都来自 @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 后,你会想知道「正在提交吗」「成功了吗」「报错了吗」——这三个状态由 useSubmissionuseSubmissions 提供,都来自 @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——它的 pendingresulterror 都是 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={...}>
命令式调用一样享受变更后的自动重验;想读这次调用的 pendingerror,照旧对同一个 removeTodouseSubmissionuseSubmissions 即可,两者说的是同一批提交。

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 的重验链路。要传参就用 .withFormData
// ✅ 直接接 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、app.config.ts 与部署 preset。

组件内部取数用 server function(见 14),但有时你要的是一个对外的 HTTP 端点——给第三方 webhook 回调、给移动端当接口、下载文件。SolidStart 里这靠 API 路由:在 routes/ 下的文件里,导出以 HTTP 方法命名的函数即可。

约定

  • 文件放 routes/api/(路径就是 URL,routes/api/posts.ts/api/posts),惯例上 API 都归到 api/ 下,和页面路由分开。
  • 导出的是大写的方法名函数GETPOSTPUTPATCHDELETE——不是 default 导出的组件。一个文件可以同时导出多个方法。

处理函数收到 APIEvent

  • 类型是 APIEvent(来自 @solidjs/start/server),它 extends FetchEvent,在其基础上多了 params(动态路由段,如 routes/api/posts/[id].ts 里的 event.params.id)。
  • FetchEvent 继承来的字段有 request(标准 Request)、responselocalsnativeEvent——下面几张卡会用到 locals

返回什么

  • 可以直接返回一个标准 Response,或用 @solidjs/routerjson(data, init?) 快速返回 JSON,redirectreload 同样能用。
// 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 路由认的是具名的大写方法函数。反过来,页面组件文件里也别去导出 GETPOST,两种角色不要混在同一文件。
API 路由是给「HTTP 边界外」的调用方用的——webhook、别的服务、命令行。如果只是你自己的组件要读写数据,别绕一圈发 fetch,直接用 server function + queryaction(14、15)更省事、还带类型和缓存。

有些事得在每个请求碰到路由、server function、API 之前统一做:验 session、记日志、按条件重定向。这就是 middleware,用 @solidjs/start/middlewarecreateMiddleware 定义。

两个钩子

钩子时机签名
onRequest请求进入、处理之前(event: FetchEvent) => Response | void | Promise
onBeforeResponse响应发出之前(event, response) => …,可读改将要发出的响应

两个字段都接一个函数或函数数组,数组按顺序跑,方便把鉴权、日志拆成几段。

onRequest 能做两件关键事

  • event.locals 上挂东西(下一张卡专讲),供后续 server function/API 读取。
  • 直接 return 一个 Response提前短路——比如没登录就返回一个 redirect,请求根本到不了受保护的路由。

注册

  • 定义好之后要在 app.config.tsmiddleware 字段登记,值是指向入口文件的字符串路径(不是把对象 import 进来传值)。
  • 入口文件用 export default createMiddleware({...}) 默认导出,SolidStart 按你给的路径去找这个默认导出。

用数组拆成几段

  • onRequest 传数组时,里面的函数按顺序依次跑,前一个往 locals 挂的东西后一个能读到——适合把「解析 session、鉴权、日志」拆成独立小函数,各管一件事。
  • 任意一个函数 returnResponse,就提前结束、后面的不再跑,请求也到不了路由。
// 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.tsmiddleware 要的是文件路径字符串,不是 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 的 onRequestevent.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 被扩展成了带 requestlocalsnativeEvent 等字段(即 FetchEvent 那套)。

典型用法

  • 在 server function 顶部拿到 event,读 event.request.headers 里的 cookie、UA,或读 event.locals.user 做鉴权。
  • 因为返回可能是 undefined(不在请求上下文里时),用之前先判空,别直接解构。

它和 API 路由的 event 什么关系

  • API 路由的处理函数是直接收APIEvent 参数的(上一类卡片那样 GET(event));server function 没有这个参数,才需要 getRequestEvent() 主动去取。
  • 两者拿到的是同一套请求上下文——requestlocalsnativeEvent 都在,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/configdefineConfig 写。前面的 middleware 注册就在这里,部署目标也在这里。

常用字段

字段作用
server.preset部署目标预设(底层是 Nitro),换一个词就换一个平台
middleware中间件入口文件路径
ssr是否服务端渲染

部署:换 preset 就换平台

  • SolidStart 构建产物由 Nitro 生成,切换部署平台通常只是改 server.preset"node""vercel""netlify""cloudflare"、静态导出等,业务代码基本不动。
  • 开发/构建/预览走脚手架给的脚本(npm run devbuildstart)。

为什么切平台这么轻

  • SolidStart 把「怎么跑」这层交给了 Nitro:你的业务代码只面对标准的 RequestResponseevent,具体是 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 相关选项要通过 defineConfigvite 字段透传,直接放一个独立 vite.config.ts 不会被 SolidStart 读到。
如果你只想先跑起来、部署到自己的服务器,preset: "node" 构建出一个标准 Node 服务是最省心的起点;等要上 Serverless/边缘平台,再换对应 preset,配置层的改动通常就这一行。

从这里到精通:路线图

接下来动手做什么、读什么、怎么自测。

知识点铺完了,从「看懂」到「会写」之间隔着的是几个亲手做完的项目。Solid 的反直觉不是靠读消化的,是靠踩过一次「解构 props 怎么不更新了」记住的。

动手项目(难度递进)

  • Todo 应用createSignal + <For> + 受控输入,支持增删改、过滤、localStorage 持久化。把 02 的信号和 05 的列表跑通。
  • 多页数据站@solidjs/router + createResource,做一个带路由、搜索、详情页的电影/图书浏览站,用 <Suspense><ErrorBoundary> 处理加载与错误(08、13)。
  • SolidStart 全栈项目:文件路由 + server function + action + middleware 鉴权,做一个含 SSR、表单提交、登录的博客或留言板(14、15、16),最后按 preset 部署上线。
  • 造轮子:亲手写一个几十行的迷你信号库(signal/effect/memo,含依赖收集与清理),对照 Solid 源码,能讲清一次 set 如何精确触达 DOM。

官方资料

  • 入门:solidjs.com 的交互式 Tutorial,边写边过信号、控制流、Store。
  • 参考:docs.solidjs.com 官方文档,SolidStart 部分在 docs.solidjs.com 的 solid-start 章节。
  • 玩:官方 Playground 可以直接贴代码看编译产物,验证「组件只跑一次」这类心智特别直观。
  • 原理:Ryan Carniato(作者)的博文与直播,是细粒度响应式的第一手资料;社区看 Solid Discord 和 solid-primitives 仓库。

把本页当索引

  • 写项目时忘了某个 API 的确切用法,回到对应章的速查卡查一眼比翻文档快:响应原语看 02 和 03,状态结构看 06,全栈看 13 起的几章。
// 一条最小的全栈闭环,串起后面几章要做的事:
// 1) 读:query 缓存化取数 + createAsync 非阻塞消费
const getTodos = query(async () => {
  "use server"; return db.todo.findMany();
}, "todos");

// 2) 写:action 变更,接到渐进增强的 form
const addTodo = action(async (fd: FormData) => {
  "use server"; await db.todo.create({ data: { text: fd.get("text") } });
}, "addTodo");

// 3) 用:读自动更新、写自动重验——这就是你要做的 Todo 项目的核心
学习路径上最常见的误区:跳过 02 的心智直接上 SolidStart,结果全栈那套「为什么不能 await 组件」「为什么不能解构 props」全成了玄学。顺序是先吃透信号与控制流,再上元框架——地基不牢,上层全是似懂非懂。
别贪多。选一个项目从头做到能部署,比把四个都开个头强得多。做②③时刻意回扣一条主线:每处更新,问自己「这是信号驱动的细粒度更新,还是我不小心又按 React 的重渲染在想」。

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

响应式心智(02、04)

  • 为什么组件函数只执行一次?这一次里到底发生了什么,之后靠什么更新 DOM?
  • 为什么不能解构 props/不能提前把 props.x 存成变量?解构那一刻丢了什么?
  • 信号为什么读取要加括号 count()?把 count(不调用)传下去会怎样?

控制流与状态(05、06)

  • <For><Index> 差别在哪?什么数据该用哪个,选错会出什么问题?
  • 为什么用 <Show><For> 而不是三元和 .map()
  • 信号里放对象、直接改属性再 set 回去为什么不触发?createStore 凭什么能细粒度更新?

异步与全栈(08、14、15)

  • querycreateAsync 是什么关系?为什么 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 为什么要这样」,答得上来才是真懂,不是背下来。