跳转到内容

AbstractControl — 表单控件实例 API

Piying-View 的 Control 系统是整个表单的核心,负责值管理、状态跟踪、验证和子字段导航。Control 是在 Schema 解析/转换过程中由库自动生成的。


Schema 定义 → convert() / FormBuilder → Control 自动创建 → field.form.control
  • Control 在解析时自动生成
  • 通过 field.form.control(根控件)或 field.form.root 访问
  • 控件通过 field.get(path) 查询其他控件

传入组件后,会自动实例化,并且可以在actions中获取到

// 模板中:传递 schema,框架自动生成 Control
<piying-view [schema]="schema" [(model)]="formData"></piying-view>

通过 actions.hooks 注册的回调函数中,可以直接从传入的 field 参数获取 Control:

import * as v from 'valibot';
import { actions, formConfig } from '@piying/view-angular-core';

const schema = v.object({
  name: v.pipe(
    v.string(),
    actions.hooks.merge({
      fieldResolved(field) {
        // field.form.control — 当前字段的 Control(FieldControl)
        console.log(field.form.control?.value);
      },
      allFieldsResolved(field) {
        // 所有字段解析完成后,可以访问根控件
        const rootCtrl = field.form.root;
        const nameCtrl = field.form.control;
        // 通过路径获取子控件
        const child = field.get(['childPath']);
      },
    }),
  ),
});

在由 Piying-View 渲染的自定义组件内,注入 PI_VIEW_FIELD_TOKEN 获取当前字段:

import { inject } from '@angular/core';
import { PI_VIEW_FIELD_TOKEN, BaseControl } from '@piying/view-angular';

@Component({
  /* ... */
})
export class MyInputComponent extends BaseControl {
  // field 是 signal,调用后返回 PiResolvedViewFieldConfig
  readonly field = inject(PI_VIEW_FIELD_TOKEN);

  ngOnInit() {
    const f = this.field(); // 当前字段配置
    console.log(f.form.control); // Control 实例
    console.log(f.parent); // 父字段
    console.log(f.children!()); // 子字段列表(Group)
  }
}
场景 访问方式 说明
Hooks 中当前字段 Control field.form.control 当前字段的 Control
Hooks 中根控件 field.form.root 整个表单的根 Control
自定义组件中 inject(PI_VIEW_FIELD_TOKEN)().form.control 注入 field 后访问 control
子控件(通过 path) root.get('child') / root.get(['a', 'b']) 返回 AbstractControl | null
父字段 field.parent (Field 层面) 当前字段的父级 Field
// 继承关系
AbstractControl<TValue>          // 抽象基类
├── FieldControl<TValue>         // 叶子节点控件(对应基础类型)
├── FieldGroupbase               // 组基类
│   ├── FieldGroup               // 对象组
│   └── FieldArray               // 数组控件
│       └── FieldLogicGroup      // 逻辑组

属性 类型 说明
control.value TValue 经过 schema 验证/转换后的当前值(getter)
control.value$$() Signal<TValue | undefined> computed signal,包含前值追踪(computedWithPrev)
// 读取当前值
console.log(control.value); // 示例: "hello"

// 通过 signal 读取(响应式)
const val = control.value$$(); // Signal 调用方式

updateValue(value, force?) — 更新控件值

Section titled “updateValue(value, force?) — 更新控件值”

以编程方式设置控件值,触发验证和转换流程。

import { firstValueFrom } from 'rxjs';

// 基础用法
control.updateValue('new value');

// 强制更新(跳过 pristine && untouched 检查)
control.updateValue('force update', true);

// 监听值变更
control.valueChanges.subscribe((val) => {
  console.log('value changed to:', val);
});

// async/await 模式
let result = await firstValueFrom(control.valueChanges);
control.updateValue('111');
result = await firstValueFrom(control.valueChanges.pipe(skip(1)));
console.log(result); // '111'

更新流程:

  1. 触发 updateValue → 设置 modelValue$ signal
  2. 通过 transformer.toView 转换(如果配置)
  3. 通过 Valibot schema 验证/转换 → 更新 value$$
  4. 触发 valueChanges observable

将控件恢复到初始状态,标记为 pristine + untouched。

// 重置到给定值
control.reset(['v3', 'v4']);

// 重置到默认值(defaultValue / schema default)
control.reset();

// 适用于 FieldControl
control.reset('default string');

// 适用于 FieldGroup
groupControl.reset({ name: '', age: 0 });

// 验证后 reset
control.viewValueChange('1');
console.log(control.errors); // 有错误
control.reset();
console.log(control.errors); // undefined(已清除)

viewValueChange(value) — 视图触发值变更

Section titled “viewValueChange(value) — 视图触发值变更”

当视图组件发生用户交互时调用,值会经过 pipe.toModeltransformer.toModel 流程。

// 视图中用户输入时调用
control.viewValueChange('user input');

// 标记为 dirty
console.log(control.dirty); // true
console.log(control.pristine); // false

// 配合 pipe 使用(debounce/filter/map)
// 见 FieldFormConfig 文档中的 pipe 部分

属性/方法 类型 说明
control.disabled boolean (getter) 最终禁用状态(考虑父级)
control.enabled boolean (getter) 等效于 !disabled
control.selfDisabled$$() Signal<boolean> 自身禁用状态,不考虑父级
control.disabled$$() Signal<boolean> 最终禁用状态(自身 或父级)
control.enabled$$() Signal<boolean> 最终启用状态
control.disable() () => void 禁用此控件
control.enable() () => void 启用此控件
// 查看禁用状态
if (control.disabled) {
  console.log('当前被禁用');
}

// 动态禁用
control.disable();

// 通过 config$ 精确控制
control.config$.update((c) => ({ ...c, disabled: true }));

// 父级禁用会传导到子级
parentControl.disable();
console.log(childControl.disabled); // true(被父级禁用)
console.log(childControl.selfDisabled$$()); // false(自身未禁用)

disabledValue 策略影响:

  • 'reserve'(默认):禁用时保留当前值
  • 'delete':禁用时移除该字段值
属性/方法 类型 说明
control.touched boolean (getter) 是否已触碰(调用 untracked 读取)
control.untouched boolean (getter) 是否未触碰
control.touched$$() Signal<boolean> computed,子级触碰会冒泡到父级
control.selfTouched$() Signal<boolean> 自身 touched 状态(signal)
control.markAsTouched() () => void 标记为已触碰
control.markAsUntouched() () => void 标记为未触碰
control.markAllAsTouched() () => void 级联标记自身及所有子级
control.markAllAsUntouched() () => void 级联清除所有 touched 状态
// 用户与字段交互后
control.markAsTouched();

// 检查表单整体 touched 状态(包含子级)
if (groupControl.touched) {
  // 任一子级被触碰,父级也会标记为 touched
}
属性/方法 类型 说明
control.dirty boolean (getter) 值是否被修改过
control.pristine boolean (getter) 值是否为原始状态(未修改)
control.dirty$$() Signal<boolean> computed,子级 dirty 会冒泡
control.markAsDirty() () => void 标记为已修改
control.markAsPristine() () => void 标记为未修改
control.markAllAsDirty() () => void 级联标记所有子级
control.markAllAsPristine() () => void 级联清除所有 dirty 状态
// viewValueChange 会自动标记为 dirty
control.viewValueChange('new value');
console.log(control.dirty); // true
console.log(control.pristine); // false

// reset 会自动标记为 pristine + untouched
control.reset();
console.log(control.pristine); // true
属性 类型 说明
control.valid boolean (getter) 是否通过所有验证
control.invalid boolean (getter) 是否未通过验证
control.pending boolean (getter) 是否正在进行异步验证
if (control.valid) {
  console.log('表单有效');
} else if (control.invalid) {
  console.log('表单无效');
} else if (control.pending) {
  console.log('正在验证中...');
}
属性 类型 说明
control.errors ValidationErrors2[] | undefined 当前错误列表(pending 时返回 undefined)
control.status$$() Signal<VALID_STATUS> ‘VALID’ | ‘INVALID’ | ‘PENDING’
control.rawError$$() Signal<rawError | PENDING | undefined> 同步+异步合并后的原始错误(含 pending)
control.syncError$() linkedSignal 仅同步验证错误
control.asyncError$$() Signal 仅异步验证错误
control.valueNoError$$() computed 无错误时为 true
// 查看当前错误
if (control.errors) {
  control.errors.forEach((err) => {
    if (err.kind === 'valibot') {
      console.log('Valibot 验证问题:', err.metadata);
    } else if (err.kind === 'descendant') {
      console.log(`子字段 ${err.key} 错误:`, err.metadata);
    } else {
      console.log(`${err.kind}:`, err.message ?? err.metadata);
    }
  });
}

// status$$ 返回字符串常量
const status = control.status$$(); // 'VALID' | 'INVALID' | 'PENDING'

错误类型(看 err.kind 区分):

kind 来源 错误内容
'valibot' schema 验证失败 metadata 为 valibot 的 issue 列表
'error' 自定义验证器抛异常 metadata 为异常对象
'descendant' 子字段错误 key 为子字段名,metadata 为其错误列表
其他自定义值 自定义验证器返回 metadata / message 由验证器自己定

通过 formConfig 注册验证器:

import { formConfig, ValidatorFn, AsyncValidatorFn } from '@piying/view-angular-core';

// 同步验证器
v.pipe(
  v.string(),
  formConfig({
    validators: [
      (control) => {
        if (!control.value?.match(/^[A-Z]/)) {
          return { uppercaseStart: '必须以大写字母开头' };
        }
        return undefined; // 通过验证返回 undefined/null
      },
    ],
  }),
);

// 异步验证器(支持 Promise / Observable / Signal)
v.pipe(
  v.string(),
  formConfig({
    asyncValidators: [
      async (control) => {
        const exists = await checkUsername(control.value);
        return exists ? { duplicate: '用户名已存在' } : undefined;
      },
    ],
  }),
);

每次 value$$() 变化时发射新值(Angular signal → RxJS 转换)。

import { firstValueFrom, skip } from 'rxjs';

// subscribe 模式
control.valueChanges.subscribe((val) => {
  console.log('值变为:', val);
});

// async/await 模式(测试常用)
let result = await firstValueFrom(control.valueChanges);
console.log(result); // undefined(初始值)

control.updateValue('111');
result = await firstValueFrom(control.valueChanges.pipe(skip(1)));
console.log(result); // '111'

每次验证状态变化时发射 VALID / INVALID / PENDING。

control.statusChanges.subscribe((status) => {
  switch (status) {
    case 'VALID':
      console.log('✅ 验证通过');
      break;
    case 'INVALID':
      console.log('❌ 验证失败');
      break;
    case 'PENDING':
      console.log('⏳ 验证中...');
      break;
  }
});

属性 类型 说明
control.root AbstractControl (getter) 根控件
control.parent AbstractControl | undefined 父控件
control.valuePath (string | number)[] 值路径(跳过 Logic 类型子级)
control.fieldPath (string | number)[] 完整字段路径(包含所有子级)
// 获取根控件
const rootCtrl = control.root;

// 向上导航到父级
if (control.parent) {
  console.log(control.parent.value);
}

// 路径追踪
console.log(control.fieldPath); // ['users', 0, 'address', 'city']
属性/方法 类型 说明
array.length number 数组当前长度
array.controls AbstractControl[] 所有子控件(fixed + reset)
array.fixedControls$() Signal<AbstractControl[]> 固定子控件
array.resetControls$() Signal<AbstractControl[]> 待重置/新增的子控件
array.clear() () => void 清除所有 reset 状态的控制项
array.removeRestControl(key) (key: number) => void 移除指定 rest 控制项
// 数组操作
result.form.control!.updateValue(['v1', 'v2']);
const arrayCtrl = result.form.control!;
console.log(arrayCtrl.length); // 2
console.log(arrayCtrl.controls.length); // 2

// 清空
arrayCtrl.clear();
console.log(arrayCtrl.controls); // []

// 设置/更新数组元素
resolved.action.set('11', 1); // set index 1 to '11'

// config$ 是 WritableSignal<FieldFormConfig>
// 通过 update 修改,使用 deepEqual 比较避免不必要的重算

// 启用/禁用
control.config$.update((c) => ({ ...c, disabled: true }));

// 设置默认值
control.config$.update((c) => ({ ...c, defaultValue: 'new default' }));

// 添加验证器
control.config$.update((c) => ({
  ...c,
  validators: [...(c.validators ?? []), myValidator],
}));

// 设置更新时机
control.config$.update((c) => ({ ...c, updateOn: 'blur' }));

// 重置配置为初始状态(注意:这不是 reset())

FieldFormConfig 完整字段说明:

配置项 适用 说明
disabled 全部 禁用此字段
disabledValue 全部 禁用时值处理:reserve 保留 / delete 不输出
transformer 全部 同步值转换,toView / toModel 两个方向
pipe 全部 对值流套 RxJS 管道,仅 toModel 方向
defaultValue 全部 默认值
validators 全部 同步验证器
asyncValidators 全部 异步验证器
updateOn 全部 更新时机:change / blur / submit
required 全部 是否必填
undefinedable 全部 允许 undefined
nullable 全部 允许 null
emptyValue group / array 聚合结果为空时的最终值
deletionMode array shrink 缩短 / mark 置空不缩
groupMode group / array 多余键值处理:loose / default / strict / reset
groupKeySchema group(record) 键的类型约束
groupValueSchema group / array 值 / 元素的类型约束
disableOrUpdateActivate 逻辑组 关闭 or 类型的自动分支激活

各项详细用法见 formConfig


仅在 updateOn: 'submit' 时触发,处理 pending 状态并标记 touched。

// 通常在表单提交时调用
resolved.form.control.emitSubmit();

FieldArray 的子级信号(Field 层面)

Section titled “FieldArray 的子级信号(Field 层面)”

数组相关信息分布在两个层面,不要与上面的控件层混淆:

层面 取法 提供什么
控件层FieldArray field.form.control length / controls / fixedControls$ / resetControls$ / clear / removeRestControl
字段层_PiResolvedCommonViewFieldConfig field children / fixedChildren / restChildren / action

_PiResolvedCommonViewFieldConfig 上,数组类型的 field 会暴露:

// Field 对象上的子级信号
field.children; // Signal<Array<_PiResolvedCommonViewFieldConfig>>,全部子字段(fixed + rest)
field.fixedChildren; // Signal<Array<_PiResolvedCommonViewFieldConfig>>,固定子字段
field.restChildren; // Signal<Array<_PiResolvedCommonViewFieldConfig>> | undefined,动态子字段
field.action; // { set(value, index?), remove(index) },按下标写 / 删

⚠️ length / controls / fixedControls$ 只存在于 FieldArray 控件上field.length 取不到值;字段层面请用 field.children().length


import * as v from 'valibot';
import { convertToField } from '@piying/view-angular';
import { formConfig } from '@piying/view-angular-core';
import { firstValueFrom, debounceTime } from 'rxjs';

const schema = v.object({
  name: v.pipe(
    v.string(),
    formConfig({
      defaultValue: '',
      validators: [(c) => (c.value.length < 2 ? { short: '至少2个字符' } : undefined)],
    }),
  ),
  email: v.pipe(v.string(), v.email()),
  tags: v.optional(v.array(v.string()), []),
});


const resolved = convertToField(() => schema, undefined, () => ({
  /* options */
}));
const root = resolved.form.control!;

// 1. 读取初始值
console.log(root.value); // { name: '', email: undefined, tags: [] }

// 2. 更新值
root.updateValue({
  name: 'Test',
  email: 'test@example.com',
});

// 3. 监听值变更
root.valueChanges.subscribe((val) => {
  console.log('form value:', val);
});

// 4. 查看状态
console.log(root.valid); // true / false
console.log(root.errors); // undefined | ValidationErrors2[]

// 5. 获取子控件
const nameCtrl = root.get(['name']);
if (nameCtrl) {
  console.log(nameCtrl.value);
  console.log(nameCtrl.invalid);
  console.log(nameCtrl.errors);
}

// 6. 视图变更触发 dirty 状态
nameCtrl.viewValueChange('新输入');
console.log(nameCtrl.dirty); // true

// 7. 重置表单
root.reset();

// 8. 禁用整个表单
root.disable();
console.log(root.disabled); // true
import * as v from 'valibot';
import { convertToField } from '@piying/view-angular';
import { formConfig, isFieldArray } from '@piying/view-angular-core';

const schema = v.object({
  items: v.pipe(v.array(v.string()), formConfig({ emptyValue: [] })),
});


const resolved = convertToField(() => schema, undefined, () => ({
  /* options */
}));
const arrayCtrl = resolved.form.control!.get(['items'])!;

if (isFieldArray(arrayCtrl)) {
  // 添加元素
  resolved.action.set('new item', arrayCtrl.length);

  // 更新值
  arrayCtrl.updateValue(['a', 'b', 'c']);
  console.log(arrayCtrl.length); // 3

  // 遍历子控件
  for (const [i, child] of arrayCtrl.activatedChildren()) {
    console.log(`[${i}]`, child.value);
  }

  // 清空数组
  arrayCtrl.updateValue([]);
}
import * as v from 'valibot';
import { convertToField } from '@piying/view-angular';
import { formConfig } from '@piying/view-angular-core';

const schema = v.pipe(
  v.number(),
  formConfig({
    transformer: {
      toView: (value) => value?.toFixed(2) ?? '0.00', // 19.9 → "19.90"
      toModel: (value) => parseFloat(value), // "19.90" → 19.9
    },
  }),
);


const resolved = convertToField(() => schema, undefined, () => ({
  /* options */
}));
const ctrl = resolved.form.control!;

// modelValue$ 设置原始值
ctrl.updateValue(19.9);

// value$$ 是经过 toView 转换后的视图值
console.log(ctrl.value); // "19.90"(字符串)

// 模拟视图变更
ctrl.viewValueChange('25.5');
console.log(ctrl.value); // 25.5(数字)

  1. Signal vs Gettervalue$$()disabled$$() 等带 $$ 的是 signal/computed,调用时需要加 ()valuedisabled 是 getter,直接访问。

  2. Deep equal 比较config$ signal 使用 fast-equals 的 deepEqual,避免相同值的重复更新。

  3. 状态冒泡:touched 和 dirty 状态会从子级冒泡到父级(通过 computed 实现)。

  4. Disabled 传导:父级禁用会自动传导到所有子级,但 selfDisabled$$() 只反映自身配置。

  5. updateOn: 'submit':设置为 submit 模式后,值变更仅在调用 emitSubmit() 时处理。