Svelte 声明标签({let/const ...})详解:模板内局部变量、作用域规则与编译器实现链路
声明标签(Declaration Tags)是 Svelte 5 模板语法中定义局部变量的新机制:可以直接在模板任意位置用 {const ...} 和 {let ...} 声明变量,让标记逻辑不再依赖冗长的表达式嵌套。本文基于 Svelte 官方文档 11-declaration-tags.md,结合当前仓库 packages/svelte 中解析、分析、代码生成三阶段的编译器源码,完整讲解声明标签的语法、作用域、响应式用法、限制条件,以及它在 Svelte 编译器内部的实际实现链路。
一、声明标签是什么
声明标签使用 const 或 let 在标记(markup)内部定义局部变量:
<!--- file: App.svelte --->
<script>
let boxes = [{ width: 10, height: 10 }, { width: 15, height: 15 }];
</script>
{#each boxes as box}
{const area = box.width * box.height}
{const label = `${box.width} × ${box.height} = ${area}`}
<p>{label}</p>
{/each}
几个关键要点(均来自官方文档原文说明):
- 版本要求:声明标签自 Svelte 5.56 起可用;
- 与
{@const}的关系:传统语法{@const ...}已被视为 legacy(遗留语法),官方建议改用声明标签; let与const的区别:const声明只读常量;let声明可被重新赋值(文档中 reactive 示例里的bind:value就绑定在let声明上),这是{@const}从未提供过的能力——{@const}只能声明常量,且只允许作为块({#if}、{#each}、{#snippet}等)、组件或<svelte:boundary>的直接子节点,而声明标签可以出现在组件内部的任何位置。
仓库中的运行时测试 declaration-tags、declaration-tags-each、declaration-tags-no-script 分别覆盖了基础用法、{#each} 内使用、以及无 <script> 块等场景,可以按目录下的 _config.js 验证断言。
二、响应式声明标签:let + $state + $derived
当声明的值需要参与响应式系统时,可以在声明标签内部直接使用 $state 和 $derived runes:
<!--- file: App.svelte --->
<script>
let user = $state({ name: 'Svelte' });
let editing = $state(false);
</script>
<p>Hello {user.name}</p>
<button onclick={() => editing = true}>edit name</button>
{#if editing}
{let name = $state(user.name)}
{const greeting = $derived(`Hello ${name}`)}
<hr>
<input bind:value={name} />
<p>{greeting}</p>
<button onclick={() => {
user.name = name;
editing = false;
}}>save</button>
{/if}
这个示例体现了声明标签的几个实战价值:
- 局部可编辑状态:
{let name = $state(user.name)}把父级 state 的一份副本提升为块内局部可写状态,<input bind:value={name} />直接绑定它; - 块内派生值:
{const greeting = $derived(...)}跟随name变化自动重算,无需在<script>里为一次性展示逻辑开辟额外变量; - 块级生命周期:由于声明位于
{#if editing}内,退出编辑状态时该局部状态随块销毁,不会泄漏到组件作用域。
此外,同一个声明标签中也可以包含多个声明符(declarator),且后一个声明符可以引用前一个——这一点在客户端代码生成中有明确注释说明,见 DeclarationTag.js:
// register the transformers _before_ visiting the declaration, so that
// later declarators can reference earlier ones
// (e.g. `{let a = $state(0), b = $derived(a * 2)}`)
对应的测试样例位于 declaration-tag-multiple-declarators。
三、作用域规则:词法作用域,块级可见性
官方文档明确了作用域语义:声明标签可以使用在组件内部的任何位置;可以引用自身外部声明的值(例如 <script> 标签内或 {#each ...} 块中的值);它对同一词法作用域内的所有代码可见(即兄弟节点,以及兄弟节点的子孙节点)。
文档给出的作用域示例完整如下:
<!--- file: App.svelte --->
{const hello = 'hello'}
{hello} <!-- 'hello' -->
<div>
{const hello = 'hi'}
{hello} <!-- 'hi' -->
<div>
{hello} <!-- 'hi' -->
</div>
</div>
{hello} <!-- 'hello' -->
可以归纳出三条规则:
- 顶层
{const hello = 'hello'}在整个模板顶层词法作用域可见(第一行{hello}与结尾{hello}都输出'hello'); - 嵌套
<div>内的{const hello = 'hi'}遮蔽(shadow) 外层同名变量,且对<div>的兄弟与后代都可见(内层两处都输出'hi'); - 遮蔽只在词法范围内生效,出了嵌套
<div>又恢复为'hello'。
在顶层作用域,声明标签不能与 <script> 实例作用域中的变量重名。分析阶段的 DeclarationTag visitor 会检测这种情况并抛出 declaration_duplicate 错误(报错文案见 compile-errors/script.md:%name% has already been declared):
const is_top_level = context.path.length === 1 && context.path[0].type === 'Fragment';
if (is_top_level) {
const duplicate = node.declaration.declarations
.flatMap((declaration) => extract_identifiers(declaration.id))
.find((id) => context.state.analysis.instance.scope.declarations.has(id.name));
if (duplicate) {
e.declaration_duplicate(duplicate, duplicate.name);
}
}
四、限制条件与编译错误一览
结合编译器源码与错误消息定义文件,声明标签的约束可以归纳为以下清单:
| 约束 | 依据(源码/错误消息位置) | 报错信息 |
|---|---|---|
只允许 let 或 const 声明,var/interface/enum 等一律拒绝 |
parse/state/tag.js 中的 regex_supported_declaration = /(?:let|const)\b/y 与 regex_unsupported_declaration = /(?:var|interface|enum)\b/y |
declaration_tag_invalid_type:Declaration tags must be let or const declarations(见 compile-errors/template.md) |
| 不能用于 legacy 模式(非 runes 组件) | 2-analyze/visitors/DeclarationTag.js:if (!runes && !maybe_runes) e.declaration_tag_no_legacy_mode(node) |
declaration_tag_no_legacy_mode:Declaration tags cannot be used in legacy mode |
| 顶层不能与实例脚本变量重名 | 见上文分析阶段代码 | declaration_duplicate:%name% has already been declared |
| 解析失败时的宽松回退(loose 模式) | tag.js 的 read_declaration 在 loose 模式下会构造占位 VariableDeclaration 以继续解析 |
见 loose-declaration-tag 解析测试 |
其中值得注意的一个实现细节:解析器对 type 关键字采取"先看形状、再确认解析"的策略。由于 type 不是保留字,它可以合法地出现在表达式中(例如属性访问 x.type),因此 tag.js 中把 type 单独列为"疑似类型声明"的正则(regex_maybe_type_declaration),先尝试用语句解析器完整解析,若结果是 TSTypeAliasDeclaration 才报 declaration_tag_invalid_type 错误,否则回退为普通表达式。
另外,declaration-tag-trailing-slash 这个编译错误样例验证了 {const ...} 后误写斜杠等语法形态会被正确拒绝。
五、编译器实现链路:从解析到代码生成
从源码结构看,声明标签的处理贯穿 Svelte 编译器三个标准阶段(packages/svelte/src/compiler/phases/):
1. 解析阶段(1-parse)
模板遇到 { 后进入 tag state。tag() 入口先排除 {#...}、{...}、{@...}、{/...} 等分支,然后调用 read_declaration() 判断是否以 let/const 开头;若是,则用内嵌的语句解析器(parse_statement_at)解析出标准 ESTree VariableDeclaration,最终生成 DeclarationTag AST 节点并挂上空的 ExpressionMetadata。非声明开头则继续按普通 {expression} 处理。
2. 分析阶段(2-analyze)
DeclarationTag visitor 完成三件事:
- 拒绝 legacy 模式使用(见上节);
- 检查顶层重复声明(见第三节);
- 以
in_declaration_tag: true标记访问声明体,并把function_depth对齐到 fragment 作用域——源码注释解释这是为了让state_referenced_locally警告("This reference only captures the initial value of %name%")计算正确; - 调用
mark_async_declaration():如果声明表达式中包含await(metadata.expression.has_await),或其依赖项中存在 async blocker(闭包读取等),就把该声明纳入async_consts分组,生成promises_id并为所有绑定设置blocker,供后续 await 块机制统一处理。
3. 代码生成阶段(3-transform)
客户端与服务器端各有一个 visitor,结构高度对称:
- 客户端 DeclarationTag:先注册
add_state_transformers(支持$state/$derived转换,且保证同一声明内后一声明符可引用前一声明符),再访问声明体;若存在promises_id则走build_async_declaration_parts+add_async_declaration把声明拆成"先声明let id,再在 async thunk 里赋值"的形式,并把依赖的 promise 通过$.wait等待;否则直接推入context.state.consts; - 服务器端 DeclarationTag:逻辑相同,但非异步声明推入
context.state.init(SSR 渲染函数体内的初始化语句),异步路径使用Promise.all代替客户端的$.wait。
这与 async-declaration-tag 及 async-const-closure-read 等运行时测试相互印证:含 await 或读取 await 块内部闭包值的声明标签,会被编译进对应的 async 声明运行(promises 分组),读取方通过 blocker 机制阻塞到值就绪。
六、{@const} 与声明标签的迁移对照
{@const ...} 的官方文档已将其标记为 legacy,并指向声明标签作为替代。对照如下:
| 维度 | {@const x = y} |
{const x = y} / {let x = y} |
|---|---|---|
| 可变性 | 仅常量 | const 只读,let 可重新赋值并可 bind:value |
| 允许位置 | 仅限块、组件、<svelte:boundary> 的直接子节点 |
组件内任意位置 |
| 响应式 | 依赖所在块的依赖跟踪 | 可直接使用 $state、$derived |
| 状态 | legacy,官方建议使用声明标签 | Svelte 5.56+ 现行语法 |
迁移方式通常是一比一的:把块内的 {@const area = box.width * box.height} 替换为 {const area = box.width * box.height} 即可;需要派生语义时可写作 {const x = $derived(y)}(这正是官方文档在 @const 页给出的替代写法)。
七、使用建议
- 优先用声明标签简化模板:把
{#each}内反复出现的计算(面积、格式化文案、分支变量)提升为{const ...},可读性优于内嵌三元与模板串; - 需要局部可编辑状态时用
let+$state:编辑弹窗、草稿输入等场景可把块级$state声明当作"作用域受限的表单状态"; - 注意词法遮蔽:内层同名声明会遮蔽外层,调试输出(如示例中
<!-- 'hi' -->注释)时先确认当前词法层级; - runs 模式前提:声明标签要求组件使用 runes 模式(或至少
maybe_runes),在纯 legacy 组件中会得到declaration_tag_no_legacy_mode编译错误,跨模式代码库中需要留意。
以上所有行为均可在当前仓库中复核:语法文档见 11-declaration-tags.md,运行时行为见 runtime-runes 声明标签测试样例,编译器错误文案见 compile-errors/template.md 与 compile-errors/script.md。
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