首页
/ Angular Signal Forms 字段元数据系统(Field Metadata):元数据键、Reducer 与受管元数据

Angular Signal Forms 字段元数据系统(Field Metadata):元数据键、Reducer 与受管元数据

2026-09-06 17:13:28作者:段琳惟

本篇指南讲解 Angular 信号表单(@angular/forms/signals)中的字段元数据系统:如何用 createMetadataKey() 定义可附着在单个字段上的响应式数据、如何在 schema 中通过 metadata() 规则写入值、如何用 reducer 组合多条规则对同一键的贡献,以及如何用受管元数据(managed metadata)为字段挂载带生命周期管理的对象(如 httpResource)。读完后你将能独立构建随字段走、随 schema 组合、随字段销毁自动清理的响应式字段数据。

你其实一直在使用元数据

字段元数据(field metadata)是可以附着到单个字段上的响应式数据。required()min() 等内置约束验证器在内部正是依赖这套系统工作的:每当你调用一个验证器,你实际上是在为对应字段的一个元数据键(metadata key)贡献一个值。

import {Component, signal} from '@angular/core';
import {form, required, FormField} from '@angular/forms/signals';

@Component({
  selector: 'app-registration',
  imports: [FormField],
  template: `
    <form>
      <label>
        Username
        @if (registrationForm.username().required()) {
          <span class="required-marker" aria-hidden="true">*</span>
        }
        <input [formField]="registrationForm.username" />
      </label>
    </form>
  `,
})
export class Registration {
  registrationModel = signal({username: ''});

  registrationForm = form(this.registrationModel, (path) => {
    required(path.username);
  });
}

调用 required(path.username) 就是向该字段的 REQUIRED 元数据键贡献了一个值;在模板中读取 registrationForm.username().required() 则拿到了这个键累积后的当前值。元数据键是连接"写入端"和"读取端"的桥梁。

需要特别说明的是,state.required 并不是什么特殊属性。从源码 packages/forms/signals/src/field/node.ts 可以看到,required 只是一个便捷 getter:

get required(): Signal<boolean> {
  return this.metadata(REQUIRED) ?? FALSE;
}

它返回的正是内置 REQUIRED 元数据键的当前值(未注册时回退为 FALSE 常量)。多个内置约束验证器遵循同样的模式:

验证器 元数据键 值类型 FieldState getter
required() REQUIRED boolean required
min() MIN 选择 MIN_NUMBER number | undefined min
max() MAX 选择 MAX_NUMBER number | undefined max
minDate() MIN 选择 MIN_DATE Date | undefined min
maxDate() MAX 选择 MAX_DATE Date | undefined max
minLength() MIN_LENGTH number | undefined minLength
maxLength() MAX_LENGTH number | undefined maxLength
pattern() PATTERN RegExp[] pattern

email()validate() 这类非约束型验证器则不贡献任何元数据:它们执行检查、产生验证错误,但不会发布可供模板读取的响应式值。

什么时候该用自定义元数据

当你需要在某个特定字段上附着响应式数据,而内置状态信号(如 valid()disabled()touched())无法覆盖时,就应该使用自定义元数据。典型场景包括:

  • 附着在可复用字段 schema 上的配置。例如价格字段上的货币符号,任何渲染该字段的模板或自定义控件都能读到并展示;或者日期字段上的 MIN_DATE / MAX_DATE,供可复用的范围选择器读取。
  • 在同一字段的多条规则间共享解析后的值。例如把电话号码一次性解析为 E.164 格式,让"格式校验规则"和"唯一性检查规则"都读取同一份规范形式,避免重复解析。
  • 从字段状态组装的展示提示。例如一个严重级别('info' | 'warning' | 'error'),UI 据此映射为徽章和图标;或根据用户输入和其他已填写字段动态变化的上下文帮助文案。

如果你发现自己在表单旁边维护了一个并行的 Map<fieldKey, value> 来跟踪每个字段的信息,那就是元数据是正确工具的信号。元数据与 schema 同处一地、天然是响应式的,并且参与字段的整个生命周期。

创建元数据键

使用 createMetadataKey<TWrite>() 创建自定义键。类型参数描述你的 schema 规则将要贡献的值类型:

import {createMetadataKey} from '@angular/forms/signals';

export const USERNAME_HELP = createMetadataKey<string>();

每次调用 createMetadataKey() 都会创建一个全新的唯一键。即便两次调用的类型参数完全相同,它们仍然是两个不同的键——所以请把每个键在模块作用域定义一次,然后在需要的地方导入使用。

注意:不传 reducer 创建的键默认使用"覆盖"(override)语义:多条规则设置同一键时,最后一条贡献生效。

这一点在源码中可以直接印证:packages/forms/signals/src/api/rules/metadata.tscreateMetadataKey 的实现是 reducer ?? MetadataReducer.override<any>()——未显式提供 reducer 时,回退到覆盖式 reducer。

在 schema 中写入值

要在特定字段上为某个键注册值,请在 schema 函数内使用 metadata(path, key, logic)

import {Component, computed, signal} from '@angular/core';
import {form, metadata, FormField} from '@angular/forms/signals';
import {USERNAME_HELP} from './metadata-keys';

@Component({
  selector: 'app-registration',
  imports: [FormField],
  template: `
    <form>
      <label>
        Username
        <input [formField]="registrationForm.username" />
      </label>
      <p class="help">{{ usernameHelp() }}</p>
    </form>
  `,
})
export class Registration {
  registrationModel = signal({username: ''});

  registrationForm = form(this.registrationModel, (path) => {
    metadata(path.username, USERNAME_HELP, ({value}) => {
      const username = value();
      if (username.length === 0) {
        return 'Choose a unique username between 3 and 20 characters.';
      }
      if (username.length < 3) {
        return 'Keep typing, usernames are at least 3 characters.';
      }
      if (username.length > 20) {
        return 'Usernames are at most 20 characters.';
      }
      return 'Looks good.';
    });
  });

  usernameHelp = computed(() => this.registrationForm.username().metadata(USERNAME_HELP)?.() ?? '');
}

logic 函数接收该字段的上下文(FieldContext),从中可以读取:

  • value——字段当前值的信号;
  • state——字段的 FieldState
  • valueOf(path)stateOf(path)——读取同一表单中其他字段的方式。

函数内读取的任意信号都会成为响应式依赖:当 value() 变化时,元数据重新计算,读取该键的模板也随之更新。从类型定义看,LogicFn 就是"接收 FieldContext、返回特定结果类型的函数",见 packages/forms/signals/src/api/types.ts。而 metadata() 规则本身在 metadata.ts 中的实现非常薄——它把 key 和 logic 注册到路径节点(pathNode.builder.addMetadataRule(key, logic)),真正的计算发生在字段构建与后续读取阶段。

从字段读取元数据

  • hasMetadata(key):如果任意一条 schema 规则在该字段上注册了这个键,返回 true
  • state.metadata(key):没有任何规则注册过该键时返回 undefined,否则返回一个持有当前归约值的信号。
registrationForm.username().hasMetadata(USERNAME_HELP); // 只要有任何 metadata() 规则注册过该键即为 true

内层值的具体形态(能否为 undefined、承载什么类型)取决于该键的 reducer,下一节详述。

键可能未注册时,用 hasMetadata() 做门控:

@if (registrationForm.username().hasMetadata(USERNAME_HELP)) {
  <p class="help">{{ registrationForm.username().metadata(USERNAME_HELP)!() }}</p>
}

确定键一定已注册时(比如同一文件的 schema 里就注册了),可以跳过 hasMetadata() 检查,用可选链作为简洁替代:

const message = registrationForm.username().metadata(USERNAME_HELP)?.();
// message: string | undefined

或者干脆断言非空:

const message = registrationForm.username().metadata(USERNAME_HELP)!();
// message: string | undefined(仍可能是 undefined,因为内层值本身可能为 undefined)

上面组件示例就是在 computed() 里用了可选链,使模板绑定到一个普通 string,并对首帧提供空串兜底。

从源码看这一读取链路(packages/forms/signals/src/field/metadata.ts):非受管键是懒加载的——第一次访问时才会创建 computed(() => logic.compute(this.node.context)) 并缓存;而 has() 并不检查缓存 map,而是检查逻辑节点上是否存在该键的规则。正因为注册与物化分离,hasMetadata() 才是可靠的"是否注册过"判据。

到这里,单个贡献者的完整 API 就讲完了。下一节回答:当多条规则贡献同一键时会发生什么,以及如何用 reducer 组合它们。

用 reducer 组合多条贡献

覆盖语义在"只有一条规则贡献某键"时没问题。一旦有两条规则贡献,第一个值会被静默丢弃

const HELP = createMetadataKey<string>();

form(model, (path) => {
  metadata(path.username, HELP, () => 'Choose something unique across the system.');
  metadata(path.username, HELP, () => 'Usernames are 3 to 20 characters.');
});

两条规则都执行后,state.metadata(HELP)!() 只会返回第二条消息。这几乎不是你想要的。实际贡献常常来自不同来源:两个通过 apply() 组合的 schema 各自附带帮助文案,或者多条验证规则各自贡献一个提示。

要组合贡献,就给 createMetadataKey() 传一个 reducer。Reducer 描述如何把各个独立值折叠(fold)为累积结果:

import {createMetadataKey, MetadataReducer} from '@angular/forms/signals';

const HELP = createMetadataKey<string, string[]>(MetadataReducer.list());

form(model, (path) => {
  metadata(path.username, HELP, () => 'Choose something unique across the system.');
  metadata(path.username, HELP, () => 'Usernames are 3 to 20 characters.');
});

// state.metadata(HELP)!() === [
//   'Choose something unique across the system.',
//   'Usernames are 3 to 20 characters.',
// ]

注意 createMetadataKey<TWrite, TAcc> 的两个类型参数:第一个是每条规则贡献的类型,第二个是 reducer 产出的类型。用 list() 时,规则贡献 string,字段读回 string[]

内置 reducer

MetadataReducer 命名空间提供六个内置 reducer(override() 有两种重载,表中单列):

Reducer 累积器类型 行为 初始值
list<T>() T[] 接受 T | undefined 的贡献;追加所有非 undefined []
or() boolean 任一贡献为 true 则结果 true false
and() boolean 所有贡献都为 true 结果才 true true
min() number | undefined 保留最小的贡献数值 undefined
max() number | undefined 保留最大的贡献数值 undefined
override() T | undefined 最后一条贡献替换先前值(默认行为) undefined
override(fn) T 同上,但由 fn() 提供初始值 fn()

这些实现在 metadata.ts 中一目了然,例如 or()reduce: (prev, next) => prev || nextgetInitial: () => falsepackages/forms/signals/test/node/api/metadata.spec.ts 中的测试也逐一验证了 andorlistmaxminoverride 六种 reducer 的折叠行为。

list() 是唯一一个元素类型比累积器元素类型"宽"的内置 reducer:规则可以贡献 undefined,reducer 会把它静默丢弃。这正是内置 PATTERN 键处理动态 pattern() 规则的方式——logic 函数返回 undefined 时,该贡献被跳过而不是进入最终的 regex 列表。

内置验证器键如何使用 reducer

MetadataReducer.min()MetadataReducer.max() 听起来像是验证器,其实它们只是 reducer:MetadataReducer.min() 选出键上最小的贡献值,而 min() 验证器强制字段值不低于某个下界。二者同名,但解决的是不同的问题。

内置约束键选择 reducer 的标准是"什么才算最严格",这往往和键名暗示的方向相反:

Reducer 理由
REQUIRED or() 任一 required() 规则为 true,字段即为必填。
MIN_NUMBER max() 最小值约束以最大者为最严格。若一条规则要求 >= 5,另一条要求 >= 10,有效下限是 10
MIN_DATE max() MIN_NUMBER:要求的日期越晚越严格,最晚的生效。
MAX_NUMBER min() 最大值约束以最小者为最严格。若一条规则限制 <= 100,另一条 <= 50,有效上限是 50
MAX_DATE min() MAX_NUMBER:允许的日期越早越严格,最早的生效。
MIN_LENGTH max() MIN_NUMBER:要求的最长长度生效。
MAX_LENGTH min() MAX_NUMBER:允许的短长度生效。
PATTERN list<RegExp>() 每次 pattern() 贡献一个正则;字段值必须匹配其中全部。

源码完全印证了这张表(metadata.ts):REQUIREDMetadataReducer.or()MIN_DATE/MIN_NUMBER/MIN_LENGTHMetadataReducer.max()MAX_DATE/MAX_NUMBER/MAX_LENGTHMetadataReducer.min()PATTERNMetadataReducer.list<RegExp>()

MINMAX选择键(selection key):它们指向与字段值类型匹配的具体键,例如 min() 对应 MIN_NUMBERminDate() 对应 MIN_DATE。这正是 field().min()field().max() 能同时适用于数字字段和日期字段的原因。node.ts 中的 min getter 展示了这一间接层:

get min(): Signal<{} | undefined> | undefined {
  const minKey = this.metadata(MIN)?.();   // 先取出 MIN 指向的具体键(如 MIN_NUMBER)
  return minKey ? this.metadata(minKey) : undefined; // 再读具体键的累积值
}

这种"最严格者胜出"的配对,让两个组合 schema 分别调用 min(path.age, 18)min(path.age, 21) 也能正确工作:每次调用各自注册一个强制具体边界的验证器(低于任一边界的值都会校验失败);同时各自向 MIN_NUMBER 键贡献值,state.min!() 报告聚合结果(21),供 UI 和自定义控件读取有效下限。

编写自定义 reducer

当内置 reducer 都不满足你的语义时,实现一个符合 MetadataReducer<TAcc, TItem> 接口的对象即可:

interface MetadataReducer<TAcc, TItem> {
  reduce: (acc: TAcc, item: TItem) => TAcc;
  getInitial: () => TAcc;
}

该接口定义在 metadata.ts。比如一个 SEVERITY 键,保留所有规则贡献的"最高严重级别":

import {createMetadataKey, type MetadataReducer} from '@angular/forms/signals';

type Severity = 'info' | 'warning' | 'error';

const SEVERITY_RANK: Record<Severity, number> = {info: 0, warning: 1, error: 2};

const maxSeverity: MetadataReducer<Severity | undefined, Severity> = {
  reduce(acc, item) {
    if (acc === undefined) return item;
    return SEVERITY_RANK[item] > SEVERITY_RANK[acc] ? item : acc;
  },
  getInitial: () => undefined,
};

export const SEVERITY = createMetadataKey<Severity, Severity | undefined>(maxSeverity);

现在任意多条规则都可以贡献严重级别,字段报告其中的最高级:

form(model, (path) => {
  metadata(path.password, SEVERITY, () => 'info');
  metadata(path.password, SEVERITY, ({value}) => (value().length < 12 ? 'warning' : 'info'));
  metadata(path.password, SEVERITY, ({value}) =>
    /password|1234/i.test(value()) ? 'error' : 'info',
  );
});

只要任何一条贡献的信号变化,reducer 就会重新执行,因此 state.metadata(SEVERITY)!() 始终与所有规则当前的"最坏情况"保持同步。

建议:保持 reducer 纯净。reduce() 只应依赖它的两个参数,getInitial() 每次调用应返回相同值。Reducer 运行在响应式计算内部,当任何贡献的信号变化时都会重跑;不纯净的 reducer 会产出不一致的元数据。

用受管元数据附着生命周期感知对象

受管元数据(managed metadata)在字段上存储的是一个生命周期感知对象,而非响应式值。适用于 resource()(获取外部数据)、effect()(与外部系统同步)、或限定在单个字段作用域内的服务句柄等每字段对象。

创建受管键

调用 createManagedMetadataKey<TRead, TWrite>(create)。你传入的 create 函数负责产出该键持有的对象:

import {Signal} from '@angular/core';
import {httpResource} from '@angular/common/http';
import {createManagedMetadataKey} from '@angular/forms/signals';

export interface UrlPreview {
  title: string;
  description?: string;
  image?: string;
}

export const URL_PREVIEW = createManagedMetadataKey((_state, url: Signal<string | undefined>) => {
  return httpResource<UrlPreview>(() => {
    const currentUrl = url();
    return currentUrl ? {url: '/api/url-preview', params: {url: currentUrl}} : undefined;
  });
});

create 函数接收字段的 FieldState 和一个 Signal<TAcc>(由 metadata() 规则为该键贡献的数据的累积结果),返回应存放在字段上的对象。返回值按原样存储:与非受管键不同,框架不会把它包进 computed()

create 在字段构建时运行一次,且在字段的注入上下文(injection context)中执行。从源码 packages/forms/signals/src/field/metadata.tsrunMetadataCreateLifecycle() 可以看到:它遍历所有带 create 函数的元数据键,在 runInInjectionContext(this.node.structure.injector, ...) 内调用 key.create!(state, computed(() => logic.compute(context)))。这带来两个关键性质:

  1. 可以在 create 里调用 inject()resource()effect()——它们都依赖注入上下文;
  2. 清理与字段生命周期绑定:字段销毁时,其注入上下文随之销毁,你在 create 里注册的 resource()effect()DestroyRef 回调会自动清理。

同时注意,create 本身不是响应式的:任何需要响应信号变化的行为,都必须放进初次调用中建立的 effect()resource()httpResource() 里。URL_PREVIEW 正是这个模式:httpResource() 在其请求函数内部读取 URL 信号,因此信号变化时请求会重新发起。分工是——schema 规则(metadata(path.url, URL_PREVIEW, ({value}) => value()))决定喂入什么数据,受管键决定拿这些数据做什么。

在表单中使用受管键

注册一条针对该键的 metadata() 规则,然后从字段状态读取返回的对象:

import {Component, computed, signal} from '@angular/core';
import {applyEach, form, metadata, FormField} from '@angular/forms/signals';
import {URL_PREVIEW} from './url-preview';

@Component({
  selector: 'app-link-editor',
  imports: [FormField],
  template: `
    <form>
      @for (link of linksForm.links; track link) {
        <fieldset>
          <label>
            URL
            <input [formField]="link.url" />
          </label>
          <!-- 读取该 link 的 url 字段上的 URL_PREVIEW 键;结果就是 create 函数产出的 resource -->
          @let preview = link.url().metadata(URL_PREVIEW);
          @if (preview?.isLoading()) {
            <p>Loading preview...</p>
          } @else if (preview?.hasValue() && preview.value(); as data) {
            <article class="preview">
              <h3>{{ data.title }}</h3>
              @if (data.description) {
                <p>{{ data.description }}</p>
              }
            </article>
          } @else if (preview?.error()) {
            <p class="error">Could not load preview.</p>
          }
        </fieldset>
      }
      <button type="button" (click)="addLink()">Add link</button>
    </form>
  `,
})
export class LinkEditor {
  linksModel = signal({links: [{url: ''}]});

  linksForm = form(this.linksModel, (path) => {
    // 为每个 link 的 url 字段注册 URL_PREVIEW 键。
    // applyEach 对每个数组项独立运行 schema,
    // 所以 create() 每个 link 执行一次,每个 link 拿到自己的 resource。
    applyEach(path.links, (itemPath) => {
      metadata(itemPath.url, URL_PREVIEW, ({value}) => value());
    });
  });

  addLink() {
    this.linksForm.links().value.update((links) => [...links, {url: ''}]);
  }
}

每个数组项都拥有自己的 URL_PREVIEW resource,因为 applyEach 独立地为每个项注册 schema 规则。用户添加链接时,新项字段的 create 会运行;移除链接时(此处未展示,但很常见),框架会连同该字段的注入器一起拆毁其 resource。

小结:元数据在信号表单中的位置

元数据的存在,是为了让响应式数据能够:

  1. 随字段穿越 schema 组合——与 schema 同处一地,组合 schema 时元数据规则一并生效;
  2. 跨规则累积——通过 reducer 把多条规则、多个 schema 的贡献折叠为单一聚合值;
  3. 随字段生命周期销毁——受管对象绑定字段注入上下文,字段销毁即自动清理。

它复用的是 Angular 内置验证器的同一套系统,而你可以用自定义键、自定义 reducer 和受管元数据把它拓展到自己的场景。相关实现集中在 packages/forms/signals/src/api/rules/metadata.ts(键、reducer、metadata() 规则与内置键)和 packages/forms/signals/src/field/metadata.ts(字段侧的懒加载物化与受管键生命周期)。

如需进一步深入信号表单,可继续阅读同一指南目录下的文档:添加表单逻辑(form-logic)验证(validation)字段状态管理(field-state-management)异步操作(async-operations),以及本文的原始出处 field-metadata.md

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