使用 AOT 时,传递给 forRoot 的对象的更改在注入时会被丢弃

When using AOT, changes to objects passed to forRoot are discarded when injected

概述

我看到奇怪的行为,因为通过解构赋值(或 Object.assign)添加到对象的属性在传递给 forRoot 时存在,但在注入到对象时不存在服务。此外,初始化后进行的更新在传递给 forRoot 时存在,但在注入服务时不存在。这仅在使用 AOT 构建时发生。

我创建了一个重现该问题的最小项目: https://github.com/bygrace1986/wat

包版本

设置

创建一个具有静态 forRoot 方法的模块,该方法将接受一个对象,然后它将通过 InjectionToken 提供并注入到服务中。

测试模块

import { NgModule, ModuleWithProviders } from '@angular/core';
import { CommonModule } from '@angular/common';

import { TestDependency, TEST_DEPENDENCY, TestService } from './test.service';

@NgModule({
  imports: [
    CommonModule
  ],
  declarations: []
})
export class TestModule {
  static forRoot(dependency?: TestDependency): ModuleWithProviders {
    return {
      ngModule: TestModule,
      providers: [
        TestService,
        { provide: TEST_DEPENDENCY, useValue: dependency }
      ]
    }
  }
}

测试服务

import { Injectable, InjectionToken, Inject } from '@angular/core';

export interface TestDependency {
    property: string;
}

export const TEST_DEPENDENCY = new InjectionToken<TestDependency>('TEST_DEPENDENCY');

@Injectable()
export class TestService {
    constructor(@Inject(TEST_DEPENDENCY) dependency: TestDependency) {
        console.log('[TestService]', dependency);
    }
}

非变异对象场景

此场景说明将未变异的对象传递给 forRoot 会正确地注入到依赖它的服务中。

要设置引用 app.component.html 中的 TestService(或它将被注入的地方)。将依赖项传递给 TestModule.

上的 forRoot 方法

AppModule

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppComponent } from './app.component';
import { TestModule } from './test/test.module';
import { TestDependency } from './test/test.service';

const dependency = {
  property: 'value'
} as TestDependency;
console.log('[AppModule]', dependency)

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    TestModule.forRoot(dependency)
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

运行 ng serve --aot.

输出

[AppModule] {property: "value"}
[TestService] {property: "value"}

变异对象场景

此场景说明了在初始化期间通过对象解构分配对对象所做的更改或初始化后所做的更改在将提供的对象注入到依赖于它的服务中时将被忽略。

设置创建一个具有附加属性的新对象,并使用对象解构将旧对象的属性分配给新对象。然后更新 dependency 上的 属性 并将其传递给 TestModule.

上的 forRoot 方法

AppModule

const dependency = {
  property: 'value'
} as TestDependency;
const dependencyCopy = { id: 1, name: 'first', ...dependency };
dependencyCopy.name = 'last';
console.log('[AppModule]', dependencyCopy);
...
TestModule.forRoot(dependencyCopy)

运行 ng serve --aot.

输出

[AppModule] {id: 1, name: "last", property: "value"}
[TestService] {id: 1, name: "first"}

意外结果

删除了通过对象解构赋值添加的任何 属性,并且在对象传递给 forRoot 和注入 TestService 之间恢复初始化后所做的任何更新。事实上,它不是同一个对象(我用 === 调试和检查过)。就好像正在使用赋值或变异之前创建的原始对象......不知何故。

AppModule 场景中提供的变异对象

此场景说明,在 AppModule 级别而不是通过 forRoot 级别提供时,不会恢复对对象的更改。

要设置,请不要将任何内容传递给 forRoot。而是使用注入令牌在 AppModule 提供者列表中提供对象。

AppModule

imports: [
  BrowserModule,
  TestModule.forRoot()
],
providers: [
  { provide: TEST_DEPENDENCY, useValue: dependencyCopy }
],

运行 ng serve --aot.

输出

[AppModule] {id: 1, name: "last", property: "value"}
[TestService] {id: 1, name: "last", property: "value"}

问题

为什么当对象被注入依赖 class 时,对通过 forRoot 提供的对象所做的更改会被还原?

更新

AOT的核心是Metadata collector

需要ts.SourceFile,然后递归遍历该文件的所有AST节点,并将所有节点转换为JSON表示。

收集器checks文件顶层的以下类型的AST节点:

ts.SyntaxKind.ExportDeclaration
ts.SyntaxKind.ClassDeclaration
ts.SyntaxKind.TypeAliasDeclaration
ts.SyntaxKind.InterfaceDeclaration
ts.SyntaxKind.FunctionDeclaration
ts.SyntaxKind.EnumDeclaration
ts.SyntaxKind.VariableStatement

Angular 编译器还尝试使用所谓的 Evaluator so that it can understand a subset of javascript expressions that is listen in docs

在运行时计算所有元数据

需要注意的是,编译器支持扩展运算符,但仅在array literals not in objects

案例一

const dependency = {
  property: 'value'               ===========> Non-exported VariableStatement
} as TestDependency;                           with value { property: 'value' }

imports: [
  ...,
  TestModule.forRoot(dependency)  ===========> Call expression with 
                                               ts.SyntaxKind.Identifier argument 
                                               which is calculated above
]

因此我们将获得如下元数据:

请注意,参数只是静态对象值。

案例二

const dependency = {
  property: 'value'               ===========> Non-exported VariableStatement
} as TestDependency;                           with value { property: 'value' }

const dependencyCopy = { 
  id: 1,                         ============> Non-exported VariableStatement
  name: 'first',                               with value { id: 1, name: 'first' }                       
  ...dependency                                (Spread operator is not supported here)
};
dependencyCopy.name = 'last';     ===========> ExpressionStatement is skipped
                                               (see list of supported types above)

...
TestModule.forRoot(dependencyCopy) ===========> Call expression with 
                                                ts.SyntaxKind.Identifier argument 
                                                which is calculated above

这是我们现在得到的:

案例三

providers: [
  { provide: TEST_DEPENDENCY, useValue: dependencyCopy }
],

在第 5 版中 angular 移动到(几乎)使用转换器的原生 TS 编译过程并引入了所谓的 Lower Expressions transformer 这基本上意味着我们现在可以在装饰器元数据中使用箭头函数,例如:

providers: [{provide: SERVER, useFactory: () => TypicalServer}]

将由 angular 编译器自动转换为以下内容:

export const ɵ0 = () => new TypicalServer();
...
providers: [{provide: SERVER, useFactory: ɵ0}]

现在让我们阅读documentation:

The compiler treats object literals containing the fields useClass, useValue, useFactory, and data specially. The compiler converts the expression initializing one of these fields into an exported variable, which replaces the expression. This process of rewriting these expressions removes all the restrictions on what can be in them because the compiler doesn't need to know the expression's value—it just needs to be able to generate a reference to the value.

现在让我们看看在第三种情况下它是如何工作的:

我们可以看到 angular 现在使用对 dependencyCopy 对象的引用。正如您已经注意到的那样,它将在生成的工厂中用作 var ɵ0 = dependencyCopy;

可能的解决方案:

test.module.ts

export class TestModule {
  static forRoot(dependency?: { data: TestDependency }): ModuleWithProviders {
    return {
      ngModule: TestModule,
      providers: [
        TestService,
        { provide: TEST_DEPENDENCY, useValue: dependency.data }
      ]
    };
  }
}

app.module.ts

TestModule.forRoot({ data: dependencyCopy })