模板表达式一长,重复就开始出现:同一个派生值在插值、绑定、控制流里算了好几遍。@let 是 Angular v18.1 引入的模板局部变量语法,专门解决这个问题——给表达式起个名字,在一个渲染周期内只算一次。它是小语法,但用对地方能同时改善可读性与性能。
语法
@let subtotal = cart().items.reduce((sum, item) => sum + item.price, 0);
<p>小计:{{ subtotal | number: '1.2-2' }}</p>
<p>税费(10%):{{ subtotal * 0.1 | number: '1.2-2' }}</p>
@let 名称 = 表达式;,分号结尾,声明后在本块内像普通变量一样使用。- 求值时机:每个变更检测周期求值一次,同一周期内复用,不会每处引用都重算。
- 表达式可以是任意模板表达式:信号调用、方法调用、管道组合都行。
解决什么问题:避免重复调用
先看反面教材:
<!-- expensive() 被调用 3 次 -->
@if (report().summary().length > 0) {
<h3>{{ report().summary().title }}</h3>
<p>{{ report().summary().excerpt }}</p>
}
信号每次读取都会重新执行依赖链,如果 summary() 是昂贵的 computed 或方法调用,代价被乘以调用次数。用 @let 收敛:
@let summary = report().summary();
@if (summary.length > 0) {
<h3>{{ summary.title }}</h3>
<p>{{ summary.excerpt }}</p>
}
一次求值、三处复用,模板同时变短。这类”模板内的中间值”正是 @let 的主场。
与信号的配合
@let 表达式里调用信号,会正确建立依赖:信号变化 → 触发变更检测 → @let 重新求值 → 使用它的绑定同步更新。Zoneless 下没有例外,行为与在插值中直接调用信号一致。
@let visibleTodos = todos().filter(t => !t.done || showDone());
@let remaining = visibleTodos.length;
<p>待完成 {{ remaining }} 项</p>
@for (todo of visibleTodos; track todo.id) {
<app-todo-row [todo]="todo" />
}
注意 @for 的集合也用上了 visibleTodos——过滤逻辑只描述一次,避免”插值处过滤一次、循环处再过滤一次”的隐形重复。
作用域规则
@let 的作用域与块控制流的块对齐:
- 声明后,在当前块及其所有后代块内可用。
- 不能在声明之前的兄弟位置使用;不能跨到声明块之外。
- 同一个块内不能重复声明同名变量(不同块可以,内层会遮蔽外层)。
- 不能自引用(
@let a = a + 1非法),声明表达式内也不可用正在声明的名字。
@if (user(); as u) {
@let city = u.address?.city ?? '未知'; <!-- 块 A 内声明 -->
<p>{{ city }}</p> <!-- 可用 -->
@if (u.vip) {
<p>VIP 所在地:{{ city }}</p> <!-- 后代块可用 -->
}
}
<p>{{ city }}</p> <!-- 错误:越出作用域 -->
@let vs 模板引用变量 #ref
两者都能”在模板里起个名字”,但本质完全不同:
| 维度 | @let | #ref |
|---|---|---|
| 绑定对象 | 任意表达式的求值结果 | DOM 元素 / 组件 / 指令实例 |
| 求值 | 每个变更检测周期重新求值 | 引用,初始化后指向稳定对象 |
| 典型用途 | 中间值、派生数据、昂贵调用去重 | 拿 input 的 value、聚焦元素、调用子组件方法 |
| 能否调用方法 | 可以 | 不适用(它不是表达式) |
对比示例:
<input #box (keyup.enter)="add(box.value)" />
@let title = box.value.trim();
<!-- 上面的 @let 并不能跟踪输入变化!box.value 读取发生在变更检测时,
但 input 事件本身若不涉及信号,不会触发新的检查周期 -->
这个例子是常见误用:想跟踪表单控件的值,应该用信号((input)="title.set($any($event.target).value)")或 Signal Forms,而不是 @let 搭配 #ref。#ref 负责”引用”,@let 负责”表达式”,各司其职。
@let vs computed()
类里能写 computed,模板里能写 @let,派生值放哪边?按三个标准判断:
| 标准 | computed() 在类里 | @let 在模板里 |
|---|---|---|
| 复用范围 | 组件类、其他模板、单元测试 | 仅当前模板 |
| 记忆化 | 惰性记忆化,依赖不变不重算 | 每个变更检测周期求值一次 |
| 可测试性 | 直接断言信号值 | 需渲染后断言 |
| 适用 | 业务派生逻辑、多处引用 | 纯展示的中间值、模板局部简化 |
经验法则:逻辑有业务含义(金额汇总、过滤条件)→ computed,可测试可复用;逻辑只是”模板写起来太长”(深层取值、一次性格式化)→ @let。两者也可以组合:computed 算出结构,@let 在模板里取字段。
实战:订单摘要卡片
综合示例,注意 @let 之间的依赖是允许的(按声明顺序):
@let items = order().items;
@let subtotal = items.reduce((sum, it) => sum + it.price * it.qty, 0);
@let discount = subtotal > 500 ? subtotal * 0.1 : 0;
@let total = subtotal - discount + order().shipping;
<div class="summary">
<p>商品 {{ items.length }} 件</p>
<p>小计 ¥{{ subtotal | number: '1.2-2' }}</p>
@if (discount > 0) {
<p class="discount">满 500 减 ¥{{ discount | number: '1.2-2' }}</p>
}
<p class="total">应付 ¥{{ total | number: '1.2-2' }}</p>
<button type="button" [disabled]="total === 0" (click)="checkout()">结算</button>
</div>
五处派生值全部一次声明多次使用,金额规则(满减门槛)直接在模板可读。如果这个满减逻辑要在测试里覆盖,就把 discount 上移为 computed——边界判断标准上一节已经给出。
小结
@let name = expr;声明模板局部变量,每个变更检测周期求值一次,块内复用。- 核心价值:昂贵表达式去重 + 模板可读性,与信号调用配合无坑。
- 作用域对齐块控制流:当前块与后代可用,不能前置使用或跨块使用。
- 与
#ref分工:#ref引用对象,@let命名表达式;跟踪表单值请用信号。 - 与
computed分工:业务派生上移到类,展示型中间值留在模板。