CHARLIE SAYS

查理如是说
DATE 2026-08-24
THEME
SERIES / ANGULAR / P-173 · Angular 高级教程

Angular 22+ 教程 09:@let 模板局部变量

模板表达式一长,重复就开始出现:同一个派生值在插值、绑定、控制流里算了好几遍。@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 分工:业务派生上移到类,展示型中间值留在模板。

系列导航

← 算法 009:插入排序(Insertion Sort) 目录 开源合规 009:能力成熟度模型集成认证 - CMMI →
← 返回文章列表