首页
/ Angular Signal Forms 表单逻辑实战:用 disabled、hidden、readonly、debounce 与 metadata 规则驱动字段行为

Angular Signal Forms 表单逻辑实战:用 disabled、hidden、readonly、debounce 与 metadata 规则驱动字段行为

2026-09-06 17:25:15作者:咎竹峻Karen

本文基于 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:与 disabledreadonly 不同,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)});
  });
}

isLockedtrue 时,标题字段变为只读。

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] 指令会自动把其中五个(REQUIREDMINMAXMIN_LENGTHMAX_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()(逻辑归并,REQUIREDor() 实现"任一规则声明 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() 依次接收:

  1. 应用逻辑的目标路径(通常是表单根路径)
  2. 返回 true(应用)或 false(不应用)的响应式逻辑函数
  3. 定义条件规则的 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);
});

当多个表单共享标准字段配置时,这一模式能显著减少重复。

小结与延伸阅读

本文覆盖的规则函数(disabledhiddenreadonlydebouncemetadataapplyWhen 等)在源码中统一实现于 packages/forms/signals/src/api/rules/ 目录下,并以 @publicApi 22.0 对外发布,规则最终通过 public_api.ts@angular/forms/signals 导出。模板侧的自动绑定(disabled/readonly 属性、验证约束属性)由 [formField] 指令完成,实现见 form_field.ts

继续深入 Signal Forms 时可参考同目录下的相关指南:

登录后查看全文
热门项目推荐
相关项目推荐