Svelte 5 等待异步:experimental.async 实验特性全景 —— 同步更新、fork 与 $effect.pending
【免费下载链接】svelteweb development for the rest of us项目地址: https://gitcode.com/GitHub_Trending/sv/svelte
从 Svelte 5.36 开始,await可以从过去"只能在 async 函数内部使用"的 JS 语法,扩展为可以直接出现在组件的<script>顶层、$derived(...)声明以及模板表达式中的响应式特性。它配合<svelte:boundary>的pending片段、$effect.pending()、settled()与 5.42 新增的fork(...)API,让 Svelte 应用可以在保持 UI 状态一致性的前提下处理加载态、错误边界与数据预加载。本文基于仓库中的官方文档 19-await-expressions.md 展开,并结合编译器与运行时源码,讲清这一特性"从哪里可用、如何保证一致性、如何显示 loading、出错后去哪、以及如何投机执行"。
需要说明的前提:该特性目前处于实验阶段,必须在编译器选项中显式开启experimental.async,且实验开关计划在 Svelte 6 中移除(届时await将直接可用)。
开启方式:experimental.async 配置
该功能必须在配置 Svelte 的地方(通常是svelte.config.js,SvelteKit 项目中即 svelte.config.js)中开启:
/// file: svelte.config.js export default { compilerOptions: { experimental: { async: true } } };开启后,await在组件中可用位置变为三类:
- 组件
<script>的顶层; $derived(...)声明内部;- 模板(markup)表达式内部。
从源码看,这一门槛在分析阶段就被强制校验。AwaitExpression.js 中,当await出现在会"挂起"的位置(顶层或表达式)时,会检查两个条件:
// disallow top-level `await` or `await` in template expressions // unless a) in runes mode and b) opted into `experimental.async` if (suspend) { if (!context.state.options.experimental.async) { e.experimental_async(node); } if (!context.state.analysis.runes) { e.legacy_await_invalid(node); } }也就是说,未开启experimental.async时会报编译错误,且 legacy(非 runes)模式下同样禁止。对应的运行时开关是一个全局标志:flags/index.js 中的async_mode_flag表示"编译时设置了experimental.async=true",后续fork等 API 会依赖它做运行时校验(见下文 Forking 一节)。
同步更新:为什么 UI 不会闪出中间态
这是整个特性最核心的语义。当一个await表达式依赖某份状态时,该状态的变化不会立即反映到 UI 上,而是要等异步工作完成后才一并更新,从而避免 UI 停留在不一致状态。官方文档给出的例子:
<!-- file: App.svelte --> <script> let a = $state(1); let b = $state(2); async function add(a, b) { await new Promise((f) => setTimeout(f, 500)); // artificial delay return a + b; } </script> <input type="number" bind:value={a}> <input type="number" bind:value={b}> <p>{a} + {b} = {await add(a, b)}</p>如果把a从 1 加到 2,<p>不会立刻显示成<p>2 + 2 = 3</p>(旧结果 + 新输入的组合);而是等add(a, b)解析后整体更新为2 + 2 = 4。换句话说,表达式中"输入侧"与"结果侧"被协调为同一次更新。
文档同时指出:更新可以重叠—— 一次快速的更新可以在更早一次慢速更新仍在进行时反映到 UI 上。从源码结构看,这种"全局协调"正是基于批处理机制:batch.js 中围绕Batch的调度逻辑负责把状态写入、派生重算与 DOM 应用组织进同一批次,settled()(见下文)也返回该批次的完成 promise。
并发:哪些 await 并行、哪些串行
Svelte 会尽可能并行地执行异步工作。例如模板中两个独立的await表达式:
<p>{await one(x)}</p> <p>{await two(y)}</p>虽然它们在视觉上顺序排列,但one与two是相互独立的表达式,会同时执行。
但这条规则不适用于<script>顶层或 async 函数中顺序书写的await—— 那些仍然按普通异步 JavaScript 的规则串行执行。有一个重要例外:相互独立的$derived表达式会各自独立更新,尽管它们首次创建时会按顺序执行。文档给出的例子:
/** @param {number} x */ async function one(x) { return x; } /** @param {number} y */ async function two(y) { return y; } let x = $state(1); let y = $state(2); // `b` 在 `a` 解析完成前不会被创建, // 但一旦创建,即使 `x` 和 `y` 同时变化, // 它们也会独立更新 let a = $derived(await one(x)); let b = $derived(await two(y));注意:写出这种"级联等待"的代码时,预期会收到
await_waterfall警告。
源码中该警告定义于 warnings.js:
export function await_waterfall(name, location) { if (DEV) { console.warn(`%c[svelte] await_waterfall\n%cAn async derived, \`${name}\` (${location}) was not read immediately after it resolved. This often indicates an unnecessary waterfall, which can slow down your app\nhttps://svelte.dev/e/await_waterfall`, bold, normal); // ... } }即:某个异步 derived 在解析后没有被立即读取,往往说明存在不必要的"瀑布式"等待,可能拖慢应用。该警告完整说明见 runtime-warnings 参考。
另外,AwaitExpression.js 中还有pickled_awaits的处理:当await前面还有其他表达式、或后面跟着其他表达式时,编译器会把await节点记入analysis.pickled_awaits并给表达式打上has_pickled_await标记,以便转换阶段恢复正确的反应式上下文——这是模板中{a + await b}这类混合表达式的底层支持。
指示加载态:pending 片段、$effect.pending 与 settled()
要渲染占位 UI,可以把内容包进带pending片段的<svelte:boundary>。文档(svelte-boundary.md)对其行为有明确界定:
{#snippet pending()} <!-- 首次创建时显示,后续更新不再显示 --> {/snippet}pending片段只在边界首次创建时显示;对于后续异步更新(它们是全局协调的),改用$effect.pending()。典型场景是在表单字段旁显示"正在异步校验你的输入"的 spinner。
从源码看,$effect.pending()由边界块实现:boundary.js 中,#effect_pending是一个订阅自#local_pending_count的响应式源,当计数发生变化时才通过internal_set更新,且带effect_pending_outside_reaction的错误约束(见 errors.js)——即它必须在反应式上下文中使用。
如果需要在一段代码里"等当前更新彻底完成",可以使用settled(),它返回一个在"当前更新完成"时解析的 promise。文档给出的例子:
import { tick, settled } from 'svelte'; async function onclick() { updating = true; // 没有这一步的话,`updating` 的变化会 // 与其他变化归入同一批, // 不会先反映到 UI 上 await tick(); color = 'octarine'; answer = 42; await settled(); // 此时,受 `color` 或 `answer` // 影响的更新已全部应用完毕 updating = false; }(例中color = 'red'; answer = -1; updating = false;为三个状态声明。)settled()的运行时实现非常简洁,见 runtime.js:
/** * Returns a promise that resolves once any state changes, * and asynchronous work resulting from them, have resolved * and the DOM has been updated * @returns {Promise<void>} * @since 5.36 */ export function settled() { return Batch.ensure().settled(); }即:settled()就是"当前批次(含其触发的异步工作)完成后 resolve",这也解释了为什么先await tick()再改状态、最后await settled()能保证 spinner 的显示与隐藏各自独立成帧。
错误处理
await表达式中的错误会冒泡到最近的错误边界(error boundary),即最近的<svelte:boundary>。这意味着异步逻辑不需要在每个await处手写 try/catch,边界组件统一接管失败渲染即可。
服务端渲染(SSR)
Svelte 通过render(...)API 支持异步 SSR:render(...)本身返回 promise,直接await即可:
/// file: server.js import { render } from 'svelte/server'; import App from './App.svelte'; const { head, body } = await render(App);如果使用 SvelteKit 这类框架,这一 await 由框架代劳。
SSR 场景下await的具体行为规则(见原文档):
- 若 SSR 时遇到带
pending片段的<svelte:boundary>,则渲染该pending片段,边界内其余内容被忽略; - 边界之外(或无
pending片段的边界内)遇到的所有await表达式,都会在await render(...)返回之前解析并渲染出内容。
原文档还注明:未来计划加入流式(streaming)实现,让内容在后台逐步渲染。
Forking:投机执行与预加载
fork(...)API 在 5.42 中加入,使得"你预期很快会发生"的await表达式可以提前运行。它主要面向 SvelteKit 这类框架:当用户表现出导航意图(如 hover、focus 一个链接)时先行预加载数据,从而让真正的导航"零等待"。
文档给出的完整示例——在按钮被 focus/hover 时预开菜单,指针离开则丢弃,点击时提交:
<script> import { fork } from 'svelte'; import Menu from './Menu.svelte'; let open = $state(false); /** @type {import('svelte').Fork | null} */ let pending = null; function preload() { pending ??= fork(() => { open = true; }); } function discard() { pending?.discard(); pending = null; } </script> <button onfocusin={preload} onfocusout={discard} onpointerenter={preload} onpointerleave={discard} onclick={() => { pending?.commit(); pending = null; // 以防 `pending` 不存在 // (若存在,这句是 no-op) open = true; }} >open menu</button> {#if open} <!-- 该组件内部的任何异步工作, 会在 fork 创建时就开始执行 --> <Menu onclose={() => open = false} /> {/if}Fork对象提供commit()(异步,把投机状态正式应用到 UI)与discard()(回滚并回收)两个操作。源码实现见 batch.js,其注释精确描述了语义:
/** * Creates a 'fork', in which state changes are evaluated * but not applied to the DOM. * ... * The `fn` parameter is a synchronous function that * modifies some state. The state changes will be reverted * after the fork is initialised, then reapplied if and * when the fork is eventually committed. * * When it becomes clear that a fork will _not_ be committed * (e.g. because the user navigated elsewhere), it must be * discarded to avoid leaking memory. * @since 5.42 */几个值得注意的实现细节:
- 入口校验:
fork未开启async_mode_flag时直接抛experimental_async_required错误——即它和experimental.async是绑定的; - 时机约束:
fork不能在批次(batch)执行期间调用,否则报fork_timing错误; - commit 流程:把投机期间记录的
source.v重新写回并递增 write version,主动 flush 受影响的$state.eager效果,再batch.flush()并await settled;若对已discard的 fork 调用commit,会报fork_discarded错误; - discard 流程:为 fork 中变化过的 source 递增 write version(让可能变脏的 derived 有机会重算),然后丢弃批次。
因此使用fork的关键纪律是:确定不会提交时务必discard(),否则可能泄漏内存(源码注释原话)。
注意事项与破坏性变更
注意事项:作为实验特性,await的处理细节(以及$effect.pending()等相关 API)有可能在 semver 主版本之外发生破坏性变更,官方表示会尽量把这类变更压到最小。
Effect 执行顺序的破坏性变更:当experimental.async为true时,效果的运行顺序会略有不同——{#if ...}、{#each ...}等块级效果现在会先于同一组件中的$effect.pre或beforeUpdate执行。文档指出,在极少见情况下(在 effect 内部更新状态、且更新了一个本应不再存在的块)可能出现异常,因此应避免在 effect 中更新状态(参见 $effect 文档"何时不使用 $effect" 一节)。
小结:一张能力对照表
| 需求 | 使用的手段 | 关键行为 |
|---|---|---|
| 顶层/derived/模板中使用 await | experimental.async: true | 未开启或非 runes 模式会编译报错 |
| 避免中间态闪烁 | 依赖驱动的 await 表达式 | 输入与结果同批更新,快速更新可覆盖慢速更新 |
| 并行异步工作 | 模板中独立await表达式 | 并行执行;$derived间独立更新 |
| 首次加载占位 UI | <svelte:boundary>+pending片段 | 仅首次创建时显示;SSR 时只渲染 pending |
| 后续更新的 loading 指示 | $effect.pending() | 需在反应式上下文使用 |
| 等待更新彻底完成 | settled()(5.36) | 返回当前批次完成 promise,基于Batch.ensure() |
| 错误兜底 | 最近的<svelte:boundary> | 错误向上冒泡至错误边界 |
| 异步 SSR | await render(App) | 边界外 await 全部解析后返回 |
| 预加载/投机执行 | fork(...)(5.42) | commit()应用、discard()回收,勿泄漏 |
所有行为均可在仓库中对应验证:编译器侧见 AwaitExpression.js(校验)与 flags/index.js(模式标志);运行时侧见 batch.js(fork与批次调度)、runtime.js(settled)、boundary.js($effect.pending计数)与 warnings.js(await_waterfall)。
【免费下载链接】svelteweb development for the rest of us项目地址: https://gitcode.com/GitHub_Trending/sv/svelte
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考