关于变化检测机制,zones和ExpressionChangedAfterItHasBeenCheckedError错误的综述
如果您更喜欢看视频的话,请点击这里。
本文删去了译者认为与主题无关的内容,您可以点击查看原文。
初次相遇
下面是一个简单的Angular组件,它在应用中发生变化检测时将时间渲染到屏幕上。时间戳的精度是毫秒。点击按钮触发变化监测:
组件的代码实现如下:
@Component({
selector: 'my-app',
template: `
<h3>
Change detection is triggered at:
<span [textContent]="time | date:'hh:mm:ss:SSS'"></span>
</h3>
<button (click)="0">Trigger Change Detection</button>
`
})
export class AppComponent {
get time() {
return Date.now();
}
}
复制代码
如你所见,这个组件非常基础。组件类中有个名为time
的getter,返回当前的时间戳。我将它绑定到了HTML中的span
上。
Angular不允许空的表达式,所以我在click的回调中放了一个0
点这里体验这个组件。当Angular运行变化检测时,它将time
属性的值传给date
管道,并使用返回的结果更新DOM。看起来似乎没有什么不对的。然而,当我打开控制台的时候却看到了ExpressionChangedAfterItHasBeenCheckedError
错误。
这令人吃惊。一般来说,这个错误出现在复杂得多的应用中。那我们是怎么在这么简单的一个功能中触发了这个错误?别担心,我们现在来调查一下。
先看一下报错的信息。
表达式在被检查之后发生了变化。之前的值:“textContent: 1542375826274”。 现在的值:“textContent: 1542375826275”。
它告诉我们,表达式产生的被绑定到textContent
上的值改变了。可以看到毫秒的数值确实不一样了。所以Angular将time | data: 'hh:mm:ss:SSS'
表达式计算了两次并且将结果进行了比较。Angular检测到了两个值不同,这就是报错的原因。
但是为什么Angular要对值进行比较?它在什么时候做了这件事?
这些问题激发了我的好奇心,并最终使我深入到变化检测的内部原理。因为为了找到这些问题的答案,我必须开始调试。我不停地调试,再调试。好吧。。。我想我大概花了一两个月 ?。我们先从第二个问题开始,这个错误在什么时候被抛出的。但我要先分享一些我的发现,这些发现可以帮助我们理解上面的错误。
组件视图和数据绑定
在Angular的变化检测中有两个主要的构成元素:
- 一个组件的视图
- 相关的数据绑定
Angular中的每个组件都有一个由HTML元素构成的模板。Angular创建了DOM节点以便将模板中的内容渲染到屏幕上,它需要有一个地方存储这些DOM节点的引用。为此,在Angular内部有一个称为视图的数据结构。它也被用来存储组件实例的引用以及绑定表达式之前的值。组件和视图之间是一对一的关系。下面是图示:
编译器在分析模板时,它会识别可能需要在变化检测期间被更新的DOM元素的属性。编译器为每个这样的属性创建一个绑定。数据绑定定义了需要更新的属性名称和Angular用于获取新值的表达式。
在我们的例子中,time
属性被用在textContent
属性的表达式中。所以Angular创建了绑定并将它关联到span
元素:
在实际的实现中,绑定不是一个有着所有必须信息的单独的对象。一个
viewDefinition
为模板元素和需要更新的属性定义了绑定。用于绑定的表达式被置于updateRenderer
函数中。
检查组件视图
如你所知,在Angular中,每个组件都会执行变化检测。我们现在已经知道组件在内部被表达为视图,因此我们可以说每个视图都会执行变化检测。
当Angular检查一个视图时,它只会运行所有编译器为视图生成的绑定。它对表达式求值然后将它们的结果存在视图的oldValues
数组中。这就是脏检查名字的由来。如果它检测到了变化,它就会更新与绑定相关的DOM属性。并且它需要将这个新的值放入视图的oldValues
数组。之后你就得到了一个更新过的UI。一旦Angular完成了当前组件的检测,它会递归地去检查子组件。
在我们的应用中,只有一个绑定,连接到App
组件中的span
元素的textContent
属性。所以在变化检测期间,Angular读取了组件类的time
属性的值,并将其应用到date
管道上,然后将返回值与储存在视图中的旧值相比较。如果它检测到不同,Angular会更新span
的textContent
属性和oldValues
数组。
但是我们的错误是从哪里跑出来的?
在开发模式下,每一次变化检测循环之后,Angular同步地运行另一次检查以确保表达式生成的值与之前在变化检测中的相同。这个检查不是原始变化监测循环的一部分。它在整个组件树的变化检查结束之后执行完全相同的步骤。然而,在这一次检查中,当Angular检测到了变化时不会更新DOM。相反,它会抛出ExpressionChangedAfterItHasBeenCheckedError
错误。
为什么
我们现在知道了这个错误在什么时候会被抛出。**但是为什么Angular需要做这次检查?**好吧,想象一下,组件类中的某些属性在变化检测运行期间已经被更新了。而结果是,表达式产生了与我们渲染到UI中的值不一致的新值。那么Angular做了什么?它当然可以再运行一次变化检测以同步应用状态和UI。但假如在这个过程中,某些属性再次被更新了呢?看到了吗?Angular可能会在无限的变化检测循环中崩溃。事实上,这在AngularJS中经常发生。
为了避免这种情况,Angular强制实行了被称为单项数据流的模式。并且在变化检测之后运行的检查和由此产生的ExpressionChangedAfterItHasBeenCheckedError
错误是强制的机制。一旦Angular处理完了当前组件的绑定,你就不能再更新绑定表达式中使用的属性。
修复错误
为了阻止这个错误,我们需要确保表达式在变化检测期间与随后的检查中返回的值是相同的。在我们的例子中,我们可以通过将求值部分移除time
getter来做到这一点:
export class AppComponent {
_time;
get time() { return this._time; }
constructor() {
this._time = Date.now();
}
}
复制代码
但这样做的话,getter time
返回的值始终都是一样的。我们仍然需要更新这个值。我们在之前了解到产生错误的检查在变化检测循环之后立即同步运行。那如果我们异步地去更新它,就可以避免这个错误。所以我们可以使用setInterval
函数每隔1ms就更新该值。
export class AppComponent {
_time;
get time() { return this._time; }
constructor() {
this._time = Date.now();
setInterval(() => {
this._time = Date.now();
}, 1);
}
}
复制代码
这个方法解决了我们最初的问题。但不幸的是,它带来了新的问题。所有的计时器,像setInterval
,都会触发Angular的变化检测。这意味着使用了这种方法,我们会陷入无穷无尽的变化检测循环中。**为了避免这个问题,我们需要一种不会触发变化检测的方式来运行setInterval
。**我们很幸运,确实有这样的一种方式。首先我们需要理解为什么在Angular中setInterval
会触发变化检测,才能知道怎么去达到我们的目的。
zones提供的自动变化检测
与React相反,Angular中的变化检测可以完全自动地由浏览器中的任何一个异步事件触发。通过使用zone.js
这个库,这种触发变化监测的方式得以实现,同时引入了zones的概念。与一般的看法相反,zones不是Angular变化检测机制的一部分。事实上,Angular的运行并不需要它们。这个库仅仅提供了一种拦截异步事件的方法,比如setInterval
,并且通知Angular发生了异步事件。Angular基于这个通知来运行变化检测。
有趣的是,在一个网页中,你可以有很多不同的zones。其中一个是NgZone
。它在Angular启动的时候被创建。Angular应用就运行在这个zone中。只有在zone中发生的异步事件才会通知Angular。
但是,zone.js
也提供了一个API,以便在Angular zone之外的zone中运行某些代码。其他zone中发生异步事件时,Angular并不会收到通知。没有通知就意味着没有变化检测。这个方法名叫runOutsideAngular
并由NgZone
服务实现。
下面是注入NgZone
并且在Angular zone之外运行setInterval
的代码:
export class AppComponent {
_time;
get time() {
return this._time;
}
constructor(zone: NgZone) {
this._time = Date.now();
zone.runOutsideAngular(() => {
setInterval(() => {
this._time = Date.now()
}, 1);
});
}
}
复制代码
现在我们不停地更新时间,但是这些操作是异步的,并且在Angular zone之外。这保证了变化检测期间和接下来的检查中getter time
返回相同的值。此外,Angular在下一次变化检测中读取到的time
的值将会被更新并且变化会被反映在屏幕上。
使用NgZone来在Angular之外运行某些代码以避免触发变化检测是一种常用的优化技巧。
调试
你也许想知道是否有办法看到Angular中的视图和绑定。事实上,确实有。在@angular/core
模块中有一个名为checkAndUpdateView
的函数。它遍历组件树中的视图(组件)并对每个视图执行检测。当我遇到与变化检测相关的问题是,我总是从这个函数开始调试。
自己尝试使用这个demo去进行调试。打开控制台,找到那个函数并打上断点。点击按钮触发变化监测。审查view
变量。下面的动图是我的演示。
第一个view
会成为宿主视图。它是Angular创建的一个根组件,用来托管app组件。我们需要恢复执行,以获得它的子视图,也就是我们AppComponent
的视图。去探索它吧。component
属性存放了App
组件的实例。node
属性存放了DOM节点的引用,这些DOM节点是为App
组件的模板中的元素创建的。oldValues
数组存储了绑定表达式的结果。
操作的顺序
我们刚刚了解到,因为单项数据流的限制,在组件被检查后,你不能在变化检测期间改变组件的某些属性。绝大多数时候,当Angular对子组件进行变化检测时,数据的更新通过共享服务或者同步事件进行广播。但是也有可能直接将一个父组件注入到一个子组件中,然后通过生命周期钩子更新父组件的状态。下面的代码可以表明这一点:
@Component({
selector: 'my-app',
template: `
<div [textContent]="text"></div>
<child-comp></child-comp>
`
})
export class AppComponent {
text = 'Original text in parent component';
}
@Component({
selector: 'child-comp',
template: `<span>I am child component</span>`
})
export class ChildComponent {
constructor(private parent: AppComponent) {}
ngAfterViewChecked() {
this.parent.text = 'Updated text in parent component';
}
复制代码
你可以在这里进行检验。我们简单地定义了两个组件的层级。父组件声明了text
属性,并绑定到视图中。子组件在构造器中注入了父组件并在ngAfterViewChecked
生命周期钩子中更新了它的属性。你能猜到我们将在控制台中看到什么吗? ?
没错,熟悉的ExpressionChangedAfterItWasChecked
错误。这是因为当Angular在子组件中调用ngAfterViewChecked
生命周期钩子时,父级App
组件的绑定已经被检查过了。但我们在检查之后更新了父组件中的text
属性。
不过这里有一个有趣的地方。假如我换一个钩子呢?也就是说,在ngOnInit
中去做这件事。你觉得我们还会看到这个错误吗?
export class ChildComponent {
constructor(private parent: AppComponent) {}
ngOnInit() {
this.parent.text = 'Updated text in parent component';
}
}
复制代码
这一次不会再报错了。请查看demo。事实上,我们可以把这段代码放到任何其他的钩子中(不包括AfterViewInit
和AfterViewChecked
),就不会在控制台中看到这个错误。那么这里发生了什么?为什么ngAfterViewChecked
钩子如此特殊?
为了理解这个行为,我们需要知道Angular在变化检测期间执行了什么操作并且是以什么顺序执行的。我们已经知道该去哪里找到答案:我之前展示过的checkAndUpdateView
函数。下面是该函数体里面的一部分代码:
function checkAndUpdateView(view, ...) {
...
// 更新子视图(组件)和指令中的绑定,
// 如果有需要的话,调用NgOnInit, NgDoCheck and ngOnChanges钩子
Services.updateDirectives(view, CheckType.CheckAndUpdate);
// DOM更新,为当前视图(组件)执行渲染
Services.updateRenderer(view, CheckType.CheckAndUpdate);
// 在子视图(组件)中执行变化检测
execComponentViewsAction(view, ViewAction.CheckAndUpdate);
// 调用AfterViewChecked和AfterViewInit钩子
callLifecycleHooksChildrenFirst(…, NodeFlags.AfterViewChecked…);
...
}
复制代码
如你所见,Angular会在变化检测期间触发生命周期钩子。**有趣的是当Angular在处理绑定时,一些钩子在渲染之前被调用,一些钩子在渲染之后被调用。**下面这张图演示了在Angular为父组件运行变化检测期间发生了什么:
让我们一步步地来理清它。首先,它为子组件更新输入绑定。之后它又调用了子组件上的OnInit
,DoCheck
和Onchanges
钩子。这一步是有意义的,因为它刚刚更新了输入绑定所以Angular需要通知子组件输入绑定已经被初始化了。然后Angular为当前组件执行渲染。在这之后,它为子组件运行变化检测。这意味着它会在子视图中重复这些操作。最后,它调用了子组件上的AfterViewChecked
和AfterViewInit
钩子让其知道已经被检查了。
在这里我们可以注意到Angular在处理了父组件的绑定之后之后才调用子组件的AfterViewChecked
生命周期钩子。另一方面,OnInit
钩子在绑定被处理之前调用。所以即使在OnInit
中改变了text
的值,在随后的检查中它仍然是相同的。这就解释了在ngOnInit
中不会有错误的奇怪行为。谜底揭晓?。
总结
现在我们总结一下刚刚学到的东西。Angular中的所有组件在内部都被表示为一种叫视图的数据结构。Angular的编译器解析模板并创建绑定。每一个绑定定义了一个要更新的DOM元素的属性和用于求值的表达式。视图中的oldValues
属性存储了在变化检测中被用于比较的旧值。在变化检测期间,Angular遍历所有绑定,对表达式求值,将它们与旧值比较,如果有必要的话就更新DOM。每个变化检测循环之后,Angular运行一次检查以确保组建的状态与用户界面同步。这次检查是同步运行的并且可能会抛出ExpressionChangedAfterItWasChecked
错误。
推荐
如果你正在找寻关于Angular中变化监测的更深入的解释,这篇文章会是一个好的起点。它收集了一些有关变化检测的深度好文,例如zones,DOM更新机制,单项数据流和ExpressionChangedAfterItWasChecked
错误。