内容来自于 Max Koretskyi aka Wizard的《A gentle introduction into change detection in Angular》
让咱们从一个简单的Angular组件开始。他表现应用程序的变化检测。这个时间戳的精度为毫秒。点击triggers按钮触发检测:node
@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运行变化检测时,它获取time属性的值,经过日期管道传递它,并使用结果更新DOM。这一切都很正常,但当我打开控制台的时候,我看到了一个错误:ExpressionChangedAfterItHasBeenCheckedError
。typescript
事实上,这让咱们感到很是惊讶。一般这个错误出如今更加复杂的程序上。但为何一个如此简单的功能会致使这个错误呢?别担忧,咱们如今就来查看他的缘由。
让咱们先从错误消息开始:express
Expression has changed after it was checked. Previous value: “textContent: 1542375826274”. Current value: “textContent: 1542375826275”.
它告诉咱们,textContent
绑定的值是不一样的。的确,毫秒不相同。由于Angular经过表达式time | date:'hh:mm:ss:SSS'
计算了两次,并比较告终果。它检测到了两次值的差别,这就是致使错误的缘由。编程
但Angular为何要这样作?或者它何时作的?
在咱们了解这些问题的答案以前,咱们还须要了解另一些东西。
组件视图和绑定
Angular的变化检测主要有两个部分:数组
每个Angular的组件都有一个HTML元素。当Angular建立DOM节点并将内容渲染到屏幕上,它须要一个地方来储存DOM节点的引用。为了实现这一目标,Angular内部有一个被称为View的数据结构。它还用于存储对组件实例的引用和绑定表达式以前的值。而且视图和组件之间的关系是一一对应的。下图展现了该关系:浏览器
当编译器分析模板时,它会辨识在变化检测期间可能须要更新的DOM元素属性。每个这样的属性,编译器都会建立一个绑定。绑定定义要更新的属性名和Angular用来获取新值的表达式。数据结构
在咱们的例子当中,time
属性用于textContent
的表达式中。因此,Angular会建立绑定来链接它和span
元素。app
实际上,绑定不是包含全部必要信息的单个对象。viewDefinition
定义模板元素和要更新的属性的实际绑定。用于绑定的表达式在updateRenderer
方法中。
*检查组件视图
如你所知,Angular会对每个组件执行变化检测。如今咱们知道每一个组件在Angular内部被称为视图(view),咱们能够说Angular对每一个视图执行了变化检测。异步
当Angular检查视图时,它只需运行编译器为视图生成的全部绑定。它计算表达式并将它们的结果与视图上旧值数组中存储的值(oldValues
)进行比较。这就是脏检查这个名字的由来。若是检测到差别,它会更新与绑定相关的DOM属性。它还须要将新值放入视图的旧值数组中。就这样。您如今有了更新的用户界面。一旦完成当前组件的检查,它将对子组件重复彻底相同的步骤。在咱们的应用程序中,在App
组件中span
元素的属性textContent
只有一个绑定。因此在变化检测期间,Angular会读取组件time
属性的值,再使用date
管道,并将它与视图中存储的先前值进行比较。若是检测到不一样,Angular会更新span
旧值(oldValues
)数组中的textContent
属性.ide
可是错误又从哪里出来的呢?
在开发模式下,每一个变化检测周期以后,Angular会同步运行另一个检查,已确保表达式产生的值与以前变化检测运行期间的值相同。该检查不是原始检查的一部分,它在对整个组件树的检查完成后运行,并执行彻底相同的步骤。然而,当这一次变化检测期间,若是检测到不一样那个的值,Angular不会去更新DOM,相反的,它会直接抛出错误ExpressionChangedAfterItHasBeenCheckedError
。
可是Angular为何要这样作?
如今咱们知道何时抛出错误了。可是为何Angular须要这个检测。假设在变化检测运行期间,又有一些组件的一些属性被更新。此时,表达式产生的新值与用户界面中呈现的值不同。这个时候Angular应该怎么作?它固然也能够另外再运行一个变化检测周期来使应用程序状态与用户界面同步。但若是在这期间,又有一些属性被更新了呢?看到问题了吗?实际上Angular可能会在变化检测的无限循环中结束。这种状况在AngularJS中常常发生。
为了不这种事情,Angular强制让数据单向流动。这种在变动检测和结果表达式变动后运行的检查是强制机制。一旦Angular处理了当前组件的绑定,就不能再更新绑定表达式中使用的组件属性。
修复这个错误
为了防止这种错误的发生,咱们须要确保在改变检测周期表达式返回的值和检查值相同。在咱们的例子当中,咱们能够将变化值从time
的getter中移除,就像这样:
export class AppComponent { _time; get time() { return this._time; } constructor() { this._time = Date.now(); } }
然而,在实际中,time
的值永远都不会变化。咱们以前了解到,产生错误的检查会在变动检测周期以后同步运行。所以,若是咱们异步的去更新它,就不会出现这种错误。因此咱们为了每一毫秒去更新一次time
的值,咱们使用setInterval
函数,就像这样:
export class AppComponent { _time; get time() { return this._time; } constructor() { this._time = Date.now(); setInterval(() => { this._time = Date.now(); }, 1); } }
这个实现的确解决了咱们最初的问题。可是不幸的是,它又引入了一个新的问题。全部的定时时间,如setInterval
,都会触发Angular的变化检测机制。这意味着,若是经过这种方式来实现,咱们将会进入一个无线循环的变化检测周期。为了不触发Angular的变化检测,咱们须要一个不会触发Angular变化检测的setInterval
。幸运的是,咱们恰好有方法来实现这个需求。要了解如何作到这一点,咱们须要先了解为何setInterval
会触发Angular的变化检测。
带区域的自动变化检测
和React不一样,浏览器的任何异步事件均可以彻底自动触发Angular的变化检测。这是经过使用zone.js库实现的,该库引入了zone的概念。与广泛的见解不一样,zones并非Angular变化检测的一部分。事实上,Angular变化检测不须要zones也能够正常工做。该库只是提供一个异步事件的拦截方法(像setInterval
),并通知Angular。基于该通知,Angular启动变化检测。
有趣的是,一个网页上能够有许多个不一样的zone。其中一个就是NgZone
。它是由Angular建立的。这是Angular运行的zone。并且Angular只获取该区域内的事件通知。
可是,zone.js还提供了一个应用编程接口,能够在Angula 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以外执行的异步操做。这保证了在变化检测和随后的检查期间,time
返回相同的值。当Angular在下一个变化检测周期读取time
值时,该值将被更新,而且变化将被反映在屏幕上。
使用NgZone在Angular以外运行一些代码以免触发变化检测是一种常见的优化技术。
Debugging
你可能想知道,是有有什么方法能够查看view和Angular的内部绑定。事实上,@angular/core
module中的checkAndUpdateView
方法就能作到。它在组件树的每一个视图(组件)上运行,并对每个view执行检查。当我在变动检测方面遇到问题时,我老是开始调试这个函数。
尝试去调试它。找到这个函数并在那里放置一个断点。点击按钮触变化检查,检查view变量。
第一个视图将是宿主视图。这是角力建立的一个根组件,用来托管咱们的应用程序组件。咱们须要不断执行以到达它的子视图,这将是为咱们的应用程序组件建立的视图。探索它!这个组件的属性包含了app 组件实例的引用。nodes属性包含对为app组件模板内的元素建立的DOM节点的引用。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
时,Angular已经完成了对父组件的检查。可是咱们在变化检测以后又更新了父组件的属性。
有趣的是,若是咱们如今换一个生命周期钩子执行这个操做呢?好比说ngOnInit
。你认为咱们还会看到这个错误吗?
export class ChildComponent { constructor(private parent: AppComponent) {} ngOnInit() { this.parent.text = 'Updated text in parent component'; } }
很好,这一次错误并无在。事实上,咱们能够把代码放在任何其余钩子中(好比AfterViewInit
和AfterViewChecked
),而且咱们不会再控制台看到错误。但这是怎么回事呢?为何ngAfterViewChecked
这么特殊呢?
为了理解这种行为,咱们须要知道Angular在变化检测期间执行什么操做以及它们的顺序。并且,咱们已经知道在哪里能够找到他们:我以前给大家看过checkAndUpdateView
方法。下面是函数主体代码的一部分。
function checkAndUpdateView(view, ...) { ... // update input bindings on child views (components) & directives, // call NgOnInit, NgDoCheck and ngOnChanges hooks if needed Services.updateDirectives(view, CheckType.CheckAndUpdate); // DOM updates, perform rendering for the current view (component) Services.updateRenderer(view, CheckType.CheckAndUpdate); // run change detection on child views (components) execComponentViewsAction(view, ViewAction.CheckAndUpdate); // call AfterViewChecked and AfterViewInit hooks callLifecycleHooksChildrenFirst(…, NodeFlags.AfterViewChecked…); ... }
就如你所看到的,Angular也会触发生命周期钩子来做为变化检测的一部分。有趣的是,当Angular处理绑定时,有些钩子在渲染部分以前调用,有些钩子在渲染部分以后调用。下面的图表演示了当Angular运行父组件的更改检测时会发生什么:
让咱们一步步的来。
OnInit
,Docheck
,Onchanges
钩子。这颇有意义,由于它刚刚更新了输入绑定,Angular须要通知子组件输入绑定已经初始化。AfterViewChecked
和AfterViewInit
钩子,让它知道它已经被检查过了。咱们注意到,Angular是在处理完父组件的绑定以后才调用子组件的AfterViewChecked
生命周期钩子。另外一方面,在绑定被处理以前调用OnInit钩子。所以,即便OnInit中的text发生了变化,在接下来的检查中,它仍将保持不变。这解释了为何ngOnInit钩子没有出现错误这种看似奇怪的行为。谜团解开了!
总结
最后,让咱们来总结一下刚刚学到的东西。Angular内部的全部组件都以称为视图的数据结构表示。Angular的编译器解析模板并建立绑定。每一个绑定定义要更新的DOM元素的属性和用于获取值的表达式。变动检测期间用于比较的先前值存储在oldValues属性的视图中。在变动检测期间,Angular在绑定上运行,评估表达式,将它们与之前的值进行比较,并在必要时更新DOM。在每一个变化检测周期后,Angular会运行检查,以确保组件状态与用户界面同步。此检查是同步执行的,可能会引起expression ExpressionChangedAfterItWasChecked
错误。