[译] 关于 `ExpressionChangedAfterItHasBeenCheckedError` 错误你所须要知道的事情

原文连接: Everything you need to know about the ExpressionChangedAfterItHasBeenCheckedError error
关于 ExpressionChangedAfterItHasBeenCheckedError,还能够参考这篇文章,而且文中有 youtube 视频讲解: Angular Debugging "Expression has changed after it was checked": Simple Explanation (and Fix)

最近 stackoverflow 上几乎天天都有人提到 Angular 抛出的一个错误:ExpressionChangedAfterItHasBeenCheckedError,一般提出这个问题的 Angular 开发者都不理解变动检测(change detection)的原理,不理解为什么产生这个错误的数据更新检查是必须的,甚至不少开发者认为这是 Angular 框架的一个 bug(译者注:Angular 提供变动检测功能,包括自动触发和手动触发,自动触发是默认的,手动触发是在使用 ChangeDetectionStrategy.OnPush 关闭自动触发的状况下生效。如何手动触发,参考 Triggering change detection manually in Angular)。固然不是了!其实这是 Angular 的警告机制,防止因为模型数据(model data)与视图 UI 不一致,致使页面上存在错误或过期的数据展现给用户。express

本文将解释引发这个错误的内在缘由,检测机制的内部原理,提供致使这个错误的共同行为,并给出修复这个错误的解决方案。最后章节解释为何数据更新检查是如此重要。api

It seems that the more links to the sources I put in the article the less likely people are to recommend it ?. That’s why there will be no reference to the sources in this article.(译者注:这是做者的吐槽,不翻译)浏览器

相关变动检测行为

一个运行的 Angular 程序实际上是一个组件树,在变动检测期间,Angular 会按照如下顺序检查每个组件(译者注:这个列表称为列表 1):angular2

  • 更新全部子组件/指令的绑定属性
  • 调用全部子组件/指令的三个生命周期钩子:ngOnInitOnChangesngDoCheck
  • 更新当前组件的 DOM
  • 为子组件执行变动检测(译者注:在子组件上重复上面三个步骤,依次递归下去)
  • 为全部子组件/指令调用当前组件的 ngAfterViewInit 生命周期钩子

在变动检测期间还会有其余操做,能够参考我写的文章:《Everything you need to know about change detection in Angular》框架

在每一次操做后,Angular 会记下执行当前操做所须要的值,并存放在组件视图的 oldValues 属性里(译者注:Angular Compiler 会把每个组件编译为对应的 view class,即组件视图类)。在全部组件的检查更新操做完成后,Angular 并非立刻接着执行上面列表中的操做,而是会开始下一次 digest cycle,即 Angular 会把来自上一次 digest cycle 的值与当前值比较(译者注:这个列表称为列表 2):less

  • 检查已经传给子组件用来更新其属性的值,是否与当前将要传入的值相同
  • 检查已经传给当前组件用来更新 DOM 值,是否与当前将要传入的值相同
  • 针对每个子组件执行相同的检查(译者注:就是若是子组件还有子组件,子组件会继续执行上面两步的操做,依次递归下去。)

记住这个检查只在开发环境下执行,我会在后文解释缘由。dom

让咱们一块儿看一个简单示例,假设你有一个父组件 A 和一个子组件 B,而 A 组件有 nametext 属性,在 A 组件模板里使用 name 属性的模板表达式:异步

template: '<span>{{name}}</span>'

同时,还有一个 B 子组件,并将 A 父组件的 text 属性以输入属性绑定方式传给 B 子组件:函数

@Component({
    selector: 'a-comp',
    template: `
        <span>{{name}}</span>
        <b-comp [text]="text"></b-comp>
    `
})
export class AComponent {
    name = 'I am A component';
    text = 'A message for the child component`;

那么当 Angular 执行变动检测的时候会发生什么呢?首先是从检查父组件 A 开始,根据上面列表 1 列出的行为,第一步是更新全部子组件/指令的绑定属性(binding property),因此 Angular 会计算 text 表达式的值为 A message for the child component,并将值向下传给子组件 B,同时,Angular 还会在当前组件视图中存储这个值:oop

view.oldValues[0] = 'A message for the child component';

第二步是执行上面列表 1 列出的执行几个生命周期钩子。(译者注:即调用子组件 BngOnInitOnChangesngDoCheck 这三个生命周期钩子。)

第三步是计算模板表达式 {{name}} 的值为 I am A component,而后更新当前组件 A 的 DOM,同时,Angular 还会在当前组件视图中存储这个值:

view.oldValues[1] = 'I am A component';

第四步是为子组件 B 执行以上第一步到第三步的相同操做,一旦 B 组件检查完毕,那本次 digest loop 结束。(译者注:咱们知道 Angular 程序是由组件树构成的,当前父组件 A 组件作了第一二三步,完过后子组件 B 一样会去作第一二三步,若是 B 组件还有子组件 C,一样 C 也会作第一二三步,一直递归下去,直到当前树枝的最末端,即最后一个组件没有子组件为止。这一次过程称为 digest loop。)

若是处于开发者模式,Angular 还会执行上面列表 2 列出的 digest cycle 循环核查。如今假设当 A 组件已经把 text 属性值向下传入给 B 组件并保存该值后,这时 text 值突变为 updated text,这样在 Angular 运行 digest cycle 循环核查时,会执行列表 2 中第一步操做,即检查当前digest cycle 的 text 属性值与上一次时的 text 属性值是否发生变化:

AComponentView.instance.text === view.oldValues[0]; // false
'A message for the child component' === 'updated text'; // false

结果是发生变化,这时 Angular 会抛出 ExpressionChangedAfterItHasBeenCheckedError 错误。

列表 1 中第三步操做也一样会执行 digest cycle 循环检查,若是 name 属性已经在 DOM 中被渲染,而且在组件视图中已经被存储了,那这时 name 属性值突变一样会有一样错误:

AComponentView.instance.name === view.oldValues[1]; // false
'I am A component' === 'updated name'; // false

你可能会问上面提到的 textname 属性值发生突变,这会发生么?让咱们一块儿往下看。

属性值突变的缘由

属性值突变的罪魁祸首是子组件或指令,一块儿看一个简单证实示例吧。我会先使用最简单的例子,而后举个更贴近现实的例子。你可能知道子组件或指令能够注入它们的父组件,假设子组件 B 注入它的父组件 A,而后更新绑定属性 text。咱们在子组件 BngOnInit 生命周期钩子中更新父组件 A 的属性,这是由于 ngOnInit 生命周期钩子会在属性绑定完成后触发(译者注:参考列表 1,第一二步操做):

export class BComponent {
    @Input() text;

    constructor(private parent: AppComponent) {}

    ngOnInit() {
        this.parent.text = 'updated text';
    }
}

果真会报错:

Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'A message for the child component'. Current value: 'updated text'.

如今咱们再一样改变父组件 Aname 属性:

ngOnInit() {
    this.parent.name = 'updated name';
}

纳尼,竟然没有报错!!!怎么可能?

若是你往上翻看列表 1 的操做执行顺序,你会发现 ngOnInit 生命周期钩子会在 DOM 更新操做执行前触发,因此不会报错。为了有报错,看来咱们须要换一个生命周期钩子,ngAfterViewInit 是个不错的选项:

export class BComponent {
    @Input() text;

    constructor(private parent: AppComponent) {}

    ngAfterViewInit() {
        this.parent.name = 'updated name';
    }
}

还好,终于有报错了:

AppComponent.ngfactory.js:8 ERROR Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'I am A component'. Current value: 'updated name'.

固然,真实世界的例子会更加复杂,改变父组件属性从而引起 DOM 渲染,一般间接是由于使用服务(services)或可观察者(observables)引起的,不过根本缘由仍是同样的。

如今让咱们看看真实世界的案例吧。

共享服务(Shared service)

这个模式案例可查看代码 plunker。这个程序设计为父子组件有个共享的服务,子组件修改了共享服务的某个属性值,响应式地致使父组件的属性值发生改变。我把它称为非直接父组件属性更新,由于不像上面的示例,它明显不是子组件马上改变父组件属性值。

同步事件广播

这个模式案例可查看代码 plunker。这个程序设计为子组件抛出一个事件,而父组件监听这个事件,而这个事件会引发父组件属性值发生改变。同时这些属性值又被父组件做为输入属性绑定传给子组件。这也是非直接父组件属性更新。

动态组件实例化

这个模式有点不一样于前面两个影响的是输入属性绑定,它引发的是 DOM 更新从而抛出错误,可查看代码 plunker。这个程序设计为父组件在 ngAfterViewInit 生命周期钩子动态添加子组件。由于添加子组件会触发 DOM 修改,而且 ngAfterViewInit 生命周期钩子也是在 DOM 更新后触发的,因此一样会抛出错误。

解决方案

若是你仔细查看错误描述的最后部分:

Expression has changed after it was checked. Previous value:… Has it been created  in a change detection hook ?

根据上面描述,一般的解决方案是使用正确的生命周期钩子来建立动态组件。例如上面建立动态组件的示例,其解决方案就是把组件建立代码移到 ngOnInit 生命周期钩子里。尽管官方文档说 ViewChild 只有在 ngAfterViewInit 钩子后才有效,可是当建立视图时它就已经填入了子组件,因此在早期阶段就可用。(译者注:Angular 官网说的是 View queries are set before the ngAfterViewInit callback is called,就已经说明了 ViewChild 是在 ngAfterViewInit 钩子前生效,不明白做者为啥要说以后才能生效。)

若是你 google 下就知道解决这个错误通常有两种方式:异步更新属性和手动强迫变动检测。尽管我列出这两个解决方案,但不建议这么去作,我将会解释缘由。

异步更新

这里须要注意的事情是变动检测和核查循环(verification digests)都是同步的,这意味着若是咱们在核查循环(verification loop)运行时去异步更新属性值,会致使错误,测试下吧:

export class BComponent {
    name = 'I am B component';
    @Input() text;

    constructor(private parent: AppComponent) {}

    ngOnInit() {
        setTimeout(() => {
            this.parent.text = 'updated text';
        });
    }

    ngAfterViewInit() {
        setTimeout(() => {
            this.parent.name = 'updated name';
        });
    }
}

实际上没有抛出错误(译者注:耍我呢!),这是由于 setTimeout() 函数会让回调在下一个 VM turn 中做为宏观任务(macrotask)被执行。若是使用 Promise.then 回调来包装,也可能在当前 VM turn 中执行完同步代码后,紧接着在当前 VM turn 继续执行回调:(译者注:VM turn 就是 Virtual Machine Turn,等于 browser task,这涉及到 JS 引擎如何执行 JS 代码的知识,这又是一块大知识,不详述,有兴趣能够参考这篇经典文章 Tasks, microtasks, queues and schedules ,或者这篇详细描述的文档 从浏览器多进程到JS单线程,JS运行机制最全面的一次梳理 。)

Promise.resolve(null).then(() => this.parent.name = 'updated name');

与宏观任务(macrotask)不一样,Promise.then 会把回调构形成微观任务(microtask),微观任务会在当前同步代码执行完后再紧接着被执行,因此在核查以后会紧接着更新属性值。想要更多学习 Angular 的宏观任务和围观任务,能够查看我写的  I reverse-engineered Zones (zone.js) and here is what I’ve found

若是你使用 EventEmitter 你能够传入 true 参数实现异步:

new EventEmitter(true);

强迫式变动检测

另外一种解决方案是在第一次变动检测和核查循环阶段之间,再一次迫使 Angular 执行父组件 A 的变动检测(译者注:因为 Angular 先是变动检测,而后核查循环,因此这段意思是变动检测完后,再去变动检测)。最佳时期是在 ngAfterViewInit 钩子里去触发父组件 A 的变动检测,由于这个父组件的钩子函数会在全部子组件已经执行完它们本身的变动检测后被触发,而偏偏是子组件作它们本身的变动检测时可能会改变父组件属性值:

export class AppComponent {
    name = 'I am A component';
    text = 'A message for the child component';

    constructor(private cd: ChangeDetectorRef) {
    }

    ngAfterViewInit() {
        this.cd.detectChanges();
    }

很好,没有报错,不过这个解决方案仍然有个问题。若是咱们为父组件 A 触发变动检测,Angular 仍然会触发它的全部子组件变动检测,这可能从新会致使父组件属性值发生改变。

为什么须要循环核查(verification loop)

Angular 实行的是从上到下的单向数据流,当父组件改变值已经被同步后(译者注:即父组件模型和视图已经同步后),不容许子组件去更新父组件的属性,这样确保在第一次 digest loop 后,整个组件树是稳定的。若是属性值发生改变,那么依赖于这些属性的消费者(译者注:即子组件)就须要同步,这会致使组件树不稳定。在咱们的示例中,子组件 B 依赖于父组件的 text 属性,每当 text 属性改变时,除非它已经被传给 B 组件,不然整个组件树是不稳定的。对于父组件 A 中的 DOM 模板也一样道理,它是 A 模型中属性的消费者,并在 UI 中渲染出这些数据,若是这些属性没有被及时同步,那么用户将会在页面上看到错误的数据信息。

数据同步过程是在变动检测期间发生的,特别是列表 1 中的操做。因此若是当同步操做执行完毕后,在子组件中去更新父组件属性时,会发生什么呢?你将会获得不稳定的组件树,这样的状态是不可测的,大多数时候你将会给用户展示错误的信息,而且很难调试。

那为什么不等到组件树稳定了再去执行变动检测呢?答案很简答,由于它可能永远不会稳定。若是把子组件更新了父组件的属性,做为该属性改变时的响应,那将会无限循环下去。固然,正如我以前说的,不论是直接更新仍是依赖的状况,这都不是重点,可是在现实世界中,更新仍是依赖通常都是非直接的。

有趣的是,AngularJS 并无单向数据流,因此它会试图想办法去让组件树稳定。可是它会常常致使那个著名的错误 10 $digest() iterations reached. Aborting!,去谷歌这个错误,你会惊讶发现关于这个错误的问题有不少。

最后一个问题你可能会问为何只有在开发模式下会执行 digest cycle 呢?我猜可能由于相比于一个运行错误,不稳定的模型并非个大问题,毕竟它可能在下一次循环检查数据同步后变得稳定。然而,最好能在开发阶段注意可能发生的错误,总比在生产环境去调试错误要好得多。

相关文章
相关标签/搜索