Svelte 5 与 SvelteKit 完整知识体系交互讲解

全景:Svelte 的定位与现状

钻进 runes 之前先回答三个问题:编译时框架到底编译出了什么、生态走到了哪里、四大框架怎么选。

Svelte 的立身之本:它是编译器,不是运行时库——组件在构建期被编译成精简的命令式 DOM 代码,浏览器里没有虚拟 DOM、几乎没有框架运行时。

心智模型一句话

  • 编译时框架 + runes 信号$state / $derived / $effect 声明细粒度信号,编译器据此生成「哪个值变了就更新哪个节点」的代码;
  • 写起来像普通变量赋值,跑起来是精准更新——因此 Svelte 没有「重渲染」概念,也不需要 memo 类优化

和另外三家怎么选

  • 本页赢在产物极小、运行时开销近零、代码量最少,输在生态与招聘体量;
  • React 生态与人才池最大,大团队长周期选它最稳;Vue 渐进式、文档中文友好;Solid 心智与 Svelte 最近但生态更小;
  • 重视包体积与首屏的内容型站点、中小应用、个人项目,选 Svelte。
<!-- Svelte:组件函数只跑一次,编译器把赋值变成精准更新 -->
<script>
  let count = $state(0);
  console.log("这行只打印一次,点多少下都不会再打");
</script>

<button onclick={() => count++}>
  点击 {count} 次
</button>

// ── 同样的东西在 React 里 ──
function Counter() {
  const [count, setCount] = useState(0);
  console.log("这行每点一次就打印一次");   // 函数整个重跑
  return <button onClick={() => setCount(c => c + 1)}>
    点击 {count} 次
  </button>;
}
当心 Svelte 3/4 时代的教程:5 换掉了几乎所有日常写法——export let$props()$:$derived/$effecton:clickonclick<slot> → snippet。搜到的答案先看它是哪一代。
版本基准 Svelte 5.56 + SvelteKit 2.70。先把 runes 三件套吃透再进模板与 SvelteKit——Svelte 的一切都建立在「编译器在读写处插入依赖追踪」这一条上。

React 的心智模型是「状态变了就把组件函数重跑一遍,再想办法少做无用功」。Svelte 的组件函数一辈子只执行一次,之后由编译器插好的代码精确更新用到那个值的地方。

「只跑一次」可以当场验证

  • <script> 顶层写一句 console.log("执行了"),挂载后连点三次按钮——控制台只有一行输出,而计数器正常从 0 走到 3
  • 推论:不写 useMemo、不写 useCallback、不做不可变更新,React 那三条肌肉记忆可以直接删掉。
<script>
  let count = $state(0);
  let history = $state([]);

  console.log("组件函数执行了一次");  // 点三次按钮,仍然只打印一行

  // 顶层的一次性计算 —— 不需要 useMemo,因为这行本来就只跑一次
  const rows = [1, 2, 3].map((n) => n * n);

  // 不需要 useCallback:bump 不会被重建,引用天然稳定
  function bump() {
    count += 1;
    history.push(count);          // 直接 push,见 02 章
  }
</script>

<p>{count} | {history.join(",")} | {rows.join(",")}</p>
<button onclick={bump}>加一</button>

<!-- 初始 "0 |  | 1,4,9";点三次后 "3 | 1,2,3 | 1,4,9"
     rows 从头到尾没被重算过,控制台也只有一行日志 -->
「组件函数只跑一次」有个直接推论:你不能靠「重跑」来重置组件内部状态。给同一个组件换一份 props,它内部的 $state 不会回到初始值——要真正重置得靠 {#key} 逼它重新创建
从 React 转过来,先把三条肌肉记忆直接删掉:不写 useMemo(用 $derived)、不写 useCallback(函数不会被重建)、不做不可变更新($state 深层响应,直接改属性)。口诀:凡是为了「避免重新渲染」而写的代码都可以删

上手:跑通第一个 Svelte 应用

先把东西跑起来。建项目、看懂单文件组件的三段结构、知道编译器替你做了什么,以及配齐开发工具。

2026 年建 Svelte 项目只有一条官方路径:npx sv create(老教程里的 npm create svelte@latest 已被它接管)。但敲命令之前先决定一件事。

两条路,选一条

  • 要做网站 → npx sv create:产出的一定是 SvelteKit 项目src/routes/+page.svelte、依赖里的 @sveltejs/kit),模板列表里没有「纯 Svelte」这一项;
  • 只想练语法 → npm create vite@latest -- --template svelte:裸 Vite + Svelte,没有路由、没有 SSR,一个 App.svelte 就是全部。
# ① 要做网站 —— 官方脚手架 sv(不是老教程里的 npm create svelte)
npx sv@latest create my-app        # 交互式:模板 / 类型 / 附加件 / 包管理器

# 想跳过所有提问,这一行可直接跑通
npx sv@latest create --template minimal --types ts --no-add-ons --no-install my-app
cd my-app && npm i && npm run dev

# 产出的是 SvelteKit 项目:
#   src/app.html                 页面外壳,缺了直接构建失败
#   src/routes/+page.svelte      首页(还有 +layout.svelte 根布局)
#   svelte.config.js             runes 除 node_modules 外一律开

# ② 只想学语法 —— 裸 Vite + Svelte
npm create vite@latest my-app -- --template svelte-ts
# 产出 src/main.ts + src/App.svelte,没有 routes/;main.ts 有效代码就三行:
#   import { mount } from "svelte";  import App from "./App.svelte";
#   mount(App, { target: document.getElementById("app") });
冲着「学个 Svelte 语法」来的人常在这里被挡住:sv create 造出来的一定是 SvelteKit,连 minimal 模板都带 src/routes/。被路由约定绕住之后很容易误以为是 Svelte 复杂——它不复杂,是你多装了一个框架。
sv 不止能 create,入口只有这一个命令:npx sv add tailwindcss已有项目里加附加件(自动改配置,不用照文档手拼),npx sv migrate svelte-5 把 Svelte 4 代码批量改写成 runes。

一个 .svelte 文件就是一个组件——没有注册、没有 export default,文件名就是组件名。里面最多三段:<script>、模板、<style>

三段分别是什么

  • <script>:组件实例的初始化代码,每个实例执行一次——不是每次更新执行一次。这是 Svelte 和 React 最大的分水岭
  • 模板:没有包裹标签的要求,写几个根元素都行;
  • <style>默认只作用于本组件,不会漏到别处。

样式隔离是怎么做到的

  • 不是 shadow DOM,也不是运行时注入,就是编译期改写选择器.hi 产出 .hi.svelte-1lj1c2h,DOM 上多一个哈希 class;
  • 代价是选不中子组件内部的元素——那正是它要防的事,真要穿透得用 :global()
<script>
  // 组件实例的初始化代码,每个实例只跑一次
  let name = $state("世界");
  let greeting = $derived(`你好,${name}!`);
</script>

<!-- 模板:script 顶层的 name / greeting 直接可见,不用 return -->
<input bind:value={name} />
<h1 class="hi">{greeting}</h1>

<style>
  /* 只作用于本组件,不需要任何配置 */
  .hi { color: rebeccapurple; }
</style>

<!-- 渲染结果(注意 input 上没有哈希 class,因为没选择器命中它):
     <input> <h1 class="hi svelte-n50uah">你好,世界!</h1>
     产出的 CSS:.hi.svelte-n50uah { color: rebeccapurple; } -->
<<script> 顶层的 let 在 Svelte 5 里不再自动响应,要写成 $state(0)——这是从 3/4 过来最容易踩的一脚,而且编译器只给一条警告、代码照样编译照样跑。警告原文与整张新旧写法对照表在 02 章。
导入组件时首字母必须大写import Card from "./Card.svelte",模板里写 <Card />。编译器就是靠首字母大小写区分「组件」和「HTML 标签」的,写成 <card></card> 会被当成未知元素原样输出,而且一点提示都没有

Svelte 的工具链很薄,值得先花五分钟配齐;然后是三条几乎人人都会撞一次的报错。

必装的两样

  • VS Code 扩展 svelte.svelte-vscode:没有它,.svelte 里没有补全、没有跳转;
  • svelte-check(脚手架已写进 npm run check):模板里的类型错误只有它查得出来。

三条高频报错

  • $state(...) can only be used as a variable declaration initializer——$state 不是普通函数,只能出现在声明位置
  • Cannot use export let in runes mode——Svelte 5 里改用 $props()
  • $state 变量传进函数会丢响应性——传的是当时的值,不是那个信号。
# ── 日常三条命令 ──
npm run dev      # vite dev,按需编译,不做全量类型检查
npm run check    # svelte-check,CI 必须跑这一条
npm run build

# ── 干净项目上跑 svelte-check,输出 ──
# svelte-check found 0 errors and 0 warnings

# ── 故意写错后,输出原文 ──
# src\lib\Bad.svelte:3:3
# Error: Type 'string' is not assignable to type 'number'. (ts)
# src\lib\Bad.svelte:6:5
# Error: Cannot find name 'titel'. Did you mean 'title'? (ts)
# ====================================
# svelte-check found 2 errors and 0 warnings in 1 file

// ── 调试用 $inspect:仅 dev 编译下存在 ──
let n = $state(0);
$inspect(n);                                  // 值一变就打印
$inspect(n).with((type, v) => console.log(type, v));
// 挂载时 init 0;n++ 之后 update 1
真正难查的是警告——编译照样通过,页面照样跑。最该认识的一条是把 $state 变量当普通值传进函数:响应性就在这一步断掉,页面从此不再更新,而控制台只给一条容易被忽略的黄字。
报错信息末尾那个 https://svelte.dev/e/错误码 是完整可用的文档地址,不是装饰:看到 state_invalid_placement 就去 svelte.dev/e/state_invalid_placement,那页会直接告诉你合法位置有哪几种。警告也走同一个 /e/ 路径,不用去猜别的地址。这套约定省掉了绝大多数搜索。

$state 与响应式基础

$state 不是「把值存起来」,而是让编译器在读写这个变量的地方插入依赖追踪。深层响应、raw 与旧写法的对应关系都在这一章。

$state(0) 不是一个函数调用。编译产物里根本不存在 $state 这个名字——编译器把它连同所有读它、写它的位置一起改写掉了。理解这一点,后面所有「为什么 Svelte 能这么写」的问题就都是推论了。

源码与产物的逐项对照

下面每一行都是 compile(src, { generate: "client" }).js.code 打出来的。

你写的编译成说明
let n = $state(0)let n = $.state(0)会被重新赋值的原始值 → 一个信号
n++$.update(n)写入处被改写成「更新信号并通知订阅者」
模板里的 {n}$.get(n)读取处被改写成「取值并登记依赖」
let o = $state({a:1})(从不重新赋值)let o = $.proxy({a:1})连信号都省了,只包一层 proxy
let r = $state.raw({a:1})(从不重新赋值)let r = { a: 1 }什么都没生成,就是个普通对象字面量

后两行尤其能说明问题:$state 不是一层固定的包装,编译器会看你在整个组件里怎么用这个变量,再决定生成什么。一个从不被重新赋值、也从不被读进响应式上下文的变量,可以什么运行时代码都不产生。

为什么这活儿非编译器不可

  • 假设 state() 是个普通函数。它拿到的只有,拿不到「谁在读它」——JavaScript 没有任何办法让一个函数感知到它的返回值后来被谁用了;
  • 于是普通函数只能返回一个盒子,让你写 n.value++。Vue 的 ref 就是这条路,那个 .value 是躲不掉的税;
  • Svelte 选择让编译器改写:你写 n++,产物里是 $.update(n)盒子还在,只是编译器替你写了拆装箱的代码
  • 代价立刻就来了:编译器必须能静态认出哪一个变量是状态。所以 $state(…) 只能出现在变量声明的初始化位置、类字段声明、或构造函数里对类字段的首次赋值——放在别处直接编译失败,报 state_invalid_placement

关键在读取处,不在写入处

  • $.get(n) 不只是取值。它顺便把「当前正在执行的那个 effect」登记为 n 的订阅者——这就是「不用写依赖数组」的全部秘密;
  • 直接推论:只有在响应式上下文里读到的 $state 才建立依赖。响应式上下文只有三处——模板、$derived$effect(见 03 章);
  • 在一个普通函数里读 n,什么依赖都不会建立,它就是一次普通的取值;
  • 反过来说,这也解释了为什么模板里那一行 {count} 一删,产物里对应的整条更新语句就跟着消失——没人读,就没人需要被通知。
<script>
  let n = $state(0);              // → let n = $.state(0)
  let o = $state({ a: 1 });       // → let o = $.proxy({ a: 1 })
  let r = $state.raw({ a: 1 });   // → let r = { a: 1 }(什么都没生成)

  function bump() {
    n++;                          // → $.update(n):写入处被改写
    o.a++;                        // → o.a++ 原样保留,靠 proxy 的 set 陷阱
  }

  // ❌ 下面三种写法都过不了编译:state_invalid_placement
  // let x; x = $state(0);            $state 不能出现在普通赋值右边
  // const y = [$state(0)];          不能出现在数组/对象字面量里
  // function f() { return $state(0); }   不能当返回值
</script>

<!-- 模板是响应式上下文:这里的 n 编译成 $.get(n),读的同时登记依赖 -->
<p>{n} {o.a} {r.a}</p>
<button onclick={bump}>加一</button>
如果运行时报 ReferenceError: $state is not defined(报错原文),八成不是拼错,而是文件后缀不对——普通 .js 里的 runes 编译器完全不处理,原样输出,跑到浏览器里当然找不到这个全局变量。改名成 .svelte.js 即可。另一个近亲错误:const count = $state(0) 之后想 count++,报 [constant_assignment] Cannot assign to constant——$state 要配 let,除非你确定只改它的属性、从不整体替换。
runes 不需要 import——它们是编译器认识的记号,不是从 svelte 包里导出的函数。所以判断「这里能不能用 $state」的标准不是「导入了没有」,而是「这个文件编译器管不管」.svelte 管,.svelte.js.svelte.ts 管,普通 .js 不管。想抽一段响应式逻辑出去复用,第一件事是把文件名改成 xxx.svelte.js

$state 传一个对象或数组,你拿到的是一个 Proxy。于是「改动」的触发点从 Svelte 3/4 的赋值语句变成了属性写入——arr.push(99)obj.a.b++ 全都会更新 DOM,不需要重新赋值。这一条改变了你写 Svelte 的几乎所有代码。

三个状态的实际表现

同一个组件里三个状态,逐次点击后的 DOM:

操作arr 显示obj.a.b 显示
初始 $state([1,2,3]) / $state({a:{b:0}})1,2,30
arr.push(99)1,2,3,990
obj.a.b++1,2,3,991

注意第三行:obj.a嵌套对象,改它的属性一样能穿透到 DOM。不需要 obj = {...obj},不需要 obj.a = {...obj.a},什么都不需要。

Proxy 是怎么做到「深层」的

  • 编译器给对象型 $state 生成的是 $.proxy(…)(产物)。这层 proxy 拦截属性的读和写:写的时候通知订阅者,读的时候登记依赖;
  • 深层是惰性的:初始化时并不会递归包装整棵树。只有当你真的读到 obj.a 时,a 那个对象才被包成 proxy。所以「深层响应」不等于「初始化时遍历一遍」,大对象的初始化成本并不高;
  • 依赖粒度是属性级的:只读了 obj.a.b 的地方,改 obj.x 不会惊动它;
  • proxy 对普通代码是透明的。Array.isArray(obj.list) 仍是 trueObject.prototype.toString.call(obj) 仍是 [object Object]JSON.stringify(obj) 正常输出 {"a":1}

和 Svelte 3/4 的根本差别

  • 3/4 里响应式的触发点是赋值语句:编译器在每个 x = … 后面插一句 $$invalidatearr.push(1) 里没有赋值,所以不更新
  • 于是老教程教你写 arr = [...arr, 1],或者更难看的 arr.push(1); arr = arr;——那句自赋值的唯一作用就是骗编译器插一次 $$invalidate
  • 在 Svelte 5 里这两种写法都还能跑,但都是多余的。看到 arr = arr 就知道这是 4 时代的代码;
  • 顺带纠正一个从 React 带过来的习惯:不要为了「不可变」而复制对象[...arr, x] 在这里既没有性能收益,还会让整个数组的订阅者全部失效,反而不如 arr.push(x) 精确。

代价

  • 每次读嵌套属性都要过一层 proxy 陷阱。绝大多数场合这点开销可以忽略,但几千上万个元素的数组在热路径上反复读是能测出来的——那时候换下一张卡的 $state.raw
  • proxy 不是原对象,交给不认识 proxy 的外部 API 会出事。structuredClone(obj) 直接抛 DOMException: #<Object> could not be cloned.
  • 解法是 $state.snapshot(obj),它把代理背后的普通对象深拷出来。structuredClone($state.snapshot(obj)) 正常通过,且数组仍然是数组。往 IndexedDBpostMessage、第三方图表库里塞数据之前都该过这一道。

唯一会当场丢掉响应性的动作:解构

  • 解构 $state 对象 = 把值拷出来let box = $state({ v: 0 }); let { v } = box; 之后再 box.v++,页面上的 v 一直停在 0——因为解构那一刻 v 就变成了一个普通变量,和 proxy 再无关系。
  • 同理,把属性传给函数、赋给中间变量、放进普通数组,都是同一个动作:一旦「读出来存起来」,就脱离了追踪。要保持响应,就一路带着那个对象走(box.v),别把 v 单拎出来。
  • 但解构 $props() 完全没问题——let { n } = $props() 是官方推荐写法,编译器会把它编译成 getter,父组件改值时子组件跟得上(0 → 1 → 2)。这两件事经常被混为一谈,记住区别:解构 props 安全,解构 state 危险
<script>
  let items = $state([{ name: "键盘", price: 300 }]);
  const TAX = 0.08;                       // 不会变的东西不放 $state
  let total = $derived(
    items.reduce((s, i) => s + i.price, 0) * (1 + TAX)
  );

  function add() {
    items.push({ name: "鼠标", price: 120 });  // ✅ push 直接生效
  }
  function rename() {
    items[0].name = "机械键盘";             // ✅ 改嵌套属性也生效
  }
  // ❌ Svelte 3/4 的写法,5 里纯属多余:
  //    items = [...items, x];   items.push(x); items = items;

  function save() {
    // proxy 不能直接结构化克隆,先取快照
    postMessage($state.snapshot(items));
  }
</script>

<p>{items.map((i) => i.name).join("、")} | {total.toFixed(2)}</p>
<button onclick={add}></button>
<!-- 初始 "键盘 | 324.00";点「加」后 "键盘、鼠标 | 453.60" -->
$state 对象丢给不认识 Proxy 的东西会当场出错。structuredClone(obj)DOMException: #<Object> could not be cloned.postMessageIndexedDB.put、Web Worker 传参走的都是同一套结构化克隆算法,会报同样的错。解法是 $state.snapshot(obj)。麻烦的是 JSON.stringify 反而正常(输出 {"a":1}),于是很多人靠它测通了就以为万事大吉,直到某天换成 postMessage 才炸。
判断口诀:想改什么就直接改什么。todo.done = !todo.donelist.splice(i, 1)obj.a.b.c = 1 全都对。真正需要整体替换的只剩一种情况——你手上根本没有原来那个对象,比如接口刚返回一批新数据,那就 items = await res.json()。除此之外,凡是「为了触发更新」而写的赋值都可以删掉。

$state.raw 常被讲成「不可变的状态,必须整体替换才能改」。这个说法会误导你——它根本没拦着你改。属性照改不误,改完也确实写进去了,只是没有人通知 UI。这个「静默成功」比「改不动」危险得多,而且有一个实验能一眼看穿它。

那个决定性的实验

let raw = $state.raw({ n: 0 }),模板里显示 {raw.n}

依次执行DOM 显示
初始0
raw.n++0
raw.n++(第二次)0
raw = { n: raw.n + 1 }3

最后一行是全部的关键。如果前两次 raw.n++ 真的「没改动」,那么此刻 raw.n 应该还是 0,替换后该显示 1。它显示 3,说明底层对象早就已经是 2 了——那两次自增完全成功,只是没有触发任何更新。

换句话说,$state.raw 的语义不是「只读」,而是「读写都随你,但只有整体替换这个变量时我才通知 UI」

机制:它其实什么都没做

  • 编译产物:一个从不被重新赋值$state.raw({a:1}),编译成 let r = { a: 1 }——一个光秃秃的对象字面量,零运行时开销
  • 如果它被重新赋值,编译成 let r = $.state({ a: 1 })——注意是 $.state不是 $.proxy。信号还在,proxy 没了;
  • 所以「静默成功」是这个设计的必然:信号盯的是变量绑定,而属性写入根本不经过变量绑定,没有任何东西能观察到它;
  • 普通 $state 之所以能观察到,靠的正是那层被去掉的 proxy。

什么时候该主动用 raw

  • 大数组/大对象,且你本来就整体替换:接口拉回来的列表、图表的数据集。这类数据的更新方式天然就是「换一批」,proxy 只是白白给每次读加一层开销;
  • 外部库的实例monaco.editor、图表实例、IntersectionObserver、第三方 SDK 返回的对象。被 proxy 包过之后,库内部的 this 绑定和 === 身份比较都可能出错——这类东西放进普通 $state 是实打实的 bug 源
  • 本来就不可变的数据结构:immer 产出的结果、immutable.js 的集合,它们的更新方式就是替换;
  • 判断口诀:翻一遍你操作这个变量的所有代码,如果一次 x.a = … 都没有,全是 x = …,那就该用 raw。反过来只要有一处改属性,就老老实实用普通 $state

这是最难查的一类 bug

普通 $state 忘写会有 non_reactive_update 警告兜着(见 01 章),$state.raw 用错什么提示都没有:编译通过、无警告、无报错、代码逻辑正确、数据也确实变了,就是屏幕不动。更坑的是它可能间歇性「好了」——别的地方碰巧整体替换了这个变量,累积的修改会一次性全冒出来,正如上面里那个从 0 直接跳到 3 的数字。查这类问题的第一个动作:$state.raw 临时改成 $state,如果好了,原因就找到了。

<script>
  let raw = $state.raw({ n: 0 });

  function mutate() { raw.n++; }              // 改到了,但 UI 不知道
  function replace() { raw = { n: raw.n + 1 }; } // 整体替换才通知

  // —— 该用 raw 的两个典型场景 ——
  let rows = $state.raw([]);              // 上万条,只整体换
  async function reload() {
    rows = await (await fetch("/api/rows")).json();
  }

  let chart = $state.raw(null);           // 外部库实例,绝不能被 proxy 包
</script>

<p>{raw.n} | {rows.length} 行</p>
<button onclick={mutate}>改属性</button>
<button onclick={replace}>整体替换</button>

<!-- 点击顺序与 DOM:
     初始 0 → 改属性 0 → 改属性 0 → 整体替换 3
     跳到 3 而不是 1,证明那两次 ++ 确实写进去了 -->
别把 $state.raw 当「只读」或「常量」用。它不会阻止任何写入,也不会报错。真实事故长这样:某人用 $state.raw 存一份配置对象「因为它不会变」,半年后另一个人加了一行 config.theme = "dark",代码看着完全正常、单测也过(数据确实改了),只有页面始终不刷新。再叠加上面的那种「累积后一次性冒出来」的表现,这个 bug 能查一整天。
先默认用 $state,量出问题了再换 $state.raw这是少数几个「先写对再优化」比「一步到位」明显划算的地方——写错方向的代价(一个静默不更新的 UI)远大于一层 proxy 的开销。唯一的例外是外部库实例:那不是性能优化,是正确性要求,第一天就该用 $state.raw 包。

你在网上找到的多数 Svelte 教程写的是 3/4:顶层 let 自动响应、$: 声明派生、export let 收 prop。在 runes 模式下这些写法不是「不推荐」,是直接编译失败。这张卡的目的只有一个——帮你把旧教程读懂并翻译过来。

新旧对应表(错误码与警告码均为)

Svelte 3/4Svelte 5 runes在 runes 模式下写旧语法会怎样
let count = 0let count = $state(0)能编译,但不响应;改了 DOM 不动,只给一条 non_reactive_update 警告
$: doubled = count * 2let doubled = $derived(count * 2)编译失败 legacy_reactive_statement_invalid
$: console.log(count)$effect(() => console.log(count))同上
export let namelet { name } = $props()编译失败 legacy_export_invalid
on:click={fn}onclick={fn}能编译,弃用警告 event_directive_deprecated
<slot />{@render children()}能编译,弃用警告 slot_element_deprecated(见 06 章)
writable(0) + $store多数场合改用 $state仍然可用、未废弃,但跨文件状态有更好的写法(见 08 章)

为什么官方非换不可

不是为了好看,是旧写法有两个结构性的缺陷,补不上。

  • 旧的顶层 let 只在 .svelte 组件顶层有效。想把一段响应式逻辑抽成函数,在几个组件间复用?做不到——一进函数体它就退化成普通变量了。runes 是真正的表达式,.svelte.js 文件里、类字段里、嵌套函数里都能用,响应式逻辑第一次变得可以打包带走(见 08 章);
  • $: 的依赖是编译期出来的。编译器扫这条语句的文本,看里面出现了哪些顶层变量名。跨函数调用猜不到、只在某个分支里才读到的变量会误判、多条 $: 之间还得靠拓扑排序决定执行顺序。$derived 的依赖是运行时读到才记的:读了什么就依赖什么,一个都不多一个都不少;
  • 还有一个致命的歧义:$: 既能声明派生值又能跑副作用,两者的执行时机完全不同,写法却一模一样。runes 把它拆成了语义清晰的 $derived$effect(见 03 章)。

迁移与混用规则

  • npx sv migrate svelte-5 能自动改掉一大半——export let$:on: 这些机械转换它做得很好,需要人判断的(比如某个 $: 到底是派生还是副作用)它会留注释;
  • 混用规则:以文件为单位。一个文件里只要出现任何一个 rune,这个文件就进入 runes 模式,旧语法立刻报错。不能在同一个组件里新旧各写一半;
  • sv create 生成的 vite.config.ts 里直接写死了 runes: ({ filename }) => filename.split(/[/\\]/).includes('node_modules') ? undefined : true——你自己的代码强制 runes 模式,node_modules 里的第三方组件不强制。这就是新项目里第三方旧组件还能用的原因,也说明官方认为你自己的代码没有理由再写旧语法。
// ══ Svelte 3/4(老教程里的写法)══
<script>
  export let name;                 // 收 prop
  let count = 0;                  // 顶层 let 自动响应
  $: doubled = count * 2;         // 派生(依赖靠编译期猜)
  $: console.log(doubled);         // 副作用(写法和上一行一样!)
</script>
<button on:click={() => count++}>{name} {doubled}</button>

// ══ Svelte 5 runes(同样的功能)══
<script>
  let { name } = $props();          // 收 prop,见 05 章
  let count = $state(0);           // 显式声明这是状态
  let doubled = $derived(count * 2); // 派生:依赖运行时记录
  $effect(() => console.log(doubled)); // 副作用:和派生彻底分家
</script>
<button onclick={() => count++}>{name} {doubled}</button>

// 把上面那段旧代码放进 runes 模式,第一条错误是
// [legacy_export_invalid] Cannot use `export let` in runes mode — use $props() instead
最容易被静默坑到的是表格第一行:let count = 0 在 runes 模式下能编译、能跑、就是不更新。它只给一条警告 non_reactive_update: `count` is updated, but is not declared with `$state(...)`. Changing its value will not correctly trigger updates(报错原文)。而这行代码在 Svelte 3/4 里是完全正确的写法——所以照着老教程抄的人不会觉得自己写错了什么,只会觉得「Svelte 坏了」。终端里的黄字别划过去。
读旧教程时的一条翻译规则:看到 $: 先分辨它是「算出一个值」还是「做一件事」。左边有变量名被赋值($: total = a + b)就是 $derived;没有赋值、纯粹在执行语句($: fetchData(id))就是 $effect。分不清的那一小撮,一律先当 $derived 试——Svelte 5 里绝大多数 $effect 都是不该写的,理由见 03 章。

$state 的成本是实打实的:一层 proxy、一份订阅者集合、每处读写都要过一遍插桩。凡是「不会变」或者「变了不需要通知任何人」的东西放进去,只有开销没有收益,还会让读代码的人误以为这里有响应式逻辑。

三类明确不该放的东西

  • 不变的常量:税率、配置项、枚举表、正则。写 const TAX = 0.08 就够了。它出现在 $derived 里也完全没问题——一个从来不变的值,天然满足「依赖没变就不重算」
  • 派生值let total = $state(0) 再找地方手动 total = price * qty,这是从 React 的 useState + useEffect 同步带过来的坏习惯。该用 $derived(见 03 章)。理由不只是「代码短」——手动同步的版本存在一个中间状态price 已经改了、total 还没跟上的那一瞬间,如果这中间有别的代码读到 total,读到的就是旧值。$derived 在语义上不存在这个瞬间;
  • DOM 节点引用bind:this={el} 的那个 el。你不会「因为 DOM 节点这个变量变了而想刷新界面」,普通 let el; 就行——这样写编译无警告

还有一类:外部世界的句柄

  • setInterval 返回的 timer id、AbortController、WebSocket 连接、事件订阅的取消函数——这些你只会「存起来,将来用它清理」,从不显示在界面上;
  • 包进 $state 除了浪费,还有实际风险:外部库实例被 proxy 包过之后,它内部的 this 绑定和 === 身份比较可能出错。真要放进响应式变量(比如你想在模板里判断 {#if chart}),用 $state.raw,见上一张卡;
  • 一句话:能不能被 proxy 包,取决于这个对象是不是「纯数据」

一条判断标准

问自己一句话:这个值变了,屏幕上有东西要跟着变吗?

  • 有 → $state
  • 没有 → 普通 let / const
  • 「有,但它是从别的状态算出来的」→ $derived,别用 $state
  • 「有,但这东西很大且我只整体替换它」→ $state.raw

这四句能覆盖 95% 的场合。剩下 5% 是跨组件共享状态,那是另一个问题(见 08 章)。

反方向的坑:该放却没放

比「放多了」严重得多。let n = 0 在 runes 模式下能编译、能跑,改了就是不更新。所幸编译器会给一条 non_reactive_update 警告——但它的触发条件比你想的窄:要同时满足「被重新赋值」「在模板里被引用」。function bump(){ n++ }onclick={bump}、模板写 {n} —— 照样告警(「在回调里赋值」根本不影响);但只要模板里不读 n、只在 $effect 或普通函数里读它,零告警。那才是真正没人提醒你的情况。养成习惯:写下 let 的那一秒就问一遍上面那句话。

<script>
  // ✅ 常量:不会变,放 $state 纯属浪费
  const TAX_RATE = 0.08;
  const LEVELS = ["低", "中", "高"];

  // ✅ 真正的源头状态
  let price = $state(100);
  let qty = $state(2);

  // ✅ 派生值用 $derived,不要 $state + 手动同步
  let total = $derived(price * qty * (1 + TAX_RATE));
  // ❌ let total = $state(0);  然后到处写 total = price * qty * ...
  //    多一个可能不同步的中间状态,且没人能保证每处都记得改

  // ✅ DOM 引用:不显示在界面上,普通 let 即可(无警告)
  let inputEl;

  // ✅ 外部句柄:只用来清理,永远不该进 $state
  let timer;
  function start() { timer = setInterval(() => qty++, 1000); }
  function stop() { clearInterval(timer); }
</script>

<input bind:this={inputEl} type="number" bind:value={qty} />
<p>合计 {total.toFixed(2)}</p>
bind:this 有个容易搞反的分寸:只在事件回调里用那个节点inputEl.focus())时,普通 let inputEl; 完全正确,编译无警告;但如果你要在模板里读它(比如 {el?.tagName}),就必须写成 let el = $state();——不加会报 non_reactive_update: `el` is updated, but is not declared with `$state(...)`,页面上那处永远是空的。注意告警只认模板里的引用:同样漏写 $state,改成在 $effect 里读 el一条告警都没有(编译器的检查不穿过函数体),那才是真正没人提醒你的情况。区别在于「谁需要知道它被赋值了」。
「屏幕上有东西要跟着变吗」是判断标准,不是「这个值会不会变」。计时器 id 每次 start() 都在变,但它不该是 $state;某个只在控制台打印的调试计数器一直在变,也不该是。反过来,一个一年才改一次的功能开关,只要它一改界面就得跟着变,就该是 $state。看的是「变化要不要传播出去」,不是变化的频率。

$state 能当类字段用,这是把「一坨相关的状态 + 操作它们的方法」打包起来的正规做法。但内置的 MapSetDateURL 不在 proxy 的射程之内——原因很具体,Svelte 也给了很直接的解法。

类字段里的 $state

  • 写法就是 class Counter { value = $state(0); inc() { this.value += 1; } }。挂载后点一下按钮,DOM 从 0 变成 1——响应式跨过了方法调用和 this
  • 编译器把这个字段改写成一对私有信号 + getter/setter。所以 this.value += 1 在类内部、在模板里、在别的文件里,行为完全一致;
  • 这比「一个 $state 对象加一堆散落各处的操作函数」强在两点:方法和数据绑在一起,以及实例可以随便传递——传给子组件、放进 context、导出到别的模块都行(见 08 章);
  • 位置限制和普通 $state 一样:只能出现在字段声明,或者构造函数顶层对该字段的首次赋值,别处报 state_invalid_placement

Map / Set 必须换成 Svelte 版

  • 模板里显示 plainMap.get("a"),点击后执行 plainMap.set("a", 9)——DOM 纹丝不动。同一次里,换成 SvelteMap 的那一份从 1 变成了 9SvelteSet 加元素后从 1 变成 1,7
  • 原因很具体:proxy 拦的是属性的读写,而 Map 的数据存在引擎内部的槽位里,map.set() 是一次方法调用,根本不产生任何属性访问,proxy 没有任何钩子可挂;
  • DateURLURLSearchParams 同理——它们的状态也藏在内部槽位里;
  • 解法就是原地换构造函数:new Map(…)new SvelteMap(…)。API 完全兼容,getsethassize/迭代全都一样。

svelte/reactivity 导出了什么

  • import * as R from "svelte/reactivity" 的导出名共七个:MediaQuerySvelteDateSvelteMapSvelteSetSvelteURLSvelteURLSearchParamscreateSubscriber
  • 另有一个子模块 svelte/reactivity/window,装的是窗口相关的响应式值(尺寸、设备像素比这类)——它必须在浏览器环境导入,在 Node 里直接 import 会因为访问 window 而报错;
  • createSubscriber 是给库作者用的:把一个外部事件源接进信号系统,让它能被 $derived$effect 追踪。写业务代码基本用不到;
  • 没有 SvelteWeakMap、没有 SvelteArray——数组不需要,普通 $state([]) 就是深层响应的。
<script>
  import { SvelteMap, SvelteSet } from "svelte/reactivity";

  // 状态与操作打包在一起,实例可以随便传
  class Counter {
    value = $state(0);              // 类字段里的 rune
    inc() { this.value += 1; }      // 点一下 DOM 从 0 → 1
  }
  const c = new Counter();

  const plain = new Map([["a", 1]]);   // ❌ set 后 DOM 不动
  const smap = new SvelteMap([["a", 1]]); // ✅ set 后 DOM 跟着变
  const sset = new SvelteSet([1]);

  function mutate() {
    plain.set("a", 9);      // Map 的数据在内部槽位里,proxy 拦不到
    smap.set("a", 9);
    sset.add(7);
  }
</script>

<p>{c.value} | {plain.get("a")} | {smap.get("a")} | {[...sset].join(",")}</p>
<button onclick={() => c.inc()}></button>
<button onclick={mutate}>改集合</button>
<!-- 初始 "0 | 1 | 1 | 1",点两个按钮后 "1 | 1 | 9 | 1,7" -->
SvelteMap 只让这个 Map 本身响应,不会把存进去的值变成深层响应的new SvelteMap([["u", { name: "阿黄" }]]),模板显示 m.get("u").name,执行 m.get("u").name = "小黑"DOM 仍是「阿黄」;改成 m.set("u", { name: "小白" }) 才更新。要让值也响应,得自己把值包一层 $state({...}) 再存进去。这个坑的表现和上一张卡的 $state.raw 一模一样——数据改了,屏幕不动,无任何报错
不确定一个内置对象要不要换 Svelte 版,看它的数据存在哪。普通对象和数组的数据就存在属性上,proxy 拦得住,用 $state 即可;MapSetDateURL 的数据存在引擎内部槽位里,必须换成 svelte/reactivity 的对应版本。一个速记:能用 obj.x = 1 改的就归 $state 管,只能用方法改的就得换

$derived 与 $effect

派生值是声明的,副作用是同步外部世界的。搞清楚这两者的分工与时序,就不会写出满屏互相触发的 effect。

$derived(n * 2) 这一行并没有算出任何东西——它把「doubled 永远等于 n 的两倍」这条公式登记给了编译器。真正的乘法要等到有人读 doubled 的那一刻才发生。

「声明」和「计算」差在哪

  • 普通的 let doubled = n * 2计算:等号右边当场求值,结果被钉死,之后 n 怎么变都跟它无关。
  • $derived声明:编译器把括号里的表达式包成一个惰性求值的信号(signal),记下它读过谁,谁变了就把它标记为「脏」。
  • 所以你永远不需要手动同步派生值,也不存在「忘了更新」这种 bug——公式只写了一遍,就只有一个真相来源。
  • 对照 Svelte 3/4:这条取代了 $: doubled = n * 2。老写法靠标签语句和赋值语句的静态分析猜依赖,rune 写法则是显式的一个值。

「按需」到底有多懒

把一个 $derived.by 声明出来但不在模板里读它,然后连续改两次依赖,公式体一次都不会执行;直到第一次读它,才执行一次;紧接着再读一次,用的是缓存,不再执行。

  • 这意味着屏幕上看不见的派生值几乎是免费的——折叠面板里那一大坨统计,没展开就不算。
  • 也意味着公式体必须是纯函数。你没法预测它跑几次、什么时候跑,在里面发请求或改别的状态,行为不可推理。

什么时候换 $derived.by

两者是同一个东西的两种书写形态,不是两种能力:

写法适用要点
$derived(expr)公式是单个表达式九成场合用它;括号里直接写表达式,没有 return
$derived.by(() => {…})需要循环、临时变量、提前 return传的是一个无参函数必须 return,否则派生值恒为 undefined

注意 $derived(() => n * 2) 是个常见误写:它合法,但派生出来的是那个函数本身,模板里渲染出来会是一串函数源码,而不是数字。想传函数就用 .by

派生可以套派生

  • 一个 $derived 完全可以读另一个 $derived,依赖图由编译器自己连,你不用关心求值顺序。
  • 依赖是每次求值时重新收集的,不是固定不变的。$derived(a ? b : c)a 为真时根本不依赖 c——改 c 不会让它变脏。这和 $effect 的追踪规则是同一套(见本章后面那张讲追踪边界的卡)。
<script>
  let n = $state(10);

  // 存的是「公式」,不是这一刻的值
  let doubled = $derived(n * 2);

  // 公式装不进一个表达式,就换 .by
  let sum = $derived.by(() => {
    let s = 0;
    for (let i = 1; i <= n; i++) s += i;
    return s;          // 忘了 return 就恒为 undefined
  });

  // 派生可以读派生,依赖图编译器自己连
  let label = $derived(`${n} → ${doubled}(前 n 项和 ${sum})`);
</script>

<button onclick={() => n++}>n = {n}</button>
<p>{label}</p>

<!-- 挂载「10 → 20(前 n 项和 55)」,点一下变「11 → 22(前 n 项和 66)」-->
$derived.by 的回调忘写 return是新手第一号错误,页面上不会报错,只会安静地渲染出空白(值是 undefined)。另一个近亲错误是把回调直接塞进 $derived$derived(() => n * 2) 完全合法,但它派生出来的是那个函数对象,模板里会渲染成一串函数源码。看到页面上冒出 () => n * 2 字样,就是这里写漏了 .by
判断该用 $derived 还是 $state,只问一句话:这个值能不能由别的值算出来。能,就必须是 $derived;不能,才是 $state。一个页面里 $state 的数量应该少得可怜,绝大多数变量都是派生的——这是把状态管理写简单的第一原则。

直觉会说「派生值是只读的,赋值应该报错」。5.56.7 不报错——但也别高兴太早:那次赋值只是把显示值临时盖住,依赖一变就被公式冲掉。它没有变成可写状态。

验证过程(可照着复现)

声明 let n = $state(10); let d = $derived(n * 2);,然后按顺序操作:

动作nd说明
挂载1020公式算出来的
n++1122正常跟随
d = 99911999不抛错,页面立刻显示 999
n++1224999 蒸发,回到 n * 2

关键看最后一行:结果是 24,不是 1000。说明 999 从来没有真正进入这条派生链,它只是坐在公式结果的上面,等着被下一次重算推下去。

为什么机制上必然如此

  • 派生信号内部存着「公式」和「上次算出的值」两样东西。给它赋值,改掉的只有后者这个缓存槽,公式原封不动。
  • 依赖一变,信号被标记为脏;下次读取时公式重跑,算出的新值把缓存槽整个覆盖——你写进去的 999 自然就没了。
  • 所以准确的说法是「临时覆盖显示值」,而不是「$derived 变成了可写状态」。这两句话听起来像,但推导出的后续行为完全相反。

唯一站得住脚的用途:乐观 UI

  • 官方给这个口子留的正当用途是乐观更新:点赞数本来是从服务端数据派生的,点击瞬间先手动写上 likes + 1 让 UI 立刻响应,等真实数据回来,派生链重算,自然把乐观值替换掉——不需要你写任何回滚代码。
  • 这套用法能成立,恰恰是因为它「会被冲掉」。你要的就是这个冲掉。

别把它当状态用

  • 如果一个值需要既能算出来、又能被用户长期改写(典型是可编辑的表单默认值),它就不该是 $derived。用 $state 存用户的编辑结果,配合 {#key} 块在数据源变化时重建组件来重置(见 04 章)。
  • $derived 加手动赋值去凑这个需求,结果是「用户改完,别处一动就全没了」这类极难复现的 bug。
<script>
  let n = $state(10);
  let d = $derived(n * 2);   // 挂载时 d = 20

  function override() {
    d = 999;               // 5.56.7 不报错,页面立刻显示 999
  }

  function bump() {
    n++;                     // 依赖一变,999 被公式结果冲掉
  }
</script>

<p>n = {n},d = {d}</p>
<button onclick={override}>把 d 改成 999</button>
<button onclick={bump}>n++</button>

<!-- 序列:20 →(n++)22 →(覆盖)999 →(n++)24 -->
<!-- 最后是 24 而不是 1000:999 从未进入公式,只是盖在结果上面 -->
别把「$derived 能赋值」误读成「$derived 是可写的」。里 d = 999 之后 n++d 直接回到 24不是 1000——写进去的值既不参与公式,也活不过下一次依赖变化。真正需要用户长期改写的值请老实用 $state,否则你会得到一个「改了能看见、切个标签页回来就没了」的幽灵 bug。
想验证某个值到底是「真状态」还是「被盖住的派生值」,只要改一下它的任意一个上游依赖再看它——会被打回原形的就是派生值。这也是排查「我明明改了这个变量,一刷别的地方它就变回去」这类怪问题的标准手法。

一条铁律,先记下来再看理由:凡是能用 $derived 表达的,就绝不要用 $effect$effect 的职责只有一个——把 Svelte 管得到的状态,推给 Svelte 管不到的外部世界。

「外部世界」指什么

  • document.titlelocalStoragematchMedia 这些浏览器 API;
  • setIntervalWebSocketResizeObserver 这类需要开和关的资源;
  • 图表库、地图库、编辑器这类自己持有 DOM 的第三方实例;
  • 日志、埋点这种「发出去就不管了」的动作。
  • 共同点是:它们的状态不在 Svelte 的信号图里,Svelte 没法自动帮你同步,只能你手动推。

为什么「用 effect 算派生值」是错的

两种写法都能让屏幕上出现正确的数字,差别在正确性是怎么来的

  • let full = $derived(last + first)
    值和公式绑死,任何时刻读到的都是最新结果;同一轮同步可用,无中间态
    公式体必须是纯的,不能在里面做异步或副作用
    为何它是「拉」模型——用的时候才算,所以永远不会过期,代价是不能有副作用。
  • let bad = $state(""); $effect(() => { bad = last + first })
    能塞进异步、能读外部数据,写法上更「随意」
    多一轮更新;存在「源变了但 bad 还没变」的中间态;依赖链变成隐式的
    为何它是「推」模型——先改源、再等 effect 补写目标,灵活性正来自这个时间差,bug 也来自这个时间差。

那个时间差有多大

  • 同一个组件里同时放 full$derived)和 bad(effect 里赋值)。改掉源之后、DOM 刷新之前去读:full 已经是新值「张四」,bad 还是旧值「张三」
  • 刷新后两者都对了。所以这个 bug 平时看不出来,只在别的代码「恰好在中间时刻读了它」时发作——这类问题极难复现。
  • 更糟的是依赖关系被藏了起来:$derived 一眼能看出 full 依赖谁,effect 版本必须读完整个函数体才知道。组件一大,这些隐式依赖链会互相触发,时序完全没法推理。

Svelte 3/4 迁移提示

老代码里的 $: b = f(a) 一律对应 $derived不是 $effect;只有 $: { 有副作用的语句块 } 才对应 $effect。很多迁移出来的代码一股脑全译成了 $effect,这是 Svelte 5 项目里最常见的结构性错误。

<script>
  let last = $state("张");
  let first = $state("三");

  // ✗ 反面教材:拿 effect 去算一个派生值
  let bad = $state("");
  $effect(() => { bad = last + first; });

  // ✓ 正解:写得出公式,就交给 $derived
  let full = $derived(last + first);

  // ✓ effect 真正的活:推给 Svelte 管不到的外部世界
  $effect(() => {
    document.title = full;      // 浏览器标题不在信号图里
  });
</script>

<p>full = {full}|bad = {bad}</p>
<button onclick={() => (first = "四")}>改名</button>

<!-- 改名后刷新前去读,full 已是「张四」,bad 还停在「张三」-->
从 Svelte 4 迁移时把 $: b = f(a) 译成 $effect(() => { b = f(a) }),是最常见的结构性错误。正确对应是 let b = $derived(f(a))。译错了不会报错,但会引入一个「源已更新、目标还没跟上」的中间态:在同一轮里 $derived 版本已经是新值,effect 版本还是旧值。只有 $: { …副作用语句块… } 才该译成 $effect
$effect 之前先问一句:「这个 effect 的函数体里,有没有给某个 $state 赋值?」如果有,八成写错了,先想想能不能改成 $derived。真正合格的 $effect 函数体应该只有「往外写」的动作——调浏览器 API、调第三方库、开关资源,而不是往回写自己的状态。

effect 不是「状态一变就立刻跑」,而是攒到本轮 DOM 更新的固定节拍上跑。记住这一串顺序,绝大多数「为什么读到的是旧值」都能当场解释。

出来的顺序

同一个组件里放一个 $effect.pre 和一个带清理函数的 $effect,打印结果是:

时刻输出顺序此时 DOM 里的值
挂载pre n=1effect n=1pre 时元素还没挂上;effect 时已是 1
n++pre n=2cleanup n=1effect n=2pre 时 DOM 还是 1;effect 时已是 2
卸载cleanup n=2

三个要点:一,pre 跑在 DOM 更新之前,它读到的 n 已经是新的 2,但 DOM 上还写着旧的 1——这个错位就是 pre 的全部价值。二,上一次的 cleanup 夹在中间,先收拾旧的再建新的,永远不会出现两份资源并存。三,卸载时最后一次 cleanup 一定会跑

清理函数:三个触发时机

  • 返回值就是清理函数,不需要引入 onDestroy。它在三种时候被调用:本 effect 因依赖变化重跑之前、组件卸载时、以及所在的 $effect.root 被销毁时。
  • 凡是「开了要关」的东西都必须配清理:setIntervaladdEventListenerWebSocket/各种 Observer/第三方库实例的 destroy()。漏了就是内存泄漏,而且在 SPA 里会随着来回切页越积越多。
  • 异步请求的防竞态也靠它:在 effect 里 let cancelled = false,清理函数里置 true,回调里判断后再写状态。否则慢的旧请求会覆盖快的新请求结果。
  • 清理函数要用闭包里的快照,不要重新去读状态。示例里先 const v = n 再在清理里用 v——清理跑的时候 n 可能已经变了,直接读会关错东西。

$effect.pre 什么时候真的需要

  • 只有一种情形:你必须在 DOM 被改写之前读到它的旧状态。教科书例子是聊天窗口的「贴底自动滚动」——新消息插进来之前先量一下 scrollTop,判断用户原本是不是贴着底,插完再决定要不要滚。等 DOM 更新完再量就晚了,那时的高度已经是新的。
  • FLIP 动画的第一步(记录旧位置)同理。
  • 除此之外一律用 $effect。把 $effect.pre 当成「早一点跑的 effect」来用是误用:在它里面读 DOM 拿到的是上一帧的数据,会写出难查的 bug。
  • 还有 $effect.root:脱离组件生命周期建一个 effect 作用域,返回值是手动销毁函数。只在写跨文件的状态工具时才需要(见 08 章),业务代码里几乎用不上。

需要精确等一帧的时候

await tick() 会等到本轮 DOM 更新落地。典型场景是「新增一行之后立刻聚焦到它的输入框」——不 await tick() 的话那个元素还不存在。

<script>
  let n = $state(1);

  $effect.pre(() => {
    // 跑在 DOM 更新前:n 已是新值,DOM 上还是旧值
    console.log("pre    n=" + n, document.querySelector("#v")?.textContent);
  });

  $effect(() => {
    const v = n;              // 存快照,清理时才拿得到当时的值
    console.log("effect n=" + v, document.querySelector("#v").textContent);
    return () => console.log("cleanup n=" + v);
  });
</script>

<p id="v">{n}</p>
<button onclick={() => n++}>n++</button>

<!-- 输出:
     挂载  pre n=1 (undefined) / effect n=1 (1)
     n++   pre n=2 (1) / cleanup n=1 / effect n=2 (2)
     卸载  cleanup n=2                              -->
别把 $effect.pre 当作「跑得早一点的 $effect」来用。n 从 1 变 2 时,pre 里读到的 n 已经是 2,但 querySelector("#v").textContent 还是 "1"——状态和 DOM 对不上。在 pre 里量尺寸、读 scrollTop、取 getBoundingClientRect(),拿到的全是上一帧的数据。除了「必须先读旧 DOM」这一种情形,一律用 $effect
清理函数里只用闭包快照,不要重新读响应式状态。写 const id = setInterval(...); return () => clearInterval(id) 是对的;写成清理时再去读某个 $state 拿 id 就可能关错对象——清理跑在「新值已就位」的时刻,你读到的往往已经不是当初开资源时的那个了。

「为什么我的 effect 不重跑」——头号答案是它根本没把那个值算成依赖。规则只有一句:effect 函数体同步执行期间读到的响应式值才进依赖表,此外一概不算。

三种「读了但不算数」的情形

写法算依赖吗原因
const x = a;同步执行期间读到
untrack(() => m)不算显式关掉了收集器
await … 之后读 late不算此时同步阶段早已结束,收集器关了
cond ? b : c(cond 为真)只算 bc 这次根本没被读到

确认:在 effect 里分别以这三种方式读 mlate,之后单独修改它们,effect 一次都没重跑(控制台无任何输出);只有改那个同步读到的 a,effect 才动。

为什么是「同步」这条线

  • 依赖收集靠的是一个全局的「当前正在收集谁」的槽位。运行 effect 前把槽位指向它,函数体里每次读响应式值就往里登记一笔,函数体同步部分一执行完立刻清空槽位。
  • await 之后的代码是下一个微任务才跑的,那时候槽位早空了,读到的值只是普通读取,没人记账。
  • 这不是 bug,是所有信号系统的通用设计。Solid、Vue 的 watchEffect 都是同一条规则。

由此推出的两条实用后果

  • 异步 effect 要在 await 之前把依赖读齐。写数据请求时,先 const id = userIdawait fetch(...);若写成 await fetch(`/api/${userId}`),参数是在同步阶段拼的、依赖也收得到,但 await 之后再读的任何状态都不算数。
  • untrack 是「我要读它的当前值,但不想被它牵着走」。典型用途:effect 里既要响应 a 的变化,又要读一份配置 m 作参考,而 m 变了不该触发重跑。它也是拆无限循环的主力工具(见本章最后一张卡)。

反过来也会咬人

依赖是每次运行重新收集的,条件分支会让依赖表在不同轮次里长得不一样。$effect(() => { if (enabled) doSomething(count) })enabled 为假时根本不依赖 count——此时狂改 count 毫无反应,等 enabled 一开才突然全对上。这不是 Svelte 的毛病,但排查时很容易看走眼。

<script>
  import { untrack } from "svelte";

  let a = $state(0);
  let m = $state(0);
  let late = $state(0);

  $effect(() => {
    const x = a;                    // 同步读到 → 进依赖表
    const y = untrack(() => m);      // 被 untrack 包住 → 不进
    console.log("effect", x, y);

    (async () => {
      await Promise.resolve();
      console.log("await 后读 late =", late);   // 也不进依赖表
    })();
  });
</script>

<button onclick={() => a++}>a++(effect 重跑)</button>
<button onclick={() => m++}>m++(不重跑)</button>
<button onclick={() => late++}>late++(不重跑)</button>

<!-- 只有点第一个按钮才会看到 effect 输出 -->
在 effect 里写 const data = await fetch(url); use(data, someState),那个 someState 不算依赖——它是在 await 之后读的,此时依赖收集早已关闭。单独修改这类值,effect 一次都不重跑,控制台毫无输出,看上去就像响应式「失灵」了。正确做法是把所有依赖在第一个 await 之前全部读进局部变量。
effect 不重跑时的排查顺序:一,那个值是不是在 await 之后才读的;二,是不是被 untrack 包住了;三,是不是被条件分支绕过去了;四,它到底是不是 $state(普通 let 没有任何响应性)。四条走完基本就定位了。想强行制造依赖,在函数体顶部写一行 void 那个值 即可。

effect 里读了某个状态、又写了同一个状态,就自己触发自己。Svelte 有循环守卫会把它拦下来,但它在开发和生产下说的话完全不一样:开发时会直接点破成因,生产下只剩一个网址。

报错原文

  • 代码:let items = $state([1]); $effect(() => { items.push(items.length + 1) })
  • 抛出:Error: https://svelte.dev/e/effect_update_depth_exceeded
  • error.message 的完整内容就是那一个 URL——没有「infinite loop detected」之类的描述,也没有指向你哪一行。第一次撞见时基本是一头雾水,只能自己点开链接。
  • 这是生产构建的行为(错误信息被抽成链接以压缩体积)。开发模式下信息会友好些,但线上日志里你看到的就是这一行,提前知道它等于什么意思,能省下大量时间

为什么 push 就会成环

  • $state 是深层响应的:数组被 Proxy 包着,items.push(...) 里的 items.length 是一次,push 本身是一次
  • 读把 items 登记成依赖,写又让 items 变脏 → effect 重跑 → 再读再写 → 死循环。守卫数到上限就抛错。
  • 同样的环也出现在这些写法里:effect 里给自己读过的对象改属性、两个 effect 互相写对方读的状态、以及在 effect 里做「排序后写回原数组」。

怎么排查

  • 看到这个 URL,直接去翻所有 $effect 的函数体,找「既读又写同一个状态」的那个。范围通常很小,因为合格的 effect 本来就不该往回写状态。
  • 不确定是哪个时,在每个 effect 开头加一行 console.log,跑一次看谁刷屏。
  • 浏览器控制台的调用栈里能看到内部帧 infinite_loop_guard,可以确认是这类问题而非别的报错。

三种改法,按优先级

  • 首选:根本不该用 effect。如果写回去的值是能算出来的,改成 $derived,环自然就没了。九成情况属于这一类。
  • 次选:用 untrack 拆掉一条边。把写操作包进 untrack(() => log.push(v)),读操作就不再登记依赖。这样改之后,挂载得到长度 1,每次改源加 1,不再抛错。
  • 最后:加条件早退。if (next === current) return; 让状态收敛到不动点。能用但脆弱——判等一旦写错(比如比的是对象引用),环立刻回来。
<script>
  import { untrack } from "svelte";

  let src = $state(1);
  let log = $state([]);

  // ✗ 这样写直接抛 Error: https://svelte.dev/e/effect_update_depth_exceeded
  //    push 里的 items.length 是「读」,push 本身是「写」,自己触发自己
  // $effect(() => { log.push(log.length); });

  // ✓ untrack 掐断「读 log」这条边,环就断了
  $effect(() => {
    const v = src;                  // 只有 src 是依赖
    untrack(() => log.push(v));
  });
</script>

<p>log 长度 = {log.length}</p>
<button onclick={() => src++}>src++</button>

<!-- 挂载后长度 1,每点一次 +1;放开上面那行注释则立刻抛错 -->
开发时它其实说得挺明白error.messageeffect_update_depth_exceeded — Maximum update depth exceeded. This typically indicates that an effect reads and writes the same piece of state,调用栈还直接指到出问题的那一行。但生产构建下,同一个错误只剩 https://svelte.dev/e/effect_update_depth_exceeded 一个光秃秃的网址——所以线上告警里看到这串东西,别以为是网络问题,它等于「有个 effect 在自己写自己读过的状态」。
碰到 effect_update_depth_exceeded,先别急着加 untrack九成情况的正解是「这段根本不该是 effect」——把往回写的那个值改成 $derived,环会自己消失,代码还短一截。untrack 是留给「确实需要写、但不想被牵连」的那一成,比如往日志数组里追加记录。

模板语法

each、if、await、key 四个块,加上插值与 {@render}。模板是编译目标,不是运行时解释的字符串。

Svelte 的模板只认一对花括号{} 里面写的是货真价实的 JavaScript 表达式,编译器会把它编译成一次精确的 DOM 赋值——不是字符串拼接,也没有模板引擎在运行时解析。

能写什么,不能写什么

  • 括号里是表达式,不是语句:三元、可选链、方法调用、字面量都行;ifforlet 这些语句不行,那是块语法的活儿。
  • 插值出来的内容一律按纯文本处理{"<b>粗</b>"} 在页面上就是显示出这几个尖括号字符,不会变成粗体——这是默认的 XSS 防线。
  • 属性里同样用花括号:src={url}。当变量名和属性名一模一样时可以简写{value},等价于 value={value}。简写和全写渲染结果完全一致,纯粹是省字。
  • 要输出花括号本身,用 HTML 实体 &#123;&#125;

{@const}:块内的局部常量

  • 只能出现在块的直接子级{#each}{#if}{#snippet} 等)里面,不能放在组件顶层。
  • 用途是给循环体里那个又长又要用好几次的表达式起个名,避免同一个计算在模板里抄三遍。
  • 它是模板局部的,跟着所在块的这一次迭代走,和 $derived 不是一回事——不需要也不会独立缓存。

{@html} 不做任何清理

  • '<img src=x onerror="alert(1)"><b>粗体</b>' 交给 {@html},渲染结果里 onerror="alert(1)" 这个属性原封不动地留在 DOM 上。Svelte 一个字符都不过滤,它就是一次 innerHTML 赋值。
  • 所以规矩很硬:内容只要沾过用户输入,必须先过 DOMPurify 这类清洗库,或者在服务端就洗干净。「这个字段应该没人乱填」不是理由。
  • 另外两个容易忘的点:{@html} 插进来的内容不受组件 scoped 样式影响(那些哈希类名不会加到它身上,需要 :global(),见 07 章);里面的 Svelte 语法也不会被解析,它就是死的 HTML。
<script>
  let user = $state({ name: "小明", age: 7 });
  let value = $state("简写");
  // 来自后端/用户的 HTML,未经清洗
  let raw = $state('<img src=x onerror="alert(1)"><b>粗体</b>');
</script>

<p>{user.name} 今年 {user.age} 岁,明年 {user.age + 1} 岁</p>

<!-- 属性简写:{value} 就是 value={value} -->
<input {value} />

<!-- @html 原样插入,零清洗 -->
<div>{@html raw}</div>

{#each [user] as u}
  <!-- @const 只能待在块里面,给长表达式起个名 -->
  {@const initial = u.name.slice(0, 1)}
  <span>{initial}</span>
{/each}

<!-- onerror 属性原封不动留在 DOM 上 -->
{@html} 不做任何清理,这一点被严重低估。传入 <img src=x onerror="alert(1)">onerror 属性完整出现在 DOM 里,图片一加载失败脚本就执行。别指望 Svelte 帮你挡——它做的就是一次 innerHTML 赋值。还有个次生坑:{@html} 的内容拿不到组件的 scoped 样式,你在 <style> 里写的规则对它一律无效,必须用 :global() 包起来。
永远优先用普通插值 {},把 {@html} 当成需要写理由的例外。真要渲染富文本,别在组件里临时清洗——在数据进入应用的唯一入口(接口层或服务端)洗一次,之后全链路都能当可信内容用。散落在各个组件里的 DOMPurify.sanitize() 迟早会漏掉一处。

块语法不是「模板里的语法糖」,而是编译目标的边界:每个 {#if}{#each} 都会被编译成一段独立的、自己管自己的 DOM 更新函数。而 {#each} 括号里那个 key,决定的是「更新时哪个 DOM 节点归哪条数据」。

{#each} 的完整形态

  • {#each items as item, i (item.id)}——item 是当前项,i 是索引,圆括号里的才是 key。三者都可选。
  • 支持解构:{#each items as { id, name } (id)},也能遍历任何可迭代对象,配合 Array.fromObject.entries 用。
  • {:else} 分支在数组为空时渲染,是写空态最省事的地方,不必再套一层 {#if items.length}
  • {#if}{:else if}{:else},条件为假时内部 DOM 是真的被销毁,不是 display:none——组件会走卸载、effect 会执行清理。

不写 key,编译产物告诉你发生了什么

  • 编译同一段 {#each}:写了 (item.id) 时,产物是 $.each(ul, 21, () => items, (it) => it.id, …)不写 key 时,第四个参数变成 $.index
  • 也就是说「不写 key」不等于「没有 key」,而是「用数组下标当 key」。这和 React 里 key={i} 是完全一样的选择,只不过 Svelte 帮你默默填上了。
  • 下标当 key 的后果就是经典的错位:数据按 id 认人,DOM 按位置认人,两边一旦不同步就串行。

删掉第一项之后

三行数据「甲乙丙」,每行一个 <input>,先在三个输入框里分别打上字,然后删掉第一项:

写法删除后的结果
有 key (it.id)乙=[用户给乙打的字] 丙=[用户给丙打的字] ✔ 对齐
无 key乙=[用户给甲打的字] 丙=[用户给乙打的字] ✘ 整体错位一格

无 key 时 Svelte 按位置复用节点:第一个 <li> 被留下来改成显示「乙」,但它内部那个输入框里用户打的字没人去改,于是「甲的输入」配上了「乙的标签」。注意这个错位只影响 Svelte 管不到的 DOM 状态(输入框内容、滚动位置、播放进度、CSS 动画进度),插值文本永远是对的——所以静态列表看不出毛病,一上表单就出事。

key 写错:直接抛错,不是警告

  • 这是和 React 最大的行为差异。React 遇到重复 key 只在控制台警告,页面照渲染;Svelte 直接抛错、组件挂不上Error: https://svelte.dev/e/each_key_duplicate
  • 这条报错分两种构建:开发时它说得很清楚——报错原文是 each_key_duplicate — Keyed each block has duplicate key `1` at indexes 0 and 1,连撞在哪两个下标都点明了;但生产构建下只剩一个光秃秃的 https://svelte.dev/e/each_key_duplicate,什么都不告诉你。
  • 好消息是这种「响亮的失败」比 React 的静默错乱好查得多——至少它不会让你在生产环境里排查半个月的诡异串行。
<script>
  let items = $state([
    { id: 1, name: "甲" }, { id: 2, name: "乙" }, { id: 3, name: "丙" }
  ]);
</script>

{#if items.length > 2}
  <p>还有不少</p>
{:else if items.length > 0}
  <p>快没了</p>
{:else}
  <p>空了</p>
{/if}

<ul>
  <!-- 圆括号里的 (item.id) 才是 key;不写它=用下标当 key -->
  {#each items as item, i (item.id)}
    <li>{i + 1}. {item.name}<input /></li>
  {:else}
    <li>暂无数据</li>       <!-- 数组为空时的分支 -->
  {/each}
</ul>

<button onclick={() => items.shift()}>删掉第一个</button>

<!-- 先往三个输入框打字再点删除。带 key 时剩下两行的字跟着数据走;-->
<!-- 去掉 (item.id) 再试,标签往前挪了,输入框里的字却留在原位 -->
key 重复在 Svelte 里是致命错误而非警告,这一点和 React 完全不同。两条数据用了同一个 id,组件直接挂不上,抛 Error: https://svelte.dev/e/each_key_duplicate——开发时它会附上 duplicate key `1` at indexes 0 and 1 告诉你撞在哪;生产构建下只剩这个 URL。常见触发场景是拿可能重名的字段当 key,或者数据里混进了 idundefined 的新建项(多个 undefined 互相撞)。
只要列表会增、删、排序,就一定写 key;key 一定用数据自带的稳定 id。判断该不该写有个更省事的办法:默认全都写上。给静态列表加 key 的成本约等于零,而漏写的代价是一个只在特定操作顺序下才复现的错位 bug。切忌用 iitem.name 当 key——前者等于没写,后者一重名就直接抛错。

这两个块解决的是同一类问题的两端:{#key} 管「什么时候该把这段 DOM 整个扔掉重来」,{#await} 管「一个还没到的值该怎么在模板里表达」。

{#key}:把「重建」变成声明式的

  • 表达式的值一变,块内内容全部销毁再全部重建。换 page 之后,块内那个 <span> 已经不是原来那个节点了(节点全等比较为 false)。
  • 因为是真的销毁重建,里面的组件会走完整的卸载和挂载:$state 回到初始值、effect 执行清理再重新建立。这正是它的用途——把「重置组件」这件事写成声明式的,而不是手动一个个把变量赋回去。
  • 最常见的搭配是过渡动画:入场过渡只在元素创建时播放,值变了但元素被复用就不会播。套一层 {#key} 强制重建,切页动画才会每次都放(见 07 章)。
  • 代价也在这里:重建意味着丢掉一切 DOM 状态和子组件状态,还要重新走一遍挂载。别拿它当性能优化,它是纯粹的开销,只在你确实想要「从头来过」时才用。

{#await}:把三态摊在模板里

  • 三个分支对应 Promise 的三个状态:块首是 pending、{:then value} 是 fulfilled、{:catch error} 是 rejected。三条路径都正常工作。
  • {:catch} 不是可选的装饰品。漏写它,Promise 一 reject 就变成未处理的拒绝——而页面上那块内容会直接消失:reject 之后 pending 块被移除,innerHTML 只剩一个注释锚点 <!---->不是停在加载态,是一片空白,这比卡在「加载中」更难被发现。
  • 两个简写:数据已经预取好、不需要 loading 态时用 {#await promise then value}(省掉 pending 块);只关心失败态时用 {#await promise catch error}(省掉 then 块)。
  • 关键限制:它认的是 Promise 这个对象本身,不是里面的值。只有换成一个新的 Promise 对象才会重新走三态。想让参数一变就重新请求,就得让这个 Promise 本身成为派生值。

在 SvelteKit 里,别急着用 {#await}

  • {#await} 在组件里发请求=请求要等组件挂载后才开始,天然有瀑布流问题,SSR 时也拿不到数据。
  • SvelteKit 的正解是在 load 里返回一个未 await 的 Promise,页面先流式渲染骨架,数据到了再填——那才是 {#await} 真正该出场的地方(见 12 章)。
  • 纯客户端场景(点按钮才触发的搜索、弹窗里的懒加载)用 {#await} 则完全合适。
<script>
  let page = $state(1);
  // 让 Promise 本身是派生的,page 一变就是个新 Promise → 重走三态
  let promise = $derived(
    fetch(`/api/page/${page}`).then((r) => r.json())
  );
</script>

<!-- key 块:page 一变,里面整个销毁重建(节点全等为 false)-->
{#key page}
  <article>第 {page} 页</article>
{/key}
<button onclick={() => page++}>下一页</button>

<!-- await 块:把 Promise 的三个状态摊平写在模板里 -->
{#await promise}
  <p>加载中</p>
{:then data}
  <p>你好 {data.name}</p>
{:catch error}
  <p>出错:{error.message}</p>   <!-- 别省这一段 -->
{/await}

<!-- 只关心成功态可以简写成 {#await promise then data} -->
{#await} 认的是 Promise 对象的身份,不是它 resolve 出来的值。把 Promise 存进 $state 之后只改别的变量,await 块纹丝不动——它只在拿到一个新对象时才重新走三态。另一个高频坑是省掉 {:catch}:请求一失败,那块内容整个消失(DOM 只剩 <!---->),控制台里是未处理的 Promise 拒绝,而用户看到的是一片空白——既没有错误提示,也没有「加载中」可以等。
想让 {#await} 跟着参数自动重新请求,把 Promise 本身做成 $derivedlet p = $derived(fetch(`/api/${id}`).then((r) => r.json()))。这样 id 一变就是个全新的 Promise 对象,await 块自然重走三态。写成 $state 再手动赋值也行,但那就得自己盯着什么时候该重新赋——没必要。

一句话讲清这对搭档:{#snippet} 定义一段可复用的模板,{@render} 调用它。定义和调用——它们的关系就是函数和函数调用,只不过返回的是 DOM 而不是值。

为什么说它「就是函数」

  • 这不是比喻。{#snippet row(text, i)} 编译之后真的变成一个 JavaScript 函数{@render row(it, i)} 就是一次普通调用,参数按位置传。
  • 既然是函数,它就服从普通的作用域规则:能读到定义处能读到的变量,也能当值传来传去——传给子组件、放进数组、用三元选一个来渲染,都行。
  • 调用时可以带可选链:{@render children?.()}。子组件没收到这个 snippet 时就什么都不渲染,这是写「有就渲染、没有就算了」的标准姿势。

它取代了 Svelte 4 的 <slot>

Svelte 4Svelte 5
<slot />{@render children()}children 是个普通 prop
<slot name="header" />父组件传一个名叫 header 的 snippet 进来
<slot {item} />let:item{@render row(item)},就是传参

关键的进步在于:slot 是一套专门为插槽发明的语法,作用域规则(let:)和别处都不一样;snippet 则彻底并入了 JavaScript 的值系统——不用记新规则,函数怎么用它就怎么用。

本卡到此为止

  • 这里只需要建立一个认识:模板里看到 {@render 某某()},那就是在调用一段模板函数,不是什么特殊魔法。
  • 把 snippet 当 prop 传递、Snippet 类型标注、隐式 children、递归 snippet、以及它和组件拆分的取舍——完整讲解在 06 章。
<script>
  let items = $state(["甲", "乙"]);
</script>

<!-- snippet:一段可复用的模板,编译后就是个函数 -->
{#snippet row(text, i)}
  <li>{i + 1}. {text}</li>
{/snippet}

<ul>
  {#each items as it, i}
    <!-- @render 调用它,参数按位置传 -->
    {@render row(it, i)}
  {/each}
</ul>

<!-- 渲染出 <ul><li>1. 甲</li><li>2. 乙</li></ul> -->

<!-- 它也能当 props 传给子组件,那是取代 Svelte 4 <slot> 的正统写法;-->
<!-- 调用时用可选链更稳:{@render children?.()} -->
{@render children()} 在父组件没传内容时,生产构建下安静地什么都不渲染(只产出 <div><!----></div>),开发时才抛 invalid_snippet — Could not `{@render}` snippet due to the expression being `null` or `undefined`. Consider using optional chaining `{@render snippet?.()}`——注意它不是普通的 TypeError,报错文案自己就把解法写出来了。写成 {@render children?.()} 即可——可选链在这里是刚需,不是洁癖。另外别把 snippet 当组件用:它没有自己的状态、没有生命周期、也拿不到独立的 scoped 样式,只是一段带参数的模板。需要封装状态和副作用时,老老实实拆成组件。
在模板里看到 {@render x()} 却找不到 x 在哪定义,先去看组件的 $props()——十有八九它是父组件传进来的 snippet,而不是本文件里的 {#snippet}。这是从 Svelte 4 的 <slot> 过来的人最容易迷路的一步:插槽从「一个特殊标签」变成了「一个普通 prop」。

总有那么些活儿是声明式模板干不了的——量尺寸、初始化一个图表库、挂个原生监听器。这三个指令就是官方开的三个口子,让你在恰当的时机拿到那个真实的 DOM 节点。

bind:this:拿到节点引用

  • <div bind:this={box}> 会把真实 DOM 节点写进 box组件挂载之前它是 undefined,所以只能在 $effectonMount 里用——在 <script> 顶层直接读必然拿到 undefined。
  • 用在组件上时,拿到的是组件实例(它 export 出来的函数和属性),不是 DOM 节点。
  • 元素被 {#if} 移除时,这个变量会被置回 null(挂载之前才是 undefined)——写 if (el === undefined) 当守卫会漏掉移除的情况,用 if (!el) 才稳。

use:action{@attach}:同一件事的两代写法

两者都是「元素出现时给我一次机会做点什么」,差别在怎么感知变化

use:action{@attach}
签名(node, arg) => { update, destroy }(node) => cleanup
响应变化只有参数变了才调 update函数体里读到的任何 $state 变了就整个重跑
清理destroy() 钩子返回的函数,和 $effect 一模一样
能否传参只能一个参数本身是表达式,随便闭包捕获

对比很能说明问题:在 action 函数体里直接读一个 $state(不通过参数传),改这个 state 之后 action 完全不重跑,DOM 停在旧值;换成 {@attach} 写同样的逻辑,改 state 立刻打印出「清理 → 重新装上」,属性同步更新。

怎么选

  • 新代码一律用 {@attach}它本质上就是一个绑在元素上的 $effect,依赖追踪、清理时机全都和你已经熟悉的 $effect 完全一致——不用再多记一套 updatedestroy 的生命周期。
  • use: 仍然可用且没有被废弃,读老代码和用老组件库时会大量遇到,需要认得。
  • 什么时候该动用它们:包装一个不认识 Svelte 的第三方库(图表、地图、富文本编辑器)、实现 tooltip/点击外部关闭/拖拽这类跨元素复用的交互行为、以及需要非被动监听器的场景。
  • 反过来说,凡是用普通属性绑定和事件属性能搞定的,就别动这些指令——它们是逃生舱,不是日常工具。
<script>
  let box;                       // 挂载前是 undefined
  let color = $state("red");

  // action:老写法,靠 update 钩子接收新参数
  function paintAction(node, arg) {
    node.setAttribute("data-color", arg);
    return {
      update: (next) => node.setAttribute("data-color", next),
      destroy: () => {}
    };
  }

  // attachment:新写法,自动追踪函数体里读到的响应式值
  function paint(node) {
    node.setAttribute("data-color", color);  // 读了 color → 跟着它重跑
    return () => {};                        // 返回值即清理函数
  }

  $effect(() => { console.log("真实节点:", box.tagName); });
</script>

<div bind:this={box} use:paintAction={color}>老写法</div>
<div {@attach paint}>新写法</div>
<button onclick={() => (color = "blue")}>换色</button>
use:action 不会追踪函数体里读到的 $state,它只在参数变化时调 update。在 action 里直接读一个 $state(没通过参数传进去),之后修改它,action 一次都不重跑,DOM 停在初始值——看上去就像响应式坏了。要么通过参数传并实现 update,要么直接改用 {@attach}。另外 bind:this 的变量在 <script> 顶层读必定是 undefined,元素那时还没生出来。
新代码直接用 {@attach},别再写 use:。理由很实在:attachment 就是一个绑在元素上的 $effect——依赖怎么追踪、清理函数什么时候跑,规则和你已经学会的 $effect 完全相同,等于零额外心智负担。而 action 要求你另外记住 updatedestroy 这套只在这里出现的生命周期。

这是理解 Svelte 的总开关:你写的模板在构建期就消失了。浏览器里跑的不是「解析模板」的代码,而是一段直白的、创建和更新 DOM 的命令式 JavaScript——没有虚拟 DOM,没有 diff,也没有模板解析器。

亲眼看一眼产物

把一段最普通的带 key 的 {#each} 交给编译器(compile(src).js.code),吐出来的就是右边那段代码。三处值得盯着看:

  • $.from_html(`<li> </li>`) ——静态结构在模块顶层就做成了一份模板,运行时靠克隆产出节点。注意 <li> 里那个孤零零的空格:那是编译器给动态文本预留的文本节点占位。
  • $.each(ul, 21, () => items, (it) => it.id, …) ——你写在圆括号里的 key 原样变成了第四个参数那个函数21 是编译期算好的标志位。
  • $.template_effect(() => $.set_text(text, $.get(it).name)) ——整份产物里只有这一行是「更新」逻辑,而且它精确到了「那一个文本节点」。名字变了就调一次 set_text,仅此而已。

由此推出的几件事

  • 没有「重渲染」这个概念。产物里根本不存在「重新执行组件函数、生成新树、和旧树比对」的代码路径。所以 Svelte 里没有 memouseCallback 这类挡住重渲染的工具——没有要挡的东西。
  • 运行时体积小得反常。框架只需要提供 eachset_texttemplate_effect 这些小工具,diff 算法和虚拟 DOM 那一整套都不必发给浏览器。
  • 模板语法必须是静态可分析的。这就解释了为什么模板里不能拼一个动态的块名、为什么 {@const} 只能待在块里、为什么 runes 只在 .svelte.svelte.js 文件里生效——编译器得在构建期就把一切看明白。
  • 代价是调试时你面对的是产物。浏览器里跑的是 $.template_effect 这类你没写过的东西。好在 source map 一直是通的,日常断点没问题(见 10 章);但遇到疑难杂症时,读一眼编译产物往往比猜快得多。

顺手验证了一件事

把同一段 {#each} 的 key 去掉再编译,第四个参数从 (it) => it.id 变成了 $.index。这就从产物层面坐实了前面那张卡的说法:「不写 key」不是「没有 key」,而是「用下标当 key」。想搞清 Svelte 的任何行为,编译一遍看产物是最短的路径。

// 你写的:
//   <ul>{#each items as it (it.id)}<li>{it.name}</li>{/each}</ul>
// 编译器吐出来的(compile(src).js.code 节选):

import * as $ from 'svelte/internal/client';

// 静态结构在模块顶层做成模板,运行时靠克隆产出节点
var root = $.from_html(`<li> </li>`);   // 空格=给动态文本预留的位置
var root_1 = $.from_html(`<ul></ul>`);

export default function List($$anchor, $$props) {
  var ul = root_1();

  // 你写的 (it.id) 原样变成第四个参数;21 是编译期算好的标志位
  $.each(ul, 21, () => $$props.items, (it) => it.id, ($$anchor, it) => {
    var li = root();
    var text = $.child(li, true);
    $.reset(li);
    // 全篇唯一的「更新」逻辑,精确到那一个文本节点
    $.template_effect(() => $.set_text(text, $.get(it).name));
    $.append($$anchor, li);
  });

  $.reset(ul);
  $.append($$anchor, ul);
}

// 去掉 key 再编译:第四个参数变成 $.index —— 不写 key = 按下标做 key
别把「Svelte 没有虚拟 DOM」误读成「Svelte 怎么写都快」。编译器优化的是更新路径,管不了你在模板表达式里干的蠢事:{items.filter(fn).sort(cmp).length} 会被编译进 template_effect,依赖一变就整条链重算一遍。这种计算该提到 $derived 里去,让它按需缓存。另外产物里全是 $.template_effect 这类你没写过的符号,第一次打开调试器看到它们不必困惑——那就是你的模板。
想搞明白 Svelte 的任何一个行为,最短的路径是编译一遍看产物compile(源码, { generate: "client", runes: true }).js.code(注意是 .js.code,不是 .code)。「这个写法会不会多一次更新」「加不加这个属性有区别吗」——这类问题读产物几秒钟就有答案,比翻文档和猜测都靠谱。

Props、事件与绑定

$props() 取代了 export let,事件回到了原生属性写法,bind: 是双向绑定的糖。三者的边界要划清楚。

Svelte 5 把组件当成一个函数看待,$props() 就是它那个参数对象——所以你对 JS 解构会的一切,在这里原样成立,不需要再学一套组件专用的声明语法。

从 export let 到 $props()

读者很可能在旧教程里见过 export let。新旧是一一对应的:

Svelte 3/4Svelte 5
export let name;let { name } = $props();
export let size = "md";let { size = "md" } = $props();
export let klass; export { klass as class }let { class: klass } = $props();
$$props / $$restProps(编译器塞进来的魔法变量)...rest(就是普通的 rest 元素)
只能一个一个标类型let { ... }: ButtonProps = $props() 一次标完

为什么非换不可

  • 方向是反的。export 的语义是「往外给」,却被借来表达「从外面收」,第一次见的人都要愣一下。
  • 没法表达「全部 props」。旧写法是一条一条声明的,想拿到剩余属性只能靠编译器凭空塞进来的 $$restProps——它不是你写的变量,查不到定义。
  • 没法给整体加类型。TypeScript 里想说「这个组件的入参是 ButtonProps」,旧写法做不到,只能逐条标注(见 09 章)。
  • 它已经不是「不推荐」,是编译不过。runes 模式下写 export let 直接报错,原文:Cannot use `export let` in runes mode — use `$props()` instead(错误码 legacy_export_invalid)。

解构不会「拍快照」

普通 JS 里 let { a } = obj 是一次性取值,之后 obj.a 变了 a 也不会变。这里不是——看编译产物就明白了:let { count } = $props() 被编译成 let count = $.prop($$props, "count", 7),而模板里每一处 count 都被改写成了一次 count() 调用。所以「解构」只是你写给自己看的语法,实际生成的是一组 getter,父组件一改就跟着变。

这也解释了另一件事:...rest 拿到的是一个 Proxy,不是一次性拷贝,所以 {...rest} 转发出去的属性同样是活的。

// 子组件 Button.svelte —— 在原生 button 外面包一层
<script>
  let {
    label,
    variant = "primary",  // 只在父组件没传时生效
    class: klass = "",     // class 是保留字,解构时必须改名
    onclick,             // 回调 prop:这就是 Svelte 5 的「事件」
    ...rest              // 其余属性照单全收,原样转发给 DOM
  } = $props();
</script>

<button class="btn btn-{variant} {klass}" {onclick} {...rest}>
  {label}
</button>

<!-- 父组件:aria-label 没在解构里列出,
     它会顺着 rest 落到真实的 button 元素上 -->
<Button label="提交 {count}" variant="danger" class="wide"
        onclick={() => count++} aria-label="提交表单" />
解构默认值只对 undefined 生效,对 null 不生效——这是 JS 的规则,不是 Svelte 的。<Child size={null} />let { size = "md" } = $props(),渲染出来是空的,不是 md。后端返回的字段常常是 null 而不是缺失,所以这条特别容易在接了真实接口之后才炸。
写「壳组件」(把原生元素包一层的那种)固定用这个模板:把你自己关心的 prop 一一解构出来,剩下的一律 ...rest{...rest} 转发。这样调用方能直接传 idaria-*data-*,你一个都不用预先声明。注意 {...rest} 要放在你自己的属性后面,后写的会覆盖先写的。

props 归父组件所有,子组件只有读的份。但 Svelte 5 拦你的方式分三档——从「直接抛错」到「一声不吭」都有,分不清这三档就会在调试上耗掉大量时间。

三种改法,三种下场(5.56.7)

你在子组件里写的实际发生了什么
count++(重新赋值一个普通 prop)不报错、不告警。子组件内部的显示变了,父组件纹丝不动
user.name = "x"(改对象型 prop 的属性)改的就是父组件那个 $state proxy,父子一起变;dev 模式打警告 ownership_invalid_mutation
rest.id = "x"(改 rest 里的属性)当场抛 props_rest_readonly,唯一一个硬拦下来的

最隐蔽的是第一档:它是「临时覆盖」,不是「改成功了」

验证过程:父组件 let n = $state(0),传 count={n}。子组件点按钮 count++,子组件显示 1,父组件仍是 0——到这里你可能以为「子组件有自己的一份了」。接着让子组件把 count 改成 100,然后父组件把 n 加 1:子组件的显示直接跳回 1,那个 100 被冲得干干净净。

换句话说,非 $bindable 的 prop 被赋值时,Svelte 给了它一个本地回退值,但只要父组件那一侧的值再变一次,回退值就作废。这和 $derived 被赋值后「一旦依赖变化就被冲掉」是同一种语义(见 03 章):能写,但写的东西没有寿命。

为什么单向是默认

  • 可追溯。某个值不对,只要沿着「谁传给我的」往上找一条链就行。允许子组件回写,这条链就变成了图。
  • 写入必须显眼。Svelte 不是禁止双向,而是要求你显式申请(下一张卡的 $bindable)。申请过之后,bind: 会出现在父组件模板里——写入通道在调用方就能一眼看见。
  • 对象型 prop 是个例外,而且是有意的。$state 是深层 proxy(见 02 章),传下去的还是同一个 proxy,所以子组件改属性真的会穿透。Svelte 拦不住它,只能在 dev 模式提示一句。
// Child.svelte —— 三种「改 props」的下场完全不同
<script>
  let { count, user, ...rest } = $props();
</script>

<!-- ① 重新赋值:本地临时覆盖。父组件看不到,
     而且父组件下次改 count 时这份覆盖直接作废 -->
<button onclick={() => count++}>子内部 {count}</button>

<!-- ② 改对象属性:user 就是父组件那个 $state proxy,
     父子会一起变,但 dev 模式会警告 -->
<button onclick={() => user.name = "改了"}>{user.name}</button>

<!-- ③ 改 rest:当场抛 props_rest_readonly -->
<button onclick={() => rest.id = "x"}>改 rest</button>

// 正确姿势:把「我想改」变成「我请父组件改」
// let { count, onIncrement } = $props();
// <button onclick={onIncrement}>{count}</button>
改对象型 prop 的属性会真的穿透到父组件,很容易让人误以为「原来 props 可以改」。dev 模式下报错原文:ownership_invalid_mutationMutating unbound props (`user`, at Child.svelte:5:30) is strongly discouraged. Consider using `bind:user={...}` in App.svelte (or using a callback) instead。注意只有 dev 模式才有这句话——生产构建里它悄无声息,所以别指望上线后才发现。
想改 props 的时候先停一秒,问一句:这份状态到底该住在谁家。答案通常是三选一——住父组件(传回调让父组件改)、住子组件(那它就该是 $state,props 只当初始值)、父子共有(那就用 $bindable 明说)。三条路都比偷偷改 prop 强。

Svelte 5 拆掉了 on: 这套自造的指令语法,让事件退回成一个普通的 DOM 属性——因为一旦事件是普通属性,它就白拿了三件旧语法给不了的能力:能当 prop 传、能被 {...rest} 转发、能被 TypeScript 直接认出来。

新旧对照(括号里是 5.56.7 的处理方式)

Svelte 3/4Svelte 5旧写法现在的下场
on:click={fn}onclick={fn}仍能编译,但会告警 event_directive_deprecated
on:submit|preventDefault在函数里自己调 e.preventDefault()仍能编译能跑,只告警 event_directive_deprecated
on:click|captureonclickcapture={fn}捕获阶段没被砍,换成了属性名后缀
createEventDispatcher + on:custom回调 prop:onsave={fn}意外地完全没事,见下

关于 createEventDispatcher 的结论,和传言不一样

网上普遍说它「已移除/已废弃」。5.56.7 它仍然从 svelte 正常导出,在 runes 模式的组件里照样能 dispatch("go", { v: 1 }),父组件用 <Child on:go={...} /> 照样收得到 e.detail——编译期零警告,运行期零警告,连元素上 on:click 那种 event_directive_deprecated 都没有。

它唯一的「废弃」痕迹在类型声明里:源码上标了 @deprecated Use callback props and/or the `$host()` rune instead,所以只有编辑器会给你划一道删除线。结论:旧项目不会因为它而跑不起来,但新代码没有任何理由用它——它把一个普通函数调用包装成 CustomEvent 再拆开,白白多了 e.detail 这层壳,还丢掉了类型推导。

回调 prop:向上通信的正路

  • 事件本质上就是「父组件交给子组件的一个函数」,那就直接当 prop 传,不用中间商。
  • 参数就是参数,不用套 CustomEvent、不用从 e.detail 里挖。
  • 可以给默认值、可以 onsave?.(...) 可选调用、可以被 TS 一眼看出签名。
  • 命名跟着原生走:onXxx。这样「原生事件」和「组件自定义事件」在调用方眼里长得一模一样。

组件上的 onclick 不会自动生效

Svelte 组件不是 DOM 元素。<MyButton onclick={fn} /> 只是把一个名叫 onclick 的函数塞进子组件的 props,没有任何东西会自动把它挂到 DOM 上。子组件必须自己接住它,要么显式写 <button {onclick}>,要么用 {...rest} 一并转发。这是从 Svelte 3/4 迁过来时最常见的「点了没反应」。

// Editor.svelte —— 事件是属性,向上通信靠回调 prop
<script>
  let { onsave } = $props();   // 回调 prop 就是「自定义事件」
  let count = $state(0);

  function submit(e) {
    e.preventDefault();          // 修饰符没了,自己动手
    onsave?.({ count });          // 参数直接给,不用包 CustomEvent
  }
</script>

<!-- 值是函数引用,属性名全小写;不是 on:click -->
<button onclick={() => count++}>点了 {count} 次</button>

<!-- 捕获阶段:属性名加 capture 后缀 -->
<div onclickcapture={() => log("先于子元素触发")}>
  <form onsubmit={submit}>
    <button type="submit">保存</button>
  </form>
</div>

<!-- 父组件:自定义事件和原生事件写法完全一致 -->
<!-- <Editor onsave={(d) => saved = d.count} /> -->
事件修饰符并没有被硬拦下来:on:submit|preventDefaulton:click|once 全都编译通过、运行正常(|once 点两次确实只加一次),告警和裸 on: 一模一样。真正编译失败的是属性形式 <button onclick|preventDefault={f}>attribute_invalid_name'onclick|preventDefault' is not a valid attribute name。报错文案完全没提修饰符,只说属性名非法,第一次撞上很难联想到是修饰符的问题。
迁移老项目时用一条机械规则就够了:on:xxxonxxxon:xxx|captureonxxxcapture,带 |preventDefault|stopPropagation 的一律改成在函数体第一行手动调。|once 没有对应属性,自己在函数里加个标志位或者用 { once: true } 的原生监听。

bind:value={x} 展开就是 value={x} 加上一个把新值写回 x 的输入监听。把它当语法糖理解,就能立刻判断什么时候该用、什么时候该退回去手写。

表单绑定的几种形态

  • bind:value——文本框、textareaselectselect 绑的是 optionvalue 属性值,可以是任意 JS 值,不限于字符串,这点比原生 DOM 强。
  • bind:checked——单个复选框,绑一个布尔值。
  • bind:group——一组 radio 共享一个值;一组 checkbox 共享一个数组。初值 ["a"] 时第一个框自动是选中的,勾上第二个后数组变成 ["a", "b"],顺序按 DOM 顺序。
  • 只读绑定——bind:clientWidthbind:clientHeight 以及媒体元素的一堆属性。它们只从 DOM 流向你,写回去没用。

bind:this 取真实节点

bind:this={el} 把 DOM 元素本身赋给变量。关键在时机:挂载完成之后才有值,所以 <script> 顶层读到的一定是 undefined,必须放进 $effect 里用(见 03 章)。用在组件标签上则拿到组件实例,可以调它 export function 出来的方法。

用 bind: 还是退回单向

这是本卡真正要做的判断。

  • 用 bind:
    表单代码量直接减半,不用写 oninput、不用从 e.target.value 里取值、不用管 number 类型输入框的字符串转换
    写入通道是隐形的。值不对时,「谁把它改成这样的」不再是一行可以搜到的代码
    为何同根于它把那段赋值代码藏起来了——你省下的正是你调试时找不到的那一段
  • 退回单向(value= 加显式事件)
    每次写入都有一行你自己的代码,可以在里面校验、格式化、去空格、节流、上报
    啰嗦,一个大表单要多写几十行
    为何同根于它把写入摊开成了一个可插入的钩子——多出来的行数就是钩子的位置

判断口径

纯粹「输入什么就存什么」的字段,直接 bind:,这是 95% 的表单。一旦这个值在写入的瞬间需要做点什么——校验、去掉首尾空格、限制长度、把输入上报给搜索接口——立刻退回单向,把逻辑放进那个显式的 handler 里。用 $effect 去「监听 bind 出来的值再修正它」是下策:它会绕一圈才生效,还容易和用户正在输入的内容打架。

<script>
  let name = $state("");
  let agreed = $state(false);
  let color = $state("blue");   // radio 组:共享一个值
  let tags = $state(["a"]);      // checkbox 组:共享一个数组
  let el = $state();

  // bind:this 挂载后才有值,所以只能在 effect 里用
  $effect(() => { el?.focus(); });
</script>

<input bind:value={name} bind:this={el}>
<input type="checkbox" bind:checked={agreed}>

<input type="radio" bind:group={color} value="blue">
<input type="radio" bind:group={color} value="red">

<input type="checkbox" bind:group={tags} value="a">
<input type="checkbox" bind:group={tags} value="b">

<p>{name} · {agreed} · {color} · {tags.join("+")}</p>
bind:this 拿到的值在组件初始化时是 undefined,在 <script> 顶层直接 el.focus() 必然抛 Cannot read properties of undefined。必须放进 $effect。同理,元素被 {#if} 移除时这个变量会被置回 null,effect 里要么写 el?.focus(),要么先判空再用。
bind: 的右边必须是一个可写的东西——$state 变量、对象属性、数组下标都行,$derived 出来的值不行。想绑「一个对象里的字段」直接写 bind:value={form.email},因为 $state 是深层 proxy(见 02 章),改属性一样会触发更新,不需要把字段拆成一堆独立变量。

$bindable() 是子组件显式开的一道口子:声明「这个 prop 允许父组件用 bind: 接管」。默认不开是有道理的——但 5.56.7 里忘了开口子的后果,比你想象的安静得多。

怎么写

  • 子组件:let { value = $bindable(0) } = $props()。括号里那个 0父组件什么都没传时的回退值,不是「初始值」——父组件传了就以父组件为准。
  • 父组件:<Rating bind:value={score} />。之后子组件对 value 的每一次赋值都会写回 score
  • 父组件也可以不绑,写成 <Rating value={3} /> 或干脆不传。这时子组件内部的赋值退化成上一张卡说的「本地临时覆盖」,组件照样能用。

它顺手解掉了「受控/非受控」这道题

在别的框架里,一个输入类组件要么由自己管状态(非受控,父组件拿不到值),要么完全由父组件喂(受控,父组件必须写一堆样板)。组件库作者通常得实现两套,或者靠约定俗成的 valueonChange 组合。

$bindable 把选择权交给调用方:同一份子组件代码,父组件写 bind: 就是受控,不写就是非受控。子组件里只有一行差别(= $bindable(...)),不需要分支。这是 Svelte 在组件 API 设计上少见的、真正比同行省事的地方。

忘了 $bindable 会怎样——什么都不会

构造:父组件 <Bad bind:value={name} />,子组件只写 let { value } = $props()。结果是挂载不报错、控制台没有任何输出;子组件把 value 改成「子改的」,子组件内部显示变了,父组件的 name 还停在「初始」。dev 模式和生产模式表现完全一致。

Svelte 的错误表里确实躺着一条 bind_not_bindable(消息是 A component is attempting to bind to a non-bindable property …),但对 5.56.7 整个包做全文搜索,这个函数只有定义、没有任何调用点——它是死代码,你等不到它抛出来。所以这个错误没有任何自动化手段能替你发现,只能靠一条人工检查:父组件每写一个 bind:xxx,就去子组件确认 xxx 那行有没有 $bindable

什么时候该开这个口子

判断很简单:这个组件是不是在「代表」一个值。输入框、评分星、日期选择器、开关、富文本编辑器——它们存在的意义就是让用户改一个值,开。而列表、卡片、弹窗、布局容器不是,它们的状态该由父组件用回调 prop 驱动。一个组件如果需要三个以上的 $bindable,基本可以确定是设计错了,应该改成传一个对象下去让子组件改属性。

// Rating.svelte —— 评分组件,代表「一个分数」
<script>
  // 括号里是父组件没传时的回退值,不是固定初始值
  let { value = $bindable(0), max = 5 } = $props();
</script>

{#each Array(max) as _, i}
  <button onclick={() => value = i + 1}>
    {i < value ? "★" : "☆"}
  </button>
{/each}
<button onclick={() => value = 0}>清零</button>

// App.svelte —— 父组件绑上去,两边永远同步
<script>
  import Rating from "./Rating.svelte";
  let score = $state(2);
</script>

<p>父组件的 score = {score}</p>
<Rating bind:value={score} max={3} />

<!-- 不绑也能用:这时它就是个非受控组件 -->
<!-- <Rating max={3} /> -->
子组件忘写 $bindable 是本章最危险的坑,因为它完全静默:不报错、不告警、DOM 在子组件里看着还是对的,只有父组件那份数据永远不动。5.56.7 里 bind_not_bindable 这条错误已经没有任何调用点,纯属死代码。排查线索只有一个——子组件里改了值,父组件却毫无反应,看到这个症状先去数 $bindable
$bindable() 的参数容易被读成「默认值」,其实它是回退值——只在父组件完全不传这个 prop 时才用得上。一旦父组件写了 bind:,Svelte 就不允许两边同时想做主:bind:value={score}scoreundefined 时,组件挂载当场抛 props_invalid_valueCannot do `bind:value={undefined}` when `value` has a fallback value(生产构建也抛,只是消息只剩一个网址)。所以要么父组件给个实打实的初值,要么子组件别写回退值。

props 向下、回调向上、context 跨层、模块级状态全局。这四条路的区别不在能力——理论上 props 层层传能顶替一切——而在「这份数据的生命周期跟谁绑」。选错的代价是数据在不该活着的时候还活着。

选型表

路径方向数据住在哪什么时候用它
props父到子,一层父组件实例默认选项。层数不超过两三层就一直用它
回调 prop子到父父组件实例子组件要通知父组件「发生了什么」
bind:$bindable双向,一层父组件实例子组件在「代表」一个值(输入类组件)
context祖先到任意深度的后代提供它的那个组件实例主题、i18n、表单组、一棵子树内的共享配置
模块级 .svelte.js 状态全局模块(进程内单例)登录用户、购物车这种真·全局(见 08 章)

context 的机制与三条硬限制

  • 机制。setContext(key, value) 往当前组件实例挂的一张 Map 里写;getContext(key) 沿组件树往找最近的一个。它是依赖注入,不是状态管理——它只负责「把东西送到手」,不负责让东西变化。
  • 必须在初始化期间同步调用。放进事件回调里直接抛错,原文:lifecycle_outside_component`getContext(...)` can only be used during component initialisation。放进 $effect 里更阴——非异步模式下它不报错,只是设了个没人看得见的值,因为后代早就初始化完了。
  • 取不到不报错。拿一个从没被 setContext 过的 key,安静地返回 undefined,然后在你用它的地方炸成 Cannot read properties of undefined。所以 key 一律用 Symbol 并由一个模块统一导出,让「拼错字符串」这种错误根本无法发生。

context 传值会丢响应式,必须包 getter

setContext(KEY, theme) 存进去的是那一刻的值,之后 theme 再变,后代手里还是旧的。正确做法是存一个带 getter 的对象:{ get theme() { return theme } }——getter 让读取发生在后代组件的渲染里,信号依赖才会被记上(见 02 章)。深层组件调 toggle(),顶层和深层同时更新。

为什么 context 不能当全局状态用

context 跟组件实例走。同一个提供者组件在页面上挂两份,两份 context 互不相干——做多标签页、多面板这类界面时这正是你要的。反过来说,真正的全局单例应该放模块级;但要留意模块级状态在服务端渲染下是跨请求共享的,把当前用户塞进模块变量会直接串号,这时反而要退回 context(见 08 章与 14 章)。这条是「跟组件实例走」和「跟进程走」的分界线,也是选型表里最值钱的一格。

// theme.svelte.js —— 用了 runes,文件名必须带 .svelte.js
import { getContext, setContext } from "svelte";

// Symbol 做 key:拼错字符串这种错误直接不可能发生
const KEY = Symbol("theme");

export function provideTheme() {
  let theme = $state("light");
  const ctx = {
    // 关键:存 getter 而不是值,读取才会发生在后代的渲染里
    get theme() { return theme; },
    toggle() { theme = theme === "light" ? "dark" : "light"; }
  };
  setContext(KEY, ctx);
  return ctx;
}

export const useTheme = () => getContext(KEY);

// App.svelte(顶层):const t = provideTheme();
// Deep.svelte(任意深度):
//   const t = useTheme();          <-- 必须在 script 顶层调
//   <button onclick={t.toggle}>{t.theme}</button>
getContext 只能在组件初始化期间调用,写进事件回调里抛 lifecycle_outside_component`getContext(...)` can only be used during component initialisation。真正难查的是另一半:取一个不存在的 key 不会报错,安静返回 undefined,报错点被推迟到你解构或调用它的那一行,堆栈里完全看不出是 context 没提供。
别一上来就上 context。判断口诀:只有当「中间那几层组件根本不关心这个数据、纯粹在当搬运工」时,才换 context。传两层的 props 是清晰,传五层才是问题。另外把 setContextgetContext 包成一对 provideXxxuseXxx 函数放进 .svelte.js,调用方就永远看不到 key,也不会在错误的地方调。

Snippets 与组件组合

snippet 取代了 slot:它是可传递的模板片段,本质是函数。组合能力比旧 slot 强得多,心智也更统一。

{#snippet 名(参数)}…{/snippet} 定义一段模板,{@render 名(实参)} 把它渲染出来。关键不在语法,而在它编译出来就是一个普通函数——旧的 <slot> 之所以处处受限,就是因为它是编译器特设的一种「洞」,而不是一个值。

两个语法,一句话说完

  • 定义{#snippet row(item, index)}{/snippet}。参数列表和 JS 函数一模一样,可以有零个参数,也可以有默认值。
  • 渲染{@render row(item, i)}。可能为空时写 {@render children?.()}——因为它就是函数,可选链原样可用。
  • 作用域:snippet 名字的可见范围和 let 声明一样——同一层模板里、以及嵌套在它内部的模板里可见。定义在 {#each} 内部的 snippet,外面就用不了。

先解决最直接的问题:模板里的重复

在有 snippet 之前,一段模板要复用只有两条路:抽成一个单独的 .svelte 文件(为了三行 HTML 新建一个文件,还要把用到的变量全部当 props 传进去),或者复制粘贴。snippet 给了第三条路——就在原地定义,直接闭包读取周围的变量,什么都不用传。

它在 {#each} 里尤其顺手:把「一行长什么样」抽成 snippet,循环体就只剩一句 {@render row(item, i)},分支多的时候可以按条件 render 不同的 snippet,而不是在模板里堆三层 {#if}

「它就是函数」能解释后面的一切

这句话值得先记住,本章剩下四张卡讲的行为都是它的推论:函数能带参数,所以 snippet 能传参;函数是闭包,所以 snippet 能读定义处的变量;函数是值,所以 snippet 能当 prop 传、能塞进数组和对象、能层层往下递。<slot> 这三件事一件都做不到,因为它根本不是个值。

<script>
  let items = $state([{ name: "笔", n: 3 }, { name: "本", n: 12 }]);
  let unit = $state("件");
</script>

<!-- 参数列表和 JS 函数一样;unit 是闭包读的,没当参数传 -->
{#snippet row(item, index)}
  <li class:even={index % 2 === 0}>
    {index + 1}. {item.name} —— {item.n}{unit}
  </li>
{/snippet}

{#snippet empty()}
  <li class="empty">还没有东西</li>
{/snippet}

<ul>
  {#each items as item, i}
    {@render row(item, i)}
  {:else}
    {@render empty()}
  {/each}
</ul>
{@render} 的参数会被 Svelte自动包成 getter 再传进去,目的是让 snippet 内部读到的永远是最新值。这在写普通 snippet 时完全无感,但你要是把一个 snippet 当普通函数直接调用(row(item, 0)),拿到的不是 HTML 也不会报错,只会得到一个诡异的结果——snippet 只能通过 {@render} 使用。
snippet 可以在定义它的位置之前就被 {@render}——和函数声明提升一样。所以习惯上把所有 snippet 定义堆在模板底部,让顶部保持成一眼能看懂的页面骨架,这比在 {#each} 中间插一大段定义可读得多。

Svelte 3/4 的三种插槽用法,在 5 里被统一成了一件事:把 snippet 当 prop 传给子组件。三个特设语法收敛成一个普通的传参动作,这是本章最值得记住的一次简化。

一张对应表

Svelte 3/4Svelte 5
子组件写 <slot />子组件写 {@render children?.()}
父组件在标签之间随便写内容一模一样,内容自动变成 children snippet
具名插槽 <slot name="foot" />普通 prop:{@render footer?.()}
父组件 <p slot="foot">父组件 {#snippet footer()}…{/snippet}
作用域插槽 <slot item={x} />let:item就是函数参数{@render item?.(x)}{#snippet item(x)}
$$slots.foot 判断有没有传{#if footer}——它就是个普通变量,判空而已

旧的 slot 现在什么状态(5.56.7)

  • 还能用,但会告警。在 runes 模式的组件里写 <slot />,编译通过,告警原文:slot_element_deprecatedUsing `<slot>` to render parent content is deprecated. Use `{@render ...}` tags instead。具名 slot 也是同一条。
  • 父组件那一侧完全没有告警。<C><p slot="foot">脚</p></C> 或者 <C let:item>,编译零警告。也就是说迁移时编译器只会提醒你改被调用方,调用方那一侧要自己排查。
  • 混用是硬错误。同一个组件里既有 <slot> 又有 {@render},编译直接失败,原文:Cannot use `<slot>` syntax and `{@render ...}` tags in the same component. Migrate towards `{@render ...}` tags completely(错误码 slot_snippet_conflict)。

为什么这不只是换个写法

<slot> 是模板里的一个「洞」——编译器要专门记住哪个洞叫什么名字、父组件的哪块内容该填进哪个洞、洞里暴露了哪些变量(let:)。这一整套机制只服务于「父传子的模板」这一个场景,而且是死的:洞不能被存起来、不能被转手、不能被条件性地换成另一个。

snippet 把这个洞变成了一个参数。于是「有没有传插槽」退化成「这个 prop 是不是 undefined」,「作用域插槽」退化成「调用时给它传实参」,「多个具名插槽」退化成「多个 prop」。三种特设语义全部消失,剩下的只是你本来就会的东西。

// Svelte 3/4 的老写法(Card.svelte,现在会告警)
// <h3><slot name="title" /></h3>
// <div><slot /></div>
// <footer><slot name="foot" item={data} /></footer>

// Svelte 5:三种插槽全变成普通 prop
<script>
  let { title, children, actions, item } = $props();
</script>

<article>
  <h3>{title}</h3>
  {@render children?.()}

  <!-- 原来的 $$slots.foot:现在就是判个空 -->
  {#if actions}
    <footer>{@render actions(item)}</footer>
  {/if}
</article>

<!-- 父组件:具名插槽写成 snippet,let:item 变成函数参数 -->
<!-- <Card title="商品" item={it}>
       <p>这段自动变成 children</p>
       {#snippet actions(x)}<button>删除 {x.name}</button>{/snippet}
     </Card> -->
一个组件里 <slot>{@render} 不许共存,直接编译失败:Cannot use `<slot>` syntax and `{@render ...}` tags in the same componentslot_snippet_conflict)。所以大组件没法「先改一半」,只能整个文件一次性迁完。改到一半跑不起来是正常的,别怀疑自己改错了方向。
迁移时按这三步机械替换:子组件的 <slot />{@render children?.()}<slot name="x" /> → 从 $props() 解构出 x{@render x?.()};父组件的 slot="x" 属性 → 包一层 {#snippet x()}let: 最后处理,把它列出的每个变量改成 snippet 的形参即可。

写在组件标签之间的内容,会被编译器自动打包成一个叫 children 的 snippet 传给子组件。这不是特例,只是一个语法便利——你完全可以手写 {#snippet children()},效果一模一样。

它是怎么来的

  • 父组件写 <Card><p>正文</p></Card>,编译器把 <p>正文</p> 收集成一个无参 snippet,以 children 为名放进传给 Card 的 props 对象里。
  • 子组件用 let { children } = $props() 接住,再 {@render children?.()} 渲染。
  • 没有内容时 children 就是 undefined,所以 ?.() 那个可选链不是可有可无——<Card /> 只要漏了 ?. 就抛 invalid_snippet,好在它的提示词很直白:Consider using optional chaining `{@render snippet?.()}`
  • 子组件里定义的具名 snippet 会作为同名 prop一起送上去:{#snippet footer()} 写在 <Card> 标签内部,子组件就能从 $props() 里解构出 footer

混着写也没问题

标签内部可以同时有散落的内容和若干个 {#snippet}:散落的部分归 children,snippet 各归各名。把 <p>正文</p>{#snippet actions(x)}…{/snippet} 写在一起,两边都正确送达,且 actions 收到的实参能在子组件里改父组件的 $state——因为 snippet 里的 x 就是那个 proxy。

children 是个普通名字,会撞车

children 没有被保留,你自己也可以传一个叫 children 的普通 prop(比如一棵树的子节点数组)。这时它会和标签内容抢同一个名字:写了标签内容,你传的数组就被覆盖掉。给树形数据起名叫 itemsnodessubtree,别碰这个词。

// Card.svelte —— children 是自动来的,actions 是具名的
<script>
  let { title, children, actions, item } = $props();
</script>

<article>
  <h3>{title}</h3>
  <!-- ?. 不能省:没写内容时 children 是 undefined -->
  {@render children?.()}
  {#if actions}
    <footer>{@render actions(item)}</footer>
  {/if}
</article>

// App.svelte —— 散落内容归 children,具名的各归各名
<script>
  import Card from "./Card.svelte";
  let it = $state({ id: 7, name: "笔记本" });
</script>

<Card title="商品" item={it}>
  <p>这段自动变成 children</p>
  {#snippet actions(x)}
    <!-- x 就是父组件那个 $state proxy,改得动 -->
    <button onclick={() => x.name += "!"}>改 #{x.id}</button>
  {/snippet}
</Card>
漏了 ?. 而调用方没传内容,抛 invalid_snippetCould not `{@render}` snippet due to the expression being `null` or `undefined`. Consider using optional chaining `{@render snippet?.()}`——这条提示很友好。但如果你 render 的是一个非空却不是 snippet 的值(手滑传了个对象),报错就变成毫无线索的 TypeError: snippet is not a function,堆栈里只有一句 in Card.svelte
{@render children?.()} 里的 ?. 请无条件写上,哪怕你确信调用方一定会传内容——这一个字符换来的是「组件被别人复用时不会莫名其妙崩掉」。同理,具名 snippet 要么用 {#if 名} 包住,要么 {@render 名?.()},二选一。

snippet 编译出来是一个函数,所以它享有函数的全部待遇:闭包捕获、当参数传、存进数据结构、条件性地挑一个来用。记住「它就是函数」这一句,本卡所有行为都不需要背。

闭包:读定义处的变量,而不是渲染处的

snippet 里用到的变量,绑定的是定义它的那个作用域。所以你可以在父组件定义一个 snippet、把它传进三层深的子组件里去渲染,snippet 里读的还是父组件的变量——而且是响应式地读。

父组件有 let unit = $state("元"),snippet 里写 {r.price}{unit},把这个 snippet 塞进一个数组传给 <Table> 去渲染。在父组件把 unit 改成「刀」,表格里两行的单位立刻跟着变。跨了组件边界、跨了数组、跨了 {#each},依赖追踪照样成立。

当值传:三种花样

  • 当普通 prop 传。<List item={myRow} />,和传字符串没有区别。写在标签内部的 {#snippet item()} 只是这件事的语法糖。
  • 层层往下递。子组件收到 item 之后可以原样 <Inner {item} /> 再传一层,不需要任何特殊语法。<slot> 时代这是做不到的。
  • 放进对象和数组。columns={[{ cell: nameCell }, { cell: priceCell }]} 完全正常——这就是写通用表格组件的标准做法:一列的「表头文字」「宽度」「怎么渲染」打包成一个对象,渲染逻辑那一项存的是 snippet。

用变量挑一个 snippet 来 render

{@render} 后面跟的是一个表达式,不必是字面量名字。{@render (mode === "card" ? cardView : listView)(item)} 是合法的,{@render lookup[type](item)} 也是。这让「按数据类型分派到不同渲染」变成一次查表,不用在模板里堆 {#if} 阶梯。

作用域是词法的,不是「渲染位置」的

反过来说:snippet 看不到它被渲染的地方的变量。在 {#each items as item} 内部 {@render row()},如果 row 定义在 each 外面,它就读不到 item——必须 {@render row(item)} 显式传。这和函数的规矩完全一致,但从 let: 迁过来的人常在这里绊一下,因为旧的作用域插槽正好是反过来的(由被渲染处向外暴露变量)。

// Table.svelte —— 列的「怎么渲染」是数据的一部分
<script>
  let { rows, columns } = $props();
</script>

<table><tbody>
  {#each rows as row}
    <tr>
      <!-- col.cell 是个存在对象里的 snippet -->
      {#each columns as col}<td>{@render col.cell(row)}</td>{/each}
    </tr>
  {/each}
</tbody></table>

// App.svelte
<script>
  import Table from "./Table.svelte";
  let unit = $state("元");
  let rows = $state([{ name: "笔", price: 3 }]);
</script>

{#snippet nameCell(r)}<b>{r.name}</b>{/snippet}
<!-- unit 是闭包读的:在这里改,表格里立刻跟着变 -->
{#snippet priceCell(r)}<i>{r.price}{unit}</i>{/snippet}

<Table {rows} columns={[{ cell: nameCell }, { cell: priceCell }]} />
snippet 只看得见定义处的变量。在 {#each items as item}{@render row()}row 定义在 each 外面、内部又用了 item——编译完全通过、零警告,一直到运行时才抛 ReferenceError: item is not defined。从旧的 let:item 作用域插槽迁过来时特别容易忘,因为那套机制的方向正好相反。改成 {@render row(item)} 显式传参即可。
判断一个东西该当 prop 传还是当 snippet 传,看它是不是要变成 DOM。「按钮上的文字」是字符串 prop,「整个按钮长什么样」是 snippet。一个组件如果开始出现 iconNamelabelHtmlbadgeColor 这种为了描述外观而堆砌的字符串 prop,就该换成一个 snippet 了。

可复用组件有两种写法:让调用方传一堆配置描述它想要什么,或者让调用方直接给一段模板。snippet 让第二种变得廉价,而第二种几乎总是对的——因为配置对象的表达力是有上限的,模板没有。

先看配置对象那条路会怎么烂掉

写一个列表组件,第一版只需要 items。然后需求来了:某些列表项要显示头像,加个 showAvatar;有的要链接,加个 linkTo;链接文案要定制,加个 labelKey;有的项要置灰,加个 disabledWhen(还得是个函数);空状态文案要换,加个 emptyText;空状态还想放个按钮,于是 emptyText 不够了……

每一次需求都在组件内部加一个分支。半年后这个组件有二十个 prop、七层 {#if},而且下一个需求依然进不来,因为你无法预见调用方到底想渲染什么。根因是方向错了:组件在试图猜测调用方的意图。

换成 snippet:把「长什么样」交回给调用方

  • 组件只负责结构与状态:加载中/空/有数据的分支、循环、布局、键盘交互、无障碍属性。
  • 调用方负责每一块具体长什么样headeritemempty 三个 snippet。
  • 组件把自己知道的东西当参数递出去——{@render header(items.length)}{@render item(it, i)}。这就是控制反转:组件不猜调用方要什么,只把原料摆出来。
  • 结果是 prop 数量停止增长。的这个 DataList 只有四个 prop,却能渲染任何形态的列表;再来十个需求也不需要改它一个字。

什么时候还是该用配置

不是所有东西都该 snippet 化。

  • snippet(传模板)
    表达力无上限,组件的 prop 数量收敛,调用处能一眼看出渲染结果
    调用处更长;多个调用方想要同一种外观时,那段模板会被复制好几遍
    为何同根于它把决定权推给了调用方——决定权在谁手里,重复就在谁那里
  • 配置对象(传数据)
    调用处极短,同一套配置能被多处共用,还能从接口动态下发
    只能表达你预先设想过的那些形态,一旦不够就得改组件
    为何同根于它把决定权收在组件里——省下的调用代码就是丢掉的表达力

判断:形态有限且可枚举(按钮的 variant、对齐方式、尺寸)用配置;形态开放(一行长什么样、空状态放什么、弹窗底部有哪些按钮)用 snippet。拿不准就先 snippet——把常见组合再包一层做成预设组件很容易,反过来把写死的分支拆出来很难。

createRawSnippet:用 JS 造 snippet

5.56.7 确实导出了 createRawSnippet,可用。它接收一个函数,返回 { render, setup }render() 返回一段 HTML 字符串作为初始结构,setup(node) 拿到真实节点,在里面用 $effect 做后续更新。参数是取值函数而不是值,因为 {@render} 会把实参包成 getter,所以内部要写 count() 而不是 count

它只服务一个场景:你在 .js.ts 里而不是 .svelte 模板里,需要造一个 snippet 交给组件——写组件库的编程式 API、做通用渲染器时会用到。日常业务代码碰不到它,也不该碰:render() 返回的是原始 HTML 字符串,拼进用户输入就是 XSS。

// DataList.svelte —— 只管结构和状态,不管长什么样
<script>
  let { items, item, empty, header } = $props();
</script>

<section>
  <!-- 把自己知道的东西当参数递出去,不猜调用方要什么 -->
  {#if header}<header>{@render header(items.length)}</header>{/if}

  {#if items.length === 0}
    {@render empty?.()}
  {:else}
    <ul>
      {#each items as it, i}<li>{@render item(it, i)}</li>{/each}
    </ul>
  {/if}
</section>

// App.svelte —— 四个 prop 就够了,再多需求也不用改组件
<DataList items={users}>
  {#snippet header(n)}<b>共 {n} 人</b>{/snippet}
  {#snippet item(u, i)}<a href="/u/{u.id}">{i + 1}. {u.name}</a>{/snippet}
  {#snippet empty()}<p>一个人都没有</p>{/snippet}
</DataList>
createRawSnippet 的参数是取值函数不是值。把 {@render badge(n)} 里的 n 在内部当普通值用,模板字符串里会插出一段函数源码(类似 () => $.get(n))而不会报任何错——必须写成 count() 调用一次。另外 render() 返回的是原始 HTML 字符串,拼接任何用户数据都是 XSS 入口。
给 snippet 型 prop 起名时用位置或角色,不要用外观:itemheaderemptyactions 是好名字,redButtonboldTitle 不是。名字一旦描述外观,就等于把调用方的决定权又收了回来,snippet 的意义也就没了。

样式、过渡与动画

样式默认作用域化是编译期做的。过渡与动画是 Svelte 的传统强项,但它们和条件渲染的配合有讲究。

组件里的 <style> 默认只作用于本组件——但这不是运行时的什么「样式沙箱」,而是编译器在生成 CSS 时给每条选择器补上一个组件专属的哈希类名,同时给模板里对应的元素也贴上同一个类。浏览器最终拿到的只是一份平平无奇的普通 CSS。

亲眼看一遍编译产物

code 里那个组件丢进 compile(src).css.code,源码与产物一一对照(svelte 5.56.7,哈希为 svelte-n50uah):

你写的编译出来的
p { color: blue; }p.svelte-n50uah { color: blue; }
.card p { margin: 0; }.card.svelte-n50uah p:where(.svelte-n50uah) { margin: 0; }
.nope { color: red; }/* (unused) .nope { color: red; }*/

与此同时模板被改写成 <div class="card svelte-n50uah"><p class="svelte-n50uah">…。哈希是按组件源码算出来的,所以同一个组件的所有实例共用同一个哈希——作用域的粒度是「组件」,不是「实例」。想让两个实例长得不一样,只能靠传 class 或 CSS 变量,指望在 <style> 里写条件是没有出路的。

那个 :where() 是干什么用的

  • 后代选择器的第二段写成 p:where(.svelte-n50uah),而不是直白的 p.svelte-n50uah
  • :where() 的特异度恒为零,所以「加哈希」这个动作一分特异度都不会额外引入
  • 这修掉的是 Svelte 3/4 时代的老毛病:作用域化本身不该改变你 CSS 的优先级排序。你写的 .card pp 谁强谁弱,编译前后完全一致。
  • 推论:调试样式冲突时,可以放心地把哈希类当作不存在来推算优先级。

未使用的选择器:是注释掉,不是删掉

产物里它并没有消失,而是整条被包成 CSS 注释留在原地:/* (unused) .nope { color: red; }*/。同时编译器抛一条告警,原文是:

  • code:css_unused_selector
  • message:Unused CSS selector ".nope"

判断依据是编译期静态可见的模板——编译器只认这个组件 markup 里写死的元素与静态类名。运行时才出现的节点,它一概看不见。

编译期作用域 vs 运行时方案

同样是「样式不泄漏」,各家的路数很不一样:

  • Svelte:编译期加哈希
    零运行时开销,产物是纯 CSS 文件,浏览器能正常缓存与并行下载;写法就是你早就会的 CSS,没有新语法要学,也不需要 css-in-js 那套构建管线。
    只能覆盖编译器静态看得见的元素。{@html} 渲染出来的内容、第三方库自己塞进来的节点,一律拿不到哈希类,样式对它们完全无效。
    为何优点和短板同根于「一切在编译期定案」:正因为运行时什么都不做,才既没有开销、也没有任何办法应付运行时才冒出来的节点。逃生门就是下一张卡的 :global
<div class="card">
  <p>作用域样式</p>
</div>

<style>
  p { color: blue; }
  .card p { margin: 0; }
  .nope { color: red; }    /* 模板里根本没有 .nope */
</style>

// ── 以上组件的真实产物(compile(src) · svelte 5.56.7)──
// css.code:
//   p.svelte-n50uah { color: blue; }
//   .card.svelte-n50uah p:where(.svelte-n50uah) { margin: 0; }
//   「(unused) .nope { color: red; }」被整条包成 CSS 注释留在产物里
//
// 渲染出的 DOM:
//   <div class="card svelte-n50uah"><p class="svelte-n50uah">…</p></div>
//
// warnings:css_unused_selector — Unused CSS selector ".nope"
{@html markdown} 的排版样式写在组件 <style> 里,会整条失效:编译器扫不到那些运行时才生成的 <p><h2>,于是把你的规则注释成 /* (unused) … */,页面上一点效果都没有,控制台只留一条 css_unused_selector — Unused CSS selector "…"。这类样式必须用 :global 包住。
哈希按组件源码计算,一个组件的全部实例共用一个哈希。所以「同一个组件在不同位置要长得不一样」这类需求,永远不要试图在 <style> 里解决,一律走 props 传 class 或传 CSS 变量(见本章后面那张动态样式卡)。

作用域化既然是「给选择器补哈希」,那么穿透它的办法就只有一个:告诉编译器这一段别补。:global 就是这个开关,它不是运行时特权,只是编译期的一句「原样输出」。

三种写法,三种产物

写法产物用在哪
:global(body) { margin: 0 }body { margin: 0 }整条全局,哈希完全不加
.a :global(.b) { … }.a.svelte-1lj1c2n .b { … }局部锚点+全局后代,最常用
:global { .x { … } }块内原样输出,产物里以 /* :global {*/ 标注一次放行一大片

第二行是重点::global() 只放行它括号里的那一段,左边的祖先选择器仍然被作用域限定。所以 .a :global(.b) 的真实含义是「本组件的 .a 内部的任何 .b」——影响面被牢牢圈在自己的子树里,这是最安全也最该优先用的写法。

:global 块不做未使用检查

  • 普通选择器没用到会被注释掉并告警;:global { … } 块里的内容原样输出、不检查、不剔除
  • 这既是方便(给 {@html} 内容写排版时不用一条条包 :global()),也是风险(写错了没人提醒你)。
  • 块内可以嵌套多条规则,编译产物里保留原缩进,只是把 :global {} 两行注释掉。

全局样式到底该放哪

能力上 :global 什么都能干,但位置决定了它的生命周期:

  • 真正的全局重置、字体、CSS 变量根定义——放独立的 app.css,在根布局里 import。它和任何组件都无关,不该被打进某个组件的 chunk。
  • 只服务于本组件内部某块动态内容的样式——用 .锚点 :global(...) 写在组件里。跟着组件走,改起来在同一个文件。
  • 要覆盖第三方组件库的内部结构——同样用局部锚点包住,绝不要裸写 :global(.some-lib-class)

「样式默认不泄漏」为什么是卖点

在没有作用域的世界里,CSS 是唯一一个全局单一命名空间的东西:任何一个 .title 都可能砸到别人头上,于是整个前端社区发明了 BEM、CSS Modules、css-in-js、原子化 CSS 一堆方案来绕开它。Svelte 的选择是把这件事下沉到编译器——你什么都不用做,写 .title 就只影响自己,删组件时样式跟着一起消失,不会留下无人认领的死 CSS。代价只有一条:动态节点要显式开门。

<style>
  /* 1) 整条全局:产物就是 body { margin: 0; },一点哈希都没有 */
  :global(body) { margin: 0; }

  /* 2) 局部锚点 + 全局后代 —— 优先用这种 */
  /*    产物:.a.svelte-1lj1c2n .b { color: blue; } 左边仍被限定 */
  .a :global(.b) { color: blue; }

  /* 3) :global 块:整块原样输出,且不做未使用检查 */
  :global {
    .markdown h2 { font-size: 1.4rem; }
    .markdown p  { line-height: 1.8; }
  }
</style>

<!-- @html 出来的节点拿不到哈希类,只能靠上面第 2/3 条兜住 -->
<div class="markdown">{@html rendered}</div>

// 产物里 :global 块长这样(注释掉了首尾两行,中间原样):
//   /* :global {*/
//     .markdown h2 { font-size: 1.4rem; }
//   /*}*/
别拿 :global 写全局重置。它会被打进这个组件的 CSS chunk,于是样式的生效范围取决于该组件有没有被加载——首屏没渲染这个组件就没有重置,路由切走了样式却还留在 <head> 里。:global 块内部还不做未使用检查,选择器写错了编译器一声不吭。重置类样式请老老实实放 app.css
:global() 写在选择器右侧、左侧留一个本组件的锚点类,是九成场合的正确答案:.editor :global(.ProseMirror p)。这样既穿透了作用域,影响面又还锁在自己的子树里,将来别人删掉这个组件,样式也一起消失。

作用域样式管的是「静态长什么样」,动态部分靠指令。Svelte 5 在老的 class:style: 之外,给 class 属性本身加了对象与数组形式——写法从此和 clsx 那类工具库对齐,但不需要装任何东西。

四种写法都验证过(svelte 5.56.7)

写法示例结果
指令class:active={on}class="base active"on 变假则移除
对象class={{ active: on, big }}键为类名、值为真才加,class="active"
数组class={["base", on && "active", { big }]}自动拍平、忽略 falsy,class="base active"
指令+对象并存class={{ base: true }} class:extra={on}合并,class="base extra"

数组形式最有价值的一点是可以直接把外部传进来的 class 拼进去class={[rest.class, { active: on }]}。在 Svelte 5 之前这要手写字符串模板,很容易漏空格或者拼出 undefined

style: 指令与 CSS 变量

  • style:color={c} 设单个内联属性;同名时它会盖掉 style="" 属性里的写法style="color: blue; font-weight: bold" style:color={color} 渲染出 style="font-weight: bold; color: red;"——蓝色被完全顶掉了。
  • style:--gap="4px" 可以直接设 CSS 自定义属性,产物里就是 --gap: 4px;
  • 给子组件传变量写成 <Child --bg={c} />,值变了会跟着更新。这是「让父组件控制子组件外观」最干净的通道——比传一堆样式 props 好,因为变量能穿透任意层级、还能被子组件用 var(--bg, 默认值) 兜底。

什么时候用指令,什么时候用表达式

  • class:x={cond} 指令
    类名是字面量、写在属性位上,编译器看得见,因此 <style> 里对应的 .x 不会被判成未使用。一两个条件类时最短。
    条件一多就排成长长一串属性,也没法把外部 class 合并进来。
    为何同根于「它是编译期可分析的静态结构」:正因为形态固定,才既便于分析、又不灵活。
  • class={对象/数组} 表达式
    条件多、要合并外部 class、类名要拼接时唯一顺手的写法。
    类名藏在表达式里,编译器的未使用检查可能扫不到,本组件 <style> 里的同名规则有被注释掉的风险。
    为何同根于「它是运行时才求值的」:换来了灵活度,就换掉了编译期的可见性。
<script>
  let on = $state(true);
  let color = $state("red");
  let { class: cls = "" } = $props();   // 外部传进来的 class
</script>

<!-- 对象形式:键是类名,值为真才加上 -->
<p class={{ base: true, off: !on }}>对象</p>

<!-- 数组形式:拍平并忽略 falsy,最适合合并外部 class -->
<p class={[cls, on && "active", { big: on }]}>数组</p>

<!-- class={} 与 class: 指令并存,合并成 class="base extra" -->
<p class={{ base: true }} class:extra={on}>并存</p>

<!-- style: 同名属性会顶掉 style="" 里的写法 -->
<!-- 产物:style="font-weight: bold; color: red;" -->
<p style="color: blue; font-weight: bold" style:color={color}>样式</p>

<!-- 给子组件传 CSS 变量,子组件里用 var(--bg, white) 取 -->
<Child --bg={color} --pad="8px" />
给组件传 CSS 变量(<Child --bg={c} />)会真的插入一个包裹元素。产物是 <svelte-css-wrapper style="display: contents; --bg: tomato;">display: contents 让它在视觉上隐形,但它仍是一个 DOM 节点:父级写 .parent > .child 这类直接子选择器会失配,依赖直接子元素的 flex/grid 布局也会被它切断。
三选一口诀:一两个条件类用 class:(编译器看得见,不会误判未使用);条件多用对象 class={{ a: x, b: y }}需要拼外部 class 用数组 class={[rest.class, { a: x }]}——数组会自动拍平并丢掉 falseundefined,不用再操心空格。

Svelte 的 transition: 不是「值变了就动一下」,它挂钩的是元素的创建与销毁。想清楚这一条,九成的「为什么我的过渡不播」就自己解决了。

三个指令的分工

  • transition:fade——进场和退场用同一套配置,且两个方向是可逆的:退场播到一半又变回可见,它会从当前位置往回走,而不是从头重播。
  • in:fly / out:slide——进出分开指定,各自独立、不可逆。要「飞入淡出」这种不对称效果就用它。
  • 同一个元素上可以只写 in:、只写 out:,或者两个都写,但不要和 transition: 混用。

svelte/transition 导出七个:blurcrossfadedrawfadeflyscaleslide。常用的就是 fade/fly/slide/scale 四个,都接受 { delay, duration, easing }

什么算「进出 DOM」

在一个 {#if visible} 里放 transition:fade={{ duration: 200 }}<div>,(jsdom + Web Animations 垫片):

  • 点「只改文字」按钮:节点没有被创建也没有被销毁,文字从 aa!不播任何过渡
  • 点开关显示:节点被创建,进场过渡播放。
  • 点开关隐藏后立刻查询:节点仍在 DOM 里;等 350ms 之后再查,才真正消失。退场动画期间元素是被「缓刑」的。

局部与全局:一个容易忽略的默认值

  • 过渡默认是局部的:只有当它自己所在的那个块的状态发生变化时才播。父级的 {#if} 整块被销毁时,里面嵌套的局部过渡不播,直接消失。
  • 想让它在父块进出时也播,加修饰符:transition:fade|global(5.56.7 编译通过、无告警)。
  • 这个默认值是对的——否则关掉一个含二十个条目的面板,会同时触发二十个退场动画,卡顿且丑。

它跑在 Web Animations API 上

Svelte 5 的过渡底层调的是 element.animate()。这意味着它不依赖 CSS 类切换、不受 <style> 作用域影响,但也意味着在没有 WAAPI 的环境里会直接抛错——jsdom 里不打垫片就是 TypeError: element.animate is not a function。写单元测试时要么打垫片,要么把过渡测试留给浏览器环境(见 10 章)。

<script>
  import { fade, fly, slide } from "svelte/transition";
  let visible = $state(false);
  let text = $state("a");
</script>

<button onclick={() => visible = !visible}>开关</button>
<button onclick={() => text += "!"}>只改文字</button>

{#if visible}
  <!-- 进出同一套,且可逆 -->
  <div transition:fade={{ duration: 200 }}>{text}</div>

  <!-- 进出分开,不可逆;|global 让父块销毁时也播 -->
  <div in:fly={{ y: -20 }} out:slide|global>飞入滑出</div>
{/if}

// ── (svelte 5.56.7 · jsdom + WAAPI 垫片)──
// 点「只改文字」:节点没进出 DOM,文字 a → a!,不播任何过渡
// 点「开关」关闭后立刻查询:节点仍在 DOM 里
// 再等 350ms:节点才真正被移除
两个高频易错点。其一:退场动画期间元素还在 DOM 里,duration 200 的 fade,点隐藏后立刻查询仍能拿到该节点,350ms 后才消失——所以别在切换后同步断言 DOM 已清空。其二:把 transition: 直接写在组件标签上会编译失败,报错 component_invalid_directive — This type of directive is not valid on components;要过渡就在组件内部的根元素上写。
元素不进出 DOM 就没有过渡。想让「内容变了」也有动画,用 {#key value}…{/key} 把它包起来——key 一变,整块销毁重建,进出过渡自然就播了。这是做「数字翻牌」「切换标签页内容淡入」最省事的办法。

transition: 管的是「来和走」,animate: 管的是「留下来的那批元素从哪挪到了哪」。它是列表排序、拖拽重排唯一能用的指令,而且硬性要求 keyed each——原因不在于规定,而在于没有 key 就根本无从测量位移。

FLIP 是什么,为什么必须有 key

  • FLIP = First / Last / Invert / Play:记下元素重排前的位置(First),DOM 更新后读取新位置(Last),用 transform 把它瞬移回旧位置(Invert),再用动画把 transform 归零(Play)。全程只动 transform,不触发布局重算,所以很流畅。
  • 关键前提是「同一个元素移动了」。没有 key 时 Svelte 按索引复用 DOM 节点——重排之后第一个 <li> 还是那个 <li>,只是里面的文字换了,节点根本没动窝,First 和 Last 完全相同,FLIP 无事可做。
  • 加上 (item.id) 之后,节点跟着数据走,重排就变成真正的节点位移,测量才有意义。这也是 04 章强调「each 一律给 key」的另一个理由。

两条编译期硬约束(5.56.7 报错原文)

  • 没给 key:animation_missing_key —— An element that uses the `animate:` directive must be the only child of a keyed `{#each ...}` block. Did you forget to add a key to your each block?
  • 不是 each 的直接子元素(比如套了一层 <li><span animate:flip>):animation_invalid_placement —— An element that uses the `animate:` directive must be the only child of a keyed `{#each ...}` block

注意这两条都是编译期就拦下来的,不是运行时静默失效——写错了根本构建不过去,这点比很多框架友好。

它的能力边界

  • svelte/animate 只导出一个 flip。它接受 { delay, duration, easing },其中 duration 可以写成函数:源码里是 duration(Math.sqrt(dx * dx + dy * dy)),也就是参数就是本次位移的像素距离,默认值为 (d) => Math.sqrt(d) * 120
  • 它生成的是 transform: translate(…) scale(…)——位置和尺寸都管,但也仅限 transform:颜色、透明度、内容变化一概不归它。
  • animate:transition: 可以写在同一个 <li> 上,互不冲突:新增/删除的条目走 transition,位置挪动的条目走 animate。这是做一个「像样的可排序列表」的标准配方。
<script>
  import { flip } from "svelte/animate";
  import { fade } from "svelte/transition";

  let items = $state([
    { id: 1, n: "甲" }, { id: 2, n: "乙" }, { id: 3, n: "丙" },
  ]);
  const shuffle = () =>
    items = items.toSorted(() => Math.random() - 0.5);
</script>

<button onclick={shuffle}>打乱</button>

<ul>
  <!-- 圆括号里的 (item.id) 是硬性要求,少了直接编译失败 -->
  {#each items as item (item.id)}
    <!-- li 必须是 each 的唯一子元素;animate 与 transition 可同时写 -->
    <!-- d 是本次位移的像素距离,默认 (d) => Math.sqrt(d) * 120 -->
    <li animate:flip={{ duration: (d) => Math.sqrt(d) * 60 }}
        transition:fade>{item.n}</li>
  {/each}
</ul>

// 去掉 (item.id) 的报错(svelte 5.56.7):
// animation_missing_key — An element that uses the `animate:` directive
// must be the only child of a keyed `{#each ...}` block.
animate: 必须是 keyed each 块的唯一直接子元素。为了包一层样式而写成 <li><span animate:flip>…</span></li>,编译直接失败:animation_invalid_placement — An element that uses the `animate:` directive must be the only child of a keyed `{#each ...}` block。把指令挪到最外层那个元素上即可。
别急着给 duration 填个固定数字。flip 的默认值本来就是 (d) => Math.sqrt(d) * 120——d 是本次位移的像素距离,挪得远的动得久。写死成 duration: 200 反而会让长距离位移显得生硬,真嫌慢就调系数((d) => Math.sqrt(d) * 60),别丢掉这个函数形式。

跨文件状态与 .svelte.js

把 runes 搬出组件,是 Svelte 5 取代 store 的方式。但导出方式写错会直接编译失败——这一章讲清能与不能。

Svelte 3/4 需要 store,是因为 $: 和赋值触发那套响应式只在组件编译器里生效,出了 .svelte 就没人管你。Svelte 5 的 runes 没有这个限制——只要文件名里带 .svelte.,编译器同样接管它,于是「跨文件共享状态」退化成了「导出一个变量」。

后缀是编译器的路由规则,不是命名习惯

  • .svelte 文件走 compile().svelte.js / .svelte.tscompileModule(),普通 .js 根本不进 Svelte 编译器,原样交给 Vite。
  • 所以 $state.svelte.js 里是编译期语法,被换成对 svelte/internal/client 的调用;在普通 .js 里它就是一个谁都没定义过的全局标识符
  • 命名上 counter.svelte.jscart.svelte.tstheme.svelte.js 都可以,关键是文件名里得真的出现 .svelte. 这一段。

写错后缀会怎样:是最难查的那种错

把带 $state 的模块存成普通 .js,在 SvelteKit 里(kit 2.70.1 + svelte 5.56.7):

  • vite build——静悄悄通过,一条报错都没有,产物里 $state(...) 原样留着。
  • 访问页面——HTTP 500,服务端日志:ReferenceError: $state is not defined

也就是说构建阶段完全不设防,只在运行时炸。看到这条报错,第一反应就该是去检查文件名。

新旧对应关系

Svelte 3/4Svelte 5差别
writable(0)$state(0)(在 .svelte.js 里)不再有 store 契约,就是个普通变量
$count 自动订阅counter.value没有魔法前缀,读什么就是什么
count.set(1) / update(fn)n = 1 / n++普通赋值,深层对象还能直接改属性
derived(count, fn)$derived(...)依赖自动追踪,不用手写依赖列表
组件卸载自动取消订阅无订阅可言不存在忘记 unsubscribe 的泄漏

为什么这比 store 更好

  • 没有第二套心智模型。组件内外用的是同一个 $state,把逻辑从组件里搬出去不需要改写成 store,搬回来也不需要改回来。
  • 细粒度依赖免费继承。Store 是「整个值变了就通知所有订阅者」,runes 是信号级追踪——模块级状态的某个属性变了,只有真正读过它的那几处会重算。
  • 类型天然。counter.value 的类型就是 number,不需要 Writable<number> 这层包装再解包。

代价在下一张卡:导出方式受限,不能随便 export let

// counter.svelte.js —— 关键是文件名里那段 .svelte. ,它是给编译器的信号
let n = $state(0);

export function inc() { n++; }
export function reset() { n = 0; }

// 不能直接 export n(下一张卡讲为什么),用带 getter 的对象转一手
export const counter = {
  get value() { return n; },
};

// 组件里:
//   import { counter, inc } from "./counter.svelte.js";
//   <button onclick={inc}>{counter.value}</button>
// 两个不同组件同时 import,点任何一个,两边都会更新
// —— store 的全部功能,到这里就已经实现完了

// ── 把同样的代码存成 counter.js(没有 .svelte.)的结果 ──
// npx vite build :静悄悄通过,一条报错都没有
// 访问页面      :HTTP 500
//                 ReferenceError: $state is not defined
文件名写错不会在构建时报错。普通 .js 里的 $state 能一路通过 vite build,直到运行时才抛 ReferenceError: $state is not defined(SSR 下直接 500)。另一个变种是从 .svelte.js 重构成 .js 时忘了改,或者用工具批量重命名——看到这条报错先查文件名,别去查逻辑。
把「状态模块」当成普通 JS 模块来设计就对了:私有 let + 导出函数。想让读者拿到值,就给一个 getter;想让他们改,就给一个具名函数。这样调用点全都是可搜索的(inc()reset()),比满屏 $count = ... 好维护得多。

模块级 $state 有一条硬约束:一个会被重新赋值的 $state 不能被 export。这不是静默失效——很多 Svelte 5 早期教程按「导出了但不响应」来写,是编译期直接报错,根本构建不过去。

根因:ES 模块导出的是绑定,不是信号

  • $state 的响应式靠的是「读取时被追踪、写入时通知」,而这层拦截挂在变量的读写位置上,由编译器在本模块内改写代码实现。
  • 一旦 export 出去,别的模块拿到的是 ES 模块的 live binding——那是引擎层面的东西,编译器插不进追踪代码。
  • 所以只要这个变量会被重新赋值,导出就必然产生「导入方读到的值不会更新」的破绽。Svelte 的选择是干脆不让你写,而不是让你在运行时慢慢调试。

四种情形(compileModule · svelte 5.56.7)

写法结果
export let count = $state(0) + 某处 count++报错 state_invalid_export
export let count = $state(0),全程从不重新赋值通过
export const box = $state({v:0})box.v++通过
export let box = $state({v:0})box = {v:1}报错 state_invalid_export

报错原文是:Cannot export state from a module if it is reassigned. Either export a function returning the state value or only mutate the state value's properties。这句话本身就把两条出路写清楚了。

两种可行写法,怎么选

  • 写法一:导出带 getter 的对象get value() { return n }
    底层可以是任意类型,包括原始值;读写口径由你完全控制,能只给 getter 做成只读;重构时改内部实现不影响调用方。
    要手写样板,每加一个字段就得加一对 getter/方法。
    为何同根于「多包了一层间接」:正因为读取动作被推迟到调用方执行,才既保住了响应式、也带来了样板成本。
  • 写法二:导出 $state 对象本身,只改属性
    零样板,box.v++ 直接就工作;$state 的深层代理让嵌套属性、数组 push 全都自动响应。
    纪律全靠自觉——整体重新赋值 box = {...} 立刻变成编译错误;也没法把某个字段做成只读。
    为何同根于「导出的是对象引用」:引用不变才追得住,所以最方便的写法同时也是最不能碰引用的写法。

判断:单值、要控制读写口径,用写法一;一坨相关字段、只做增删改,用写法二。再复杂就上下一张卡的 class。

// ✗ 编译期直接失败:state_invalid_export
export let count = $state(0);
export function inc() { count++; }   // 有这行重新赋值,就报错

// ✓ 写法一:导出带 getter 的对象,值本身是模块私有的 let
let n = $state(0);
export const counter = {
  get value() { return n; },  // 读取推迟到调用方,追踪才跟得上
  bump() { n++; },
};

// ✓ 写法二:导出对象本身,只改属性、永不整体重新赋值
export const box = $state({ v: 0, list: [] });
export function bump() {
  box.v++;                // OK:深层代理,改属性/push 都响应
  box.list.push(box.v);   // OK
  // box = { v: 0, list: [] };  ← 一写这行就变成编译错误
}

// 报错原文(compileModule · svelte 5.56.7 · state_invalid_export):
// Cannot export state from a module if it is reassigned.
// Either export a function returning the state value
// or only mutate the state value's properties
别信「导出 $state 会静默失去响应性」这个说法,那是 Svelte 5 预览期的行为。5.56.7 是编译期硬错误state_invalid_export — Cannot export state from a module if it is reassigned. Either export a function returning the state value or only mutate the state value's properties。反过来说,只要能构建成功,就不用担心这一类响应性丢失。
判断能不能直接导出,只问一句:这个变量本身会不会出现在赋值号左边。会,就必须包 getter 或改用对象属性;不会(比如 export const cfg = $state({...}) 全程只改字段),直接导出最省事。reset() 这类函数最容易破坏纪律——把它写成 box.v = 0 而不是 box = { v: 0 }

当一坨状态开始带方法、带派生值、还要能开多份实例时,class 是 Svelte 5 最顺手的组织方式:$state$derived 可以直接当类字段写,编译器会把它们改写成私有信号。

它编译成了什么

  • items = $state([]) 被改写成 #items = $.state($.proxy([])) + 一对存取器。也就是说类字段变成了私有信号加 getter/setter,外部读写 cart.items 时自然被追踪。
  • total = $derived(...) 同样可以是类字段,而且可以在里面用 thistotal = $derived(this.items.reduce(...)) 正常工作,依赖自动追踪到 this.items
  • 因为是真正的 class,new Cart() 出来的每个实例状态互相独立——两个实例分别加东西,互不干扰。

两种用法,一个文件里都能给

  • 导出类:给需要多份实例的场景用(每个购物车面板一个 new Cart(),或者配合 context 每个子树一份)。
  • 顺手导出一个单例export const cart = new Cart()。注意这里能直接 export const 而不触发上一张卡的 state_invalid_export——因为导出的是实例引用,从不重新赋值,响应式全在实例内部。class 天然绕开了那条约束,这也是它比裸对象更受推荐的原因之一。

this 的坑:方法一旦脱离实例就废了

类方法在原型上,this 是调用时才绑定的。所以下面这行会炸:

  • <button onclick={cart.add}>——事件处理器被当成裸函数调用,此时 this 是那个 <button> 元素而非实例,TypeError: Cannot read properties of undefined (reading 'push')
  • 三个解法,任选:包一层箭头函数 onclick={() => cart.add(x)}(最常用,还能传参);把方法写成箭头函数字段 clear = () => { this.items = [] }onclick={cart.clear} 可以直接传,this 在构造时就绑死了);或者构造函数里 this.add = this.add.bind(this)
  • 取舍:箭头函数字段每个实例各存一份,实例极多时有内存成本;但对状态容器这种通常只有个位数实例的东西,为了能直接传引用,完全值得。

什么时候别用 class

  • 只有一两个值、没有方法——用上一张卡的裸对象就够了,class 是纯粹的噪音。
  • 需要结构化克隆、JSON.stringify 往返、或者塞进 structuredClone——class 实例过一遍就退化成普通对象,方法全丢。这类数据用纯对象。
// cart.svelte.js
export class Cart {
  items = $state([]);
  coupon = $state(0);

  // $derived 字段里可以用 this,依赖自动追踪到 this.items
  total = $derived(
    this.items.reduce((s, i) => s + i.price, 0) - this.coupon
  );

  add(item) { this.items.push(item); }   // 原型方法:this 靠调用点

  // 箭头函数字段:this 构造时绑死,可以直接当 onclick 传
  clear = () => { this.items = []; };
}

// 单例:导出的是实例引用、从不重新赋值,所以不触发 state_invalid_export
export const cart = new Cart();

// 组件里(行为):
//   <button onclick={() => cart.add({ price: 10 })}>加购</button>   ✓
//   <button onclick={cart.clear}>清空</button>                     ✓ 箭头字段
//   <button onclick={cart.add}>                                    ✗
//     TypeError: Cannot read properties of undefined (reading 'items')
//   new Cart() 出来的另一个实例与单例互不干扰
<button onclick={cart.add}> 这种「看起来很干净」的写法会直接炸,TypeError: Cannot read properties of undefined (reading 'push')——注意成因:Svelte 的事件委托是以 this = 那个 DOM 元素 调用 handler 的(打印出来是 BUTTON),并不是 thisundefined;是 button 上没有 items 属性,取到 undefined.push 才炸。而且这个错点下去才出现,静态检查和构建都不会拦。要么包一层箭头函数,要么把该方法写成箭头函数字段。
定一条队内规矩:要被当作引用传出去的方法(事件处理器、回调)写成箭头函数字段,其余写原型方法。这样既避开了绝大多数 this 事故,又不至于把整个类的方法都变成每实例一份。$derived 字段里放心用 this,追踪正常。

很多人以为 Svelte 5 里 store 已经进了弃用倒计时。不是:5.56.7 的 svelte/store 完整可用、$store 自动订阅在 runes 模式下照常工作、源码里也没有任何 @deprecated 标记。它不是遗留物,是一份仍然被支持的契约

结果(svelte 5.56.7)

  • svelte/store 的导出清单:derived, fromStore, get, readable, readonly, toStore, writable
  • 在一个 runes 模式组件里 const count = writable(0),模板写 {$count}{$doubled},点击 count.update(c => c + 1)——DOM 从 0 / 0 正常更新到 1 / 2
  • 编译告警:空数组。一条弃用提示都没有。
  • 对比:svelte/motionspringtweened 才是真的被标了 @deprecated,官方要你换成 SpringTween 类。别把这两件事混为一谈。

什么时候仍然值得用 store

  • 第三方库返回的就是 store。大量生态包(含 SvelteKit 自己的部分历史 API)对外暴露的是符合 store 契约的对象,你没得选。
  • 你要给别人提供 store 契约。写一个要同时服务 Svelte 4 与 5 用户的库时,subscribe 方法是最大公约数。
  • 需要在组件之外、非 rune 语境里订阅。比如一段纯 JS 的埋点代码要监听某个值——store 的 subscribe 是普通函数,随处可调;$effect 则必须活在组件或 $effect.root 里。
  • 除此之外的新代码,一律用 runes。理由在前几张卡:没有第二套心智模型、细粒度追踪、类型更干净。

两边互转:fromStore / toStore

这两个 API 存在,是新旧世界的桥:

API方向用法
fromStore(store)store → rune返回带 .current 的对象,读它就是响应式的
toStore(get, set?)rune → store() => n(v) => n = v 包成标准 store

fromStore(count).currentcount.update() 之后从 0 跟着变成 1toStore(() => n, v => n = v) 得到的对象可以直接被 get() 读取。接入第三方 store 优先用 fromStore,别自己写 subscribe + 手动清理。

<script>
  import { writable, derived, get } from "svelte/store";
  import { fromStore, toStore } from "svelte/store";

  // 老 API 原样可用,5.56.7 无任何弃用告警
  const count = writable(0);
  const doubled = derived(count, ($c) => $c * 2);

  // store → rune:读 .current 就是响应式的
  const mirrored = fromStore(count);

  // rune → store:交给只认 store 契约的第三方代码
  let n = $state(5);
  const asStore = toStore(() => n, (v) => (n = v));
</script>

<button onclick={() => count.update((c) => c + 1)}>+1</button>

<!-- $ 前缀自动订阅在 runes 模式下照常工作 -->
<p>{$count} / {$doubled} / {mirrored.current} / {get(asStore)}</p>

// 初始「0 / 0 / 0 / 5」,点一下变「1 / 2 / 1 / 5」
// 编译告警:[] —— 空数组
$store 这个前缀语法只在 .svelte 组件文件里有效——它是模板编译器的特性。在 .svelte.js 模块里写 $count 直接编译失败,不是「当成普通标识符」——抛 store_invalid_subscription_module — Cannot reference store value outside a `.svelte` file。模块里要取值请用 get(count)(一次性读取,不建立订阅)或 fromStore(count).current。另外别把 store 和 svelte/motionspringtweened 混淆,后两个才是真被标了 @deprecated
遇到第三方库给你一个 store,别急着手写 subscribeonDestroy 清理:一行 const s = fromStore(theirStore),之后全程读 s.current,订阅与清理都由 Svelte 托管。反过来要把自己的 rune 交给只认 store 的旧代码,用 toStore(() => n, v => n = v)

模块级 $state 在浏览器里是「整个标签页一份」,在服务端却是「整个 Node 进程一份」。SSR 场景下这意味着 A 用户的数据会被 B 用户的页面渲染出来——这是 Svelte 5 最危险、也最容易在本地测不出来的坑。

一次真实的串号

在 SvelteKit(adapter-node,vite buildvite preview)里放一个模块级 export const session = $state({ name: "未登录" }),一个路由在 load 里写它,另一个路由只读它。按顺序请求:

#请求渲染出的 session.name
1GET /read(还没人写过)未登录
2GET /leak?u=alicealice
3GET /read另一个毫不相干的用户alice ← 串了

第 3 步就是事故:一个完全独立的请求读到了上一个用户的数据。而且 Svelte 与 SvelteKit 都不会给任何告警,控制台一片安静。

为什么本地测不出来

  • 本地开发通常只有你一个人点,请求天然串行、且每次都路过写入的那条路径,覆盖得刚刚好,看起来一切正常。
  • 上了线才会出现「并发请求交错」「某些路由只读不写」这两种放大器,于是变成偶发的、无法复现的用户数据错乱。
  • 纯 CSR(ssr = false)或纯静态站不会遇到,所以这个坑还高度依赖渲染模式(见 15 章)。

三条通道,各管一段

放哪生命周期适合什么
模块级 $state浏览器:整个标签页;服务端:整个进程纯客户端的 UI 偏好:侧栏折叠、主题、列表视图模式、当前打开的 Tab
setContextgetContext每一次渲染的组件树各一份与本次渲染绑定的数据:当前用户、当前主题实例、表单上下文
event.locals(服务端)每个请求各一份会话与鉴权:登录态、权限、租户 ID(见 14 章)

context 是安全的这一点也验证过:同一套代码改用 setContext 在 layout 里注入,请求 ?u=alice 得到 alice,紧接着不带参数的请求得到 匿名——没有串号。因为 context 挂在组件树上,而每个请求都会渲染出一棵全新的树。

一条可执行的判据

要把某个状态放模块级之前,问自己:「这个值如果被另一个陌生人看到,会不会出事?」

  • 不会(主题是深色还是浅色、侧栏收没收起来)——放模块级,随便用。
  • 会(用户名、余额、权限、任何来自 load 或数据库的东西)——一律不许放模块级。要么由 load 返回、当 props 一路传下去,要么在根 layout 里 setContext 一份。
// ✗ user.svelte.js —— 模块级:Node 进程只有一份,SSR 下会跨请求串号
export const session = $state({ name: "未登录" });
export function setUser(n) { session.name = n; }

// (kit 2.70.1 + adapter-node + vite preview):
//   GET /leak?u=alice      → session.name = alice
//   GET /read(另一个人)  → session.name = alice   ← 串号,且无任何告警

// ✓ 正确做法:数据由 load 带下来,在 layout 里注入 context
// +layout.svelte
<script>
  import { setContext } from "svelte";
  let { children, data } = $props();
  setContext("user", { get name() { return data.user; } });
</script>
{@render children()}

// 任意后代组件
<script>
  import { getContext } from "svelte";
  const user = getContext("user");
</script>
<p>{user.name}</p>

// 同样场景改用 context 的?u=alice → alice;随后无参请求 → 匿名,不串
这个坑本地几乎测不出来:单人点击时请求串行、且总会路过写入路径,看起来完全正常,上线后才在并发下暴露成偶发的用户数据错乱。确认 Svelte 与 SvelteKit 对此不给任何告警。更隐蔽的变体是「模块级缓存」——在 .svelte.js 里存一份 const cache = $state({}) 缓存接口结果,同样是全进程共享,会把 A 用户的响应喂给 B。
一句话判据:「这个值被陌生人看到会不会出事?」不会(主题、侧栏折叠、列表视图模式)就放模块级;会(用户名、权限、余额、任何来自 load 的数据)就一律走 load + props,或在根 layout 里 setContext。写模块级状态文件时,顺手在文件头留一行注释说明「此处只放纯客户端 UI 偏好」。

TypeScript + Svelte

组件 props 的类型、泛型组件、事件与 bind 的类型,以及 svelte-check 该怎么用。

<script> 加一个 lang="ts" 就能写 TypeScript——但要立刻记住一件事:Svelte 编译器对类型做的唯一一件事是把它删掉。它不做任何类型检查。你在开发服务器里看到的绿灯,跟类型对不对毫无关系。

「擦除」是字面意思

把这段带明显类型错误的组件丢给编译器:

  • 源码let n: number = $state(0); let s: string = n;,外加一个 interface P 和一处 as unknown as P
  • compile() 不抛错、不产生任何类型相关的警告,产物里 interface 整个消失、as 断言消失、类型注解消失,只剩 let n = 0; let s = n;

原因很简单:Svelte 用 esbuild 风格的方式剥离类型语法,而剥离与检查是两件事。类型检查在 Svelte 项目里是一个完全独立的进程,你不主动跑它,它就永远不跑。这跟 Vite 下的 React 是同一套逻辑,只是 Svelte 多了模板这一层——模板里的类型错误连 tsc 都看不见,因为 tsc 不认识 .svelte 文件。

所以必须装 svelte-check

svelte-check 是官方的检查器:它把 .svelte 文件翻译成 TypeScript 能理解的形式(模板也一起翻译),再交给 tsc 的 API 检查。在 svelte 5.56.7 上装 svelte-check 4.7.3,对一个 <Greet name={n} />nnumbername 声明为 string)的项目跑 npx svelte-check,输出如下:

  • src/App.svelte:6:8
  • Error: Type 'number' is not assignable to type 'string'. (ts)
  • svelte-check found 1 error and 0 warnings in 1 file

注意错误定位在 模板里那一行——这正是 tsc 单独跑做不到的事。把 svelte-check 加进 package.jsoncheck 脚本和 CI,才算真的开了类型。

版本坑:svelte-check 4 跑不动 TypeScript 7

npm i -D typescript 装到 7.0.2(Go 重写版)之后,svelte-check 启动即崩:TypeError: Cannot read properties of undefined (reading 'useCaseSensitiveFileNames')。原因是 svelte-check 走 CommonJS 拿 typescript.sys,而 TS 7 的包结构变了。降到 typescript@5(5.9.3)立刻正常。起项目时把 TypeScript 锁在 5.x 是目前唯一稳妥的做法。

// package.json 里把检查变成一条命令,别指望 dev/build 帮你查
// "check": "svelte-check --tsconfig ./tsconfig.json"
// "check:watch": "svelte-check --watch"

<script lang="ts">
  // 下面这行类型是错的,但 vite dev / vite build 都不会吭声
  let n: number = $state(0);
  let s: string = n;

  // interface 与 as 断言在产物里会被整个删掉,只剩 let 声明
  interface P { a: string }
  const p = { a: 1 } as unknown as P;
</script>

<p>{s}{p.a}</p>

// $ npx svelte-check
// src/App.svelte:3:7
// Error: Type 'number' is not assignable to type 'string'. (ts)
// svelte-check found 1 error and 0 warnings in 1 file
装依赖时随手 npm i -D typescript 会拿到 TypeScript 7,此时 svelte-check 直接崩在 TypeError: Cannot read properties of undefined (reading 'useCaseSensitiveFileNames'),看着像 svelte-check 坏了,其实是版本不兼容。显式写 npm i -D typescript@5
svelte-check 当成另一个必须常驻的终端——npm run dev 一个窗口、svelte-check --watch 一个窗口。只在提交前跑一次的团队,类型错误会攒到几十条再一起爆。CI 里直接跑 svelte-check 就够——有错以非零码退出是它的默认行为,不需要额外参数。要机器可读输出用 --output machine--threshold error 只是「不想看 warning」时用来过滤显示的,和格式、退出码都无关。

Svelte 5 的 props 就是一次对象解构,所以类型也就写在解构模式的类型注解上——没有专门的 props 语法,你已经会的 TypeScript 就够用了。这跟 Svelte 3/4 需要给每个 export let 单独标类型完全不同。

一个注解管全部

写法是 let { a, b = 1 }: { a: string; b?: number } = $props()。三件事要分清:

  • 可选性由类型里的 ? 决定,不是由有没有默认值决定。写了 b?: number,父组件才可以不传;
  • 默认值照常写在解构里,TypeScript 会把 b 在组件内部收窄成 number(不含 undefined);
  • props 数量一多就抽成 type Props = { … } 放在 <script> 顶部,可读性远好于挤在一行。

事件回调也是普通 props,直接写函数类型:onclose?: () => void。这就是 runes 时代取代 createEventDispatcher 的方式(见 05 章),而它的类型完全不需要额外机制。

snippet 的类型是 Snippet

snippet 作为 props 传递时(见 06 章),类型从 svelte 导入:

写法含义
Snippet无参数的 snippet
Snippet<[T]>接收一个 T 类型参数
Snippet<[A, B]>接收两个参数

注意泛型参数是一个元组,不是单个类型——即使只有一个参数也要写方括号。Snippet<[{ count: number }]> 这种嵌套看着别扭,但它准确表达了「参数列表」这件事。{#snippet footer({ count })} 里的 count 能被 svelte-check 正确推断成 number

ComponentProps:别手抄别人的类型

要引用某个组件的 props 类型(比如做包装组件、写预设对象、做 Pick/Omit),用 ComponentProps<typeof Card>。它是官方工具类型,从组件的实际声明推出来,组件改了它跟着改。手抄一份 props 类型是最容易腐烂的代码——抄的时候对,三个月后组件加了字段就再也对不上了。

<script lang="ts">
  import type { Snippet } from 'svelte';

  // props 类型抽成一个 type,可选性由 ? 决定而非默认值
  type Props = {
    title: string;
    level?: 1 | 2 | 3;
    body: Snippet;                            // 无参 snippet
    footer?: Snippet<[{ count: number }]>;    // 参数列表是元组,别漏方括号
    onclose?: () => void;                     // 事件就是普通函数 prop
  };

  // 默认值照写,组件内 level 被收窄成 1|2|3 而非可能 undefined
  let { title, level = 2, body, footer, onclose }: Props = $props();
</script>

<section>
  <h2>{title}({level})</h2>
  {@render body()}
  {#if footer}{@render footer({ count: 3 })}{/if}
  <button onclick={onclose}>关闭</button>
</section>
Snippet 的泛型参数是元组。把 Snippet<[T]> 写成 Snippet<T> 时 TypeScript 会把 T 当成整个参数列表,于是 {@render row(item)} 处报出一堆看不懂的元组不匹配错误。只有一个参数也要带方括号。
想复用组件的 props 类型就 type P = ComponentProps<typeof Card>,再配 PickOmit 裁剪——比如包装组件里 Omit<P, 'onclose'> 表示「除关闭回调外原样透传」。这套组合能被 svelte-check 完整推断到模板里的展开语法 {...preset}

写一个「列表组件」时你会立刻遇到问题:items 是什么类型?写 any[] 等于放弃类型。Svelte 给了一个只在 .svelte 文件里存在的语法——在 <script> 标签上加 generics 属性,声明这个组件的类型参数。

语法与结论

写法是 <script lang="ts" generics="T">,之后 T 就能在整个 <script> 块和模板里当类型用。在 svelte 5.56.7 + svelte-check 4.7.3 上完全可用:一个 items: T[] 的列表组件,传入 { id: number; nick: string }[] 之后,在 {#snippet row(u, i)} 里写 u.nopesvelte-check 报出

  • Error: Property 'nope' does not exist on type '{ id: number; nick: string; }'. (ts)

——注意报的是具体类型而不是 T,说明类型参数确实从调用点推断出来,并且一路贯穿到了 snippet 的参数上。这是泛型组件真正的价值:调用方一行类型都不用写,就拿到了完整的补全和检查。

为什么需要一个专门的属性

因为 .svelte 文件不是一个函数声明,没有地方挂 <T>。TypeScript 的泛型语法总是绑在函数或类上,而组件是「一个模块导出一个组件」。generics 属性是 Svelte 编译工具链和语言服务约定的一个逃生口——它的值就是一段 TypeScript 类型参数列表的源码,所以约束也照常写:generics="T extends { id: string | number }",多个参数用逗号分隔。

泛型组件值不值得写

它不是免费的。

  • 写成泛型(items: T[]
    调用方零成本拿到完整类型;组件可以真正复用在任意数据上,snippet 参数自动收窄
    组件内部能对 T 做的事变少了,任何字段访问都得先写进约束里;类型报错会变长变难读
    为何同根在「把决定权交给调用方」——正因为组件自己不知道 T 是什么,调用方才能随便传;也正因为不知道,组件自己就用不了它
  • 写成具体类型(items: User[]
    组件内部可以随便读字段,报错短,新人一眼看懂
    换一种数据就得复制一份组件
    为何同根在「组件知道数据长什么样」——知道才能用,用了就绑死

判断口诀:组件自己需要读数据字段的,别写泛型;组件只负责摆放、把每一项原样交给 snippet 的,一律写泛型。

// List.svelte —— generics 属性是 .svelte 独有的,普通 .ts 里没有
<script lang="ts" generics="T">
  import type { Snippet } from 'svelte';

  // 组件自己不碰 T 的任何字段,只负责摆位置
  let { items, row }: { items: T[]; row: Snippet<[T, number]> } = $props();
</script>

<ul>
  {#each items as item, i}
    <li>{@render row(item, i)}</li>
  {/each}
</ul>

// App.svelte —— 调用方一个类型都不用写
<script lang="ts">
  import List from './List.svelte';
  const users = [{ id: 1, nick: 'a' }];
</script>

<List items={users}>
  {#snippet row(u, i)}<b>{i}{u.nick}</b>{/snippet}
</List>
// 把 u.nick 改成 u.nope,svelte-check 报错:
// Property 'nope' does not exist on type '{ id: number; nick: string; }'.
generics 必须和 lang="ts" 一起用,且只能加在实例 <script> 上,不能加在 <script module> 上——模块块在所有组件实例间共享,谈不上「每个实例一个 T」。另外它不是 TypeScript 语法,别指望 tsc 单跑能认,检查一律走 svelte-check
需要约束就把整段 TypeScript 写进属性值里:generics="T extends { id: string | number }",多个参数写成 generics="T, K extends keyof T"这个属性只有 svelte-check 和编辑器语言服务读得懂——运行时它和其他类型一样被擦掉,所以写错了不会崩,只会失去检查。

剩下的类型问题几乎都能一句话回答:能推断就别标。Svelte 的类型定义把 rune 都写成了泛型函数,$state(0) 就是 number,标注反而是噪音。真正需要你动手的只有四处。

一、$state 何时要显式标注

规则是初值撑不出你要的完整类型时才标。两种情形:

  • let x = $state() 不给初值,x 的类型是 unknown(不是 any)。之后读 x.foo 直接报 'x' is of type 'unknown'.——好事,逼你写清楚;
  • let s = $state(0) 之后 s = 'str'Type 'string' is not assignable to type 'number'.,说明推断确实生效。

所以要标注的是「初值是窄的、后面会变宽」的场景:$state<User | null>(null)$state<string[]>([])。写成 let u: User | null = $state(null) 也等价,选一种风格贯彻即可。

二、事件对象与 bind:this

DOM 事件的 target 在标准类型里是 EventTarget | null,读 .value 必然报错。正确做法是用交集类型收窄 currentTarget(e: Event & { currentTarget: HTMLInputElement })。用 currentTarget 而不是 target,因为前者才保证是绑事件的那个元素。

bind:this 的类型是具体的元素接口,而且挂载前是 null,所以必须写成 HTMLInputElement | null 并初始化为 null。之后用可选链 input?.focus()。绑到组件上时类型则是组件本身,用 let c: MyComp | null = $state(null)

三、.svelte.ts 里的类型

跨文件状态(见 08 章)写在 .svelte.ts 里时,类型上没有任何特殊之处——class 字段用 $state,getter 标返回类型,私有字段用 #。一个带 #step 私有字段、get double(): number 的计数器类,导入到组件里 shared.doublenew Counter() 都被正确推断。导入时扩展名写 './counter.svelte'(省掉 .ts,这跟普通 TypeScript 模块的解析规则一致。

模板里的类型错误只有 svelte-check 看得见

tsc 不认识 .svelte,所以 {user.nick} 里的拼写错误、传错的 prop、snippet 参数不匹配,全都只有 svelte-check 能抓。tsc --noEmit 当作类型防线是无效的——它连你一半的代码都没看到。

<script lang="ts">
  import { shared } from './counter.svelte';   // .svelte.ts 导入时省掉 .ts

  // 能推断就别标:这里 keyword 已经是 string
  let keyword = $state('');

  // 初值撑不出完整类型才标。不给初值的 $state() 类型是 unknown
  let user = $state<{ id: number; nick: string } | null>(null);

  // bind:this 挂载前是 null,类型必须带 | null
  let input: HTMLInputElement | null = $state(null);

  // 用交集类型收窄 currentTarget,才能安全读 .value
  function onInput(e: Event & { currentTarget: HTMLInputElement }) {
    keyword = e.currentTarget.value;
  }

  function focus() { input?.focus(); }
</script>

<input bind:this={input} value={keyword} oninput={onInput} />
<button onclick={focus}>聚焦</button>
<p>{keyword}{user?.nick ?? '未登录'}{shared.double}</p>
let x = $state() 不给初值时类型是 unknown,之后任何属性访问都报 'x' is of type 'unknown'.。很多人以为会是 any 于是照着写,结果被一串莫名其妙的报错拦住。要么给初值,要么写 $state<T | undefined>()
事件处理函数写成具名函数再传给属性,而不是内联箭头函数——内联时 Svelte 能自动推出事件类型,一旦你想抽出来复用就必须自己写注解,此时统一用 Event & { currentTarget: HTMLXxxElement } 这个模式。表单元素常用的三个是 HTMLInputElementHTMLSelectElementHTMLTextAreaElement

测试与调试

Vitest 加 Testing Library 怎么测组件,$inspect 怎么用,以及几条高频报错的原文与成因。

Svelte 组件测试的地基是 Vitest——因为它直接复用你项目里那份 vite.config.jsvite-plugin-svelte 已经会编译 .svelte 了,测试环境不需要再配一遍转译。上层再加 @testing-library/svelte,就得到一套「渲染组件、找元素、点它、断言 DOM」的完整闭环。

可用的组合

svelte 5.56.7 上装 vitest 4.1.10 + @testing-library/svelte 5.4.2 + @testing-library/user-event + jsdom + @sveltejs/vite-plugin-svelte 7.2.0,一个带 $state$derived 的计数器组件测试一次跑通。所以「Svelte 5 必须换成浏览器模式才能测」的说法不成立——jsdom 这条路是通的,只是有一个配置必须写对(下面那条)。

官方近年更推荐 vitest-browser-svelte 走真实浏览器,那条路更接近真实环境(真的布局、真的事件),代价是要装浏览器、启动慢一个量级。先把 jsdom 这条便宜的路走通,等你确实被「jsdom 没有布局引擎」卡住了再换。

唯一的必配项:resolve.conditions

不配的话第一条测试就死在这里(报错原文):

  • Svelte error: lifecycle_function_unavailable
  • `mount(...)` is not available on the server

原因是 Node 默认按 node 条件解析包,会拿到 svelte 的服务端构建,而服务端构建里 mount 就是一个专门用来抛错的函数。解法是在配置里写 resolve: { conditions: ['browser'] }注意它要写在 vite 配置的顶层,不是写在 test 里面——放进 test.resolve 完全不生效,报错一字不差。

测行为,不测实现

一条实用的分界线:断言只允许出现「用户能看见的东西」

该测不该测
点了按钮后屏幕上的数字变成 4内部变量 n 等于 4
输入非法值后出现了错误提示文案validate() 被调用了一次
getByRole('button', { name: '加一' }) 找元素querySelector('.btn-primary') 找元素

理由是机制性的:Svelte 组件没有实例对象可以窥探——编译产物是一个函数,$state 变成了闭包里的信号,你想测内部状态也无从下手。这个限制是好事,它把你逼到唯一稳定的接口上:DOM。按角色和可见文本查询还顺带保证了可访问性。

// vite.config.js —— conditions 必须在顶层,放进 test 里不生效
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';

export default defineConfig({
  plugins: [svelte()],
  resolve: { conditions: ['browser'] },
  test: { environment: 'jsdom', setupFiles: ['./setup.js'] }
});

// setup.js
import '@testing-library/jest-dom/vitest';

// counter.test.js —— 只碰用户看得见的东西
import { it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import Counter from './Counter.svelte';

it('点击后计数增加', async () => {
  render(Counter, { start: 3 });          // 第二个参数就是 props
  expect(screen.getByTestId('out')).toHaveTextContent('3 / 6');

  // userEvent 内部会等一轮微任务,必须 await
  await userEvent.click(screen.getByRole('button', { name: '加一' }));
  expect(screen.getByTestId('out')).toHaveTextContent('4 / 8');
});
vite-plugin-svelte 7 已经删掉了 hot 选项,老教程里的 svelte({ hot: false }) 会打印 invalid plugin options "hot" in inline config。这只是警告不是错误,测试照跑,所以很容易被忽略几个月——看到就删掉这个参数。
查询优先级照着这个顺序退:getByRolegetByLabelTextgetByTextgetByTestId越往后越脆,但 getByTestId 仍然远好于 querySelector('.some-class')——类名是给样式用的,改个皮肤就把测试改挂了,而 data-testid 是明确写给测试的契约。

Svelte 5 的 DOM 更新是批处理的:改一个 $state 只是把依赖它的 effect 标记为脏,真正写 DOM 要等到这一批调度跑完。所以在测试里「点一下、立刻断言」一定会读到旧值——这不是 bug,是新手写 Svelte 测试的头号困惑。

不 flush 看到什么

一个 {n} / {doubled} 的计数器,直接派发原生点击后立刻读 DOM,输出:

  • 挂载后:0 / 0
  • 调了 button.click() 之后、未 flush:0 / 0点了但没变
  • flushSync() 之后:1 / 2
  • 再点一次并 await tick() 之后:2 / 4

请特别注意第二行:事件处理函数确实已经同步跑完了n 在内存里已经是 1,只有 DOM 还停在 0。所以你看到的失败信息会是 expected '0 / 0' to contain '1 / 2'——一个让人怀疑事件根本没触发的假象。

flushSync 与 tick 怎么选

flushSync()await tick()
调用方式同步,不用 await返回 Promise,必须 await
做什么立刻把当前挂起的更新全部执行完等调度器自然跑完这一轮
适合测试里,你想精确控制「现在就更新」组件代码里,更新后要读 DOM 尺寸等

测试里优先用 flushSync():它是同步的,堆栈更干净,也不会因为漏了一个 await 而静默通过。tick() 更适合写在组件内部——比如改完列表后要 scrollIntoView,必须等 DOM 真的更新了才有意义。

用 testing-library 时坑换了个样子

@testing-library/sveltefireEventuserEvent 内部已经帮你等了,但前提是你 await 它们。同一个组件:

  • 直接 btn.click() 不 await → 0 / 0
  • fireEvent.click(btn) 但不 await → 0 / 0
  • await fireEvent.click(btn)2 / 4

所以规矩很简单:testing-library 的每一个交互 API 前面都写 await,别管它看起来像不像同步的。

import { it } from 'vitest';
import { mount, unmount, flushSync, tick } from 'svelte';
import Counter from './Counter.svelte';

it('不 flush 会读到旧值', async () => {
  const target = document.createElement('div');
  document.body.appendChild(target);
  const app = mount(Counter, { target, props: { start: 0 } });
  flushSync();                       // 挂载本身也要 flush 一次

  const out = () => target.querySelector('[data-testid=out]').textContent;
  console.log('挂载后:', out());                // 0 / 0

  target.querySelector('button').click();
  console.log('未 flush:', out());               // 0 / 0 ← 事件跑了,DOM 没动

  flushSync();
  console.log('flushSync 后:', out());           // 1 / 2

  target.querySelector('button').click();
  await tick();                          // tick 是异步的,漏 await 等于没写
  console.log('await tick 后:', out());          // 2 / 4

  unmount(app);
});
tick() 返回 Promise,漏掉 await 时不会报任何错,测试依然是绿的——因为断言读的是旧 DOM,而旧 DOM 恰好也常常符合期望(比如断言「初始值还在」)。这种测试永远不会失败,也就永远保护不了你。测试里能用 flushSync() 就别用 tick()
写一个只有三行的小助手贯穿整个测试文件:const html = () => { flushSync(); return target.innerHTML; }把 flush 藏进读取函数里,就再也不会出现「断言前忘了 flush」这类问题,而且失败时打印出来的一定是最新 DOM。

在 Svelte 里 console.log(x) 常常骗人:它只打印那一刻的值,而你真正想知道的是「这个值什么时候变了、被谁改的」。$inspect 就是为此存在的——它是一个符文(rune),编译器会为它建一个专门的 effect,值一变就打印。

三种形态与输出

  • $inspect(n, obj):可以一次盯多个值。挂载时打印 0 { a: 1 },之后每次变化再打印一次,并额外附一组可折叠的 stack trace(初次挂载没有堆栈,只有后续更新才有)。这个堆栈就是「谁改的」的答案;
  • $inspect(n).with(fn):接管打印。回调签名是 (type, ...values)type 是字符串 'init''update'。注意只有 .with 这条路才拿得到 init/update 标记,默认形态不打印它;最常见的用法是 .with(console.trace)
  • $inspect.trace():写在 $effect 的第一行,回答「这个 effect 为什么重跑」。输出是一棵树:我的effect (13.45ms) 下面挂 $derived sum 11,再下面挂 $state a 1,每一层还带创建位置的堆栈。

另外一个细节:打印出来的值是 snapshot() 后的深拷贝,所以你在控制台展开时看到的是当时那一刻的快照,不是会随后续修改而变的 proxy——这正是 console.log 一个 $state 对象最容易骗人的地方。

它只在开发构建里存在

同一个组件分别以 dev: truedev: false 编译:前者按上面所述打印,后者一行输出都没有。因为编译器在生产模式下直接把 $inspect 整句删掉,运行时那个 inspect 实现也不会被打进产物。

好处是你不必像 console.log 那样在提交前满世界找残留;坏处是它永远帮不了你排查线上问题。生产环境的调试只能靠正经的日志和错误上报。

什么时候用哪个

判断口诀:「值不对」用 $inspect,「跑太多次」用 $inspect.trace()前者回答一个值的历史,后者回答一个 effect 的依赖。绝大多数「effect 无限循环」「组件疯狂重算」的问题,$inspect.trace() 一行就能定位到那个你没意识到自己读了的信号。

<script>
  let a = $state(1);
  let b = $state(10);
  let sum = $derived(a + b);

  // 默认形态:打印值本身 + 一组可折叠的 stack trace(初次挂载没有堆栈)
  $inspect(a, sum);

  // .with 接管打印,回调第一个参数是 'init' / 'update'
  $inspect(sum).with((type, v) => console.log('[sum]', type, v));

  $effect(() => {
    // 必须是 effect 里的第一句,回答「我为什么又跑了」
    $inspect.trace('我的effect');
    console.log('sum =', sum);
  });
</script>

<button onclick={() => a++}>go</button>

// dev 构建输出(点击一次后):
//   sum = 12
//   我的effect (2.84ms)
//     $derived sum 12
//       $state a 2
// 同一份代码用 dev:false 编译,则一行都不打印
只被读、从不被重新赋值的 $state 会被编译器优化成普通变量,于是它根本不是信号,也就不会出现在 $inspect.trace() 的依赖树里。let b = $state(10) 若全程没被改过,产物里就是 let b = 10。别把这种「缺席」当成 trace 失灵。
$inspect.trace() 必须是 $effect 回调里的第一条语句,编译器靠这个位置把后面整段函数体包起来计时和采样。放错位置根本到不了运行时:放第二行直接编译失败,报 inspect_trace_invalid_placement — `$inspect.trace(...)` must be the first statement of a function body。而 no reactive dependencies 是位置正确、但被追踪的函数体确实没读到任何响应式值时才打的——看到它应该去查为什么没依赖,不是去挪位置。

Svelte 的报错分两类:编译期的(compile() 直接抛,代码根本不会生成)和运行期的(只在开发构建里有人话,生产构建只剩一个 URL)。分清这一点,你就知道该去看构建日志还是浏览器控制台。

五条报错原文

错误码消息原文成因
state_invalid_placement`$state(...)` can only be used as a variable declaration initializer, a class field declaration, or the first assignment to a class field at the top level of the constructor.$state() 写在了赋值右边或函数里。它不是函数调用,是编译器识别的声明形式
props_invalid_placement`$props()` can only be used at the top level of components as a variable declaration initializer.svelte.js 或函数体里调 $props()。props 属于组件实例,模块里没有这个概念
rune_outside_svelteThe `$state` rune is only available inside `.svelte` and `.svelte.js/ts` files在普通 .js 里用了符文。这条是运行期的——文件名不带 .svelte.,构建工具根本没送它去编译,$state 就是个未定义的全局
state_invalid_exportCannot export state from a module if it is reassigned. Either export a function returning the state value or only mutate the state value's properties.svelte.js 导出了会被重新赋值的 $state。导出的是值的拷贝,重新赋值后导入方拿不到新值
effect_update_depth_exceededhttps://svelte.dev/e/effect_update_depth_exceeded只有这个 URL,没有任何文字描述。$effect 里写了会改到自己依赖的状态,撞上了无限循环守卫

怎么读文件名后缀这条线

符文能不能用只由文件名决定store.js 里写 $state 运行时炸,改名成 store.svelte.js 就一切正常,因为构建插件按后缀决定要不要送去 compileModule。看到这条报错别改代码,改文件名(见 08 章)。

一条已经不再报错的错误

老资料会告诉你「bind: 到没标 $bindable 的 prop 会抛 bind_not_bindable」。在 5.56.7 上不抛了。父组件 <Child bind:v />、子组件 let { v } = $props(),子组件里改 v 之后一声不吭,父组件的值原封不动停在初始值。翻源码可见 bind_not_bindable 这个错误函数仍然定义着,但整个运行时没有任何地方调用它

也就是说这是纯静默失败,只能靠自己记住:子组件想回写,必须写 let { v = $bindable() } = $props()(见 05 章)。

// 1) state_invalid_placement —— $state 不是普通函数调用
let n = 0;
n = $state(1);          // ✗ 只能出现在声明的初始化位置

// 2) rune_outside_svelte —— 由文件名决定,不由代码决定
// store.js       里写 $state → 运行时抛错
// store.svelte.js 里写 $state → 正常

// 3) state_invalid_export —— 导出的是值的拷贝
let count = $state(0);
export { count };                // ✗ 因为下面重新赋值了它
export function bump() { count = count + 1; }

// 改成导出对象、只改属性,就合法了
export const box = $state({ v: 0 });
export function bumpBox() { box.v++; }

// 4) effect_update_depth_exceeded —— 报错里只有一个 URL
let list = $state([]);
$effect(() => {
  list.push(list.length);       // ✗ 读了 list 又改 list,自我触发
});

// 5) 静默失败:bind 到非 bindable 的 prop,5.56.7 不报错也不生效
// 子组件必须写:let { v = $bindable() } = $props();
effect_update_depth_exceeded 抛出来的 Error.message 只有一行 URL,没有任何人话。第一次撞上时几乎所有人都以为是网络问题或者依赖装坏了。记住它的含义:某个 $effect 改了自己读过的状态,守卫在若干轮之后强行掐断。
所有 Svelte 报错消息末尾那个 https://svelte.dev/e/xxx 就是错误码本身。遇到看不懂的,直接拿 URL 末尾那串下划线标识符去搜代码库比搜错误文本准得多——因为生产构建里文字描述会被剥掉,只剩这个 URL,两边能对上的只有它。

SvelteKit 上手与路由

文件即路由、+page 与 +layout 的分工、导航与特殊文件。元框架多给了什么,什么时候不需要它。

Svelte 只负责「把一个组件变成会更新 DOM 的 JS」。它不知道 URL 是什么,不知道页面怎么在服务端先渲染一遍,也不知道构建产物要怎么塞进 Node 或 Cloudflare。SvelteKit 就是把这四件事——路由、渲染、数据、部署——一次性做完的那层。

裸 Vite 加 Svelte 少了什么

  • 路由:你只有一个 App.svelte。想要 /about,得自己监听 popstate、自己写路径匹配、自己拦截 <a> 的点击、自己做代码分割。
  • 服务端渲染:Svelte 确实有 svelte/serverrender(),但把它接成一个能处理请求、能吐出正确 HTML 外壳、能让客户端接着水合(hydration)的服务,是纯体力活。
  • 数据层:谁在页面渲染之前把数据取好?取到的数据怎么从服务端传到客户端而不重复请求一次?这套约定不存在,你得自己发明。
  • 部署:Node 服务、静态站、Vercel 函数、Cloudflare Workers,每种目标的入口签名都不一样。SvelteKit 用 adapter 把这个差异收敛成配置里的一行。

约定的代价换来的是构建期的确定性

SvelteKit 的路由表不是运行时扫描出来的,而是构建期根据 src/routes/ 的目录结构生成的静态清单。所以它能提前知道哪些页面可以预渲染成纯 HTML、每个路由该切成哪个 chunk、导航到某个链接前该预取哪个文件。这跟 Svelte 编译器把模板编译成精确 DOM 操作是同一种思路——能在构建期确定的事,绝不留到运行时

该不该上 SvelteKit

不是「全都用」,有明确的分界线。

  • 用 SvelteKit
    多页面、需要 SEO、需要首屏 HTML、需要服务端读数据库或藏密钥——这些场合它省下的是几千行胶水代码。
    你必须接受它的目录约定和 + 前缀文件名,必须理解「这段代码跑在哪一侧」,心智负担实打实地涨一截。
    为何省代码靠的正是约定。约定越强,生成的东西越多,你能偏离的自由度就越小。
  • 裸 Vite 加 Svelte
    只有一个入口、没有 URL 概念的东西——嵌到别人页面里的挂件、Electron 或 Tauri 的界面、可视化面板、组件库的 demo——启动快、依赖少、没有服务端概念要绕。
    哪天要加第二个可分享的 URL,你就得从头补一套路由,而且大概率补得比 SvelteKit 差。
    为何轻,是因为它对「页面」这个概念一无所知。而 URL 恰恰是页面概念的入口。

一句话的判断标准

问自己一个问题:用户需要把某个界面状态发链接给别人吗?需要,就上 SvelteKit。不需要,裸 Vite 就够,别为了「以后可能」预先付这个成本。库和组件包也不用——那种项目该走 svelte-package,不是 SvelteKit 应用。

// 裸 Vite + Svelte:入口自己写,路由这个概念根本不存在
import { mount } from "svelte";
import App from "./App.svelte";
mount(App, { target: document.getElementById("app") });
// 想要 /about?自己听 popstate、自己匹配路径、自己拦 <a> 的点击

// SvelteKit:上面这些换成目录约定,构建期生成路由表
// src/routes/+page.svelte        → /
// src/routes/about/+page.svelte  → /about
// 入口、路由表、SSR、代码分割、链接预取,全部由构建期产出

// svelte.config.js —— 唯一要你拍板的是「最后部署到哪」
import adapter from "@sveltejs/adapter-node";
export default { kit: { adapter: adapter() } };
// 换成 adapter-static 就是纯静态站,换 adapter-cloudflare 就是 Workers
// 业务代码一行不用改——这是 adapter 存在的全部意义
别把 SvelteKit 当成「Svelte 的推荐脚手架」而照单套用。它的 +page.js+page.server.js 强制你时刻回答「这段代码跑在哪一侧」,这个心智负担对一个只有单入口的挂件项目是纯亏损。另外库不要用 SvelteKit 应用模板,发包走 svelte-package
判断要不要上 SvelteKit,只问一句:用户需不需要把某个界面状态发链接给别人?需要就有路由需求,上。挂件、桌面端外壳、组件库 demo 这类没有 URL 概念的东西,裸 Vite 加 mount() 更省事,别提前付约定的成本。

SvelteKit 没有路由配置文件。src/routes/ 底下的目录结构就是 URL 结构,带 + 前缀的文件名则是给构建期看的角色标记——这个前缀存在的唯一理由,就是把「框架文件」和你自己的组件在同一个目录里一眼分开。

两条规则就够了

  • 目录 = 路径段src/routes/blog/archive/ 对应 /blog/archive。目录本身不需要任何声明。
  • 文件 = 角色+page.svelte 是这个路径要渲染的页面,+layout.svelte 是包裹它的布局,+page.js+page.server.js 负责取数据(见 12 章),+server.js 是 API 端点,+error.svelte 是错误页。

没有 + 前缀的文件不进路由。所以你可以放心把 blog/PostCard.svelteblog/utils.js 就近搁在路由目录里,它们不会变成 /blog/PostCard。这是 + 前缀真正的收益——组件可以贴着用它的页面放。

src/app.html 是必需的

它不是可选的模板美化,是整个应用唯一的 HTML 外壳。把它删掉再构建,直接失败Error: src\app.html does not exist。里面必须留下两个占位符%sveltekit.head%(注入 <link><script>、页面级 <svelte:head> 的内容)和 %sveltekit.body%(注入渲染出的页面)。

还有一条容易忽略的%sveltekit.body% 要放在一个元素里面,比如 <div>,不要直接放在 <body> 下。因为浏览器扩展经常往 <body> 里塞节点,水合时那些节点会打乱 SvelteKit 对 DOM 结构的预期。

只有 +page.svelte 才产出页面

只放一个 +page.js 而没有 +page.svelte,这个路径不会成为页面。想要一个只返回数据的接口,用的是 +server.js,那是完全不同的东西(导出 GETPOST 等 HTTP 方法函数,返回 Response)。

// src/routes/ 的目录结构就是 URL 结构
//   +layout.svelte           → 包住下面所有页面
//   +page.svelte             → /
//   about/+page.svelte       → /about
//   blog/+page.svelte        → /blog
//   blog/PostCard.svelte     → 不进路由,就近放着即可
//   api/hello/+server.js     → /api/hello(返回 Response,不是页面)

<!-- src/routes/about/+page.svelte —— 就是一个普通组件 -->
<script>
  import PostCard from "../blog/PostCard.svelte";
</script>
<h1>关于我们</h1>

<!-- src/app.html —— 必需;删掉构建直接报 src\app.html does not exist -->
<!doctype html>
<html lang="zh">
  <head>%sveltekit.head%</head>
  <!-- body 里再包一层 div,挡住浏览器扩展塞进来的节点 -->
  <body><div>%sveltekit.body%</div></body>
</html>
删掉或改名 src/app.html 会让构建直接失败,报错Error: src\app.html does not exist。另一个常见错是把 %sveltekit.body% 直接放在 <body> 下——浏览器扩展往 body 里插节点会打乱水合,官方模板一律外面再包一层 <div>
把只服务于某个路由的组件和工具函数就近放进那个路由目录,比如 src/routes/blog/PostCard.svelte。没有 + 前缀的文件不进路由,这正是 + 前缀设计出来的目的。只有真正跨路由复用的才提到 src/lib/(用 $lib 别名引)。

+layout.svelte 是一个「会自动收到子路由作为内容」的组件。Svelte 5 里它接子内容的方式已经不是 <slot>,而是从 $props() 里拿一个叫 children 的 snippet,再用 {@render children()} 渲染出来。

新旧写法的对应

Svelte 4 时代Svelte 5 现在
<slot />let { children } = $props(){@render children()}
export let datalet { data } = $props()

为什么要换?因为 slot 是模板层面的特殊语法,编译器要为它单独开一套机制;而 snippet 就是一个能当值传递的普通 prop(见 06 章)。布局收子内容和你自己写组件收内容,从此走的是同一条路,没有第二套规则。老教程里那句「布局里写个 <slot /> 就行」在 Svelte 5 项目里是死的。

嵌套是自动的

布局按目录层级从外往里套,不需要任何声明。src/routes/+layout.sveltesrc/routes/nested/+layout.svelte 再加 src/routes/nested/deep/+page.svelte,访问 /nested/deep 渲染出来的顺序是根布局的 <nav> 在最外、nested 布局的 <aside> 在中间、页面在最里。

更关键的是布局在导航之间不会被销毁重建。从 /nested/deep 跳到 /nested/other,两层布局的组件实例原地保留,只有页面部分换掉——所以放在布局里的侧边栏滚动位置、展开状态、播放中的音频都不会丢。这是文件路由带来的直接好处,不用你手动做状态提升。

用 @ 跳出布局链

偶尔有个页面不想要祖先布局,比如结账页要全屏、登录页不要侧边栏。这时把文件名加个 @ 后缀,指明它要挂到哪一层+page@.svelte 直挂根布局,+page@blog.svelte 挂到 blog 那一层的布局上。+layout@.svelte 同理,让整个子树跳出去。

routes/reset/inner/+layout@.svelte 访问 /reset/inner,输出里根布局的 <nav> 仍在,被跳过的只有中间那层 routes/reset/+layout.svelte。注意这一点@ 后面为空是「重置到根布局」,不是「什么布局都不要」。

<!-- src/routes/+layout.svelte —— 根布局,包住所有页面 -->
<script>
  // Svelte 5 里子内容是一个 snippet prop,不再是 <slot>
  let { children } = $props();
</script>

<nav>
  <a href="/">首页</a> <a href="/blog">博客</a>
</nav>
<main>{@render children()}</main>
<!-- 导航切页面时,这个 nav 的组件实例原地保留,不重建 -->

// 嵌套自动生效,无需任何声明:
//   +layout.svelte             ← 最外层
//   nested/+layout.svelte      ← 中间层
//   nested/deep/+page.svelte   ← 最里层

// 想跳出祖先布局,文件名加 @ 后缀:
//   checkout/+page@.svelte        → 只保留根布局(不是「全都不要」)
//   blog/x/+page@blog.svelte      → 挂到 blog 那一层的布局上
//   reset/inner/+layout@.svelte   → 整个 inner 子树跳出 reset 布局
老教程里的 <slot /> 在 Svelte 5 布局里已经不能用了,必须写 let { children } = $props(){@render children()},漏了后者页面区域会是空白而不报错。还有+page@.svelte@ 后为空是「重置到布局」,根布局的 <nav> 依然渲染,不是把布局全部去掉。
布局在导航之间不会被销毁重建,只有页面部分会换。所以侧边栏的展开状态、滚动位置、正在播放的音频,放进 +layout.svelte 就天然跨页面保持,不用做状态提升,也不用往全局 store 里塞。

目录名上的方括号就是参数声明。四种括号形态覆盖了几乎所有 URL 形状,全部在构建期解析成正则,运行时只是拿这些正则去匹配路径——所以匹配顺序是确定的,不会因为文件加载顺序而变。

四种括号,一张表

写法目录命中params
必选段blog/[slug]//blog/helloparams.slug === "hello"
剩余段files/[...rest]//files/a/b/cparams.rest === "a/b/c"(含斜杠的一整串)
可选段opt/[[lang]]//opt/opt/zh 都命中前者 undefined,后者 "zh"
路由组(marketing)/pricing//pricing组名不进 URL

路由组是为了布局,不是为了 URL

(group) 唯一的作用是让一批路由共享一个 +layout.svelte,而不在 URL 上留痕。典型用法是 (app)/ 里放需要登录态侧边栏的页面、(marketing)/ 里放着陆页,两组各有各的布局,URL 却都在根一级。routes/(marketing)/pricing/+page.svelte 访问的就是 /pricing

匹配器让 404 发生在路由层

src/params/ 下放一个导出 match 函数的文件,就能在目录名里用 [id=integer] 约束这一段的形状。匹配失败不是进页面再报错,而是这条路由根本不参与匹配。routes/nums/[id=integer]/ 访问 /nums/42 正常渲染,访问 /nums/abc 直接 404,错误页拿到的 page.error.messageNot Found

这比在 load 里写 if (!/^\d+$/.test(params.id)) error(404) 好在两点约束写在路由定义上一眼可见,且同一路径可以按形状分给不同页面(/[id=integer] 给文章、/[username] 给用户主页)。

别用 [...rest] 兜底一切

剩余参数会吃掉整个子树,包括你以为写好的具体路由的兄弟路径。它适合文档站这种真正层级不定的场景,以及放在根目录当自定义 404 页。日常路由请老老实实写具体段。

// src/params/integer.js —— 匹配器,match 返回 false 就当这条路由不存在
export function match(param) {
  return /^\d+$/.test(param);
}

// 目录形态与结果:
//   blog/[slug]/+page.svelte          /blog/hello   → params.slug = "hello"
//   files/[...rest]/+page.svelte      /files/a/b/c  → params.rest = "a/b/c"
//   opt/[[lang]]/+page.svelte         /opt 和 /opt/zh 都命中
//   (marketing)/pricing/+page.svelte  /pricing      → 组名不进 URL
//   nums/[id=integer]/+page.svelte    /nums/42 命中;/nums/abc 直接 404

<!-- src/routes/blog/[slug]/+page.svelte -->
<script>
  import { page } from "$app/state";
  // params 也会作为 load 的入参传进来,取数据时用那个更合适
</script>
<h1>文章:{page.params.slug}</h1>

<!-- opt/[[lang]]:可选段没命中时是 undefined,记得给默认值 -->
<p>语言:{page.params.lang ?? "zh"}</p>
[[optional]] 段没命中时 params.langundefined不是空字符串,直接拼进 URL 会得到字面量 undefined,务必 ?? 默认值。另外 [...rest] 会吞掉整个子树,把它放在浅层目录会悄悄截胡兄弟路由,只在文档站或根级 404 兜底时用。
需要「这一段必须是数字」这类约束时,写 src/params/ 匹配器而不是在 load 里手动校验。不匹配的路径直接 404(page.error.messageNot Found),既省一次判断,又能让 /[id=integer]/[username] 这种同层不同形状的路由和平共处。

SvelteKit 没有 <Link> 组件。普通的 <a href="/about"> 就是客户端导航——路由器在根容器上挂了一个 click 监听(在 kit 客户端运行时的 container.addEventListener("click", ...)),命中站内链接就 preventDefault 掉,改走客户端跳转。

为什么这点值得单独强调

从 React 生态过来的人会本能地找 <Link>,找不到就以为要自己包一个。不用。这个设计的直接好处是渐进增强是免费的——JS 还没加载完或者根本挂了,那些 <a> 依然是能点的原生链接,只是退化成整页刷新。用组件包裹的方案做不到这一点,因为组件本身要等 JS。

要调节行为,用 data-sveltekit-* 属性,可以加在链接上,也可以加在任意祖先元素上一次管一片data-sveltekit-preload-data="hover"(悬停就开始跑该页的 load)、data-sveltekit-reload(强制整页刷新)、data-sveltekit-noscroll(跳转后不回到顶部)。

编程式导航

goto() 来自 $app/navigation,返回 Promise。同一模块还有 invalidateinvalidateAll(见 12 章)、preloadDatabeforeNavigateafterNavigate、以及不产生新导航只改 URL 的 pushStatereplaceState

goto() 从 SvelteKit 2 起不接受站外 URL,源码里的报错文案是 Cannot use `goto` with an external URL. Use `window.location = "..."` instead。外链就照它说的用 window.location

$app/state 已经取代 $app/stores

当前页信息从 $app/state 取,导出三个pagenavigatingupdated。它们是基于符文的响应式对象,直接读属性page.url.pathnamepage.params.slugpage.datapage.statuspage.error

老的 $app/stores 导出同名的 Svelte store,要写成 $page.url.pathname。它仍然能用——同一个组件里同时引入两者,构建通过、SSR 输出一致。但要注意:这个废弃是 JSDoc 层面的,源码里三个导出都挂着 @deprecated Use `page` from `$app/state` instead,编辑器会给你划删除线,但构建期和运行时都不会打任何告警。别指望控制台提醒你迁移。新代码一律用 $app/state

<script>
  import { goto } from "$app/navigation";
  import { page, navigating } from "$app/state";
  // $app/state 是符文驱动的对象,直接读属性,没有 $ 前缀
</script>

<!-- 普通 a 标签就是客户端导航;JS 没加载完它也还是能点的原生链接 -->
<nav data-sveltekit-preload-data="hover">
  <a href="/about" class:active={page.url.pathname === "/about"}>关于</a>
  <!-- 需要整页刷新时才显式退回原生行为 -->
  <a href="/legacy" data-sveltekit-reload>老系统</a>
</nav>

{#if navigating.to}<p>正在前往 {navigating.to.url.pathname}</p>{/if}

<button onclick={async () => {
  await goto("/dashboard", { replaceState: true });
}}>去面板</button>

// 站外链接 goto 会抛错,必须用 window.location
// window.location = "https://example.com";
$app/stores 的废弃只写在 JSDoc 里——源码三个导出都标了 @deprecated,但构建和运行时一条告警都不打,所以老写法能一直静悄悄地留在代码里。另外两者前缀不同($page.urlpage.url),混用时漏掉 $ 会读到 store 对象本身而不是值。
在祖先元素上写一次 data-sveltekit-preload-data="hover",底下所有链接鼠标一悬停就开始跑目标页的 load,等真的点下去数据往往已经到了。<body> 或根布局的 <nav> 上加一处即可,成本几乎为零,观感提升最明显。

SvelteKit 把错误分成两类:你主动用 error() 抛的是「预期内的」,消息原样送到用户面前;其它任何异常都是「意外的」,消息一律被替换成 Internal Error。这条分界线是安全设计,不是 bug。

两种错误的区别

路由 /boomload 里写 error(418, "我是茶壶:自定义错误消息"),响应是 HTTP 418,错误页拿到的 page.error.message 就是那句中文原文。

路由 /crashload 里直接 throw new Error("这是一条不该泄露给用户的内部数据库密码 hunter2"),响应是 HTTP 500,而 page.error.message 被换成了 Internal Error——原始消息只出现在服务端终端,连同完整调用栈。

所以别指望在错误页上看到未捕获异常的真实原因,那是故意的,防的正是把数据库连接串、内部路径这类东西直接印在页面上。要留档就在 hooks.server.jshandleError 里接住上报。

error()redirect() 不用写 throw

两者都从 @sveltejs/kit 导入。SvelteKit 2 起它们内部自己抛,直接调用即可,写不写 throw 都行。真正要小心的是别把它们包在 try/catch——那样等于把控制流信号当异常吞掉,页面会带着残缺数据继续渲染。

+error.svelte 就近生效

它会被最近的祖先捕获routes/blog/+error.svelte 只接管 /blog 子树的错误,并且渲染在 routes/blog/+layout.svelte 里面(布局还在,只有出错那一层被替换)。根级 routes/+error.svelte 兜底。注意 +error.svelte 接不住根 +layout 自己 load 时抛的错——那种情况没有布局可挂了,走的是内置的 src/error.html

三个页面选项

+page.js+layout.js 导出常量即可,写在 layout 上就是整棵子树的默认值,页面可覆盖。prerender = true 在构建产物里多出一个 .svelte-kit/output/prerendered/pages/pre.htmlssr = false 的页面 HTML 里搜不到页面内容但仍带 JS 入口(退化成 SPA);csr = false 的页面内容 HTML 里,而整个文档一个 <script> 标签都没有——纯静态,任何交互都失效。

// src/routes/blog/[slug]/+page.server.js
import { error, redirect } from "@sveltejs/kit";

export async function load({ params, locals }) {
  // SvelteKit 2 起不必写 throw,内部会抛;千万别 try/catch 包住
  if (!locals.user) redirect(303, "/login");

  const post = await db.getPost(params.slug);
  // 主动抛的错:消息原样送到用户面前
  if (!post) error(404, "这篇文章不存在或已下架");
  return { post };
  // 而这里若冒出未捕获异常,用户只会看到 Internal Error
}

<!-- src/routes/blog/+error.svelte:只接管 /blog 子树 -->
<script>
  import { page } from "$app/state";
</script>
<h1>{page.status}</h1>
<p>{page.error.message}</p>

// src/routes/about/+page.js —— 页面选项,写在 +layout.js 上则整树生效
export const prerender = true;   // 构建期生成静态 html
export const csr = false;       // 产物里一个 script 标签都没有
error()redirect() 写进 try/catch 是高频坑——它们靠抛出来传递控制流,被 catch 吞掉后页面会带着残缺数据继续渲染,不报错也不跳转。另外 csr = false 会让产物里一个 <script> 都不剩,页面上所有事件绑定连同 goto() 全部静默失效。
凡是你能预料的失败——找不到、没权限、参数不合法——都用 error(status, 人话消息),这样消息才到得了用户。别用 throw new Error("文章不存在"),它会被统一换成 Internal Error,用户看到的和真正的服务器崩溃毫无区别。

数据加载

load 函数跑在哪一侧、universal 与 server 的区别、依赖失效与重新运行的规则。

SvelteKit 数据层只有一个真正需要背下来的事实:文件名决定这段代码被编译进哪个产物+page.server.js 只进服务端 bundle,+page.js 两边都进。其余所有规则都是这一条的推论。

两者的执行位置

在两种 load 里各打一行日志再构建预览,首屏请求时服务端终端两条都出现——因为 SSR 时通用 load 当然也在服务端跑。区别在客户端导航:通用 load 的代码被打进了客户端 chunk,跳转到该页时会在浏览器里再跑一次;服务端 load 的代码浏览器根本拿不到,客户端导航时 SvelteKit 改为向 /路径/__data.json 发一个请求,让服务端跑完把结果送回来。

所以「通用」这个名字容易误导。它不是「跑一次、两边通用」,而是「同一份代码,两边都可能跑」——首屏在服务端跑,之后每次客户端导航在浏览器里再跑。

怎么选

需求用哪个原因
连数据库、读文件、用 $env/static/private+page.server.js代码不能进客户端 bundle
带密钥调第三方 API+page.server.js密钥一旦编译进前端就等于公开
读 cookie 或 locals+page.server.js只有服务端有请求上下文
返回值里要放类实例、函数、组件构造器+page.js服务端 load 的返回值必须能序列化
调用完全公开的外部 API+page.js客户端导航时可以直连,不用绕自家服务器一圈

拿不准就先写 +page.server.js。把东西留在服务端从来不会错,把东西泄到客户端会。

两者共存时,通用 load 的返回值会「顶掉」服务端的

同一路由同时有两个文件时,服务端 load 先跑,结果作为 data 参数交给通用 load。这里有个坑:最终 data prop 里只有通用 load 返回的东西。服务端返回 { srvKey: "S" }、通用 load 返回 { uniKey: "U" },组件拿到的是 {"uniKey":"U"}——srvKey 不见了。想要就得自己 ...data 展开。

序列化的边界

服务端 load 的返回值要跨进程送到浏览器,走的是 devalue,比 JSON 宽DateMapSetBigIntRegExp 乃至循环引用都能过。函数和类实例不行。ORM 查出来的模型对象常常带方法,记得先摊平成普通对象。

// src/routes/posts/+page.server.js —— 只进服务端产物
import { DATABASE_URL } from "$env/static/private";

export async function load({ locals, cookies }) {
  // 密钥、DB、cookie 只有这里能碰
  const rows = await db.query("select id, title from posts");
  return { rows };   // 走 devalue 序列化:Date/Map/Set 可以,函数不行
}

// src/routes/posts/+page.js —— 两边都会跑
export async function load({ data, fetch }) {
  const extra = await (await fetch("/api/stats")).json();
  // 不写 ...data 的话,服务端返回的 rows 会整个丢失
  return { ...data, extra };
}

// src/routes/posts/+page.svelte
// let { data } = $props();  → data.rows 和 data.extra 都在

// 执行位置:
//   首屏 SSR    两个 load 都在服务端终端打日志
//   客户端导航  +page.js 在浏览器里重跑
//               +page.server.js 改为请求 /posts/__data.json
同一路由同时有 +page.server.js+page.js 时,通用 load 的返回值会整个顶掉服务端的。服务端返回 {srvKey:"S"}、通用返回 {uniKey:"U"},组件收到的是 {"uniKey":"U"}srvKey 无声消失。必须显式写 return { ...data, ... }
拿不准就默认写 +page.server.js。把逻辑留在服务端最坏是多一次往返,把密钥或数据库调用漏进 +page.js 则是直接编译进前端 bundle 给全世界看。只有当返回值必须包含函数、类实例这类不可序列化的东西时,才非用通用 load 不可。

load 的返回值以一个叫 data 的 prop 交给同目录的 +page.svelte。Svelte 5 里接 prop 的写法是 let { data } = $props()——export let data 是 Svelte 4 的写法,在符文模式下已经不成立,老教程和大量博客还停在那儿。

data 是只读的快照,不是状态

data 会随导航和失效重跑而整体替换。所以别把它当可变状态用data.post.title = "改了" 在下一次 load 重跑时会被冲掉,而且这个改动只在浏览器里存在,服务端一无所知。

需要基于服务端数据做本地编辑,正确姿势是派生一份本地状态,用 $state 存草稿、用 $deriveddata 算展示值(见 03 章)。真要写回服务端则走表单 Action(见 13 章)。

类型从 ./$types 来,不用手写

SvelteKit 会为每一个路由目录.svelte-kit/types/ 下生成一个 $types.d.ts,里面的 PagePropsPageLoadPageServerLoadLayoutProps 都是针对这个具体路由算出来的——params 的键名直接来自目录里的方括号,data 的形状直接来自你 load 的返回值。

这就是为什么它必须是生成的routes/blog/[slug]/params 类型里有 slugroutes/about/ 的没有,这种逐路由的差异手写不出来。写 /** @type {import("./$types").PageProps} */ 就能在纯 JS 项目里拿到全套提示,不必上 TypeScript(见 09 章)。

找不到 ./$types 通常不是配置错了

这些类型是 vite devsvelte-kit sync 生成的。刚 clone 下来还没跑过任何命令时,编辑器会报找不到模块。跑一次 npx svelte-kit sync 即可,不用去改 tsconfig

<!-- src/routes/blog/[slug]/+page.svelte -->
<script>
  // Svelte 5 写法;export let data 是 Svelte 4 的,符文模式下无效
  /** @type {import("./$types").PageProps} */
  let { data } = $props();

  // data 每次导航整体替换,别直接改它
  // 要本地编辑就派生一份自己的状态出来
  let draft = $state("");
  const wordCount = $derived(data.post.body.length);
</script>

<h1>{data.post.title}</h1>
<p>共 {wordCount} 字</p>
<textarea bind:value={draft}></textarea>

// src/routes/blog/[slug]/+page.js —— load 侧也有生成好的类型
/** @type {import("./$types").PageLoad} */
export async function load({ params }) {
  // params.slug 有提示,因为类型是按这个目录名生成的
  return { post: await getPost(params.slug) };
}
照着老教程写 export let data 在符文模式组件里拿不到数据。另一个高频困惑是编辑器报找不到 ./$types——那些声明是 vite dev 生成的,刚 clone 完没跑过命令就会缺,执行一次 npx svelte-kit sync 就好,别去动 tsconfig
在纯 JS 项目里也写上 /** @type {import("./$types").PageProps} */。这些类型是 SvelteKit 按每个路由目录生成的,params 的键名直接来自目录里的方括号,改了目录名类型立刻跟着变——白拿的编译期校验,不用上 TypeScript。

布局也能有 load,它取到的数据会自动并进所有子路由的 data。合并方向永远是从外往里,键名冲突时内层赢

合并规则

布局 load 返回 { luniversal: "LU", collide: "layout-value" },页面 load 返回 { pageKey: "P", collide: "page-value" }。结果布局组件的 data{"luniversal":"LU","collide":"layout-value"},页面组件的 data{"luniversal":"LU","collide":"page-value","pageKey":"P"}

两个结论其一,页面自动继承了布局的 luniversal,不用手动往下传;其二,collide 这个键在页面侧是页面的值,子层覆盖父层。反过来不成立——布局看不到子页面 load 的返回值,因为布局要在子路由之间复用,它不能依赖某个具体子页面存在什么。

await parent() 用来「读」,不是用来「传」

数据合并是自动的,所以 parent() 不是拿数据用的——它是当你的 load 逻辑本身需要父级结果时才用。+page.jsawait parent() 得到的正是布局 load 合并后的结果 {"luniversal":"LU","fromLayoutServer":"LS"}

典型场景是子 load 要用父 load 查出来的用户 id 再去查订单。除此之外别调它,因为——

parent() 会制造请求瀑布

默认情况下同一次导航里,各层 load 是并行跑的。一旦你 await parent(),本层就必须等父层完全跑完才开始,并行退化成串行。所以有个实用技巧:把不依赖父级的活儿先发出去,最后才 await parent()

还有一条顺序上的事实+layout.server.js 的结果会先送给 +layout.js(作为它的 data 参数),fromLayoutServer 确实拿到了 "LS"。而通用布局 load 里 await parent() 拿到的是父级布局的数据,不是同层服务端 load 的数据——那个走 data 参数。

放什么进布局 load

只放整棵子树都要用的东西当前登录用户、站点导航树、多语言词条。放了一个只有某个页面用得上的重查询,就等于让子树里每一个页面都白等它一次——而且布局 load 在同一布局下的页面间切换时默认不会重跑,这既是性能优势,也意味着它的数据可能比你想的更陈旧。

// src/routes/(app)/+layout.server.js —— 整棵子树都要的东西
export async function load({ locals }) {
  return { user: locals.user };   // 每个子页面的 data.user 都有它
}

// src/routes/(app)/orders/+page.js
export async function load({ parent, fetch }) {
  // 不依赖父级的请求先发出去,别被 parent() 挡住
  const ratesPromise = fetch("/api/rates").then((r) => r.json());

  // 只有真的需要父级结果才 await:这里要用 user.id 去查订单
  const { user } = await parent();
  const orders = await getOrders(user.id);

  return { orders, rates: await ratesPromise };
}

// 合并结果:
//   布局组件 data → {"luniversal":"LU","collide":"layout-value"}
//   页面组件 data → {"luniversal":"LU","collide":"page-value","pageKey":"P"}
//   即:父数据自动向下合并,同名键由子层覆盖父层
//   反向不成立:布局永远看不见子页面 load 的返回值
await parent() 会把默认并行的各层 load 拧成串行,写在函数第一行就等于给整个页面加了一次完整的父级耗时。另一个反直觉点:布局的 data 里看不到子页面 load 的返回值,想在布局里显示页面标题得走 page.data 或别的机制,不能指望合并。
布局数据是自动并进子页面 data 的,别为了传数据而调 parent()。只有当子 load 的逻辑需要父级结果(比如拿 user.id 去查订单)时才用它,而且要把不依赖父级的请求先发出去,最后才 await parent()

load 不是「每次导航都重跑」,而是「只有它记录在案的依赖变了才重跑」。这套机制和 Svelte 的 $derived 是同一种思路——运行时记下你读过什么,之后只在那些东西变化时失效。

依赖是真的被记下来的

传给 load 的 paramsurlfetch 都是被代理过的对象。你读了 params.id、读了 url.searchParams.get("q")、调了 depends("app:ticker"),这些全部会记进这个节点的依赖表。

这不是说法,是能直接看到的。一个 +page.server.js 里读了 params.id、读了 ?q、调了 depends("app:ticker"),SSR 出来的 HTML 里那个 load 节点的记录是uses:{dependencies:["app:ticker"],search_params:["q"],params:["id"]}。而一个什么都没读的 load,记录是 uses:{}——它永远不会被任何 invalidate() 触发,只有 invalidateAll() 能强推。

只读了没用到的东西也算依赖

反过来说,读过就是读过。在 load 里为了打日志而读一下 url.pathname,这个 load 就跟着 URL 走了。想读又不想被追踪,用 untrack(load 参数里那个,不是 svelte 包的)把读取包起来。

invalidate 的完整链路

invalidate("app:ticker") 时发生的事,可以逐步看到客户端向 /当前路径/__data.json 发请求,并在查询串里带上哪些节点被判定为失效。服务端逐节点决定跑不跑,把结果拼成一个数组返回。

关键是没失效的节点会被跳过。同一个 __data.json 请求,带上失效标记后返回的是 {"type":"data","nodes":[{"type":"skip"},{"type":"data","data":[...],"uses":{...}}]}——第一个节点(布局)是 {"type":"skip"},服务端根本没跑它的 load,客户端复用旧值。所以 invalidate精确的,不是「整页重来」。

三种触发方式怎么选

写法作用范围什么时候用
invalidate("/api/todos")所有 fetch 过这个 URL 的 load你知道具体是哪个接口的数据变了
invalidate("app:todos")所有 depends("app:todos") 的 load数据源不是 URL(数据库、本地存储),或想按业务概念划分
invalidateAll()当前页所有 load,含 uses:{}登录登出这种全局身份变化;日常别用

自定义标识必须带冒号前缀app:xxx),这是为了和 URL 区分开。

// src/routes/todos/+page.server.js
export async function load({ params, url, depends }) {
  // 读过就算依赖:params.id 和 ?q 都会被记进 uses
  const keyword = url.searchParams.get("q");
  // 数据源是数据库不是 URL,只能手动声明一个标识
  depends("app:todos");
  return { todos: await db.findTodos(params.id, keyword) };
}
// SSR 产物里这个节点记的是:
// uses:{dependencies:["app:todos"],search_params:["q"],params:["id"]}

<!-- src/routes/todos/+page.svelte -->
<script>
  import { invalidate } from "$app/navigation";
  let { data } = $props();

  async function addTodo(text) {
    await fetch("/api/todos", { method: "POST", body: text });
    // 只重跑声明了 app:todos 的 load,其余节点服务端返回 type:"skip"
    await invalidate("app:todos");
  }
</script>

<button onclick={() => addTodo("写文档")}>新增</button>
一个什么参数都没读的 load,依赖表是 uses:{},任何 invalidate(url) 都推不动它,只有 invalidateAll() 有效——这是「我调了 invalidate 但数据没变」的头号原因。反向的坑是只为打日志读了一下 url.pathname,从此这个 load 跟着 URL 变化狂跑。
改完数据别用 invalidateAll()。给每个数据源在 load 里 depends("app:某某"),然后精确 invalidate("app:某某")——未失效的节点服务端会直接返回 {"type":"skip"},一次都不跑。invalidateAll() 只留给登录登出这类身份级变化。

load 参数里那个 fetch 不是全局 fetch。它是 SvelteKit 包装过的版本,替你解决了三个在 SSR 场景下必然会遇到的问题——所以在 load 里永远用参数里的 fetch,用全局的就等于自愿放弃这三项。

其一,服务端调同源接口不走网络

SSR 时调 fetch("/api/hello"),SvelteKit 认出这是自家路由,直接在进程内调用那个 +server.js 的处理函数,不开 TCP 连接、不经过 HTTP 栈。服务端终端确实打出了端点里的日志,说明处理函数跑了,但省掉的是服务器向自己发一次请求的全部开销——那件事在容器和无服务器环境里还经常因为不知道自己的公网地址而直接失败。

其二,响应内联进 HTML 供水合复用

这是最值钱的一条。服务端 load 里 fetch 到的响应,会被原样写进返回的 HTML/load/fetchtest 的 HTML 里有这么一段<script type="application/json" data-sveltekit-fetched data-url="/api/hello">{"status":200,"statusText":"","headers":{},"body":"{\"msg\":\"hello-from-api\"}"}</script>

客户端水合时通用 load 会再跑一次,这时它的 fetch 发现要请求的 URL 已经躺在页面里,直接读走,一次网络请求都不发。没有这个机制,「通用 load 两边都跑」就意味着每个页面首屏必然多打一次接口。

其三,转发 cookie 与请求头

服务端本身没有浏览器上下文。用全局 fetch 调自家需要鉴权的接口,请求上不会带任何 cookie,你会稳定拿到 401。包装版会把当前请求的 cookieauthorization 头带上,同源请求还会把接口 Set-Cookie 的响应头回传给浏览器。

注意边界:跨域请求不转发 cookie——那样等于把用户凭据泄给第三方。要给外部服务传凭据必须自己显式写进 headers。

它只在 load 内部有效

这个 fetch 只能在 load、Action、+server.js 的处理函数体内使用。存到模块级变量里、或者在异步回调里延迟到 load 返回之后再调,都会失效——它绑定的是当次请求的上下文,请求结束上下文就没了。

// src/routes/dash/+page.js —— 注意 fetch 是从参数解构来的
export async function load({ fetch }) {
  // SSR 时不走网络,直接调 src/routes/api/hello/+server.js 的 GET
  const res = await fetch("/api/hello");
  const body = await res.json();

  // 跨域请求不会自动带 cookie,凭据必须自己写进 headers
  const ext = await fetch("https://api.example.com/v1/me", {
    headers: { Authorization: "Bearer " + token },
  });

  return { body, me: await ext.json() };
}

// SSR 输出的 HTML 里会多出这一段,供客户端水合时复用:
// <script type="application/json" data-sveltekit-fetched
//   data-url="/api/hello">{"status":200,"statusText":"",
//   "headers":{},"body":"{\"msg\":\"hello-from-api\"}"}</script>
// 于是通用 load 在浏览器里重跑时,这次 fetch 零网络请求

// 反例:这样写就完全拿不到上述任何一项能力
// export async function load() { await fetch("/api/hello"); }
在服务端用全局 fetch 调自家鉴权接口会稳定拿 401,因为服务端没有浏览器上下文、不会自动带 cookie。还有个隐蔽的包装版 fetch 绑定当次请求上下文,把它存进模块级变量或在 load 已经返回后的回调里再调,一律失效。
load 时先把签名敲成 load({ fetch }) 再写函数体。少解构这一个参数,代码照样跑,但你会静默失去内联复用、进程内直调和 cookie 转发三项能力——它退化成了普通 fetch,而且不会有任何提示。

load 返回一个没有 await 的 Promise,SvelteKit 会先把页面骨架发出去、保持连接不关,等 Promise 落定后再追加一段 <script> 把值送过去。慢数据不再拖住整个首屏。

确认 2.70.1 的行为

服务端 load 返回 { fast: "FAST_VALUE", nested: { slow }, topLevel },两个 Promise 都延迟 1.5 秒。curl 首字节 0.028s,整个响应 1.525s——骨架立刻就到,连接一直开着等 Promise。

顶层 Promise 不再被自动 await。页面上打印 typeof data.topLevel 的判断输出 PROMISE,说明顶层和嵌套一视同仁,都走流式。老资料里那句「顶层 promise 会被自动 await,要流式必须包一层对象」在 2.70.1 已经不成立了。

传输长什么样

初始 HTML 里,两个 Promise 都被换成了占位符 __sveltekit_xxx.defer(1)defer(2)。文档 </html> 结束之后,服务端又追加了两个分块,报错原文是 <script>__sveltekit_4s0hru.resolve(1, () => ["SLOW_RESOLVED"])</script> 这样的形式。浏览器边收边执行,Promise 就在客户端落定,{#await} 随之切换。

只有服务端 load 能流式

这是里最反直觉的一条。同样的代码放进 +page.js,响应 15 毫秒就结束了,总共 882 字节,尾部一个 resolve 分块都没有,页面停在 {#await} 的 pending 分支。

原因很直白通用 load 会在客户端重跑一遍,那个 Promise 到了浏览器里自然会自己解决,服务端没有任何理由为它撑住连接。所以要流式就必须写在 +page.server.js

什么该流、什么不该

  • 该流:评论区、推荐位、销量统计这类「慢、且没有它页面也成立」的数据。
  • 不该流:标题、正文、价格——SEO 爬虫拿到的是初始那段 HTML,流式补上的内容大概率不算数。也别流会影响布局高度的东西,否则用户会看着页面跳。
  • 必须处理失败:流式 Promise 一旦 reject 且没有 {:catch},错误发生在响应头早就发出去之后,SvelteKit 已经无法改成错误页了。
// src/routes/post/+page.server.js —— 流式只对服务端 load 生效
export async function load() {
  return {
    post: await getPost(),        // await 了:SEO 要它,必须在首屏 HTML 里
    comments: getComments(),      // 不 await:骨架先发,稍后追加
  };
  // 2.70.1 顶层 promise 也走流式,不再自动 await
}

<!-- src/routes/post/+page.svelte -->
<script>
  let { data } = $props();
</script>

<article>{data.post.body}</article>

{#await data.comments}
  <p>评论加载中</p>
{:then comments}
  {#each comments as c (c.id)}<p>{c.text}</p>{/each}
{:catch}
  <!-- 必须写:响应头早发出去了,这里不写就只能白屏 -->
  <p>评论加载失败</p>
{/await}

// curl:首字节 0.028s,整个响应 1.525s(连接一直等着 Promise)
把不 await 的 Promise 写在 +page.js 里,流式完全不会发生——响应 15 毫秒结束、尾部没有任何 resolve 分块、页面停在 pending 分支,因为通用 load 会在客户端重跑,服务端没理由撑住连接。要流式必须写 +page.server.js
只流「没有它页面依然成立」的东西——评论、推荐、统计。标题正文价格一律 await,因为爬虫读的是初始那段 HTML,流式补上的内容大概率不计入索引。还有一条铁律:流式的 {#await} 必须写 {:catch}

表单与 Actions

渐进增强是 SvelteKit 的招牌:没有 JS 也能提交。form actions、use:enhance 与校验错误的完整链路。

Form Actions 不是「另一种写 API 的姿势」,它是把浏览器从 1993 年就有的原生表单提交通道接上服务端。所以「没有 JavaScript 也能提交」不需要你做任何额外工作——它是起点,不是奖励。

一个路由,两端代码

  • +page.server.js 导出一个 actions 对象,里面的函数只在服务端跑,永远不会进客户端产物。
  • +page.svelte 里写一个普通的 <form method="POST">。不写 action 属性时,浏览器默认提交到当前 URL,SvelteKit 收到这个 POST 就去找同路径的 actions
  • 请求体由浏览器按 application/x-www-form-urlencoded 序列化,服务端 await request.formData() 拿到标准的 FormData。中间没有你写的任何一行胶水代码。
  • action 的返回值通过 form prop 回到组件(Svelte 5 里是 let { form } = $props(),见 05 章)。

产物里的 form 就是裸的

  • 把这样一个页面构建后 curl 下来,抓到的标签原文就是 <form method="POST">——没有被换成 onsubmit 回调,没有多出任何 data 属性,客户端 JS 里也找不到包装函数。
  • 用 curl 直接 POST 到同一路径(带上同源 Origin 头),服务端返回的是一整页 HTML,状态码就是 action 决定的那个。这条链路上浏览器只做了「序列化表单、发 POST、渲染回来的 HTML」三件事,全是它自带的能力。
  • 对比一下:如果框架要求你写 onSubmit={handler} 再在 handler 里 fetch,那么 JS 没加载完的那段时间里,点提交按钮什么都不会发生——表单在 HTML 里根本没有目的地。

表单先行,还是客户端函数先行

同样是提交数据,起点选在哪一层,决定了退化时剩下什么。

  • 以原生 form + action 为起点(SvelteKit 默认)
    JS 未加载、加载失败、被浏览器扩展拦掉,表单照样能用;爬虫和无障碍工具也读得懂。
    数据形状被 FormData 限死,全是字符串,嵌套结构要自己编解码;默认行为是整页导航。
    为何优点和短板同根于「交给浏览器做」——浏览器可靠,但它只认这一种数据格式和这一种跳转方式。
  • 以客户端函数为起点
    参数随便传,可以是任意 JSON,交互细节完全自己掌控。
    没有 JS 就等于没有功能,而且这个退化路径你平时根本测不到。
    为何自由度来自「绕开浏览器自己实现」,代价就是浏览器原本免费给的那份兜底也一起绕掉了。
// src/routes/todos/+page.server.js —— 只在服务端跑
import { fail } from "@sveltejs/kit";

export const actions = {
  // default 对应「表单不写 action 属性」的那种提交
  default: async ({ request }) => {
    const data = await request.formData();
    const title = String(data.get("title") ?? "").trim();
    if (!title) return fail(400, { title, msg: "标题必填" });
    await addTodo(title);
    return { success: true, title };
  },
};

<!-- src/routes/todos/+page.svelte —— 客户端一行 JS 都不用 -->
<script>
  let { form } = $props();   // action 的返回值从这里进来
</script>

<form method="POST">
  <input name="title" value={form?.title ?? ""}>
  <button>添加</button>
</form>
{#if form?.msg}<p>{form.msg}</p>{/if}
<form> 上漏写 method="POST" 是最常见的一刀。表单会按默认的 GET 提交,字段全跑进查询串,action 根本不会被调用,页面看上去就是「点了没反应、地址栏多了个问号」。检查表单时先看这个属性。
想验证一个「渐进增强」是不是真的,别看文档看产物:构建后 curl 页面,把 <form> 标签抓出来。上面只有 methodaction 就是真的;如果多了 onsubmit 或者一堆 data 属性,那它离了 JS 就是个死按钮。

一个页面常常要做不止一件事——新建、删除、切换状态。SvelteKit 不让你为此拆出三个路由,而是让同一个 actions 对象挂多个具名函数,靠查询串 ?/name 区分。

两种形态,只能选一种

  • default:页面只有一件事要做时用它,表单不写 action 属性,提交到当前 URL 即可。
  • 具名:actions = { create, remove },表单写 action="?/create"。这个查询串就是路由的判别依据。
  • 两者不能混用。actions 里同时放 default 和具名 action,构建照样通过,但一旦有请求打到这个页面就 500,服务端抛出的原文是:When using named actions, the default action cannot be used. See the docs for more info: https://svelte.dev/docs/kit/form-actions#named-actions。注意这是运行期错误,构建期不拦你。

一页多表单的写法

  • 每个 <form> 写自己的 action="?/xxx",各自提交、互不干扰。
  • 列表里「每行一个删除按钮」不用为每行套一个 form——同一个 form 里用 <button formaction="?/remove"> 也能覆盖掉 form 上的 action,use:enhance 也认这个属性。
  • 要传行 id 就加个 <input type="hidden" name="id">,或者直接写进查询串 ?/remove&id=7——后者服务端从 url.searchParams 取。
  • 所有具名 action 共享同一个 form prop。想在界面上区分是谁返回的,就在返回值里带一个标记字段,比如 return { did: "create" }

为什么是查询串,而不是别的地方

  • 要区分 action,标识必须写在浏览器不用执行任何 JS 就能读到的位置。表单的 action 属性正好是这样一个位置,而查询串是往 URL 里塞信息最省事的办法。
  • 换成藏在请求体里的隐藏字段行不行?技术上行,但服务端得先把整个请求体读完、解析成 FormData,才知道该调哪个函数——路由分发被迫依赖于载荷解析,文件上传这种大请求体尤其别扭。
  • 放在查询串还带来一个附赠品:<button formaction="?/remove"> 能直接覆盖表单上的 action,一个 form 里放多个按钮走不同分支,全靠浏览器原生能力完成。
  • 代价是 action 名会出现在地址栏里。原生提交完成后 URL 上会挂着 ?/create,看着不太干净——加上 use:enhance 之后不发生导航,这个问题自然就没了。

提交到不存在的 action

往一个只有具名 action 的页面裸 POST(不带 ?/),返回 404,服务端消息是 No action with name 'default' found。这条在排查「表单点了没反应」时很有用:404 说明请求到了对的路由但没找到 action,通常就是 action 属性拼错或者漏了。

// src/routes/todos/+page.server.js
import { fail } from "@sveltejs/kit";

// 一旦用了具名,就不能再有 default —— 运行期会 500
export const actions = {
  create: async ({ request }) => {
    const d = await request.formData();
    const title = String(d.get("title") ?? "").trim();
    if (!title) return fail(400, { did: "create", msg: "标题必填" });
    await addTodo(title);
    return { did: "create" };
  },
  // id 走查询串,服务端从 url 取,不用藏 hidden 字段
  remove: async ({ url }) => {
    await removeTodo(url.searchParams.get("id"));
    return { did: "remove" };
  },
};

<!-- +page.svelte:两个表单各走各的 action -->
<form method="POST" action="?/create">
  <input name="title"><button>新建</button>
</form>

{#each data.todos as t (t.id)}
  <form method="POST" action="?/remove&amp;id={t.id}">
    <button>删掉 {t.title}</button>
  </form>
{/each}
把表单的 action 指向别的路由的 action(比如 action="/other?/create"),提交本身能成功,但 form prop 不会更新——use:enhance 的默认处理只在提交目标与当前页面同路径时才回填。跨路由的写操作请改用重定向,或者干脆搬到本页来。
default 与具名的选择依据是这个页面未来会不会出现第二个表单。会,就直接上具名——从 default 改成具名要同时改服务端和每一个 <form>,而具名加一个函数即可。默认写具名几乎不会亏。

fail(400, {...})return {...} 的返回值都会进 form prop,真正的区别在 HTTP 状态码,以及客户端要不要把它当成「这次提交成功了」。

两者的响应差在哪

写法原生提交(浏览器直接 POST)enhance 提交(fetch)
return { success: true }HTTP 200,返回整页 HTML{"type":"success","status":200,…}
fail(400, { msg })HTTP 400 Bad Request,同样返回整页 HTML{"type":"failure","status":400,"data":…}
fail(422, {...})HTTP 422 Unprocessable Entitytypefailurestatus 为 422

关键点:fail 传的状态码就是真正的 HTTP 响应状态,不是装饰。而两种情况下页面都被完整重新渲染了一遍——fail 不是抛异常,它不会把你踢到错误页。

回填才是 fail 的主要用途

  • 原生提交会让浏览器重新加载页面,用户刚敲的字全没了。所以 fail 的 data 里要把用户已输入的值原样带回去,模板上写 value={form?.title ?? ""}
  • 密码之类的敏感字段不要回填,让用户重敲。返回值会被序列化进 HTML,回填等于把明文密码写进页面源码。
  • 校验多个字段时,返回一个 errors 字典比返回一句话有用得多:fail(400, { values, errors: { email: "格式不对" } }),模板上按字段名取。

成功之后:return 还是 redirect

  • 只是更新了当前页上的数据(改标题、切换状态)——直接 return。原生提交下浏览器会重新加载本页,看到的就是新数据;use:enhance 下会自动重跑 load,同样是新数据。
  • 产生了新资源或者该换页了(下单成功、登录成功)——用 redirect(303, "/orders/123")。303 会让浏览器把跳转请求改成 GET,用户刷新页面不会重复提交,这就是老规矩里的「POST 之后重定向」。
  • redirectfail 相反:它是抛出的,不需要写 return,调用即中断后面的代码。use:enhance 收到 redirect 类型的结果会执行客户端跳转,不会整页刷新。

别用 fail 表达「服务器炸了」

fail 是给可预期的用户输入问题用的——4xx。真正的异常(数据库连不上、第三方超时)应该直接抛,让它走 error+error.svelte 那条路(见 14 章)。把 500 塞进 fail 会让用户看到一个「校验失败」样式的提示,而你的错误上报什么都收不到。

// +page.server.js —— 校验失败时把用户输入原样带回
import { fail } from "@sveltejs/kit";

export const actions = {
  default: async ({ request }) => {
    const d = await request.formData();
    const email = String(d.get("email") ?? "");
    const errors = {};
    if (!email.includes("@")) errors.email = "邮箱格式不对";
    if (Object.keys(errors).length) {
      // 400 是真的 HTTP 400;email 带回去用于回填,密码绝不带
      return fail(400, { values: { email }, errors });
    }
    await signUp(email, d.get("password"));
    return { success: true };
  },
};

<!-- +page.svelte -->
<script>
  let { form } = $props();
</script>

<form method="POST">
  <input name="email" value={form?.values?.email ?? ""}>
  {#if form?.errors?.email}<small>{form.errors.email}</small>{/if}
  <input name="password" type="password">
  <button>注册</button>
</form>
fail(...) 写成了 throw fail(...)fail 返回的是一个普通结果对象,必须 return;抛出去会被当成未捕获异常,用户直接看到 500 错误页,而不是你精心写的那条校验提示。
form prop 的取值时机口诀:它只在本次提交之后有值,导航到别的页面再回来就是 null。所以模板里一律写 form?.xxx 而不是 form.xxx,并且不要拿它当页面数据的来源——那是 data 的活(见 12 章)。

use:enhance 的设计前提是「这个表单本来就能用」——它只是拦下提交事件,改用 fetch 发同一个请求,然后把结果贴回页面。所以正确顺序永远是:先让表单没 JS 能跑通,再加 enhance 让它跑得更顺畅。

它到底做了什么

  • 拦截 submit,用 fetch POST 到 form 的 action,请求头带上 accept: application/jsonx-sveltekit-action: true——服务端就是靠这两个头决定回 JSON 而不是整页 HTML 的。
  • 把响应反序列化成 ActionResult,然后按类型分派。整个过程不发生导航,滚动位置、输入焦点、页面上其它组件的状态都保住了。
  • 如果 JS 没加载完用户就点了提交,use: 指令还没绑上,浏览器就按原生方式提交——这就是「回退」,它不是一段兜底代码,而是「什么都没发生」的自然结果。

不写回调时的默认行为

结果类型默认动作
success重置表单(调用 form.reset())+ invalidateAll() 重跑所有 load + 更新 form prop
failure重置、失效重载,只更新 form prop 和 page.status
redirect执行客户端跳转
error渲染最近的 +error.svelte

「成功就清空、失败就保留」正是表单该有的行为,所以绝大多数场合 use:enhance 后面什么都不用写。

什么时候才需要自定义回调

  • 要做提交中状态(禁用按钮、转圈):在回调体里置 true,在返回的函数里置回 false
  • 要改默认动作:返回的函数里调 update({ reset: false })update({ invalidateAll: false })只要你返回了函数,就必须自己调 update(),否则上表里那些默认动作一个都不会发生——这是最容易踩空的一步。
  • 要在提交前拦下来:回调参数里有 cancel(),调用它这次提交就取消。

不用 enhance 的时候

  • 如果连 enhance 都不够用(比如要做乐观更新、要自己控制并发、要在一次交互里连打两个 action),可以完全手写 fetch——但请求头得自己带 x-sveltekit-action,响应要用 $app/forms 导出的 deserialize() 解开,因为返回值经过特殊序列化,直接 JSON.parse 拿到的 data 是一串编码后的字符串而不是对象。解开之后再交给 applyAction()
  • 这条路存在,但它同时也是「你正在放弃渐进增强」的信号。手写之前先确认:表单在 JS 失效时还剩什么?如果什么都不剩,那就不如老实用 enhance 加自定义回调。
  • enhance 的另一个隐含好处是提交期间用 AbortController 管着请求,页面导航走了会自动取消。手写时这类细节全得自己补。
<script>
  import { enhance } from "$app/forms";
  let { form } = $props();
  let saving = $state(false);
</script>

<!-- 去掉 use:enhance 这一整行,表单依然完全可用 -->
<form method="POST" use:enhance={() => {
    saving = true;
    // 返回函数 = 接管结果处理,默认行为一律要自己 update() 触发
    return async ({ update }) => {
      await update({ reset: false });   // 成功后也保留输入
      saving = false;
    };
  }}>
  <input name="title" value={form?.values?.title ?? ""}>
  <button disabled={saving}>{saving ? "保存中…" : "保存"}</button>
</form>

{#if form?.errors?.title}<p>{form.errors.title}</p>{/if}
{#if form?.success}<p>已保存</p>{/if}
自定义回调里返回了函数却忘了调 update(),页面就会「提交成功但界面纹丝不动」——数据其实写进去了,只是没重置表单也没重跑 load,看着像失败。另外 use:enhance 只能用在 POST 表单上,开发模式下用错会抛 use:enhance can only be used on <form> fields with method="POST"
开发时把浏览器的 JavaScript 关掉,再走一遍你的主要表单流程。能跑完就说明渐进增强是真的,这比读任何文档都快。跑不完,说明你在某处偷偷把 enhance 当成了功能本体,而不是增强。

客户端校验是给用户看的体验,服务端校验是给系统用的防线。任何绕开浏览器的请求(curl、脚本、改过的页面)都会直接落到你的 action 上,所以 action 第一行就该假设输入是恶意的。

校验分两层,职责完全不同

  • requiredtype="email"minlength 这些 HTML 属性写上去,成本几乎为零,能挡住绝大多数手滑。但它们只是提示,不构成任何保证
  • 服务端校验必须覆盖所有字段,而且要重新推导权限:不要因为界面上没显示删除按钮就假设没人会提交删除。授权判断只能基于 event.locals 里服务端自己算出来的身份(下一章展开)。
  • FormData 取出来的值可能是 stringFilenull,永远先 String(...) 或做类型判断再用。

CSRF:默认开着,但只盖表单

  • SvelteKit 默认带 CSRF 保护。不带 Origin 头 POST 一个表单到生产构建,直接 403,响应体原文是 Cross-site POST form submissions are forbidden
  • 机制是比对请求的 Origin 头和站点自身 origin,配置项是 kit.csrf.trustedOrigins。它只对表单类 content-type 生效——application/x-www-form-urlencodedmultipart/form-datatext/plain
  • 关键盲区application/json 不在保护范围内。拿 Origin: http://evil.example 加 JSON body POST 到一个 +server.js 端点,正常返回 201,没有被拦。所以 +server.js 的写操作要自己做鉴权(见 14 章)。
  • 另一个盲区:这段检查在开发模式下不执行。dev 环境测不出来的问题,构建后才会现形。

文件上传

  • 表单必须写 enctype="multipart/form-data",否则浏览器只会把文件名当字符串发过去。
  • 服务端 data.get("file") 拿到的是标准 File 对象,有 namesizetype,都能正常读到。
  • 用户什么都没选时,拿到的是一个 size 为 0 的 File,不是 null——所以判空要看 size
  • type 由浏览器猜、可被伪造,别拿它当安全依据;大小上限也要在服务端卡,前端 accept 属性同样只是提示。
// +page.server.js —— 上传头像
import { fail } from "@sveltejs/kit";
const MAX = 2 * 1024 * 1024;

export const actions = {
  default: async ({ request, locals }) => {
    // 授权只信服务端算出来的身份,不信表单里传的 userId
    if (!locals.user) return fail(401, { msg: "请先登录" });

    const d = await request.formData();
    const file = d.get("avatar");
    // 没选文件时拿到的是 size 为 0 的 File,不是 null
    if (!(file instanceof File) || file.size === 0) {
      return fail(400, { msg: "请选择文件" });
    }
    if (file.size > MAX) return fail(413, { msg: "超过 2MB" });

    await saveAvatar(locals.user.id, await file.arrayBuffer());
    return { success: true, name: file.name };
  },
};

<!-- 少了 enctype,服务端只会收到一个文件名字符串 -->
<form method="POST" enctype="multipart/form-data">
  <input type="file" name="avatar" accept="image/*">
  <button>上传</button>
</form>
本地 vite dev 一切正常,上线后表单全部 403。原因是 CSRF 检查在开发模式下被跳过,只在生产构建里生效。凡是走反向代理、内嵌 iframe、或者从别的域名提交的场景,都要在 vite preview 下先跑一遍,别拿 dev 当验收环境。
把校验规则抽成一个纯函数放 $lib,服务端 action 和客户端提示共用同一份。这样两边永远不会说法不一,而且服务端那一次调用才是有约束力的那次——客户端那次纯粹是为了让用户早点看到红字。

服务端、hooks 与鉴权

hooks.server 的 handle 链、locals 怎么传、cookies 与会话,以及 API 路由该怎么写。

src/hooks.server.js 导出的 handle 会包住每一个服务端请求——页面、API 路由、数据请求,一个都跑不掉。它拿到的不是「请求进来时通知你一声」,而是「请求的处理过程本身被交到你手上」。

resolve 的位置就是一切

  • 签名是 handle({ event, resolve })event 是这次请求的上下文,resolve(event) 才是「真正去跑路由、渲染页面」这一步,返回一个 Response
  • resolve() 之前的代码:改 event(塞 locals、读 cookie、重写路径),这时还没有任何路由被执行。
  • resolve() 之后的代码:改 Response(加安全响应头、记录状态码和耗时)。这时页面已经渲染完了。
  • 不调 resolve() 直接返回一个 Response,就等于短路——维护页、强制跳转、限流拦截都是这么做的。

sequence 串起来是洋葱,不是队列

  • 多个 hook 用 sequence(a, b) 组合。在两个 hook 里各打一对日志,一次请求的输出顺序是:
    [A] before → [B] before → (路由执行) → [B] after → [A] after
  • 也就是先注册的在最外层:进入时正序,出来时逆序。写日志和计时要放最外层(sequence 的第一个参数),写鉴权要放它后面——这样鉴权失败被短路时,最外层的日志依然记得到。
  • 另一条发现:预渲染时 handle 也会跑。构建阶段的日志里能看到预渲染页面的那条 [A] before /pre。所以 hook 里别假设 event 一定来自真实用户,也别在里面连线上数据库连接池。

典型分工

放在哪干什么
最外层 hook请求日志、耗时统计、trace id 生成
中间层解析 session、填充 event.locals
内层按路径做粗粒度访问控制、短路重定向
resolve统一加 Content-Security-Policy 等响应头

resolve 的第二个参数

  • resolve(event, options) 还能收一个选项对象,这是 hook 里少有人用但很有用的一层。
  • transformPageChunk({ html, done }):在 HTML 流出去之前改它。最常见的用途是把 <html lang="%lang%"> 这类占位替换成按用户语言算出来的值——注意它是按块调用的,不保证一次拿到整页,所以别在里面做跨块的正则匹配。
  • filterSerializedResponseHeaders(name, value):决定服务端 loadfetch 拿到的响应,有哪些头会被序列化后送到客户端。默认几乎什么都不送,用到自定义头时要在这里放行。
  • preload({ type, path }):控制往 <head> 里塞哪些预加载标签。默认会给 JS 和 CSS 加,字体和图片默认不加。
// src/hooks.server.js
import { sequence } from "@sveltejs/kit/hooks";
import { redirect } from "@sveltejs/kit";

// 最外层:进得最早、出得最晚,所以计时最准
async function logger({ event, resolve }) {
  const t0 = Date.now();
  const res = await resolve(event);
  console.log(event.request.method, event.url.pathname, res.status, Date.now() - t0);
  return res;
}

async function auth({ event, resolve }) {
  event.locals.user = await userFromSession(event.cookies.get("session"));
  // 不调 resolve 直接返回 = 短路,路由根本不会执行
  if (event.url.pathname.startsWith("/admin") && !event.locals.user) {
    redirect(303, "/login");
  }
  const res = await resolve(event);
  res.headers.set("x-frame-options", "DENY");   // resolve 之后才有 Response
  return res;
}

// 顺序即嵌套层级:logger 在外,auth 在内
export const handle = sequence(logger, auth);
handlereturn resolve(event) 之后还想改响应头是改不到的——必须先 const res = await resolve(event) 拿到对象再改。另外 resolve() 每个请求只能调一次,写成循环重试会抛错,重试逻辑要放到更外层。
handle 当成「每请求都要付的税」来算成本。它对静态资源之外的每个请求都跑,里面放一次数据库查询,就等于给全站每个页面加一次查询。session 解析尽量做成一次带缓存的轻量校验,别在这里做重活。

在长期运行的 Node 服务里,模块顶层的变量是进程级的——所有用户共用同一份。event.locals 存在的全部理由,就是给你一个随请求生灭、绝不会串到别人身上的地方。

用法:一处塞,处处取

  • handle 里写:event.locals.user = await userFromSession(...)
  • 之后本次请求的所有 load+page.server.js / +layout.server.js)、所有 action、所有 +server.js 处理函数都能从各自的 event 上读到它。
  • locals 不会自动传到客户端。想让页面用到,必须在某个 load 里显式 return { user: locals.user }——这一步是刻意的,逼你想清楚哪些字段可以公开(见 12 章)。
  • 用 TypeScript 时在 src/app.d.ts 里扩展 App.Locals 接口,整个项目就都有补全了(见 09 章)。

模块级状态真的会串号

+page.server.js 顶层放一个 let moduleUser = null,第一个请求把它设成 "alice";紧接着发一个完全无关的请求,load 读到的仍然是 {"moduleUser":"alice"}。同一次里 locals 则各是各的。

这正是 08 章讲跨文件状态时提醒过的那个危险:浏览器里一个模块实例对应一个用户,所以模块级状态天然就是「当前用户的状态」;服务端一个模块实例对应成千上万个用户,同一个变量就成了公共黑板。

后果不是崩溃,是静默的数据泄露——甲看到乙的购物车、乙拿到甲的权限。它在本地只有一个人点的时候永远复现不出来,一上线就是事故。

一条简单的判据

  • 「这个值对不同用户是不同的」→ 只能放 event.locals,或者当参数一路传下去。
  • 「这个值对所有用户都一样,而且只读」→ 模块级常量没问题,比如配置、编译好的正则、路由表。
  • 「所有用户共用但会变」→ 那是缓存或连接池,可以放模块级,但必须按 key 隔离,绝不能有「当前的 xxx」这种字段名。看到 currentUseractiveTenant 这类模块级变量,基本可以直接判定是 bug。
// src/hooks.server.js —— 唯一往 locals 里写的地方
export async function handle({ event, resolve }) {
  const token = event.cookies.get("session");
  event.locals.user = token ? await userFromSession(token) : null;
  return resolve(event);
}

// src/routes/+layout.server.js —— 显式挑出可公开的字段
export function load({ locals }) {
  // 直接 return { user: locals.user } 会把密码哈希也送到浏览器
  return { user: locals.user && { id: locals.user.id, name: locals.user.name } };
}

// src/routes/admin/+page.server.js
import { error } from "@sveltejs/kit";

// 反面教材:进程级变量,所有用户共用同一份
let lastUser = null;

export function load({ locals }) {
  lastUser = locals.user;              // 下一个请求会读到别人的身份
  if (!locals.user?.isAdmin) error(403, "没权限");
  return { stats: loadStats() };
}
locals.user 整个 return 给客户端,密码哈希、邮箱、内部标记会跟着一起序列化进页面 HTML。SvelteKit 不会替你过滤——locals 只保证不自动外泄,你手动送出去它一个字都不拦。
在服务端文件里看到模块顶层的 let,先问一句:换个用户来访问,这个值该不该变?该变就必须搬进 locals。这条判据不需要理解框架的任何内部机制,扫一眼文件顶部就能用。

event.cookies 是 SvelteKit 对 cookie 的统一入口。它和原生 header 操作的区别在于:set 之后本次请求里立刻 get 得到,不用等响应发出去——这让「登录 action 里设 cookie,后续 load 立刻读到新身份」变得自然。

三个方法

  • cookies.get(name) — 读,返回字符串或 undefined
  • cookies.set(name, value, opts) — 写。opts.path 是必填的,漏了会直接抛错。
  • cookies.delete(name, opts) — 删,同样必须给 path,而且要和当初 set 时一致,否则删的是另一个 cookie。

安全属性:一个都不能省

属性作用建议值
httpOnly禁止 document.cookie 读到,XSS 也偷不走会话 cookie 一律 true
secure只走 HTTPS生产 true;SvelteKit 在 localhost 上会自动放宽
sameSite限制跨站携带"lax";有跨站嵌入需求才用 "none"(必须配 secure
maxAge有效期(秒)按业务定;不给就是会话期

一次 cookies.set("session", tok, { path: "/", httpOnly: true, sameSite: "lax", maxAge: 3600 }),响应头原文是 set-cookie: session=tok-bob; Max-Age=3600; Path=/; HttpOnly; SameSite=Lax

最小可用的登录态

  • cookie 里存不透明的会话 id,不要存用户信息本身。服务端拿 id 去查会话表,这样才能随时吊销。
  • 要用 JWT 之类的自包含令牌也可以,但要清楚代价:签出去就撤不回,只能靠短过期时间兜。
  • 登录成功后一定要换发新的会话 id——沿用登录前那个会留下会话固定(session fixation)的口子。
  • 登出时同时删 cookie 和服务端那条会话记录,只删 cookie 等于没删。

两个容易忽略的边界

  • cookie 是有大小上限的,浏览器普遍卡在 4KB 左右,而且每个请求都会把它原样带上。往里塞用户资料、权限列表、购物车,会让站内每一次请求(包括每张图片)都多背几 KB。这也是「只存不透明 id」的另一个理由——它顺带把这个成本压到了几十字节。
  • sameSite: "lax" 会挡掉跨站 POST 回调。OAuth 提供方以 POST 方式回跳、或者支付网关 POST 回你的站时,浏览器不会带上 lax 的 cookie,你在回调里就认不出这个用户是谁。解法是给回调流程单独用一个 sameSite: "none"secure 的临时 state cookie,而不是把主会话 cookie 整个放宽。
  • event.cookies 只在服务端存在。客户端要读什么,走 load 返回值,不要试图在组件里读 document.cookie——真正重要的那些本来就是 httpOnly,读不到。
// src/routes/login/+page.server.js
import { fail, redirect } from "@sveltejs/kit";

export const actions = {
  login: async ({ request, cookies }) => {
    const d = await request.formData();
    const user = await verify(d.get("email"), d.get("password"));
    // 失败信息保持模糊,别告诉攻击者是账号错还是密码错
    if (!user) return fail(400, { msg: "账号或密码不正确" });

    // 登录后换发新 id,堵住会话固定
    const sid = await createSession(user.id);
    cookies.set("session", sid, {
      path: "/",              // 必填,漏了直接抛错
      httpOnly: true,         // JS 读不到,XSS 也偷不走
      sameSite: "lax",       // 跨站 POST 不会带上它
      secure: process.env.NODE_ENV === "production",
      maxAge: 60 * 60 * 24 * 7,
    });
    redirect(303, "/");   // 303 让浏览器改用 GET 跳转
  },

  logout: async ({ cookies }) => {
    await destroySession(cookies.get("session"));
    cookies.delete("session", { path: "/" });   // path 要和 set 时一致
    redirect(303, "/login");
  },
};
cookies.set 漏掉 path 会直接抛错,比较容易发现;真正阴的是 cookies.delete 时给的 path 和当初 set 的不一致——不报错,cookie 也没删掉,表现就是「点了登出还是登录状态,清浏览器缓存才好」。
登录 action 结尾用 redirect(303, ...) 而不是 return。303 会让浏览器把后续请求改成 GET,用户刷新页面不会重复提交表单——这是 POST 之后重定向这个老规矩在 SvelteKit 里的具体写法。

+server.js 按 HTTP 方法导出函数,返回标准的 Response。它和 Form Actions 是两套东西:action 服务于「页面上的表单」,+server.js 服务于「不是页面的调用方」。

写法

  • 在任意路由目录放 +server.js,导出 GET / POST / PUT / PATCH / DELETE / OPTIONS。同一目录下 +page.svelte+server.js 可以共存(按 accept 头区分)。
  • 参数和 load 拿到的是同一个 eventrequesturlparamscookieslocals 全都有。hooks.server.jshandle 一样会先跑,locals.user 在这里能正常读到。
  • json(data, init) 是个便利函数,等价于 new Response(JSON.stringify(data), { headers: { "content-type": "application/json" } })。要返回别的格式就直接构造 Response

action 还是 +server.js

同样是写数据,选错会多写一半代码或者少掉一层保护。

  • Form Actions
    免费拿到渐进增强、CSRF 检查、form prop 回填、提交后自动失效重载。
    只服务于自家页面,绑死 FormData,外部调用方用起来别扭。
    为何这些便利全来自「假设调用方是本站的一个 form」——假设越强,白送的东西越多,通用性越差。
  • +server.js
    任意 content-type、任意方法、任意调用方;webhook、移动端、第三方都能接。
    鉴权、限流、CSRF、错误格式全要自己写;页面数据不会自动刷新。
    为何它就是一个裸的 HTTP 端点——不做任何假设,所以也不提供任何默认保护。

CSRF 保护不覆盖 JSON 端点

Origin: http://evil.exampleContent-Type: application/json+server.jsPOST 打请求,正常返回 201,没有被拦;换成表单 content-type 立刻 403。因为 SvelteKit 的同源检查只覆盖表单类型(见 13 章)。

所以 +server.js 里凡是改数据的方法,都必须自己校验 locals.user,或者要求一个 Authorization 头 / webhook 签名。别指望框架兜底。

// src/routes/api/todos/+server.js
import { json, error } from "@sveltejs/kit";

export async function GET({ url, locals }) {
  const page = Number(url.searchParams.get("page") ?? 1);
  const items = await listTodos(locals.user?.id, page);
  // 公开的只读接口,允许 CDN 缓存 60 秒
  return json(items, { headers: { "cache-control": "public, max-age=60" } });
}

export async function POST({ request, locals }) {
  // 关键:JSON 请求不在 CSRF 保护范围内,这层必须自己写
  if (!locals.user) error(401, "未登录");

  const body = await request.json();
  if (typeof body?.title !== "string") error(400, "title 必须是字符串");

  const todo = await addTodo(locals.user.id, body.title);
  return json(todo, { status: 201 });
}

// src/routes/webhooks/stripe/+server.js —— 典型的非表单场景
export async function POST({ request }) {
  const raw = await request.text();   // 验签要用原始字节,不能先 json()
  if (!verifySignature(raw, request.headers.get("stripe-signature"))) {
    error(401, "签名不对");
  }
  await handleEvent(JSON.parse(raw));
  return new Response(null, { status: 204 });
}
webhook 里先 await request.json() 再验签,签名永远对不上——签名算的是原始字节,JSON.parsestringify 会改掉空格和键序。而且请求体是流,只能读一次,读完 json() 再调 text() 会拿到空。
判据只有一句:调用方是不是本站页面上的一个 <form>是就用 action,不是就用 +server.js。为了「以后可能有 App 要用」而提前把表单逻辑写成 REST 接口,是这一层最常见的过度设计。

SvelteKit 对未捕获异常的默认态度是「一个字都不告诉浏览器」。这个默认值是对的,但它也意味着:线上出错时你手上什么线索都没有,除非你自己在 handleError 里造。

默认真的抹得很干净

  • load 里抛 new Error("绝密内部信息 SECRET_INTERNAL_12345"),不写 handleError
  • 浏览器拿到:HTTP 500,页面上的 page.error{"message":"Internal Error"}——原始消息、堆栈、文件路径全部消失。
  • 服务端控制台:完整打印 [500] GET /boom 加上带真实文件路径的堆栈。
  • 没有任何自动生成的关联 id。这一点和某些框架给每个错误发一个 digest 不一样——SvelteKit 什么都不发。用户说「我看到报错了」,你在日志里根本对不上是哪一条。

handleError 的正确用法

  • 签名 handleError({ error, event, status, message })error 是原始异常,message 是安全后的消息(500 时就是 "Internal Error")。
  • 它的返回值会成为客户端 page.error 的内容。所以要给用户一个可报的编号,就在这里生成一个随机 id,同时写进日志和返回值。
  • 只对未预期的异常触发。用 error(404, "没找到") 主动抛出的 HTTP 错误不会走这里——那是你已经预料到的情况。
  • 它也会捕获框架自身抛的错误,比如往只有具名 action 的页面裸 POST 时的 No action with name 'default' found(404)。预渲染阶段的错误同样会经过它。

handleFetch 与 +error.svelte

  • handleFetch 拦截服务端 load 里用的那个 fetch。典型用途:给内部接口加上认证头(这个头绝不能让浏览器看到),或者把公网域名改写成集群内地址省一跳。在里面改 Request 的 header 再转发,下游能正常收到。
  • +error.svelte 是错误的落地页,就近生效:src/routes/admin/+error.svelte 只管 admin 下面,根目录那个兜住其余。
  • 错误页里用 page.statuspage.error 渲染。把 handleError 生成的那个 id 显示出来,客服和排障链路才算接通。
  • +layout.svelte 自身出错时 +error.svelte 也救不了,那时候会退回到框架内置的裸错误页——所以根布局里别放可能抛异常的逻辑。
// src/hooks.server.js
export async function handleError({ error, event, status, message }) {
  // 框架不生成关联 id,得自己造,否则线上日志和用户反馈对不上
  const id = crypto.randomUUID();
  await reportToSentry({ id, error, status, path: event.url.pathname });

  // 返回值 = 客户端 page.error 的内容,别把 error.message 放进来
  return { message: "服务开小差了,请稍后再试", id };
}

// 给服务端内部 fetch 加认证头,浏览器永远看不到它
export async function handleFetch({ event, request, fetch }) {
  if (request.url.startsWith("https://internal.api/")) {
    request.headers.set("authorization", "Bearer " + INTERNAL_TOKEN);
  }
  return fetch(request);
}

<!-- src/routes/+error.svelte -->
<script>
  import { page } from "$app/state";
</script>

<h1>{page.status}</h1>
<p>{page.error?.message}</p>
{#if page.error?.id}
  <!-- 把 id 显出来,用户报过来就能直接查日志 -->
  <p>错误编号:<code>{page.error.id}</code></p>
{/if}
handleError 的返回值会原样送到浏览器,把 error.messageerror.stack 塞进去,等于亲手拆掉框架默认的那层遮挡——数据库连接串、内网地址、SQL 语句都可能出现在用户的页面源码里。要区分「记到日志」和「返回给用户」这两件事。
上线前必做的一件事:在 handleError 里生成一个 uuid,同时写进错误上报和返回给客户端的对象,再在 +error.svelte 上把它显示出来。没有这条链路,你收到的用户反馈永远只有「网站坏了」四个字。

渲染模式与部署

SSR、CSR、预渲染三种模式怎么选,adapter 是什么,以及静态站与 Node 部署各自的限制。

SvelteKit 不逼你在项目级别选「这是个 SPA 还是 SSR 应用」。ssrcsrprerender 是三个独立的布尔开关,写在路由文件里,逐路由生效——一个站点里三种模式可以并存。

写在哪、怎么继承

  • 写成模块顶层的具名导出:export const prerender = true;,放在 +page.js+page.server.js+layout.js+layout.server.js+server.js 里。
  • 布局里的设置会被子路由继承,子路由自己再写一次就覆盖掉。所以「整站预渲染,只有 /search 例外」的写法是:根 +layout.jsprerender = true/search/+page.jsprerender = false
  • prerender 还能写成 "auto",意思是「能预渲染就预渲染,被爬到才算数」。

三种配置下页面产物长什么样

配置服务端返回的 HTML是否带引导脚本
默认(都开)完整渲染好的内容有,一个 <script>kit.start(...)
prerender = true完整内容,构建期就生成好落在 build/prerendered/ 下的 .html(还自动带了 .gz.br有,而且多出 8 条 modulepreload
ssr = false只有一个空壳<head> 里没有任何预加载,<body> 里只有那段引导脚本
csr = false完整内容一个 <script> 标签都没有

csr = false 那一行值得多看一眼:产物里 <button onclick={...}> 渲染成了光秃秃的 <button>点</button>——事件处理器随着运行时一起消失了。这不是「JS 变少了」,是交互整个没有了

各自的适用场合

  • 默认(SSR + CSR):99% 的页面。首屏由服务端渲染,之后接管成客户端路由。不用动任何开关。
  • prerender = true:内容在构建时就确定的页面——文档、博客、落地页、关于页。它是三者里唯一能真正省掉运行时成本的。
  • ssr = false:重度依赖浏览器 API、SEO 无所谓的内页,比如仪表盘、编辑器。代价是首屏白屏。
  • csr = false:纯静态内容页,没有任何交互。省掉整个客户端包,但连客户端路由都没了,站内跳转都是整页刷新。用得比想象中少。

预渲染是「时机」,不是「模式」

  • 很容易把 prerender 理解成第三种渲染方式,其实它和 SSR 是同一段渲染代码,区别只是跑在构建期还是请求期。这也解释了一个现象:预渲染时 hooks.server.jshandle 照样会执行,因为走的就是那套服务端流程。
  • 推论一:预渲染页面里不能有任何依赖具体请求的东西——cookie、请求头、当前用户。构建期根本没有「这个用户」。
  • 推论二:动态段必须能被枚举。SvelteKit 会从入口页开始爬站内链接来发现路由;没有任何链接指向的页面就得靠导出 entries() 显式告诉它。
  • 推论三:内容变了就得重新构建部署。这是预渲染真正的代价,而且它不随流量增长——所以更新频率低的页面越大越划算。
// src/routes/+layout.js —— 根级设置,所有子路由继承
export const prerender = true;

// src/routes/blog/[slug]/+page.js —— 继承 prerender=true
// 动态参数要能被枚举,SvelteKit 才知道要生成哪些页面
export async function load({ params, fetch }) {
  const res = await fetch("/api/posts/" + params.slug);
  return { post: await res.json() };
}

// src/routes/search/+page.js —— 覆盖掉继承来的设置
export const prerender = false;   // 结果随查询串变,构建期算不出来

// src/routes/dashboard/+layout.js —— 整个仪表盘只在客户端跑
export const prerender = false;
export const ssr = false;      // 首屏是空壳,换来能自由用 window

// src/routes/terms/+page.js —— 纯文本条款页,一点交互都没有
export const csr = false;      // 产物里连 script 标签都不会有
export const prerender = true;
为了「用 window 方便」就在根布局关掉 ssr,整站的 SEO 和首屏都赔进去了。正确做法是把浏览器专属逻辑放进 $effectonMount——那里本来就只在客户端跑(见 03 章),完全不需要动渲染模式。
ssrcsrprerender 记成三个正交问题:服务端渲不渲染首屏浏览器接不接管这次渲染发生在构建期还是请求期。想不清该开哪个时,先回答这三问,答案自然就出来了。

SvelteKit 构建出来的东西是环境无关的:一堆客户端静态资源,加一个「收 RequestResponse」的处理函数。adapter 的全部工作,就是把这两样东西包装成某个具体平台认识的形状。

adapter 不负责什么

  • 不影响你的应用代码,也不影响路由、load、action 的行为。
  • 不决定渲染模式——那是上一张卡里那三个开关的事。adapter 只是承接结果。
  • 不做构建。构建由 Vite 完成,adapter 在最后一步接手 .svelte-kit/output/ 里的产物,做搬运和包装。构建日志的最后一行就是 Using @sveltejs/adapter-node,在所有打包信息之后。
  • 换平台只改 svelte.config.js 里的一行 import——前提是新平台支持你用到的能力。

常用的几个

adapter产出什么时候用
adapter-auto构建时探测 CI 环境变量,自动装对应平台的 adapter脚手架默认值;部署目标已定就该换掉
adapter-node一个能 node build 起来的独立服务自托管、容器、K8s、需要长连接或本地文件
adapter-static纯 HTML/CSS/JS 目录文档站、博客、落地页;配合任意 CDN 或对象存储
adapter-vercel / -netlify / -cloudflare该平台的函数格式用到平台特有能力(边缘运行时、ISR、KV)

adapter-auto 的代价

它靠环境变量猜平台,然后在构建时联网下载真正的 adapter。这意味着:本地跑 vite build 时它猜不出平台会报错;离线或内网 CI 里会失败;package.json 上也看不出这个项目实际部署到哪。

脚手架默认给 adapter-auto 是因为它不知道你要去哪。一旦部署目标确定,第一件事就是把它换成具体的 adapter,写进依赖,让构建可复现。

「换一行就换平台」的前提

  • 这句宣传语成立,但有个隐含前提:你的代码没有用到目标平台给不了的能力。adapter 只做包装,它变不出运行环境里没有的东西。
  • 典型的易错点:本地开发时随手用了 fs 读文件、用了原生依赖、开了长连接——在 adapter-node 下一切正常,换成边缘运行时就直接报模块找不到,换成 adapter-static 更是连服务端都不存在。
  • 反过来看,这也说明 adapter 的存在恰恰是把「平台差异」收拢到了一个文件里。真正需要为平台做的适配(比如避开某些 Node API),是应用代码层面的事,不是 adapter 能替你抹平的。
  • 实践建议:项目一开始就按最小公共能力写——只用 Web 标准的 RequestResponsefetch,把文件系统和原生依赖挡在业务代码之外。这样「换一行」才真的是换一行。
// svelte.config.js —— 换平台只动这一行 import
import adapter from "@sveltejs/adapter-node";

export default {
  kit: {
    adapter: adapter({
      out: "build",          // 产物目录,默认就是 build
      precompress: true,    // 顺手生成 .gz / .br,省掉运行时压缩
    }),
  },
};

// 静态站:换成这两行,其余代码一个字不用改
// import adapter from "@sveltejs/adapter-static";
// adapter({ pages: "build", assets: "build", fallback: undefined })

# 本地验证构建产物的标准动作
npx vite build                    # 末尾会打印 Using @sveltejs/adapter-node
npx vite preview --host 127.0.0.1 # 用生产产物跑一遍,别拿 dev 当验收

# adapter-node 的产物结构
# build/index.js       独立服务入口,node build 直接起
# build/handler.js     中间件形式,可挂进已有的 Express/Polka
# build/client/        带指纹的静态资源,应该交给 CDN
# build/server/        服务端渲染代码
# build/prerendered/   预渲染出来的 .html(含 .gz/.br)
在装了 adapter-auto 的项目里本地构建,会因为探测不到平台而失败或退化。更麻烦的是它在 CI 里现下载 adapter,网络一抖构建就红——把 adapter 固定成具体那个并写进 devDependencies,这类问题一次性消失。
本地 vite build 之后一定要跑一次 vite preview。dev 服务器和生产产物的行为差别真实存在——CSRF 检查、环境变量注入、预渲染这几件事都只在生产构建里发生,dev 下全看不出来。

静态站的约束只有一条:构建结束时,所有会被访问的 URL 及其内容都必须已经确定adapter-static 做的检查全部围绕这一条展开,而它的报错信息也确实把话说明白了。

一:有动态路由就直接拒绝

  • 把一个带 actions、API 路由和普通页面的项目换成 adapter-static,构建失败,末尾是 Error: Encountered dynamic routes
  • 前面那段提示原文是:@sveltejs/adapter-static: all routes must be fully prerenderable, but found the following routes that are dynamic:,然后逐条列出所有没被预渲染的路由。
  • 它同时给出四个选项:设 fallback 做单页应用、在根布局加 export const prerender = true、给 +server.js 加 prerender、或者 strict: false 忽略检查。最后还补了一句 @sveltejs/adapter-static can only be used for sites that don't need a server for dynamic rendering, and can run on just a static file server.

二:加了全站 prerender,actions 才是真的过不去

  • 按提示在根 +layout.js 加上 export const prerender = true 再构建,动态路由那关过了,接着卡在预渲染阶段,抛出 Cannot prerender pages with actions
  • 这条是硬约束,没有绕过去的选项:actions 的存在意义就是接收 POST 请求,而静态文件服务器根本没有「接收 POST」这个概念。
  • 同时看到另一个细节:预渲染时 hooks.server.jshandlehandleError 照样会执行。所以 hook 里那些依赖真实请求的假设(读 cookie、看 Origin)在构建期会得到一堆空值。

静态站上能做和不能做的

能力静态站替代方案
Form Actions不行用第三方表单服务,或客户端直接调外部 API
server load 里读 cookie / 认身份不行登录态整个搬到客户端,或改用 Node 部署
+server.js 动态响应不行可以预渲染成固定 JSON 文件;真动态就得换 adapter
动态路由 [slug]可以,但必须能枚举entries() 导出所有参数,或让内部链接指向它们由爬取发现
客户端 fetch 外部 API可以——这是静态站做动态内容的主要出路

还有一个中间档位:给 adapter-staticfallback: "200.html",未知路径全部落到那个文件,交给客户端路由接管。这样动态路由不用枚举也能跑,但代价是这些页面完全没有服务端渲染,首屏是空壳,SEO 归零——本质上是把站点降级成了一个挂在 CDN 上的单页应用。要做后台管理这类不在乎 SEO 的东西,它是个不错的选择。

// svelte.config.js
import adapter from "@sveltejs/adapter-static";

export default {
  kit: {
    adapter: adapter({
      pages: "build",
      assets: "build",
      // 传 "200.html" 就退化成单页应用:任何未知路径都交给客户端路由
      fallback: undefined,
      strict: true,        // 保持 true,让它替你拦住漏网的动态路由
    }),
  },
};

// src/routes/+layout.js —— 静态站几乎一定要有这一行
export const prerender = true;

// src/routes/blog/[slug]/+page.server.js
// entries 告诉预渲染器「这个动态段一共有哪些取值」
// 不导出它,就只能靠站内链接被爬到,没被链接的页面会悄悄漏掉
export function entries() {
  return [{ slug: "hello" }, { slug: "kit-notes" }];
}

export function load({ params }) {
  return { post: readPost(params.slug) };
}
strict: false 骗过去是最坏的结果——构建变绿了,但那些没被预渲染的路由在线上一律 404,而且是上线之后才发现。这个选项只在你确认那些路由本来就不该存在时才能用,不是用来消警告的。
评估一个项目能不能做静态站,只看两件事:有没有 actions所有路由能不能在构建时列全。这两条都过了,剩下的基本都能靠客户端 fetch 补上;任何一条不过,就老老实实上 Node。

环境变量分成四种不是为了灵活,是为了画一条编译期就能检查的安全边界:名字带不带 PUBLIC_,决定了这个值会不会被写进客户端产物。

四个模块,两个维度

模块取值时机能否进客户端
$env/static/private构建期烤进产物不能
$env/dynamic/private运行期从 process.env不能
$env/static/public构建期烤进产物能,必须 PUBLIC_ 前缀
$env/dynamic/public运行期读能,必须 PUBLIC_ 前缀

static 的值可以被打包器做常量折叠和摇树,性能略好;dynamic 的好处是同一份镜像换个环境变量就能跑,这在容器部署里几乎是刚需。把 RUNTIME_ONLY 通过启动命令注入,$env/dynamic/private 里立刻读得到,不用重新构建。

一:PUBLIC_ 是真的写进客户端文件里

PUBLIC_SITE_NAME 在一个 .svelte 组件里 import 后构建,直接 grep 客户端产物目录,命中了:.svelte-kit/output/client/_app/immutable/nodes/4.KIK4QH9A.js,文件里就是那个明文值。任何人打开开发者工具都能看到。

同一次构建里 grep 私有变量的值,客户端目录零命中,只在服务端产物里出现。这条边界是实打实的。

二:写错了构建直接失败

+page.svelteimport { SECRET_TOKEN } from "$env/static/private",构建报错原文:

Cannot import $env/static/private into code that runs in the browser, as this could leak sensitive information.

后面还会指明是哪个文件引的:src/routes/envleak/+page.svelte imports $env/static/private。也就是说这不是靠自觉,SvelteKit 在构建期就拦,你没机会把密钥漏出去。

但要注意它拦的是直接 import。如果你在服务端 load 里读了密钥再 return 给页面,那是你主动送出去的,框架一个字都不会拦——序列化后的值就明明白白躺在页面 HTML 里。

static 还是 dynamic:容器部署几乎只能选 dynamic

  • static 那两个在构建时就把值替换成字面量,所以一次构建只对应一套配置。测试环境和生产环境要跑同样的代码,就得构建两次、产出两个镜像——这和「一次构建、层层晋级」的发布流程是冲突的。
  • dynamic 在运行期读 process.env,同一个镜像换一组环境变量就能跑。启动时注入 PUBLIC_RUNTIME_NAME,页面上直接就是新值,无需重新构建。
  • 值得注意的是 $env/dynamic/public 在客户端也能用:客户端产物里只有变量名,没有值,值是服务端渲染时随页面送到浏览器的。所以它同样是「运行期可变」的,代价是每次页面渲染都要多带一点数据,也没法被打包器摇树。
  • 选法:会随环境变的一律用 dynamic;只有那些编译期就永久固定、且希望被摇树优化掉的开关(比如功能标志)才值得用 static。
// src/routes/+page.server.js —— 私有变量只能出现在服务端文件
import { DATABASE_URL } from "$env/static/private";
import { env } from "$env/dynamic/private";

export async function load() {
  const db = await connect(DATABASE_URL);
  const region = env.DEPLOY_REGION ?? "unknown";   // 换环境不用重新构建
  // 危险:这样 return 出去,密钥会原样进页面 HTML,框架不拦
  return { region, rows: await db.query("select 1") };
}

// 组件里只能用 PUBLIC_ 前缀的;换成 private 构建直接失败
import { PUBLIC_SENTRY_DSN } from "$env/static/public";

# .env —— 命名本身就是那道边界
DATABASE_URL=postgres://user:pw@host/db   # 无前缀,服务端专用
PUBLIC_SENTRY_DSN=https://xxx@sentry.io/1 # 有前缀,会进客户端产物

# adapter-node 的启动方式
npx vite build
PORT=3000 ORIGIN=https://example.com node build

# 上线前自查这一条,比读十遍代码管用
grep -r "你的密钥片段" build/client/     # 必须零命中
adapter-node 上线后表单一律 403、重定向跳到 localhost,多半是 ORIGIN 环境变量没设。服务跑在反向代理后面时它看不出自己对外的地址,CSRF 的同源比对就会全部失败。启动命令里带上 ORIGIN=https://你的域名
grep -r 密钥片段 build/client/ 加进 CI,作为部署前的一道闸。SvelteKit 的编译期检查挡得住直接 import,但挡不住「服务端 load 把密钥 return 给页面」这种写法——那一类只有 grep 产物才抓得到。

这三条路的差别不在性能,在你要为「有个服务器在跑」这件事付多少钱和多少心力。先算这笔账,再谈别的。

三种部署形态

同一份 SvelteKit 代码能落到这三处,但它们的运维成本差一个量级。

  • 静态站(adapter-static)
    没有服务器就没有服务器故障;CDN 直接吐文件,全球延迟一致;成本接近零;几乎不可能被打挂。
    没有 actions、没有服务端登录态、内容更新必须重新构建部署。
    为何所有好处都来自「运行期什么都不算」,所以运行期需要算的东西一样也做不了。
  • 自托管 Node(adapter-node)
    完整的 Node 环境:长连接、本地文件、原生依赖、连接池、任意时长的任务,全都能用;不锁平台,换云厂商就是换台机器。
    进程管理、健康检查、扩缩容、日志收集、证书续期全归你;单区域部署对远端用户天生慢。
    为何自由度和运维负担同根于「你拿到的是一台真机器」——它什么都能做,也什么都要你管。
  • 边缘 / 无服务器平台
    部署即上线,自动扩缩到零,就近执行,运维几乎为零。
    运行时是裁剪过的,很多 Node API 和原生模块用不了;有执行时长上限;数据库连接数容易被并发实例打爆;迁走要改代码。
    为何「不用管」的前提是平台替你把运行环境定死了,能力边界就是它的规则,不是你的选择。

直接给判断

  • 内容站、文档、博客、营销页——静态站,没有第二个选项。别为了「以后可能要加评论」就上 Node,评论用第三方服务接。
  • 有登录、有表单、有数据库的常规产品——默认 adapter-node,一个容器起步。它对本章前面讲的所有能力零限制,也不锁厂商。
  • 已经在用某个平台的生态(它的数据库、它的 KV、它的鉴权),或者用户分布在全球且首字节延迟是硬指标——用该平台专用 adapter,接受它的限制。
  • 拿不准的时候选 adapter-node。它是三者里迁移成本最低的:往静态走或者往平台走都比反过来容易。

监控与错误上报接在哪

  • 服务端异常hooks.server.jshandleError,唯一的汇聚点(见 14 章)。生成关联 id、上报、返回安全消息,三件事在这里一次做完。
  • 客户端异常hooks.client.jshandleError,形状和服务端那个一样,捕获浏览器侧的未捕获错误。
  • 请求指标handleresolve() 前后打点,能拿到方法、路径、状态码、耗时——这是最省事的一层可观测性,几行代码就有。
  • 健康检查:加一个 src/routes/healthz/+server.js 返回 200,给负载均衡和容器编排用。记得给它 export const prerender = false,别让它被预渲染成一个永远说「健康」的静态文件。
// src/routes/healthz/+server.js —— 给编排系统看的存活探针
export const prerender = false;   // 千万别让它变成静态文件

export async function GET() {
  const ok = await pingDatabase().then(() => true, () => false);
  return new Response(ok ? "ok" : "degraded", { status: ok ? 200 : 503 });
}

// src/hooks.client.js —— 浏览器侧的兜底上报
export function handleError({ error, event }) {
  reportToSentry({ error, path: event.url.pathname, side: "client" });
  return { message: "页面出了点问题" };
}

# Dockerfile —— adapter-node 的典型部署,产物很小
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx vite build
# 只留运行期依赖和 build 目录,devDependencies 可以全删
RUN npm prune --omit=dev
ENV PORT=3000
EXPOSE 3000
# ORIGIN 必须给,否则 CSRF 检查和重定向都会认错自己的地址
CMD ["node", "build"]
给健康检查路由忘了写 export const prerender = false,在根布局开了全站预渲染的项目里,它会被烤成一个恒返回 200 的静态文件。之后无论服务多不健康,探针永远绿灯——故障期间流量照样往这个实例上打。
选型先问「这个站有没有服务端登录态」。没有就往静态站的方向使劲,能省掉的运维成本远超你以为;有,就直接 adapter-node,别在无服务器平台的限制里绕。这一个问题能定掉八成的项目。

性能与编译产物

编译到底产出了什么代码、包体积从哪来、什么时候需要 $state.raw,以及大列表怎么办。

「Svelte 没有虚拟 DOM」这句话听一百遍不如看一眼产物。把一个二十行的组件丢给 compile(),你会发现它变成了一段直白得近乎手写的 DOM 操作代码——没有 render 函数,没有 diff,没有任何「先算出新树再比对」的环节。

逐段读一遍

源组件是一个 $state 计数器加一个 keyed 列表。产物(generate: 'client')的骨架是:

  • var root_1 = $.from_html(`<h1> </h1> <button>+1</button> <ul></ul>`, 1)——模板被压成一个字符串常量,运行时用它建一次 <template>,之后每次实例化都是 cloneNode。这是整个方案最省的一步:浏览器原生的克隆比逐个 createElement 快得多;
  • var h1 = $.first_child(fragment); var button = $.sibling(h1, 2);——拿元素引用靠数位置,不靠查询。编译期就知道每个动态点在树里的第几个孩子,所以运行时既不需要 querySelector 也不需要 id;
  • let n = $.state(0) 建信号,读写分别是 $.get(n)$.set(n, 0)n++ 会编译成更省的 $.update(n)
  • $.template_effect(() => $.set_text(text, $.get(n)))——这就是全部的「更新逻辑」:一个只负责往某个文本节点写字符串的 effect。它读了 n,于是订阅了 nn 一变它就重跑,只改那一个文本节点;
  • $.each(ul, 21, () => items, (item) => item.id, ($$anchor, item) => { … })——列表块。第二个参数 21 是编译期算出的位掩码(是否 keyed、是否需要索引等),第三个是取数组的函数,第四个是取 key 的函数(不写 key 时这里是 $.index);
  • $.delegated('click', button, …) 配文件末尾的 $.delegate(['click'])——事件是委托到根上的,不是每个按钮各绑一个监听器。

对比一下:同样的组件在虚拟 DOM 框架里

Svelte 编译产物虚拟 DOM
状态变了先做什么直接跑订阅了它的那个 effect重跑整个组件函数,产出新树
怎么知道改哪里编译期就绑定好了:这个信号对应这个文本节点运行时和旧树逐节点比对
组件里无关的部分完全不参与也会被重新执行一遍
代价落在哪产物体积(每个组件带自己的更新代码)运行时 CPU(每次更新都要 diff)

所以「Svelte 快」不是因为它优化得好,而是因为它把一部分工作从运行时挪到了编译时——运行时不需要知道 UI 长什么样,那些知识已经变成了代码本身。

顺带看到的编译期优化

还有一处很能说明问题:let b = $state(10) 如果全程没被重新赋值过,产物里就是朴素的 let b = 10——连信号都不建。同理 $state.raw([1,2,3]) 只读不换的话也是一个普通数组。编译器能做整个模块的静态分析,这是运行时框架拿不到的信息。

// 源组件
<script>
  let items = $state([{ id: 1, name: 'a' }]);
  let n = $state(0);
</script>
<h1>{n}</h1>
<button onclick={() => n++}>+1</button>
<ul>{#each items as item (item.id)}<li>{item.name}</li>{/each}</ul>

// —— 编译产物(节选,compile(src).js.code)——
import * as $ from 'svelte/internal/client';
var root = $.from_html(`<li> </li>`);
var root_1 = $.from_html(`<h1> </h1> <button>+1</button> <ul></ul>`, 1);

export default function App($$anchor) {
  let items = $.proxy([{ id: 1, name: 'a' }]);  // 深层响应靠 Proxy
  let n = $.state(0);
  var fragment = root_1();                        // cloneNode,不是逐个 createElement
  var h1 = $.first_child(fragment);
  var button = $.sibling(h1, 2);                  // 数位置拿引用,无需查询
  var ul = $.sibling(button, 2);
  $.each(ul, 21, () => items, (item) => item.id, …);  // 21 是位掩码
  $.template_effect(() => $.set_text(text, $.get(n))); // 全部更新逻辑就这一行
  $.delegated('click', button, () => $.update(n));
}
$.delegate(['click']);                             // 事件委托到根
别把产物里的 $.from_html 当成 innerHTML 式的字符串拼接。它只在模块初始化时执行一次,把静态骨架变成一个可克隆的模板;所有动态部分都是后面 set_text / set_attribute 精确写入的。看到模板字符串就担心 XSS 是误会——插值从不走这条路。
想自己看产物不用建项目:import { compile } from 'svelte/compiler',然后 compile(src, { generate: 'client', runes: true }).js.code注意是 .js.code 不是 .code——返回值还包含 csswarningsast。搞不清某个语法到底生成了什么时,这是最快的答案来源。不想写脚本就用 svelte.dev/playground 的「JS output」标签页,改一个字符产物就跟着变。三个最值得看一眼的实验:把 $state({a:1}) 改成 $state.raw({a:1}),看 $.proxy 消失;给组件加个 {#if},看它生成一个独立的块函数而不是重新渲染整个组件;把模板里的 {count} 删掉,看那条 template_effect 整条消失。

Svelte 的体积故事有两个数字:起点(运行时有多大)和斜率(每加一个组件涨多少)。只谈起点是宣传,只谈斜率是抬杠。下面两组数都是在同一台机器、同一个 vite 8.1.5 上。

数字

组件是同一份:三个状态、一个派生值、一个按钮、一个 keyed 列表;两边都是 vite build 默认压缩,取 gzip 后大小。

规模Svelte 5.56.7React 19.2.8
只有一个静态组件32.5 kB / gzip 12.8 kB190.4 kB / gzip 60.0 kB
30 个组件51.4 kB / gzip 16.9 kB——
100 个组件77.7 kB / gzip 19.3 kB217.4 kB / gzip 61.5 kB
每个组件平均增量gzip 约 55 Bgzip 约 15 B

先解释起点:Svelte 的 12.8 kB 不是「运行时」而是「运行时里被这个应用用到的那部分」——它是可摇树的,你不用 {#each} 就不会打进 $.each。React 的 60 kB 则包含了调度器与协调器,那些东西无论应用多简单都得在。

斜率为什么反过来

因为两边把「知道怎么更新 UI」这件事放在了不同地方:

  • React:更新逻辑是通用的(diff 算法),一份代码服务所有组件。组件本身只是一个返回描述的函数,编译产物几乎就是你写的那点 JSX;
  • Svelte:更新逻辑是每个组件自己生成的——上一张卡里那些 from_htmlsiblingtemplate_effect 全都要按组件复制一份。组件越多,这部分越多。

所以「Svelte 组件多了会追上 React」这个说法方向是对的。但按斜率算一笔账:起点差 47 kB gzip,每个组件差 40 B 左右,要抹平需要一千个组件量级。绝大多数应用远到不了那个规模,而且真实组件里大部分字节是你自己的业务逻辑(两边一样多),不是框架生成的胶水。

不要拿这些数字当选型的主要理由

47 kB gzip 的差距在 4G 上大概是一百多毫秒。它对首屏有影响,但通常排在图片没压缩、字体没子集化、第三方脚本一堆之后。体积是 Svelte 的一个真实优势,但很少是决定性的那一个;生态成熟度和团队熟悉度的权重高得多(见 00 章)。

// 自己量一遍:一个组件的最小项目
// npm create vite@latest size-test -- --template svelte
// npx vite build   →  终端会直接打印 gzip 后的大小

// (svelte 5.56.7 + vite 8.1.5,esbuild 压缩)
// 单个静态组件      dist/assets/index.js  32.54 kB │ gzip: 12.78 kB
// 100 个有状态组件  dist/assets/index.js  77.67 kB │ gzip: 19.32 kB

// 对照组:同样的 100 个组件用 react 19.2.8 写
// 单个静态组件      dist/assets/index.js 190.41 kB │ gzip: 59.97 kB
// 100 个有状态组件  dist/assets/index.js 217.42 kB │ gzip: 61.46 kB

// 想看是谁把包撑大的,加一个分析插件:
// npm i -D rollup-plugin-visualizer
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
  plugins: [
    svelte(),
    visualizer({ gzipSize: true, open: true })   // build 后自动开图
  ]
});
// 经验:跑完你八成会发现最大的块不是 svelte,
// 而是某个日期库、图标库或 markdown 解析器。
拿网上流传的「Svelte 运行时只有 1.6 kB」当依据会出错——那是 Svelte 3 时代的数字。Svelte 5 引入了信号系统与深层 proxy,运行时明显变大了,一个最简单应用的 gzip 产物是 12.8 kB。数字要自己量,尤其是跨大版本的。
判断体积问题先看绝对值再看框架:打开构建输出,如果最大的 chunk 不是框架而是某个依赖(moment、整包引入的图标库、highlight.js 全语言包),那么换框架省下的几十 kB 毫无意义。先把那个依赖换掉或按需引入。

$state 的深层响应是靠 Proxy 实现的:你访问的每一层对象都会被包一层代理,每个被读过的属性都会建一个信号。这在几十个字段上完全无感,在几万条数据上就是实打实的开销。$state.raw 就是关掉这层代理的开关。

代价有多大

两万条 { id, v, tag: { a } } 的数组,分别用 $state$state.raw 存,组件里对它做一次全量求和:

操作$state$state.raw
建 state约 1.5 ms约 1.8 ms
挂载并求和两万项58–101 ms1.2–1.3 ms
组件外遍历二十轮478–531 ms6.8–11.5 ms

差距在五十到一百倍。注意「建 state」那一行几乎没差别——因为 proxy 是惰性的,只在你真的读到某个属性时才为那一层建代理和信号。所以代价不在创建,全在读取;数据越大、遍历越多,账越难看。

三类该用 raw 的数据

  • 大数组 / 大对象:几千条以上、而且你总是整体替换(重新拉一次列表、过滤出新数组)的数据。既然从不改单个元素,深层响应就是白付钱;
  • 外部库的实例:地图、图表、编辑器、WebSocketDate 之外的复杂对象。这类对象往往依赖 this 和内部标识,被 proxy 包住之后行为可能直接出错——这不是性能问题而是正确性问题;
  • 不需要细粒度更新的快照数据:从服务端拿回来只用于展示、生命周期内不改的配置。

raw 最坑的地方:改属性是「静默成功」

raw = $state.raw({ n: 0 }) 之后执行 raw.n++DOM 停在 0 不动;紧接着执行 raw = { n: raw.n + 1 },DOM 直接跳到 2

这说明那次 raw.n++ 确实改到了底层对象,只是没有通知 UI。它不是「改不动」,是「改了不说」——于是你的内存状态和屏幕从此不一致,而且没有任何报错。用 raw 就必须贯彻「只整体替换、永不改属性」(见 02 章)。

默认还是用 $state

说了这么多,判断口诀依然是:先一律用 $state,被性能剖析器指着鼻子了再换 raw。过早换成 raw 换来的是一堆「为什么没更新」的调试时间,而那正是 Svelte 想帮你省掉的东西。

<script>
  // 大数组:只整体替换,从不改单个元素 → 用 raw
  let rows = $state.raw([]);

  async function reload() {
    const data = await fetch('/api/rows').then((r) => r.json());
    rows = data;                 // ✓ 整体替换,UI 会更新
  }

  function wrong() {
    rows[0].v = 999;              // ✗ 改到了数据,但 UI 一声不吭
  }

  function right() {
    rows = rows.map((r, i) =>
      i === 0 ? { ...r, v: 999 } : r);  // ✓ 造新数组再整体赋值
  }

  // 外部库实例:被 proxy 包住可能直接坏掉,一律 raw
  let chart = $state.raw(null);
  $effect(() => {
    chart = new SomeChart(el);
    return () => chart?.destroy();
  });
</script>

<p>共 {rows.length} 行</p>
// 两万行:$state 全量遍历 20 轮约 500ms,raw 约 8ms
$state.raw 改属性不报错也不生效raw.n++ 之后 DOM 停在旧值,但下一次整体赋值时数字会「跳两格」——因为那次修改其实生效了,只是没通知 UI。这种 bug 表现为「偶尔数字对不上」,极难定位。用 raw 就守死「只整体替换」。
一个不用测就能用的判断法:「我会不会写 x.a.b = 1 这样的语句?」——会,就用 $state;只会写 x = 新的东西,就用 $state.raw。凡是外部库 new 出来的实例,无论大小一律 raw,这条没有例外。

{#each list as item} 不写 key 时,Svelte 按位置复用 DOM 节点;写了 (item.id) 才按身份复用。这个区别在纯文本列表上看不出来,一旦列表里有输入框、有动画、有播放中的视频,就是灾难。

往三项列表头部插一项

列表是 a, b, c,每个 <li> 里有一个 <input>。先在第一个输入框里打上「用户输入」,然后在头部插入 z

keyed(写了 key)unkeyed(没写 key)
渲染顺序z, a, b, cz, a, b, c
复用了几个原 li3 / 33 / 3
用户输入去哪了跟着 a 移到了第二格被冲掉了

两边都「复用了三个节点」,但含义完全不同:keyed 是把原来的三个 <li> 整个搬了位置,节点里的一切(输入值、焦点、滚动位置、CSS 动画进度)原样跟着走;unkeyed 是把原来三个 <li> 留在原地、把内容改写成新数据,于是第一格的输入框被强行写成了 z,用户打的字消失得无声无息。

注意这不只是性能问题——unkeyed 在这个例子里其实动的 DOM 更少。它是一个正确性问题。

key 选什么

  • 选数据自己的稳定标识:数据库 id、uuid、业务单号。它必须在这条数据的整个生命周期里不变;
  • 不要用数组下标:下标就是位置,用它当 key 等于没写 key,还多骗自己一次;
  • 不要用 Math.random() 或每次新建的对象:key 每次都变,等于每次全部重建,比不写 key 还糟;
  • 实在没有 id 时,用几个字段拼一个:(`${row.date}-${row.sku}`),只要能保证唯一即可。

大列表要不要虚拟化

虚拟化就是只渲染视口里的那几十行。

  • 直接渲染全部
    代码简单;浏览器原生滚动、原生查找(Ctrl+F)、无障碍读屏全部正常工作
    行数上千后首次挂载明显卡顿——每一行都要建真实 DOM 节点
    为何同根在「DOM 里真的有这么多节点」——正因为它们真实存在,浏览器的一切原生能力才对它们有效,也正因为真实存在,建它们才要花时间
  • 虚拟化
    十万行和一百行的挂载开销几乎一样
    行高要可预测或要测量;Ctrl+F 搜不到、读屏器读不全;粘性表头、跨行选中都得自己实现
    为何同根在「DOM 里其实没有那么多节点」——省下的开销和丢掉的原生能力是同一件事的两面

门槛给个具体数:一屏之外还剩不到一千行,就别虚拟化;先把 key 写对、把行组件里的 $effect 清干净、必要时给数据用 $state.raw。这三件事通常就够了。

<script>
  let rows = $state([
    { id: 'a', note: '' },
    { id: 'b', note: '' }
  ]);

  // 往头部插入,最能暴露 key 写没写对
  function prepend() {
    rows = [{ id: crypto.randomUUID(), note: '' }, ...rows];
  }
</script>

<button onclick={prepend}>插到最前</button>

<!-- ✓ 按身份复用:整个 li 连同用户打的字一起搬位置 -->
<ul>
  {#each rows as row (row.id)}
    <li><input bind:value={row.note} /></li>
  {/each}
</ul>

<!-- ✗ 按位置复用:li 留在原地被改写,输入框里的字被冲掉 -->
<ul>
  {#each rows as row}
    <li><input bind:value={row.note} /></li>
  {/each}
</ul>
// 头部插入后:keyed 三个原 li 全部搬位置,unkeyed 三个原 li 原地被改写
用数组下标当 key({#each rows as row, i (i)})是最隐蔽的错误:它写了 key 的形式,却保留了按位置复用的语义,代码审查时看着完全正常。key 必须来自数据本身,下标来自数组结构,两者一变就露馅。
判断一条列表要不要 key,问一句:「这一行里有没有 DOM 自己保存的状态?」——输入框的值、焦点、滚动位置、<video> 的播放进度、正在跑的过渡动画,任何一个有,就必须写 key。只有纯静态文本的列表才可以省,而省下来的收益基本为零。

服务端渲染让用户更早看到内容,但更早看到不等于更早能用——中间隔着 hydration:浏览器要把那份 HTML 重新认领一遍,接上事件和信号。这一段的成本经常被低估。(部署与渲染模式的配置见 15 章,这里只谈性能视角。)

服务端产物长什么样

同一个组件用 generate: 'server' 编译,产物连一个 DOM API 都没有

  • $$renderer.push(`<button>${$.escape(n)}</button> <ul>…`) 一路字符串拼接;
  • {#each} 变成一个朴素的 for 循环,往 renderer 里 push;
  • let n = $state(0) 直接变成 let n = 0——服务端没有信号,也没有响应式,因为一次渲染就结束了,没有「之后变化」这回事。

这解释了为什么 SSR 那么快:它本质上是模板字符串拼接。也解释了为什么 SSR 阶段的 $effect 不会跑(见 03 章)——服务端根本没有 effect 系统。

hydration 的成本

一个两千行的列表组件,在同一个 jsdom 环境里对比:

耗时还付了什么
mount() 从零建 DOM143.7 ms首屏在 JS 跑完前是空白
hydrate() 接管已有 DOM108.1 ms外加 30.9 kB 的 HTML 要先传下来

结论要说清楚:hydration 只比从零建便宜约四分之一,因为它省掉的只是创建节点,该建的信号、该绑的事件、该跑的 effect 一个都不能少,还多了一步「核对现有 DOM 是否与预期一致」。(jsdom 比真实浏览器慢,这里看比例不看绝对值。)

所以 SSR 换来的是更早的第一次绘制,代价是更大的传输量一段可交互之前的空窗期。这段空窗期里页面看着完好却点不动,用户体感反而可能比白屏更差。

哪些页面该关掉客户端渲染

SvelteKit 允许整页设 export const csr = false,产物里就不带任何组件 JS。适合的场景有共同特征:页面上没有任何需要 JS 的交互

  • 文档页、博客文章、条款页、营销落地页——链接和原生表单足够;
  • 纯展示的报表页面、邮件里点进来的确认页;
  • 反过来,只要有下拉菜单、客户端校验、拖拽、实时数据,就必须留着 CSR。

还有一个中间档:保留 CSR,但把重型组件(图表、地图、富文本编辑器)做成动态导入,让它们不进首屏那个包。这通常比一刀切关掉 CSR 收益更大也更安全。

// 同一个组件,generate: 'server' 编译出来的东西(节选)
import * as $ from 'svelte/internal/server';

export default function App($$renderer, $$props) {
  let { rows } = $$props;
  let n = 0;                       // 服务端没有信号,$state 就是普通变量

  $$renderer.push(`<button>` + $.escape(n) + `</button> <ul>`);

  const each_array = $.ensure_array_like(rows);
  for (let i = 0; i < each_array.length; i++) {
    $$renderer.push(`<li>` + $.escape(each_array[i].name) + `</li>`);
  }
  $$renderer.push(`</ul>`);        // 全程字符串拼接,没有任何 DOM API
}

// +page.js —— 纯展示页可以整页关掉客户端渲染
export const csr = false;      // 产物不含组件 JS,代价是页面完全不能交互

// 更常用的中间档:重型组件动态导入,不进首屏包
let Chart = $state.raw(null);
$effect(() => {
  import('./HeavyChart.svelte').then((m) => { Chart = m.default; });
});

// 2000 行列表:mount 143.7ms / hydrate 108.1ms,HTML 30.9 kB
csr = false 的页面上,所有客户端交互都会静默失效onclick 不响应、bind:value 不双向、过渡动画不播。本地开发时因为 dev 服务器行为不同,往往看不出问题,一部署到生产才发现按钮点不动。设了这个开关就要在预览构建上完整点一遍。
衡量 SSR 值不值得,别只看「首屏出现的时间」,要看「可以点的时间」。一个 HTML 先到但要等两秒才能交互的页面,比白屏一秒后立刻可用的页面体验更差——因为用户会去点,然后发现没反应。浏览器开发者工具里对应的指标是 INP 和 TBT。

从这里到精通:路线图

地图铺完了。难度递进的动手项目、按阶段的资料,以及一份能验出真懂假懂的自测清单。

Svelte 的知识点已经铺完,从「看懂」到「精通」之间隔着的,是几个亲手做完的项目。

动手项目(难度递进)

  • Todo 应用$state + each 块 + bind:value,产出支持增删改、过滤和 localStorage 持久化的单页 Todo。做完自问一句:你的 each 块写 key 了吗?不写会怎样——这一条就能验出你有没有真读懂 04 章。
  • 多页数据应用:SvelteKit 文件路由 + load 函数,做一个带路由、搜索与详情页的电影/图书浏览站。重点不在功能而在三种态都要认真做:加载态、错误态、空态,再搞清楚搜索框改变查询串时 load 到底重跑没重跑(见 12 章的依赖追踪)。
  • SvelteKit 全栈项目:load + form actions + hooks 鉴权,做一个含 SSR、表单提交与登录鉴权的博客或留言板,并真的部署上线。做完把浏览器的 JavaScript 关掉再试一次表单——如果还能提交成功,说明你真的用对了 SvelteKit(见 13 章)。
  • 深入原理:把可复用的响应式逻辑抽成 .svelte.js 信号工具库发布,或读一读编译产物(svelte.dev Playground 的 JS Output),能讲清一次赋值如何变成一次精准 DOM 更新。

资料(按阶段)

  • 入门:Svelte Tutorial 交互教程(svelte.dev/tutorial),边写边学,覆盖 Svelte 5 与 SvelteKit,是四大框架里最好的官方教程之一。
  • 看编译产物:svelte.dev 的 Playground 有个 JS Output 面板,把你写的组件实时编译给你看——这是理解「编译时框架」最快的路子,本页多张卡的都出自这里。
  • 进阶:svelte.dev/docs 的 Svelte 与 SvelteKit 双文档,配合 Playground 随手验证。
  • 社区:Svelte Society、Joy of Code 博客与视频,填补第三方实践空白。
  • 动向:svelte.dev/blog 跟进演进。本页写的是 2026 年年中的现状(svelte 5.56.7 / kit 2.70.1),这个生态迭代很快,一年后请自行核对。
# ① Todo:纯前端,Vite 裸 Svelte 就够
npx sv create my-todo        # 选 "Svelte library" 之外的最小模板

# ② 多页数据应用:需要路由与 load,上 SvelteKit
npx sv create my-browser     # 选 SvelteKit minimal

# ③ 全栈项目:同上,加数据库与鉴权
npx sv add drizzle lucia     # sv 自带的官方 add-on

# ④ 把响应式逻辑抽成库发出去
npx sv create my-lib         # 选 Svelte library,产出带 svelte-package 的骨架
npm run package && npm publish --access public

# 全程别忘了这两条:类型检查 + 测试
npx svelte-check
npx vitest run
最没效率的学法是「把文档从头读到尾再动手」。Svelte 的坑几乎都是体感型的:解构丢响应性、effect 互相触发撞上循环守卫、模块级状态在 SSR 下跨请求串号——你不亲手踩一次,读多少遍都记不住。正确顺序是:读一章 → 立刻写一个十分钟能跑起来的小例子 → 故意写错一次看看报什么 → 再回头读那一章。本页每一章的结论都是这么来的。
一条自测标准:给你一个 Svelte 4 老组件(export let$: 派生、on: 事件),你能否把它完整迁移到 runes 写法,并说清这两件事的区别——解构 $props() 完全不影响响应性(它就是官方推荐写法),而解构一个 $state 对象会当场丢掉响应性(拿到的只是一份拷贝)。能把这两者分清、并讲明白 $effect 何时该换成 $derived,这一页就毕业了。

如果只能带走一样东西,带走这段编译产物——本页十八章讲的每件事,都是它的展开。

右边那段代码是编译器真的吐出来的

源码只有六行:一个 $state、一个 $derived、一个按钮。编译产物里值得注意的是这几处:

  • $.from_html(...) 在模块顶层执行一次:整个模板被压成一个 HTML 字符串,只解析一次,之后靠克隆复用。
  • 组件函数体只跑一次App() 里没有任何「重新执行整个函数」的机制,它只负责建立结构和订阅关系。这是和 React 最根本的分歧(见 00 章)。
  • $.template_effect(...) 是唯一会重复执行的东西,而且它只干一件事——把新文本写进那个特定的文本节点。没有虚拟 DOM,没有 diff,没有对账
  • $.get(n) / $.set(...) 就是依赖追踪的全部:读的时候登记,写的时候通知。$state$derived$effect 三个 rune 编译出来都落在这套原语上(见 02、03 章)。
  • $.delegated('click', ...):事件也是编译期决定的,统一委托,不是每个节点各挂一个监听。

一份自测清单

下面每一条你都能不查资料答上来,这一页就算毕业了:

  • 为什么 $state 必须是编译器的活儿,写成一个普通函数做不到?
  • arr.push(1) 能触发更新,而 $state.rawraw.n++ 不能——后者到底是「没改成」还是「改了没说」?
  • let { n } = $props()let { v } = someState,哪个会丢响应性?为什么?
  • 什么样的 $effect 应该改写成 $derived?判断标准是什么?
  • 模块级的 $state 在 SSR 下为什么危险?该换成什么?
  • 关掉浏览器的 JavaScript,你的 SvelteKit 表单还能提交吗?为什么?
// 源码(六行):
<script>
  let n = $state(0);
  let d = $derived(n * 2);
</script>
<b>{n} / {d}</b>
<button onclick={() => n++}>+</button>

// ── 编译产物(逐字照抄,未作删改)──
import * as $ from 'svelte/internal/client';

var root = $.from_html(`<b> </b> <button>+</button>`, 1);

export default function App($$anchor) {
  let n = $.state(0);
  let d = $.derived(() => $.get(n) * 2);
  var fragment = root();
  var b = $.first_child(fragment);
  var text = $.child(b);
  $.reset(b);
  var button = $.sibling(b, 2);

  // 唯一会重复执行的东西:只改这一个文本节点
  $.template_effect(() => $.set_text(text, `${$.get(n) ?? ''} / ${$.get(d) ?? ''}`));
  $.delegated('click', button, () => $.update(n));
  $.append($$anchor, fragment);
}
别把「没有虚拟 DOM」直接等同于「一定更快」。Svelte 省掉的是 diff 的开销,但每个组件都带着自己那份更新代码,组件数量上去之后产物增长比 React 快(React 的运行时是一次性成本)。真实项目里的性能差距,通常远小于基准测试给人的印象,更多取决于你有没有把大列表虚拟化、有没有滥用 $effect。这一页的立场是:选 Svelte 是为了写法和心智,不是为了跑分(见 16 章)。
把这段产物和 React 的心智对照着看,Svelte 的取舍就一目了然:React 每次更新重跑组件函数、再靠 diff 找出差异;Svelte 在编译期就把「谁依赖谁、变了要改哪个节点」算好了。所以 Svelte 不需要 memo、不需要依赖数组、不需要 key 来对账(它的 key 只影响列表复用)。代价是这些结论必须在编译期成立——这也正是「为什么 runes 不能是普通函数」的答案。