Angular信号表单:computed() / linkedSignal在使用runInInjectionContext重新创建表单后会无限触发
背景
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);
});
这在测试中成立,但无限重计算的问题只有在运行时,当表单上下文被重新创建时才会显现。
问题
- 为什么通过
runInInjectionContext重新创建信号表单会导致computed()/linkedSignal无限循环? - 有没有办法在替换旧表单之前,正确地销毁/注销旧的信号表单的响应式上下文?
- 是否有在组件初始化之外重建信号表单的推荐模式——例如,为了支持每路由一个干净的表单实例?
解决方案
我无法用你给出的代码重现无限循环(问题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);
}
)
)
)
);