You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Storybook中Formly验证触发NG0100错误,输入框无法聚焦

问题描述

我正在为表单字段自定义Formly包装器,想要在Storybook中展示该包装器。参考示例添加了中间组件StoryBookFormlyComponent,通过Storybook args配置包装器属性,代码如下:

import { moduleMetadata, type Meta, type StoryObj } from '@storybook/angular';
import { Component, Input } from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { FormlyModule, type FormlyFieldConfig } from '@ngx-formly/core';
import { FormlyBootstrapModule } from '@ngx-formly/bootstrap';

import { FormComponentsModule } from '../lib/form-components/form-components.module';
import { FieldWrapperComponent } from '../lib/form-components/field-wrapper/field-wrapper.component';

@Component({
  selector: 'storybook-formly', // eslint-disable-line @angular-eslint/component-selector
  template: `
    <form [formGroup]="form">
      <formly-form [model]="model" [fields]="fields" [options]="options" [form]="form"></formly-form>
    </form>
  `
})
export class StoryBookFormlyComponent {
  @Input() label: string;
  @Input() placeholder: string;
  @Input() description: string;
  @Input() required: boolean;
  @Input() inputType: string;

  form = new FormGroup({});
  model = { example: '' };
  options = {};
  get fields(): FormlyFieldConfig[] {
    return [
      {
        key: 'example',
        wrappers: [FieldWrapperComponent],
        type: this.inputType,
        props: { label: this.label, placeholder: this.placeholder, description: this.description, required: this.required }
      }
    ];
  }
}

const meta: Meta<StoryBookFormlyComponent> = {
  title: 'Atoms/Forms/Standard Inputs',
  component: StoryBookFormlyComponent,
  tags: ['autodocs'],
  decorators: [
    moduleMetadata({
      imports: [
        ReactiveFormsModule,
        FormComponentsModule,
        FormlyBootstrapModule,
        FormlyModule.forRoot({ validationMessages: [{ name: 'required', message: 'This field is required' }] })
      ]
    })
  ],
  render: (args: StoryBookFormlyComponent) => ({
    props: args
  }),
  argTypes: {}
};

export default meta;

type Story = StoryObj<StoryBookFormlyComponent>;

export const Primary: Story = {
  args: {
    label: 'Example Label 2',
    placeholder: 'My placeholder',
    description: 'Some description',
    required: true,
    inputType: 'input'
  }
};

运行Storybook时组件显示正常,控件可管理属性,但点击输入框会立即触发错误:

ERROR Error: NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value for 'ng-untouched': 'true'. Current value: 'false'. Expression location: StoryBookFormlyComponent component.

此时验证触发并显示错误,但输入框失去焦点,无法重新聚焦或输入内容。原无Storybook args的示例无此问题,当前环境为Angular 16、Storybook 7.1.0,包装器组件代码如下:

import { Component } from '@angular/core';
import { FieldWrapper } from '@ngx-formly/core';

@Component({
  selector: 'component-library-field-wrapper',
  template: `
    <ng-template #labelTemplate>
      <label *ngIf="props.label && props.hideLabel !== true" [attr.for]="id" class="form-label">
        {{ props.label }}
        <span *ngIf="props.required && props.hideRequiredMarker !== true" aria-hidden="true" class="required"> (Required)</span>
      </label>
    </ng-template>

    <div class="mb-3" [class.form-floating]="props.labelPosition === 'floating'" [class.has-error]="showError">
      <ng-container *ngIf="props.labelPosition !== 'floating'">
        <ng-container [ngTemplateOutlet]="labelTemplate"></ng-container>
      </ng-container>

      <div *ngIf="props.description" class="form-text">{{ props.description }}</div>

      <div *ngIf="showError" class="invalid-feedback">
        <formly-validation-message [field]="field"></formly-validation-message>
      </div>

      <ng-template #fieldComponent></ng-template>

      <ng-container *ngIf="props.labelPosition === 'floating'">
        <ng-container [ngTemplateOutlet]="labelTemplate"></ng-container>
      </ng-container>
    </div>
  `
})
export class FieldWrapperComponent extends FieldWrapper {}

解决方案

这个错误源于Angular变更检测机制检测到组件初始化后表达式值发生了变化,结合Formly与Storybook的场景,可通过以下方式修复:

方法1:延迟初始化Formly字段配置

将fields从getter改为在ngOnInit中初始化,避免每次变更检测都重新生成字段配置对象:

import { Component, Input, OnInit } from '@angular/core';
// 保留其他导入内容

@Component({
  selector: 'storybook-formly',
  template: `
    <form [formGroup]="form">
      <formly-form [model]="model" [fields]="fields" [options]="options" [form]="form"></formly-form>
    </form>
  `
})
export class StoryBookFormlyComponent implements OnInit {
  @Input() label: string;
  @Input() placeholder: string;
  @Input() description: string;
  @Input() required: boolean;
  @Input() inputType: string;

  form = new FormGroup({});
  model = { example: '' };
  options = {};
  fields: FormlyFieldConfig[];

  ngOnInit(): void {
    this.fields = [
      {
        key: 'example',
        wrappers: [FieldWrapperComponent],
        type: this.inputType,
        props: { 
          label: this.label, 
          placeholder: this.placeholder, 
          description: this.description, 
          required: this.required 
        }
      }
    ];
  }
}

方法2:手动触发变更检测

若需保留getter写法,可注入ChangeDetectorRef并在视图初始化后手动触发变更检测:

import { Component, Input, ChangeDetectorRef, AfterViewInit } from '@angular/core';
// 保留其他导入内容

@Component({
  selector: 'storybook-formly',
  template: `
    <form [formGroup]="form">
      <formly-form [model]="model" [fields]="fields" [options]="options" [form]="form"></formly-form>
    </form>
  `
})
export class StoryBookFormlyComponent implements AfterViewInit {
  @Input() label: string;
  @Input() placeholder: string;
  @Input() description: string;
  @Input() required: boolean;
  @Input() inputType: string;

  form = new FormGroup({});
  model = { example: '' };
  options = {};

  constructor(private cdr: ChangeDetectorRef) {}

  ngAfterViewInit(): void {
    this.cdr.detectChanges();
  }

  get fields(): FormlyFieldConfig[] {
    return [
      {
        key: 'example',
        wrappers: [FieldWrapperComponent],
        type: this.inputType,
        props: { 
          label: this.label, 
          placeholder: this.placeholder, 
          description: this.description, 
          required: this.required 
        }
      }
    ];
  }
}

方法3:调整包装器组件的错误判断逻辑

在包装器模板中,直接使用表单控件的状态组合判断错误,替代showError属性,减少变更检测冲突:

<div class="mb-3" [class.form-floating]="props.labelPosition === 'floating'" [class.has-error]="field.formControl.touched && field.formControl.invalid">
  <!-- 其余模板内容保持不变 -->
</div>

关键原因说明

原代码中使用get fields()会在每次变更检测时重新生成FormlyFieldConfig数组,导致Formly内部重复初始化字段控件,进而触发控件状态(如ng-untouched)在变更检测周期内发生变化,触发Angular严格模式错误。将字段配置改为初始化时生成,可避免重复创建配置对象,解决状态变更冲突。

内容的提问来源于stack exchange,提问作者richard

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.15 07:28:10