Angular信号表单:computed() / linkedSignal在使用runInInjectionContext重新创建表单后会无限触发

前端开发 2026-07-11

背景

Angular的信号表单API(例如来自 @angular-signal-forms 这样的库)需要在创建时生成一个注入上下文,这意味着只能在组件/服务的构造阶段初始化。为绕过这个限制并在标准初始化阶段之外创建信号表单,可以使用 runInInjectionContext

this.controls = runInInjectionContext(injector, () => form<T>(initial));

起初这挺好用——但当表单实例被重新创建时就会出现问题。


问题

当把信号表单包装在一个上下文类中并重新创建该类(例如在路由变更时)时,任何依赖于表单控件的 computed()linkedSignal 都会进入一个无限的响应式循环

以下是该模式的一个最小示例:

class FormContext<T, TRequest> {
  readonly controls: FieldTree<T>;
  readonly request: Signal<TRequest>;

  constructor(injector: Injector) {
    const initial = signal({ sample: '' });
    this.controls = runInInjectionContext(injector, () => form<T>(initial));

    this.request = computed(() => convertToRequestModel(this.controls));
  }
}

这个类可以在任何提供 Injector 的地方实例化——例如,在一个服务中。在首次创建时,一切如预期工作:this.request 能以响应式的方式正确反映表单状态。

然而,当 FormContext 实例被重新创建(例如路由改变时,用新实例替换旧实例)时,request 信号会无限被触发——即使没有任何用户交互或数值变化。


我想实现的目标

该模式背后的动机,是实现路由切换时的干净表单重建,类似于Angular的 ReactiveFormsModule 已经处理的方式。例如,在用户资料页,当路由中的用户的 id 发生变化时,不需要手动重置表单,而是简单地创建一个新的 FormContext 实例:

// In a service or smart component
readonly context = computed(() => {
  const userId = this.route.params().userId;
  return new FormContext(this.injector); // fresh form per route
});

这种方法的好处:

  • 每个路由获得一个全新、彼此独立的表单——没有残留状态
  • 与旧上下文相关的任何进行中的异步操作都不会与新上下文冲突
  • 业务逻辑保持简洁,并按上下文进行作用域划分

我尝试过的

我发现信号表单的单元测试使用 TestBed.inject(Injector) 来创建表单,这也证实了 injector 选项是“非上下文创建”的受支持路径:

it('should not forward methods through the proxy', () => {
  const f = form(signal(new Date()), { injector: TestBed.inject(Injector) });
  // @ts-expect-error
  expect(f.getDate).toBe(undefined as any);
});

这在测试中成立,但无限重计算的问题只有在运行时,当表单上下文被重新创建时才会显现。


问题

  1. 为什么通过 runInInjectionContext 重新创建信号表单会导致 computed() / linkedSignal 无限循环?
  2. 有没有办法在替换旧表单之前,正确地销毁/注销旧的信号表单的响应式上下文?
  3. 是否有在组件初始化之外重建信号表单的推荐模式——例如,为了支持每路由一个干净的表单实例?

解决方案

我无法用你给出的代码重现无限循环(问题1).当我在一个 computed 中创建一个 form,就像你示例中那样,我得到:
NG0602: effect() cannot be called from within a reactive context.

这是应该的,因为表单正在注册一个副作用,否则你可能会反复创建无限数量的副作用,直到组件被销毁才会被清理。

问题2:
你可以提供你自己的 Injector,并在 FormContext 被处置时将其销毁。只要需要处置的一切都在使用 DestroyRef,就应该被清理干净(据我所知,副作用在使用它)(effect is using it afaik)。

class FormContext<T, TRequest> {
  public readonly controls: FieldTree<T>;
  private readonly injector: DestroyableInjector;

  constructor(
    injector: Injector,
    initial: T,
    schema: SchemaOrSchemaFn<T, PathKind.Root>
  ) {
    this.injector = Injector.create({ providers: [], parent: injector });
    const formModel = signal(initial);
    this.controls = form<T>(formModel, schema, { injector: this.injector });
    const unregisterDestroyCallback = injector.get(DestroyRef)
        .onDestroy(() => this.dispose());
    this.injector.get(DestroyRef).onDestroy(unregisterDestroyCallback);
  }

  public dispose() {
    this.injector.destroy();
  }
}

问题3:
由于你不能使用 computed,你可以这样做:

protected formContext = toSignal(
  toObservable(this.id).pipe(
    tap(() => this.formContext()?.dispose()),
    map(
      (id) =>
        new FormContext(
          this.injector,
          {
            id: id,
            name: id.toUpperCase(),
          },
          (schema) => {
            minLength(schema.name, 4);
          }
        )
    )
  )
);

完整示例

站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。

相关文章