Svelte:Runes 与编译期细粒度更新
最后核验:2026-06-30|适用版本:Svelte 3–5(示例锁定 5.56.4)|前置知识:HTML、CSS、JavaScript、TypeScript
Svelte 的编译器取舍
Svelte 把组件模板、样式和响应式声明交给编译器,输出直接操作 DOM 的 JavaScript。它适合希望组件语法贴近 Web 平台、减少通用虚拟树运行时并接受编译器约束的项目。固定成本仍包括构建工具、Svelte 语义和生态集成;静态页面只有少量行为时,原生 HTML 与 JavaScript 依然更小。
“编译时框架”不等于零运行时。组件生命周期、调度、上下文和 Runes 仍需要运行时代码;最终成本要看产物和交互,不从宣传标签推导。
从组件源码到更新指令
编译器解析 .svelte 文件,分析脚本中的状态依赖、模板读取和样式作用域,再生成创建与更新代码。运行时状态变化只执行与依赖相关的更新路径,而不是重新调用整个组件函数。
观察生成代码用于理解边界,不应依赖私有输出形状。编译器升级可以在语义不变时重写实现;应用契约是组件输入、事件、DOM 与官方 Runes 语义。
.svelte 组件、模板块与样式边界
单文件组件可以包含实例 <script>、模板、<style>,以及只运行一次的模块脚本。实例脚本为每个组件实例执行;模块脚本用于类型、常量或模块级逻辑,不能偷偷保存请求用户状态,否则 SSR 进程中的多个请求可能互相污染。
<script lang="ts">
import EntryRow from './EntryRow.svelte';
type Entry = { id: string; title: string; tags: string[] };
let { entries, onselect }: {
entries: Entry[];
onselect: (id: string) => void;
} = $props();
let query = $state('');
let filtered = $derived(
entries.filter((entry) => entry.title.toLowerCase().includes(query.toLowerCase())),
);
</script>
<label>
搜索
<input bind:value={query} />
</label>
{#if filtered.length === 0}
<p role="status">没有匹配结果</p>
{:else}
<ul>
{#each filtered as entry (entry.id)}
<li><EntryRow {entry} {onselect} /></li>
{/each}
</ul>
{/if}
<style>
ul { padding-inline-start: 1.25rem; }
</style>普通 {expression} 以文本或属性语境输出并转义;{@html expression} 绕过转义。{#if} 建立条件块,{#each} 建立重复块,{#await} 为 Promise 提供 pending/then/catch 分支,{#key value} 在值变化时销毁并重建子树。块不是字符串宏,编译器会为每个分支产生创建、更新和销毁逻辑。
事件使用 onclick={handler} 等属性,数据使用普通属性表达式。bind:value 是输入值与状态之间的双向连接,适合真正可编辑的控件;业务状态仍应通过显式函数校验,而不是让任意子组件直接绑定。bind:this 取得 DOM/组件引用,挂载前为 undefined,只应用于聚焦、测量和第三方库等命令式边界。
组件样式默认经过作用域处理,选择器只匹配组件生成的标记;:global(...) 可越过边界。它能避免大部分命名冲突,却不是 Shadow DOM:继承属性、CSS 自定义属性和显式全局规则仍会跨组件。真正需要原生封装和可分发自定义元素时,要选择 custom element 编译模式并重新评估事件、slot 和 SSR 限制。
DOM 更新、列表身份与调度
Svelte 客户端输出通常包含预先创建静态片段的逻辑、动态文本/属性 Effect、事件委托和块控制器。状态写入使相关消费者失效,同一 JavaScript turn 内的多次写入会批处理,随后更新 DOM。它不需要每次构造整棵通用 VNode 树再递归比较,但条件块和列表仍必须解决“哪些旧 DOM 对应哪些新数据”。
无 key 的 {#each items as item} 主要按位置复用块。删除第一项时,原第一个块可能接收第二项数据,而块内只在创建时捕获的常量、局部状态或未受控输入仍属于旧实例。keyed each {#each items as item (item.id)} 用 key 维持业务身份,可以移动、插入、删除正确实例。key 必须稳定且唯一;对象引用只有在对象身份稳定时才适合作 key,从接口重新解析出的等值对象并不相同。
{#each entries as entry (entry.id)}
<EntryRow
{entry}
selected={selectedIds.includes(entry.id)}
onselect={() => toggle(entry.id)}
/>
{/each}不要把“keyed 一定更快”当规则:只追加且无局部状态的短列表,无 key 可能更简单;会排序、过滤、插入,或子项拥有焦点、动画和组件状态时,稳定 key 是正确性约束。大量 DOM 的根因也不会因细粒度更新消失,成千上万行仍需分页或虚拟化。
tick() 返回一个 Promise,在待处理状态变更应用后解决,可用于写入状态后聚焦新节点或读取新布局。它不是通用延时器,也不保证图片加载或 CSS 动画结束。连续“写 DOM—读布局—再写 DOM”会触发布局抖动,应批量写入,并用浏览器 Performance 验证。
$state 与可写源状态
Svelte 5 的 Runes(符文)用 $state 标记可写响应式状态。数组和普通对象会获得深层响应式代理,局部修改可以通知精确消费者;类实例和某些内建对象有不同边界。跨模块共享状态时使用 .svelte.ts,不要把普通 .ts 变量误认为响应式容器。
<script lang="ts">
let query = $state('');
let selectedIds = $state<string[]>([]);
function toggle(id: string) {
selectedIds = selectedIds.includes(id)
? selectedIds.filter((item) => item !== id)
: selectedIds.length < 4 ? [...selectedIds, id] : selectedIds;
}
</script>替换数组和直接修改代理都能工作,但团队应选择清楚的状态所有权。来自外部 JSON 的数据仍要先运行时验证,$state 不会把不可信值变成领域类型。
深层代理会递归代理普通对象和数组,直到遇到非普通对象。读 todos[0].done 能形成细粒度依赖,赋值也能通知消费者;但把代理交给不理解 Proxy 的库、使用严格身份比较、结构化克隆或序列化时,需要明确边界。
let editor = $state({
title: '',
tags: [] as string[],
});
editor.tags.push('compiler'); // 经过代理,相关消费者会失效
const payload = $state.snapshot(editor); // 得到可序列化的普通快照$state.raw(value) 保持浅层响应式:只能替换整个值,不能依赖内部原地修改,适合大型不可变结构或外部库对象。$state.snapshot(proxy) 生成当下的非 Proxy 快照,适合 structuredClone、日志或请求载荷;快照不会继续响应。不要为了消除开发期所有权警告就到处 snapshot,那会断开期望的更新;正确做法是让状态只有一个明确所有者,通过回调请求修改,或由组件 API 显式声明可绑定 prop。
Runes 只能出现在编译器支持的位置和文件中;跨模块可将状态放入 .svelte.ts/.svelte.js。把 $state 的值解构成普通局部值会捕获当时值,不再自动读取原属性;传给函数的 primitive 也是按值传递。需要持续读取时传 getter、传代理对象,或在函数内部读取响应式来源。
$derived 与纯派生
$derived 根据读取到的响应式值建立依赖,并在源状态变化后重新计算。派生表达式应保持纯,不写其他状态、不发请求。复杂逻辑可以用 $derived.by,但普通函数足够时不增加包装。
let filtered = $derived(search(entries, query));
let selected = $derived(
selectedIds.map((id) => entries.find((entry) => entry.id === id)).filter(Boolean),
);把筛选结果另存成 $state 再用 Effect 同步,会制造第二份真值。能由当前输入计算的值优先 $derived,需要保留用户决策或异步结果的值才进入 $state。
$derived 采用“推送失效、拉取求值”:依赖写入时先把派生标记为脏,真正读取时才重新计算,并按结果身份决定下游是否需要工作。依赖是运行时动态收集的;分支中没执行的读取不会成为本轮依赖。
let showSelected = $state(false);
let entries = $state<Entry[]>([]);
let selectedIds = $state<string[]>([]);
let visible = $derived.by(() => {
if (!showSelected) return entries;
const selected = new Set(selectedIds);
return entries.filter((entry) => selected.has(entry.id));
});showSelected 为 false 时,派生不读取 selectedIds,所以选择变化无需重算这个分支。untrack(() => value) 可做不应成为依赖的偶然读取,但它是高级逃生口;如果必须大量使用,通常意味着状态图职责混乱。新版本允许临时覆盖派生值以实现乐观 UI,但服务器结果、回滚和下一次源更新会重新决定值,不能把派生长期当第二个可写源。
$effect 与浏览器外部系统
$effect 在浏览器中追踪依赖并执行副作用,返回函数用于清理。它适合 History、存储、订阅和第三方控件;不适合数据派生。服务端渲染期间不执行普通 $effect,因此首屏必须由 props、load 数据或可确定状态产生。
$effect(() => {
history.replaceState(
null,
'',
writeUrlState({ query, selectedIds }) || location.pathname,
);
});需要访问 DOM 时优先绑定引用和 tick(),而不是查询任意全局节点。Effect 中启动的请求必须在清理时取消,避免卸载后的旧结果覆盖新页面。
Effect 首次在组件挂载后运行,执行期间同步读取的响应式值成为依赖;await 之后或异步回调里的读取不会被自动追踪。重新运行前先调用上一次返回的清理函数,组件销毁时也会清理。依赖循环的典型形式是 Effect 读数组后又无条件写入该数组;修正方式不是加布尔锁,而是把派生改成 $derived,或让副作用只写外部系统。
<script lang="ts">
let query = $state('');
let results = $state<Entry[]>([]);
$effect(() => {
const controller = new AbortController();
const currentQuery = query;
fetch(`/api/search?q=${encodeURIComponent(currentQuery)}`, {
signal: controller.signal,
})
.then((response) => response.ok ? response.json() : Promise.reject(response))
.then((value) => { if (!controller.signal.aborted) results = value; })
.catch((error) => {
if (error.name !== 'AbortError') report(error);
});
return () => controller.abort();
});
</script>这个例子展示取消语义,但真实搜索还应做防抖和运行时数据验证。$effect.pre 在 DOM 更新前运行,适合根据旧布局决定更新后的滚动;$effect 在 DOM 更新后运行。两者都不是服务端数据加载机制。需要确认某段代码是否在追踪上下文中,可使用 $effect.tracking();需要建立可手工销毁的非组件响应式根时才考虑 $effect.root。
Props、事件与组件边界
Svelte 5 使用 $props() 声明输入,回调 props 是明确事件边界;组件绑定会建立双向写入,应只用于调用方确实拥有可写状态的场景。Snippet 与 {@render} 负责可组合内容,不需要为了一个固定目录创建通用 render-prop 系统。
| 需求 | Svelte 5 机制 | 常见错误 |
|---|---|---|
| 可写本地状态 | $state | 把外部输入直接当可信状态 |
| 纯派生 | $derived | 用 $effect 同步第二份值 |
| 外部副作用 | $effect | Effect 内无清理地订阅 |
| 父子输入 | $props | 子组件修改无所有权的对象 |
| 组合片段 | Snippet | 为固定标记提前造插槽框架 |
$props() 可解构默认值、剩余属性和回调。默认值不是自动深层代理;子组件修改父组件传来的代理对象会产生所有权警告,因为写入来源模糊。若组件本质是输入控件,可使用 $bindable() 明确允许 bind:value,否则由父级持有状态并传 onchange 回调。
<!-- SearchBox.svelte -->
<script lang="ts">
let {
value = $bindable(''),
label = '搜索',
onsubmit,
}: {
value?: string;
label?: string;
onsubmit?: (value: string) => void;
} = $props();
</script>
<label>{label}<input bind:value /></label>
<button type="button" onclick={() => onsubmit?.(value)}>提交</button>Snippet 是可传递、可带参数的模板片段。组件用 {@render children?.()} 或具名 snippet 组合内容,比字符串 HTML 安全,也比为每个布局都创建包装组件直接。Snippet 仍在调用者的词法作用域中读取值;设计公共组件时要限制参数面,不要把整个内部状态对象泄漏给任意渲染函数。
$props.id() 为当前实例生成在服务端与客户端一致的唯一 ID,适合连接 <label for> 与输入、ARIA 描述关系。不要用 Math.random() 生成首屏关联 ID,否则 SSR 与客户端不同,可能导致水合错误。
生命周期、Context、Store 与 DOM 扩展
Svelte 5 的组件生命周期核心是创建和销毁;细粒度 Effect 各自负责更新。onMount 只在浏览器挂载后执行,适合依赖 DOM/浏览器的初始化,并可同步返回销毁函数。onDestroy 也会在服务端组件销毁时运行,是唯一会参与 SSR 生命周期的传统钩子。异步 onMount(async () => ...) 返回的是 Promise,不会被当成清理函数,应在同步回调里启动异步任务并同步返回 cleanup。
<script lang="ts">
import { onMount } from 'svelte';
let canvas: HTMLCanvasElement;
onMount(() => {
const chart = createChart(canvas);
const observer = new ResizeObserver(() => chart.resize());
observer.observe(canvas);
return () => {
observer.disconnect();
chart.destroy();
};
});
</script>
<canvas bind:this={canvas} aria-label="技术趋势图"></canvas>Context 通过组件树传递值,避免中间组件层层转发。它只对子孙可见,且 setContext/getContext 必须在组件初始化期间调用。Context 中放 $state 对象可以共享响应式状态,但不要替换整个引用后期待旧消费者自动切换;更稳妥的是提供只读读取和命令函数。SSR 中每棵组件树应创建自己的 context,不能用模块全局对象承载用户会话。
Store 是具有 subscribe 契约的对象,writable、readable、derived 仍适合已有生态、外部 observable 或需要手工订阅协议的库;模板中的 $store 语法会自动订阅并在销毁时解除。Runes 让大多数应用内状态不再必须包装 store,但不应为迁移而重写工作良好的自定义 store。选择依据是边界契约,不是新旧标签。
Action 的 use:action 在元素挂载时运行,并可更新/销毁,适合焦点陷阱、手势和旧组件库。Svelte 5.29 起 Attachment {@attach ...} 提供更易组合的元素扩展,并在 Effect 中运行。无论哪种形式,都必须移除监听器、observer 和第三方实例;若只是普通事件或属性,直接模板语法更清楚。Attachments
SSR、客户端挂载与水合
独立 Svelte 组件库负责渲染语义,不负责完整路由和服务端数据协议;实际 SSR 通常由 SvelteKit 组织。服务器编译目标输出 HTML,客户端目标输出恢复交互的代码。纯客户端入口使用 mount(Component, { target, props }),复用服务端 HTML 使用 hydrate(Component, ...);unmount 负责销毁组件和 Effect。
import { hydrate, mount } from 'svelte';
import App from './App.svelte';
const options = {
target: document.getElementById('app')!,
props: { initialEntries: window.__INITIAL_ENTRIES__ },
};
const app = document.getElementById('app')!.hasChildNodes()
? hydrate(App, options)
: mount(App, options);服务端和客户端首次结果必须结构一致。时区、locale、当前时间、随机数、浏览器专属分支和未经排序的集合都会产生差异。普通 $effect 不在 SSR 运行,所以用它计算首屏必需状态会让服务器缺内容;首屏数据应通过 props、SvelteKit load 或服务端可执行的纯派生进入模板。
水合不是把所有服务端 HTML重新渲染一遍,而是让生成的客户端逻辑认领节点并连接响应式更新。编译器使用的 hydration marker 和序列化数据属于安全边界;攻击者可控字符串若能突破注释、属性或脚本上下文,就可能改变认领结构或执行脚本。这也是为什么必须及时升级安全 patch,并对生产 SSR 响应而非只对客户端组件做 XSS 测试。
性能与故障定位
Svelte 已减少通用运行时比较,但性能仍由发送字节、执行 JavaScript、DOM 数量、布局和网络共同决定。合理顺序是:
- 用浏览器 Performance/Network 找长任务、重复请求、布局抖动和大 chunk,再查看 Svelte Devtools 的组件与状态。
- 删除重复
$state与搬运状态的$effect,让纯派生进入$derived。 - 为重排列表选稳定 key;真正大列表分页或虚拟化,不能指望编译器消除 DOM 成本。
- 路由或重组件做动态导入;检查共享依赖是否仍进入首包。
- 第三方 DOM 库放在清理完整的 Attachment/onMount 边界,避免每次 Effect 更新都重建实例。
| 故障 | 应检查的机制 | 典型修复 |
|---|---|---|
| 状态变了但 UI 不动 | 是否解构成普通值、文件是否支持 Runes | 保持代理读取或传 getter,跨模块用 .svelte.ts |
| Effect 无限执行 | 是否读写同一响应式源 | 改成 $derived 或隔离唯一写入方 |
| 排序后输入内容串行 | each 是否无 key 或 key 不稳定 | 使用业务 ID 的 keyed each |
| 卸载后仍请求/监听 | Effect、onMount、Attachment 是否返回清理 | Abort、unsubscribe、disconnect、destroy |
| SSR 正常但水合警告 | 首次结构、ID、时间或非法 HTML 不一致 | 使用确定性输入与 $props.id(),修正 HTML |
编译警告包含无障碍、状态所有权和过时语法等高价值信号,不应默认全局关闭。生产问题要用 production build 复现,因为开发期检查和生成代码可能不同;若怀疑编译器 bug,最小复现应同时固定 Svelte 版本、编译选项、服务端/客户端目标和实际输入。
知识目录示例的运行证据
Svelte 示例使用 $state 保存目录、查询、选择和详情,$derived 生成筛选与对比条目,$effect 只同步 URL。请求在 onMount 中启动,并在卸载时通过 AbortController 取消。
pnpm --filter @roadmap/svelte-slice test
pnpm --filter @roadmap/svelte-slice preview --port 4204输入:访问 http://127.0.0.1:4204/,搜索 runes,打开并关闭详情,选择四项后尝试第五项。
预期结果:模型测试和 Vite 生产构建通过;详情关闭后焦点返回触发按钮,第五项被拒绝且已有选择保留,查询与选择写入 URL。
调试、性能与安全证据
Svelte Devtools 检查组件与状态,浏览器 Performance 检查主线程和布局,Vite 构建产物检查实际传输。性能先减少状态、Effect 和水合范围,再讨论 keyed each、惰性加载或手工优化。编译器已经能生成更新路径,不要为八条数据增加虚拟列表。
模板插值默认转义文本;{@html} 直接注入原始 HTML,必须只接受已经在可信边界净化的内容。CSS 作用域不能形成安全隔离,客户端隐藏也不能替代权限控制。Svelte @html
重大编译器重构、安全修复与 Svelte 3 到 5 迁移
Svelte 5 的 Runes 是响应式语义重构,不是机械语法替换。Svelte 3/4 编译器从顶层赋值和 $: 推断依赖;Svelte 5 用 $state、$derived、$effect 显式表达可写源、纯派生和外部同步,并把同一模型扩展到 .svelte.ts。组件事件也从 dispatcher 转向回调 props,slot 逐步转向 snippets,组件创建 API 改为 mount/hydrate。legacy mode 允许逐组件迁移,因此不需要全仓一次重写。
| 旧模型 | 新模型 | 迁移难点 |
|---|---|---|
let + 赋值触发更新 | $state 深层代理或原始状态 | 类实例、外部对象和解构不一定进入深层代理 |
$: 混合派生与副作用 | $derived 与 $effect 分开 | 把纯计算误写成 Effect 会产生顺序和循环问题 |
export let | $props() | 默认值、可绑定性和 TypeScript 类型需显式表达 |
createEventDispatcher | 回调 prop | 事件冒泡与组件 API 契约需要重新设计 |
new Component(...) | mount / hydrate | 返回值、销毁和测试挂载方式变化 |
2026-05 公告的 GHSA-rcqx-6q8c-2c42 修复了内部框架状态被 DOM clobbering 后导致 XSS 的问题:Svelte <=5.55.6 在 form 与内部 input/button 同时使用攻击者可控属性展开/动态 name 时受影响,5.55.7 修复。本示例 5.56.4 已越过修复线。升级后仍应删除不可信对象的整包属性展开,只映射允许的 name、value、ARIA 等字段;普通 {value} 转义与 {@html} 的不可信 HTML 风险是另一条边界。Svelte DOM clobbering 公告
同批安全公告还覆盖 SSR spread attributes、动态 <svelte:element> 标签、Promise 序列化和 hydration marker XSS。处理这些问题不能只检查浏览器 DOM:必须用生产 SSR 产物,把恶意属性名、原型继承属性、注释边界和动态标签作为输入,确认响应 HTML 不形成新节点或脚本上下文。Svelte Security Advisories
Svelte 3/4 使用顶层赋值、$: 和组件 dispatcher 表达响应式;Svelte 5 引入 Runes,让依赖在 .svelte 与 .svelte.ts 中使用统一语义,并提供兼容的 legacy mode。迁移应逐组件运行官方工具,先保持行为,再把可写状态、派生和 Effect 分开;不要机械把每个 $: 都替换成 $effect。迁移指南
验收标准:能解释编译器分析什么、$state/$derived/$effect 分别拥有哪类状态,并能从生成产物、浏览器事件和生命周期清理定位错误。
Svelte 官方资料
- Svelte Overview:组件与编译器定位;核验于 2026-06-30。
- What are runes?:Runes 的设计与适用文件;核验于 2026-06-30。
$state、$derived、$effect:状态、派生与副作用语义;核验于 2026-06-30。- Svelte 5 Migration Guide:legacy 与 Runes 迁移;核验于 2026-06-30。