最近在开发过程中,遇到了 ExpressionChangedAfterItHasBeenCheckedError 这个错误,网上搜索一翻后,发现各种说法众说纷纭,所有抽出时间深入了解一下这个错误,做一下总结,也可以避免以后在遇到这个问题的时候不知道怎么处理
简单来说,这个错误主要涉及到 Angular 的变化监测机制,不过关于 Angular 中的变化监测机制到底是什么样的可以参考 Angular 中的变化检测机制 这篇文章,本章主要介绍的是如何解决和避免 ExpressionChangedAfterItHasBeenCheckedError 这个错误
Angular 中的变化监测机制
在 Angular 当中,每个 Angular 应用都是以组件树的形态呈现的,Angular 在变化监测阶段会按以下的顺序对每个组件执行如下操作(标记为 List1)
- 更新所有绑定在子
component/directive上的属性 - 调用所有子
component/directive的ngOnInit,ngOnChanges,ngDoCheck生命周期函数 - 解析、更新当前组件
DOM上的value - 运行子
component的变化监测流程(List1) - 调用所有子
component/directive上的ngAfterViewInit生命周期
这里需要注意,在每一步操作之后,Angular 都会保存与这次操作有关的 values 值,这个值被存在组件 view 的 oldValues 属性中,在开发模式下,所有组件完成变化监测之后 Angular 会开始下一个监测流程,第二次监测流程并不会再次执行上面列出的变化监测流程,而会比较之前变化监测循环保存的值(存在 oldValues 中的)与当前监测流程的值是否一致(标记为 List2)
- 检查被传递到子组件的
values(oldValues)与当前组件要被用于更新的values(instance.value)是否一致 - 检查被用于更新
DOM元素的values(oldValues)与当前要被用于这些组件更新的values(instance.value)是否一致 - 对所有子
component执行相同的检查
需要注意的是,这些额外的检查(
List2)只发生在开发模式下
出现原因
接下来我们来看一个例子,假设你有一个父组件 A 和一个子组件 B,A 组件中有两个属性 name 和 text,A 组件的模板中使用了 name 属性
1 | template: '<span>{{name}}</span>' |
然后在模板中加入 B 组件,并且通过输入属性绑定给 B 组件输入 text 属性
1 | // A 组件当中使用 B 组件 |
那么 Angular 在开始变化监测后会发生什么呢?按照上面的流程,List1 变化监测会从 A 组件开始检查,第一步将 text 表达式中的 A message for the child component 向下传递到 B 组件,并且将这个值存在 view 上
1 | view.oldValues[0] = 'A message for the child component' |
然后到了变化监测列表里的第二步,调用相应的生命周期函数,接下来再执行第三步,将 name 表达式解析为 I am A component 文本,将解析好的值更新到 DOM 上,并且存入 oldValues
1 | view.oldValues[1] = 'I am A component' |
最后 Angular 对 B 组件执行相同的操作(List1),一旦 B 组件完成以上的操作,此次变化监测循环便完成了,但是如果 Angular 在开发模式下运行,那么将会执行另一个监测流程(List2),text 属性在传递给 B 组件时的值是 A message for the child component 并存入 oldValues ,现在想象一下 A 组件在此之后将 text 的值更新为 updated text,然后 List2 的第一步将会检查 text 属性是否被改变
1 | AComponentView.instance.text === view.oldValues[0] // false |
这个时候 Angular 就该抛出这个错误了
1 | Angular Debugging "Expression has changed after it was checked": Simple Explanation (and Fix) |
同理,如果更新已经被渲染在 DOM 中并且被存在 oldValues 中的 name 属性,也会抛出相同的错误
1 | AComponentView.instance.name === view.oldValues[1] // false |
现在你可能会有些疑惑,这些值怎么会被改变呢?
数据改变的原因
引起这个错误的罪魁祸首一般都是子组件或指令,下面我们来详细的看一下之前的示例,我们将在子组件的 ngOnInit(此时数据已绑定)生命周期钩子中更新 text 属性
1 | // B 组件 |
我们可以看到预期的错误
1 | Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. |
现在我们对被用于父组件模板的 name 属性做相同的操作
1 | ngOnInit() { |
这时候程序并没有报错,为什么会这样呢?如果你仔细看变化监测(List1)的执行顺序,你会发现子组件的 ngOnInit 将在当前 component 的 DOM 更新之前被调用(在记录 oldValues 前改变了数据),这就是为什么上面的例子中更改 name 属性却不会报错,然后我们来利用一个在 DOM 中 values 更新之后的钩子来做实验,比如 ngAfterViewInit
1 | export class BComponent { |
我们又一次得到了预期的错误
1 | AppComponent.ngfactory.js:8 ERROR Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. |
当然现实中遇到的情况会更加错综复杂,父组件中属性在二次监测之前被更新通常是使用的外部服务或 observabals 间接导致的,但是其本质原因是相同的
可行解决方案
如果你 google 过这个错误,那么你应该看过一些回答推荐使用异步更新数据和强制增加一个变化监测循环两种方法来解决这个错误,比如在动态创建组件的情况下,解决这个问题最好的方案是改变创建组件时所处的生命周期钩子,虽然这两种方式都可以解决问题,但是还是更推荐重新设计你的应用而不是使用这两种方法来解决这个问题
异步更新
你应该注意到一件事,不管是变化监测还是第二次的验证 digest 都是同步执行的,这意味着如果我们在代码中异步更新属性的值,那么在第二次验证循环运行时这些属性是不会被改变的,那么也就不会报错了
1 | export class BComponent { |
确实没有错误抛出,setTimeout 将函数加入 Macrotask 队列中,函数会在下一个 VM 周期里被调用,也可以通过使用 Promise 里的 then 回调将函数加入当前 VM 周期其他同步代码被执行完之后
1 | Promise.resolve(null).then(() => this.parent.name = 'updated name') |
Promise.then 并不会被放入 Macrotask,而是创建一个 Microtask,Microtask 队列将在当前周期中所有同步代码被执行完毕之后执行,因此属性的更新会发生在验证步骤之后
另外补充一个知识点,给
EventEmitter传一个true能使事件的emit变为异步
1 | new EventEmitter(true) |
强制变化监测
另一个解决方案是在父组件 A 的第一和第二次验证之间强制加一个变化监测循环,触发强制变化监测的最佳位置是在 ngAfterViewInit 生命周期内,这时候所有的子组件的流程都已经执行完毕,所以随便在之前的哪个位置改变父组件的属性都无所谓
1 | export class AppComponent { |
一样没有报错,但是其实这里有个问题,当在父组件 A 中触发新添加的变化监测时,Anuglar 同样会为所有的子组件运行一次变化监测,那么父组件可能会被又一次更新
为什么需要第二次监测循环
Angular 强制使用至上而下的单向数据流,在父元素完成变化监测之后不允许内部子组件在第二次变化监测前改变父组件的属性,这能确保第一次变化监测后的组件树是稳定的,如果在监测循环周期里有属性的改变导致依赖这些属性的使用者需要同步更新变化,那么这棵组件树就是不稳定的,上面例子中子组件 B 依赖父组件的 text 属性,每当属性的值改变,在这些改变被传递到 B 组件之前这棵组件树都处于不稳定的状态
这同样体现在 DOM 与属性之间的关系上,DOM 作为这些属性的使用者,然后将这些属性渲染到 UI 界面上,如果某些属性没有同步更新到界面上,用户将会看到错误的界面,所以如果你在数据同步过程完成之后再通过子组件修改父组件中的属性会发生什么呢?是的,你留下了一个不稳定的组件树,其中数据变更的顺序将无法预测,大部分时候这将会给用户呈现出一个有错误数据的页面,而且问题的排查将十分困难
可能你会问了,那为什么不等到组件树稳定之后再进行变化监测呢?
答案很简单,组件树可能永远不会稳定下来,一个子组件更新了父组件中的属性,父组件的属性又更新子组件的状态,子组件状态的更新又触发更新父组件的属性…,这将是个无限循环,之前展示了很多组件对属性直接更新或依赖的情况,但实际中的应用对属性的更新和依赖通常是间接,不易排查的
最后一个问题是,为什么第二次循环监测只在开发模式下运行?
猜想这是因为数据层不稳定在框架运行时并不会产生引人关注的错误,毕竟数据在下一次监测循环后就会稳定下来,当然,在开发时期将可能得错误解决总好过在上线后的应用中排查错误
示例一,共享服务
示例见 共享服务
这个应用中父组件和子组件共用一个共享服务,子元素通过共享服务设置一个属性的值并反映到父元素上,这个模式下子元素改变父元素的值的方式并不像上面简单例子中那么显而易见,是间接更新了父元素的属性
示例二,同步事件广播
示例见 同步事件广播
这个应用中父元素监听一个子元素广播的事件,这个事件导致父元素的属性被更新,这个属性又被用于子元素的 Input 绑定,这同样间接更新了父元素的属性
示例三:动态的组件实例化
示例见 动态的组件实例化
这种模式与之前两种模式略有不同,前两种模式都是 List2 中的第一步检测抛出的错误,而这种模式是由 DOM 更新检测(List2 第二步)抛出的错误,这个应用中父组件在 ngAfterViewInit 生命周期中动态添加子组件,该生命周期发生在当前组件 DOM 初次更新之后,而添加子组件将会修改 DOM 结构,那么前后两次 DOM 中所使用的 values 值就不同了(前提是子组件带有新的 value 引用),所以抛出了错误
解决这个问题最好的方案是改变创建组件时所处的生命周期钩子,动态创建组件的流程就可以被移到 ngOnInit 中,即使文档中说明了 ViewChildren 只能在 ngAfterViewInit 之后被获取到,但是创建视图时就在填充子组件了,所以能提前获取 ViewChildren