Angular Signal Forms 表单逻辑实战:用 disabled、hidden、readonly、debounce 与 metadata 规则驱动字段行为
本文基于 Angular 官方仓库中的 Signal Forms 指南 Adding form logic 展开。Signal Forms 允许你在 schema(模式)中为表单字段声明式地添加逻辑:条件禁用字段、根据其他值隐藏字段、设为只读、对输入做防抖、附加自定义元数据。读完本文,你将掌握 disabled()、hidden()、readonly()、debounce()、metadata() 与 applyWhen() 规则的完整用法、选型依据,以及它们在 packages/forms/signals 源码中的实现机制。
何时为表单添加逻辑
当字段行为依赖其他字段的值、或者需要响应式地更新时,就应该使用规则(rules)。文档给出的典型场景:
- 订单总额过低时禁用的优惠券输入框
- 仅在需要配送时才显示的地址字段
- 通过防抖减少 API 调用次数的搜索框
验证逻辑在另一篇指南(Validation guide)中覆盖,本文聚焦 schema 中可用的其他规则。
规则的工作机制:响应式 when 函数
规则把响应式逻辑绑定到表单中的特定字段上。大多数条件规则接受一个带 when 函数的配置对象,when 函数在它所引用的信号发生变化时会自动重算,行为类似 computed:
const orderForm = form(this.orderModel, (schemaPath) => {
disabled(schemaPath.couponCode, {when: ({valueOf}) => valueOf(schemaPath.total) < 50});
//~~~~~~ ~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//rule path reactive logic function
});
响应式逻辑函数会收到一个 FieldContext 对象,提供 valueOf()、stateOf() 等辅助方法来访问字段值与状态,实践中通常会解构出来直接使用。
NOTE:schema 回调的参数(上述示例中的
schemaPath)是一个SchemaPathTree对象,提供表单中所有字段的路径。你可以把它命名为任意名字。
FieldContext 属性与方法的完整细节见 Validation guide。
用 disabled() 阻止字段更新
disabled() 规则配置字段的禁用状态。它与 [formField] 指令协作,自动基于字段状态绑定 HTML disabled 属性,因此你不需要在模板里手写 [disabled]="yourForm.fieldName().disabled()"。
NOTE:禁用字段会跳过验证——不参与表单验证检查。字段值会被保留但不会被验证,详见 Validation guide。
永久禁用
只需要字段路径即可让字段永久禁用:
import {Component, signal} from '@angular/core';
import {form, FormField, disabled} from '@angular/forms/signals';
@Component({
selector: 'app-settings',
imports: [FormField],
template: `
<label>
System ID (cannot be changed)
<input [formField]="settingsForm.systemId" />
</label>
`,
})
export class Settings {
settingsModel = signal({
systemId: 'SYS-12345',
userName: '',
});
settingsForm = form(this.settingsModel, (schemaPath) => {
disabled(schemaPath.systemId);
});
}
从源码 disabled.ts 可以确认这一行为:当 config 未提供时,内部逻辑默认为 true,即无条件生成一条禁用原因记录(addDisabledReasonRule),when 结果非 undefined 时都会向父字段状态贡献 disabled 状态。
条件禁用
提供 when 函数,返回 true(禁用)或 false(启用):
import {Component, signal} from '@angular/core';
import {form, FormField, disabled} from '@angular/forms/signals';
@Component({
selector: 'app-order',
imports: [FormField],
template: `
<label>
Order Total
<input type="number" [formField]="orderForm.total" />
</label>
<label>
Coupon Code
<input [formField]="orderForm.couponCode" />
</label>
`,
})
export class Order {
orderModel = signal({
total: 25,
couponCode: '',
});
orderForm = form(this.orderModel, (schemaPath) => {
disabled(schemaPath.couponCode, {when: ({valueOf}) => valueOf(schemaPath.total) < 50});
});
}
在这个示例中,当订单总额低于 50 时,优惠券字段被禁用。
禁用原因(disabled reasons)
禁用字段时可以返回字符串而不是 true,提供面向用户的解释:
import {Component, signal} from '@angular/core';
import {form, FormField, disabled} from '@angular/forms/signals';
@Component({
selector: 'app-order',
imports: [FormField],
template: `
<label>
Order Total
<input type="number" [formField]="orderForm.total" />
</label>
<label>
Coupon Code
<input [formField]="orderForm.couponCode" />
</label>
@if (orderForm.couponCode().disabled()) {
<div class="info">
@for (reason of orderForm.couponCode().disabledReasons(); track reason) {
<p>{{ reason.message }}</p>
}
</div>
}
`,
})
export class Order {
orderModel = signal({
total: 25,
couponCode: '',
});
orderForm = form(this.orderModel, (schemaPath) => {
disabled(schemaPath.couponCode, {
when: ({valueOf}) =>
valueOf(schemaPath.total) < 50 ? 'Order must be $50 or more to use a coupon' : false,
});
});
}
when 函数的返回值约定:
- 返回字符串:以该原因为禁用字段
- 返回
false表示启用字段(必须是显式的false,而不是任意假值)
原因通过字段状态上的 disabledReasons() 信号读取,每条原因带有 message 属性。对应源码中,when 返回字符串时会构造 {fieldTree, message} 结构,返回真值(非字符串)时仅记录 fieldTree 而不带消息——这解释了为什么 true 与字符串在展示效果上不同。
多条禁用原因
可以在同一字段上多次调用 disabled(),所有返回的原因会累积:
orderForm = form(this.orderModel, (schemaPath) => {
disabled(schemaPath.promoCode, {
when: ({valueOf}) =>
!valueOf(schemaPath.hasAccount) ? 'You must have an account to use promo codes' : false,
});
disabled(schemaPath.promoCode, {
when: ({valueOf}) => (valueOf(schemaPath.total) < 25 ? 'Order must be at least $25' : false),
});
});
若两个条件同时成立,字段会展示两条禁用原因。这个模式适合把复杂的可用性规则拆分成互相独立的条件。
用 hidden() 配置字段隐藏状态
hidden() 规则只设置程序化状态——字段是否在 UI 中出现由你控制。
IMPORTANT:与
disabled和readonly不同,DOM 没有原生的hidden属性对应字段级隐藏语义,[formField]指令不会向元素应用hidden属性。你必须使用模板中的@if或 CSS 基于hidden()状态条件渲染字段。
NOTE:与禁用字段一样,隐藏字段也会跳过验证。
基本隐藏
when 返回 true(隐藏)或 false(可见):
import {Component, signal} from '@angular/core';
import {form, FormField, hidden} from '@angular/forms/signals';
@Component({
selector: 'app-profile',
imports: [FormField],
template: `
<label>
<input type="checkbox" [formField]="profileForm.isPublic" />
Make profile public
</label>
@if (!profileForm.publicUrl().hidden()) {
<label>
Public URL
<input [formField]="profileForm.publicUrl" />
</label>
}
`,
})
export class Profile {
profileModel = signal({
isPublic: false,
publicUrl: '',
});
profileForm = form(this.profileModel, (schemaPath) => {
hidden(schemaPath.publicUrl, {when: ({valueOf}) => !valueOf(schemaPath.isPublic)});
});
}
永久隐藏
只传字段路径即可永久隐藏:
import {Component, signal} from '@angular/core';
import {form, FormField, hidden} from '@angular/forms/signals';
@Component({
selector: 'app-profile',
imports: [FormField],
template: `
<label>
<input type="checkbox" [formField]="profileForm.isPublic" />
Make profile public
</label>
<!-- The publicUrl is permanently hidden, so this block never renders -->
@if (!profileForm.publicUrl().hidden()) {
<label>
Public URL
<input [formField]="profileForm.publicUrl" />
</label>
}
`,
})
export class Profile {
profileModel = signal({
isPublic: false,
publicUrl: '',
});
profileForm = form(this.profileModel, (schemaPath) => {
// This field is now permanently hidden and excluded from active validation
hidden(schemaPath.publicUrl);
});
}
对应源码 hidden.ts 中可以看到:未提供 when 时逻辑被替换为 () => true,与文档"只传路径即永久隐藏"的语义一致。
用 readonly() 展示不可编辑字段
readonly() 规则阻止用户更新字段。[FormField] 指令自动把该状态绑定到 HTML readonly 属性——用户仍可聚焦并选择文本,只是无法编辑。
NOTE:只读字段同样跳过验证。
永久只读
import {Component, signal} from '@angular/core';
import {form, FormField, readonly} from '@angular/forms/signals';
@Component({
selector: 'app-account',
imports: [FormField],
template: `
<label>
Username (cannot be changed)
<input [formField]="accountForm.username" />
</label>
<label>
Email
<input [formField]="accountForm.email" />
</label>
`,
})
export class Account {
accountModel = signal({
username: 'johndoe',
email: 'john@example.com',
});
accountForm = form(this.accountModel, (schemaPath) => {
readonly(schemaPath.username);
});
}
[FormField] 指令会根据字段状态自动绑定 readonly 属性。
条件只读
import {Component, signal} from '@angular/core';
import {form, FormField, readonly} from '@angular/forms/signals';
@Component({
selector: 'app-document',
imports: [FormField],
template: `
<label>
<input type="checkbox" [formField]="documentForm.isLocked" />
Lock document
</label>
<label>
Document Title
<input [formField]="documentForm.title" />
</label>
`,
})
export class Document {
documentModel = signal({
isLocked: false,
title: 'Untitled',
});
documentForm = form(this.documentModel, (schemaPath) => {
readonly(schemaPath.title, {when: ({valueOf}) => valueOf(schemaPath.isLocked)});
});
}
当 isLocked 为 true 时,标题字段变为只读。
hidden、disabled、readonly 三选一
这三个函数以不同方式控制字段可用性,选择依据如下:
选择 hidden(),当字段:
- 不应出现在 UI 中
- 与当前表单状态无关
- 例:"与账单地址相同"勾选后的配送地址字段
选择 disabled(),当字段:
- 应可见但不可编辑
- 需要展示不可用的原因(disabled reasons)
- 应被排除在 HTML 表单提交之外
- 例:表单有效前禁用的提交按钮、非管理员禁用的审批字段
选择 readonly(),当字段:
- 应可见但不可编辑
- 包含用户需要查看、选择或复制的数据
- 应包含在 HTML 表单提交中
- 例:订单确认号、系统生成的参考码
三者激活时都跳过验证并阻止编辑,关键差异如下:
| 特性 | hidden() |
disabled() |
readonly() |
|---|---|---|---|
| 在 UI 中可见 | 否 | 是 | 是 |
| 用户可聚焦/选择 | 否 | 否 | 是 |
| 包含在 HTML 表单提交中 | 否 | 否 | 是 |
用 debounce() 延迟输入操作
debounce() 规则延迟表单模型的更新,适用于性能优化与减少快速输入期间的不必要操作。
防抖在做什么
没有防抖时,每次击键都会立即更新表单模型,可能触发:
- 依赖模型值、每次变化都重算的昂贵 computed 信号
- 每个字符后的验证检查
- 与模型值绑定的 API 调用或其他副作用
防抖通过延迟这些更新减少无谓开销。
基本防抖
指定毫秒数即可:
import {Component, signal} from '@angular/core';
import {form, FormField, debounce} from '@angular/forms/signals';
@Component({
selector: 'app-search',
imports: [FormField],
template: `
<label>
Search
<input [formField]="searchForm.query" />
</label>
<p>Searching for: {{ searchForm.query().value() }}</p>
`,
})
export class Search {
searchModel = signal({
query: '',
});
searchForm = form(this.searchModel, (schemaPath) => {
debounce(schemaPath.query, 300);
});
}
300ms 防抖的行为:用户输入时,模型仅在停止输入 300ms 后更新;持续输入会重置计时器;停顿 300ms 后模型以最终值更新。
源码层面的补充:在 debounce.ts 中,debounce() 的配置参数实际上是 number | 'blur' | Debouncer 三种形态:
- 正数毫秒 → 内部构造基于
setTimeout的 debouncer,且监听 abort 信号,保证被中止时立即解除延迟(见下文"时序保证"); - 字符串
'blur'→ 返回一个永不自行 resolve 的 Promise,依赖节点在字段失焦时同步挂起的更新(配合自定义FormValueControl使用); - 数值
0→ 返回immediate,即不延迟直接同步。
也就是说,文档示例中的 debounce(schemaPath.query, 300) 只是最常用的一种形态。
时序保证
debounce() 通过以下机制保证用户不会丢失数据:
- 标记为 touched 时:值立即同步,任何挂起的防抖延迟被中止。这发生在字段失焦(blur)或被显式标记为 touched 时。
- 表单提交时:验证前所有字段都会被标记为 touched,确保所有防抖值立即同步。
用户因此可以快速输入、Tab 切走或直接提交表单,而无需等待防抖延迟结束。从源码看,这一保证由 abortSignal 机制实现:每次新值到来都会中止上一次未完成的延迟,abort 触发时 onAbort 回调清除定时器并立即 resolve Promise。
自定义防抖逻辑
需要更精细的控制时,传入一个 debouncer 函数——它会在每次控件值更新时被调用,可以返回 undefined(立即同步)或一个 Promise(在 resolve 前阻止同步):
import {Component, signal} from '@angular/core';
import {form, FormField, debounce} from '@angular/forms/signals';
@Component({
selector: 'app-search',
imports: [FormField],
template: `
<label>
Search
<input [formField]="searchForm.query" />
</label>
`,
})
export class Search {
searchModel = signal({
query: '',
});
searchForm = form(this.searchModel, (schemaPath) => {
debounce(schemaPath.query, () => {
// Return a promise that resolves after 500ms
return new Promise<void>((resolve) => {
setTimeout(() => resolve(), 500);
});
});
});
}
debouncer 函数可返回:
undefined:立即同步值Promise<void>:在其 resolve 前阻止同步
自定义逻辑的适用场景:超出简单延迟的自定义计时、与外部事件协调同步、基于应用状态的有条件防抖。
何时使用防抖
适合使用:
- 存在依赖字段值的昂贵 computed 信号
- 字段会触发 API 调用或其他副作用
- 希望减少快速输入期间的验证开销
- 性能剖析显示模型更新导致卡顿
不适合使用:
- 字段需要即时更新以保证 UX(如计算器输入)
- 性能收益微乎其微
- 用户预期实时反馈
用 metadata() 为字段关联数据
元数据把响应式数据附加到字段上。验证规则内部就使用这套系统,你也可以发布自己的键来承载帮助文本、配置或计算展示值等应用级信息。
Signal Forms 内置了预定义元数据键,由内建验证器自动填充:
| 键 | 填充来源 | 读取方式 |
|---|---|---|
REQUIRED |
required() |
field().required() |
MIN |
min()、minDate() |
field().min() |
MAX |
max()、maxDate() |
field().max() |
MIN_LENGTH |
minLength() |
field().minLength() |
MAX_LENGTH |
maxLength() |
field().maxLength() |
PATTERN |
pattern() |
field().pattern() |
[formField] 指令会自动把其中五个(REQUIRED、MIN、MAX、MIN_LENGTH、MAX_LENGTH)绑定到原生表单控件的对应 HTML 属性。PATTERN 是例外,因为 Signal Forms 支持一个字段多个 pattern,而 HTML pattern 属性只接受单个正则。
import {Component, signal} from '@angular/core';
import {form, FormField, required, min, max} from '@angular/forms/signals';
@Component({
selector: 'app-age',
imports: [FormField],
template: `
<label>
Age (between {{ ageForm.age().min?.() }} and {{ ageForm.age().max?.() }})
<input type="number" [formField]="ageForm.age" />
</label>
@if (ageForm.age().required()) {
<span class="required-indicator">*</span>
}
`,
})
export class Age {
ageModel = signal({age: 0});
ageForm = form(this.ageModel, (schemaPath) => {
required(schemaPath.age);
min(schemaPath.age, 18);
max(schemaPath.age, 120);
});
}
响应式元数据
验证规则可以从其他字段派生约束,使发布的元数据具有响应性:
import {Component, signal} from '@angular/core';
import {form, FormField, max} from '@angular/forms/signals';
@Component({
selector: 'app-inventory',
imports: [FormField],
template: `
<label>
Item
<select [formField]="inventoryForm.item">
<option value="widget">Widget</option>
<option value="gadget">Gadget</option>
</select>
</label>
<label>
Quantity (max: {{ inventoryForm.quantity().max?.() }})
<input type="number" [formField]="inventoryForm.quantity" />
</label>
`,
})
export class Inventory {
inventoryModel = signal({
item: 'widget',
quantity: 0,
});
inventoryForm = form(this.inventoryModel, (schemaPath) => {
max(schemaPath.quantity, ({valueOf}) => {
const item = valueOf(schemaPath.item);
return item === 'widget' ? 100 : 50;
});
});
}
max() 验证规则根据所选商品响应式地设置 MAX 元数据,因此任何读取 field().max() 的模板或控件都会在商品切换时更新。
源码层面的机制:在 metadata.ts 中,metadata(path, key, logic) 把值写入规则,但同一条目上多个规则对同一键的贡献会通过该键自带的 reducer 归并成最终信号值。框架内置了 MetadataReducer 的一组现成归并策略:list()(累积成数组,PATTERN 即用它支持多正则)、min()/max()(取极值)、or()/and()(逻辑归并,REQUIRED 用 or() 实现"任一规则声明 required 即必填")、override(后写覆盖先写,createMetadataKey() 的默认行为)。自定义键则通过 createMetadataKey(reducer) 或需要生命周期管理对象的 createManagedMetadataKey(create, reducer) 创建,详见 Field metadata guide。
组合规则
可以往同一字段应用多条规则,也可以用条件逻辑基于表单状态应用整组规则。
一个字段上的多条规则
import {Component, signal} from '@angular/core';
import {form, FormField, disabled, hidden, debounce, metadata} from '@angular/forms/signals';
import {PLACEHOLDER} from './metadata-keys';
@Component({
selector: 'app-promo',
imports: [FormField],
template: `
@if (!promoForm.promoCode().hidden()) {
<label>
Promo Code
<input [formField]="promoForm.promoCode" />
</label>
}
`,
})
export class Promo {
promoModel = signal({
hasAccount: false,
subscriptionType: 'free' as 'free' | 'premium',
promoCode: '',
});
promoForm = form(this.promoModel, (schemaPath) => {
disabled(schemaPath.promoCode, {
when: ({valueOf}) => (!valueOf(schemaPath.hasAccount) ? 'You must have an account' : false),
});
hidden(schemaPath.promoCode, {
when: ({valueOf}) => valueOf(schemaPath.subscriptionType) === 'free',
});
debounce(schemaPath.promoCode, 300);
metadata(schemaPath.promoCode, PLACEHOLDER, () => 'Enter promo code');
});
}
这些规则协同工作的优先级:
- hidden 优先——字段隐藏时,disabled 状态不再有意义
- disabled 阻止编辑——与 readonly 状态无关
- debounce 影响模型更新——与其他状态无关
- metadata 相互独立——始终可用
用 applyWhen 做条件逻辑
用 applyWhen() 按条件应用整组规则:
import {Component, signal} from '@angular/core';
import {form, FormField, applyWhen, required, pattern} from '@angular/forms/signals';
@Component({
selector: 'app-address',
imports: [FormField],
template: `
<label>
Country
<select [formField]="addressForm.country">
<option value="US">United States</option>
<option value="CA">Canada</option>
</select>
</label>
<label>
Zip/Postal Code
<input [formField]="addressForm.zipCode" />
</label>
`,
})
export class Address {
addressModel = signal({
country: 'US',
zipCode: '',
});
addressForm = form(this.addressModel, (schemaPath) => {
applyWhen(
schemaPath,
({valueOf}) => valueOf(schemaPath.country) === 'US',
(schemaPath) => {
// Only applied when country is US
required(schemaPath.zipCode);
pattern(schemaPath.zipCode, /^\d{5}(-\d{4})?$/);
},
);
});
}
applyWhen() 依次接收:
- 应用逻辑的目标路径(通常是表单根路径)
- 返回
true(应用)或false(不应用)的响应式逻辑函数 - 定义条件规则的 schema 函数
条件规则只在条件为真时执行,适合验证规则或行为随用户选择变化的复杂表单。从实现看(structure.ts 中的 applyWhen),它把子 schema 以条件函数为约束合并进目标路径节点,因此条件为真时整棵规则子树才参与生效;同文件还导出了 applyWhenValue(),可基于类型守卫对字段值收窄类型后再应用 schema。
可复用的 schema 函数
把常见规则配置抽成可复用函数:
import {SchemaPath, debounce, metadata, maxLength} from '@angular/forms/signals';
import {PLACEHOLDER} from './metadata-keys';
function emailFieldConfig(path: SchemaPath<string>) {
debounce(path, 300);
metadata(path, PLACEHOLDER, () => 'user@example.com');
maxLength(path, 255);
}
// Use in multiple forms
const contactForm = form(contactModel, (schemaPath) => {
emailFieldConfig(schemaPath.email);
emailFieldConfig(schemaPath.alternateEmail);
});
const registrationForm = form(registrationModel, (schemaPath) => {
emailFieldConfig(schemaPath.email);
});
当多个表单共享标准字段配置时,这一模式能显著减少重复。
小结与延伸阅读
本文覆盖的规则函数(disabled、hidden、readonly、debounce、metadata、applyWhen 等)在源码中统一实现于 packages/forms/signals/src/api/rules/ 目录下,并以 @publicApi 22.0 对外发布,规则最终通过 public_api.ts 从 @angular/forms/signals 导出。模板侧的自动绑定(disabled/readonly 属性、验证约束属性)由 [formField] 指令完成,实现见 form_field.ts。
继续深入 Signal Forms 时可参考同目录下的相关指南:
- Field State Management:学习在模板和组件逻辑中使用这些函数创建的状态信号
- Validation:验证规则与错误处理
- Custom Controls:自定义控件如何读取元数据与状态自动配置自身
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 StartedRust0627
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