Svelte 5 最佳实践:编写快速、可靠组件的官方完整指南
本文基于 Svelte 官方文档《Best practices》整理,系统讲解如何编写"快速且稳健"的现代 Svelte 代码,覆盖 $state/$derived/$effect/$props 四大 rune 的正确使用姿势、事件处理、snippets、each 块、CSS 作用域、Context 以及异步 Svelte 等主题,并结合当前仓库的编译器与运行时源码逐一印证这些实践背后的实现原理。读完后,你将掌握一套可直接套用于 Svelte 5 项目的状态管理与组件编写规范。
这份文档在仓库中同时也是面向 AI Agent 的 svelte-core-bestpractices skill(见 documentation/docs/07-misc/01-best-practices.md 头部的 frontmatter 标注),意味着它是官方认为"在 Svelte 项目中编写、修改或分析组件时应当加载"的核心规范。
一、$state:只对真正需要响应式的变量使用
第一条原则非常明确:只有那些会引起 $effect、$derived 或模板表达式更新的变量才应该用 $state,其余变量应该使用普通变量。
对象和数组($state({...}) 或 $state([...]))会被制成深度响应式(deeply reactive)——对内部属性的 mutation 也会触发更新。这是一个有代价的设计:为了细粒度响应式,这些对象必须被 Proxy 代理,存在性能开销。官方给出的优化建议是:当你处理"只会被整体重新赋值、而不会被修改内部"的大型对象时(例如 API 响应),应改用 $state.raw。
源码印证:$state.raw 与普通 $state 的区别
从源码结构看,这条建议有明确的编译器实现支撑:
- 在 VariableDeclarator.js 中,编译器把
$state声明的绑定标记为kind: 'state',而$state.raw标记为kind: 'raw_state'——两者在后续转换阶段走不同的代码路径; - 在 sources.js 中,
mutable_source接收一个immutable参数:当不可变(即 raw 语义)时,信号使用safe_equals比较并跳过深层代理,赋值即整体失效,避免了对大对象做 Proxy 遍历的开销。
也就是说,$state.raw 并不是"不可写",而是"不深度代理"——这正是"API 响应只会被整体替换"这一场景的最优选择。
二、$derived:用派生值而非 $effect 计算
凡是"从已有状态计算出来的值",都应使用 $derived 而不是 $effect:
let num = 0;
// do this
let square = $derived(num * num);
// don't do this
let square;
$effect(() => {
square = num * num;
});
官方给出了三点补充说明,值得逐条掌握:
$derived接收的是表达式,不是函数。 如果表达式复杂到必须写成一个函数体(比如包含多条语句、条件分支),应使用$derived.by;- Derived 是可写的——你可以像对
$state一样给它赋值,只不过当它的依赖变化时,它会重新求值覆盖你的赋值; - 如果 derived 的表达式求值出对象或数组,结果是原样返回的,不会被深度响应式化。 在极少数确实需要嵌套深度响应式的场景,可以在
$derived.by内部对结果使用$state。
源码印证:可写 derived 的实现
"derived 可写但会被依赖变化重新求值"这一行为可以直接在运行时源码中找到:sources.js 的 internal_set 中,写入一个带 DERIVED 标志的信号时,会立刻 execute_derived 重新追踪依赖并更新其状态——所以"给 derived 赋值"本质上是把该 derived 标记为脏并触发重算,而非持久保存你的值。derived 的核心执行逻辑位于 deriveds.js 的 execute_derived,其中还包含 DEV 模式下对自引用(递归 derived)的栈检测与友好报错。
三、$effect:作为逃生舱使用,能用别的方案就不要用它
官方对 $effect 的定位是:它是 escape hatch(逃生舱),应尽量回避,尤其是不要在 effect 内部更新状态。 文档给出了四类常见需求的更优替代方案:
| 需求 | 推荐方案 |
|---|---|
| 与 D3 等外部库同步状态 | 使用 {@attach ...} 标签 |
| 响应用户交互执行代码 | 直接写在事件处理器里,或使用函数绑定 |
| 调试时打印值 | 使用 $inspect |
| 观察 Svelte 之外的外部系统 | 使用 createSubscriber |
另外有一条硬性规则:永远不要写 if (browser) {...} 之类的判断去包裹 effect 内容——effect 在服务端本来就不会执行,这类防御代码是多余的。
createSubscriber 是 Svelte 官方的"外部响应式桥",其实现位于 create-subscriber.js,文档中的 JSDoc 给出了完整的 MediaQuery 集成示例:start 回调在首个 effect 激活时被调用一次(多个 effect 共享一个订阅),回调中收到的 update 函数被调用时所有依赖 effect 重新运行,返回的清理函数在最后一个 effect 销毁时才被调用。它内部通过 source(0) + increment 构造版本号信号来通知依赖更新(见 create-subscriber.js),因此是替代"在 $effect 里订阅 WebSocket / MediaQuery / IntersectionObserver"的正规做法。
四、$props:把 props 当作随时会变来对待
props 应该被当作可能随时变化的值来使用。 因此依赖 props 计算出来的值,通常应该用 $derived:
let { type } = $props();
// do this
let color = $derived(type === 'danger' ? 'red' : 'green');
// don't do this — `color` will not update if `type` changes
let color = type === 'danger' ? 'red' : 'green';
错误的写法中,color 只在初始化时求值一次;当父组件改变 type 后它不会更新。
从源码结构看,$props() 在分析阶段就被编译器精确记录:VariableDeclarator.js 会把对象模式中每个属性标记为 kind: 'prop'(rest 属性为 rest_prop),支持默认值,并在遇到 $bindable() 初始值时标记为 kind: 'bindable_prop'。由于 props 本质上是响应式绑定,编译器能追踪其变化,$derived 依赖 props 后自然会在 props 更新时重算——这就是"把 props 当变化的"这一实践的底层保证。
五、$inspect.trace:响应式调试利器
$inspect.trace 是一个专门用于调试响应式的工具。当某个值没有按预期更新、或者更新得过于频繁时,可以在 $effect 或 $derived.by(以及它们所调用的任意函数)的第一行加入 $inspect.trace(label),追踪该反应的依赖关系并发现到底是哪一个依赖触发了更新。
这一点与编译器的一致性校验完全吻合:CallExpression.js 中对 $inspect.trace 有专门的静态检查——它必须是函数体的第一条语句(否则报 inspect_trace_invalid_placement 错误),且不能出现在生成器函数中(inspect_trace_generator),因为它会捕获函数退出时的异步栈信息。如果省略 label 参数,编译器还会自动生成一个带函数位置信息的默认标签(见 CallExpression.js),并在 DEV 模式下把 analysis.tracing 置为 true 启用追踪模式。
六、事件:on* 属性与 <svelte:window> / <svelte:document>
任何以 on 开头的元素属性都会被当作事件监听器处理,这是 Svelte 5 的核心事件模型:
<button onclick={() => {...}}>click me</button>
<!-- attribute shorthand also works -->
<button {onclick}>...</button>
<!-- so do spread attributes -->
<button {...props}>...</button>
需要监听 window 或 document 上的事件时,使用专用的特殊元素:
<svelte:window onkeydown={...} />
<svelte:document onvisibilitychange={...} />
官方明确提醒:避免用 onMount 或 $effect 去手动 addEventListener 完成同样的事——专用元素会在组件销毁时自动清理监听器,且语义更清晰。
七、Snippets:可复用的模板片段
Snippets 是定义可复用标记块的方式,可以用 {@render ...} 标签实例化,也可以作为 props 传给组件。它们必须在模板内声明:
{#snippet greeting(name)}
<p>hello {name}!</p>
{/snippet}
{@render greeting('world')}
官方补充了一条容易被忽略的进阶用法:声明在组件顶层(即不在任何元素或块内部)的 snippet,可以在 <script> 中被引用;如果该 snippet 不引用组件状态,它还可以在 <script module> 中使用,并可以导出供其他组件使用。 这使得 snippet 可以承担"模块级模板工具函数"的角色。
八、Each 块:优先使用 keyed each
最佳实践是:优先使用带 key 的 each 块。它能显著提升性能——Svelte 可以据此对项做"外科手术式"的插入/移除,而不是去更新已存在项的 DOM。
{#each items as item (item.id)}
<li>{item.name}</li>
{/each}
两条关键告诫:
- key 必须能唯一标识对象,不要用索引作为 key(索引 key 在列表重排时会导致 DOM 状态错乱);
- 如果需要对项做 mutation(例如
bind:value={item.count}这类绑定),避免在 each 中解构,否则绑定无法正确回写到原数组元素。
九、CSS 与 JavaScript 变量:style: 自定义属性指令
如果你有一个 JS 变量想在 CSS 中使用,官方推荐的方式是用 style: 指令设置 CSS 自定义属性:
<div style:--columns={columns}>...</div>
然后在组件的 <style> 中用 var(--columns) 引用它。这比直接把 JS 值内联进 CSS 声明更灵活,因为 CSS 变量可以参与继承、被媒体查询覆盖等。
控制子组件样式的两种手段
组件 <style> 中的 CSS 是作用域限于该组件的。父组件若要控制子组件样式,官方首选方案仍是 CSS 自定义属性(自定义属性可以穿透作用域边界):
<!-- Parent.svelte -->
<Child --color="red" />
<!-- Child.svelte -->
<h1>Hello</h1>
<style>
h1 {
color: var(--color);
}
</style>
如果这条路走不通(例如子组件来自第三方库、无法修改),可以用 :global 覆盖样式:
<div>
<Child />
</div>
<style>
div :global {
h1 {
color: red;
}
}
</style>
自定义属性方案的前提是"父组件只设置变量、子组件决定如何使用",这是更健康的组件 API 设计;:global 则是最后手段,官方特意加了作用域限定(div :global 而非全局裸 :global)来降低误伤面。
十、Context:优先于共享模块级状态
考虑使用 context 而不是在共享模块中声明状态。 这样做的两个好处:
- 状态被限定在真正需要它的组件子树内,作用域更清晰;
- 消除 SSR 场景下状态在不同用户请求之间泄漏的可能性(模块级单例状态在服务端是跨请求共享的,极易产生串数据 bug)。
建议使用 createContext 而不是分别使用 setContext / getContext,因为它提供类型安全。其实现位于 shared/context.js:create_context 内部生成一个唯一的对象作为 key,并以 [get, set, has] 三元组返回——get 在 key 不存在时直接抛出 missing_context 错误,使"读取不存在的 context"在开发期就暴露出来,同时 TypeScript 用户获得完整的类型推导。
十一、Async Svelte:await 表达式与 hydratable
如果使用的是 5.36 及以上版本,可以使用 await 表达式 与 hydratable 直接在组件内使用 Promise——组件可以 await 数据而无需手动管理 loading/error 状态。
需要注意的适用前提:这些能力尚未被视为完全稳定,必须在 svelte.config.js 中启用 experimental.async 选项才能使用。运行时侧对应的实现信号(如 async_derived、ASYNC 标志、批处理的挂起/恢复逻辑)可以参见 deriveds.js,其中 async_derived 还处理了"旧请求被新请求取代"时的 OBSOLETE 取消语义,避免陈旧值覆盖新值。
十二、避免遗留特性:新代码一律使用 runes 模式
官方对新代码的要求非常直接:始终使用 runes 模式,并避免一切已有更现代替代物的特性。 完整对照表如下:
| 遗留写法 | 现代替代 |
|---|---|
隐式响应式(let count = 0; count += 1) |
$state |
$: 赋值与语句 |
$derived 和 $effect(且仅在无更好方案时才用 effect) |
export let、$$props、$$restProps |
$props |
on:click={...} |
onclick={...} |
<slot>、$$slots、<svelte:fragment> |
{#snippet ...} 与 {@render ...} |
<svelte:component this={DynamicComponent}> |
<DynamicComponent> |
<svelte:self> |
import Self from './ThisComponent.svelte' 后用 <Self> |
| 用 stores 在组件间共享响应式 | 带 $state 字段的 class |
use:action |
{@attach ...} |
class: 指令 |
class 属性中的 clsx 风格数组/对象 |
这套映射的完整迁移说明可参考仓库中的 v5 迁移指南;遗留特性本身的参考文档集中在 documentation/docs/99-legacy/ 目录(包括 $:let、export let、on: 指令、slots 等),供维护老项目时查阅,但新代码不应再引入它们。
小结
这份官方最佳实践可以浓缩为一组判断准则:
- 状态分三类:会变化的用
$state(大对象只重赋值用$state.raw)、算出来的用$derived、副作用最后才考虑$effect; - props 和列表都按"会变"来设计:依赖 props 的值用
$derived,each 块用唯一 key; - 事件用
on*属性,全局事件用专用特殊元素,不用onMount/$effect手动挂监听器; - 样式交互优先 CSS 自定义属性,
:global是最后手段; - 跨组件状态用 context(
createContext)而非模块级状态; - 调试响应式用
$inspect与$inspect.trace; - 新代码全面 runes 化,按上述对照表替换遗留特性。
每一条建议在当前仓库的编译器与运行时源码中都有对应的实现与静态校验(rune 位置检查、raw_state 绑定区分、derived 重算逻辑、context 类型安全封装等),可以按文中给出的文件路径进一步深入阅读。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00