Agent Developer Roadmap
前端框架

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));
});

showSelectedfalse 时,派生不读取 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 同步第二份值
外部副作用$effectEffect 内无清理地订阅
父子输入$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 契约的对象,writablereadablederived 仍适合已有生态、外部 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 数量、布局和网络共同决定。合理顺序是:

  1. 用浏览器 Performance/Network 找长任务、重复请求、布局抖动和大 chunk,再查看 Svelte Devtools 的组件与状态。
  2. 删除重复 $state 与搬运状态的 $effect,让纯派生进入 $derived
  3. 为重排列表选稳定 key;真正大列表分页或虚拟化,不能指望编译器消除 DOM 成本。
  4. 路由或重组件做动态导入;检查共享依赖是否仍进入首包。
  5. 第三方 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 取消。

运行效果Svelte 知识目录运行效果
新窗口打开

正在加载示例……

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 已越过修复线。升级后仍应删除不可信对象的整包属性展开,只映射允许的 namevalue、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 官方资料

在 GitHub 上编辑此页

本页目录