Angular Signal Forms 字段元数据系统(Field Metadata):元数据键、Reducer 与受管元数据
本篇指南讲解 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.ts 中 createMetadataKey 的实现是 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 || next、getInitial: () => false;packages/forms/signals/test/node/api/metadata.spec.ts 中的测试也逐一验证了 and、or、list、max、min、override 六种 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):REQUIRED 用 MetadataReducer.or(),MIN_DATE/MIN_NUMBER/MIN_LENGTH 用 MetadataReducer.max(),MAX_DATE/MAX_NUMBER/MAX_LENGTH 用 MetadataReducer.min(),PATTERN 用 MetadataReducer.list<RegExp>()。
MIN 和 MAX 是选择键(selection key):它们指向与字段值类型匹配的具体键,例如 min() 对应 MIN_NUMBER、minDate() 对应 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.ts 的 runMetadataCreateLifecycle() 可以看到:它遍历所有带 create 函数的元数据键,在 runInInjectionContext(this.node.structure.injector, ...) 内调用 key.create!(state, computed(() => logic.compute(context)))。这带来两个关键性质:
- 可以在
create里调用inject()、resource()、effect()——它们都依赖注入上下文; - 清理与字段生命周期绑定:字段销毁时,其注入上下文随之销毁,你在
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。
小结:元数据在信号表单中的位置
元数据的存在,是为了让响应式数据能够:
- 随字段穿越 schema 组合——与 schema 同处一地,组合 schema 时元数据规则一并生效;
- 跨规则累积——通过 reducer 把多条规则、多个 schema 的贡献折叠为单一聚合值;
- 随字段生命周期销毁——受管对象绑定字段注入上下文,字段销毁即自动清理。
它复用的是 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。
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 StartedRust0624
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