两年前在学习Angular的时候写过一篇关于变更检测的文章Angular-变更检测,但总结的比较简单,前面参加ngChina会议见识的比较多,下来做了些研究总结,本文将从检测机制、检测策略、单向数据流三个方面再详细聊聊Angular变更检测相关的东西。
一、变更检测机制
Angular中的变更检测机制是当component状态有变化的时候,angular都能检测到这些变化,并且能够将这些变化反应到页面上。作为框架,这个在我们看来是理所应当的,其实在angular内部涉及到很多复杂的操作,包括:变化检测、脏数据检查、数据绑定、单向数据流、更新DOM、NgZone等。
我们知道Angular应用本质上是一棵组件树,变更检测都是沿着组件树从root组件开始至上而下执行的,所以上面变更检测机制涉及的很多操作和属性都是组件级的,下面分别说明。
(1)component view
在Angular里,每个component组件都有一个html模板,而在在angular内部,编译器在component和模板之间会生成一个component view,可以看做每个组件的“wacher”;数据绑定、脏数据检查和更新DOM都是基于这个component view实现的。
Angular需要在component view保存每个DOM节点引用,同时也要保存component数据引用、数据之前的值和取值表达式。当状态发生变化时候,会触发变更检测,angular会做数据脏检查,也就是对比当前值和之前的值是否一样,如果发现两者不一致,会把当前的值更新到页面上;同时也会把当前的值保持为oldvalue。
这里有一段示例代码,完整的Demo项目在:angular-pwa。
// src/app/child.component.ts
@Component({
selector: 'app-child',
template: `
<h1 (click)="counter()">Child: {{name}}</h1>
<h5>Title: {{name + ' 555'}}</h5>
<p>Today: {{getDate() | date}}</p>
<p>Count: {{count}}</p>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChildComponent implements OnInit, OnChanges, DoCheck, AfterContentInit, AfterViewInit {
@Input() name = '';
count = 0;
constructor(private parentComponent: ParentComponent,
public cdr: ChangeDetectorRef) {
}
ngOnInit(): void {
// this.parentComponent.title = 'angular next!';
console.log('child-----OnInit');
}
ngOnChanges(changes: SimpleChanges): void {
// this.parentComponent.title = 'angular next!';
console.log('child-----OnChanges');
}
ngDoCheck(): void {
// this.parentComponent.title = 'angular next!';
console.log('child-----DoCheck');
}
ngAfterContentInit(): void {
// this.parentComponent.title = 'angular next!';
console.log('child-----AfterContentInit');
}
ngAfterViewInit(): void {
// this.parentComponent.title = 'angular next!';
console.log('child-----AfterViewInit');
}
getDate() {
return new Date();
}
counter() {
this.count++;
}
}
我们看看编译后的代码是啥样的,怎么看呢?使用ngc
说到这里,想先了解下angular的编译机制的,可以看看Angular-聊聊angular 的编译机制这篇文章,这里就不详细展开。
具体做法是这样的:
> git clone 项目rep
> cd angular-pwa
> npm i
> npm run complier
其中package.json里
"scripts": {
...
"complier": "ngc"
},
ngc编译的输出文件目录结构和真正的项目目录结构一样,在对应的文件夹下会有以下文件,我们在dist/out-tsc/src/app找到child.component.ts编译后的几个文件,其他ts文件编译类似:
ngc-child-component.png
其中child.component.ngfactory.js,记录了创建组件、渲染组件(涉及DOM操作)、执行变化检测(获取oldValue和newValue对比)、销毁组件的代码,也就是上面所说的component view。
打开ngfactory.js看一下内容,该组件编译后代码和标注如下:
ngc-child-component-ngfactory.png
可以看到Angular的组件和模板都得到了编译,包括差值表达式和 date管道等这些特定语法;
- function View_ChildComponent_0(_l) {...}
主要负责组件视图的渲染(根据template,包括事件绑定)、绑定和变化检测。 - function View_ChildComponent_Host_0(_l) {...}
主要负责组件宿主元素app-child的渲染,并使用View_ChildComponent_0管理组件内部视图,构造组件树
大概的关系如下:
ngc-child-component-ngfactory-2.png
总之,在编译器分析组件和渲染模板的时候,会分析变化检测时需要更新的变量、表达式、函数等属性,给他们创建绑定,类似与vuejs里的依赖收集和变更通知。
(2)变更检测触发方法
当然是事件驱动,来源有以下三大类:
- 事件:页面 click、submit、mouse down……
- XHR:从后端服务器拿到数据
- Timers:setTimeout()、setInterval()。
这几类事件形式不同,而且还有一个共同点:都是异步的,是不同类型的webApi。
(3)状态变化怎么通知变更检测
主要通过NgZone和zonejs。
NgZone继承于开源的zone.js,而zone通过”猴子补丁“的方式强制重写了浏览器关于异步事件的捕获处理,NgZone在此基础上又进行了相应的扩展,比如:可控制不通知变更检测等。
通过NgZone可以hook异步事件任务的执行上下文,然后做出一些动作,比如:每个异步事件callback以后通知angular检测机制执行变更检测,它有几个事件,
onTurnStart(): 事件开始事前发射,一个浏览器任务只处理一个
onTurnDone() :当事件处理完,调度到其他任务钱发射
onEventDone():当onTurnDone调用完发射,也就是发送检测通知的时间
当然,它也提供了接口来控制通不通知,以及何时通知。
在angular源码中有一个ApplicationRef对象,可以监听NgZones onTurnDone事件,每当onTurnDone触发后,它会立马执行tick()方法,然后将会从上到下沿着组件树触发变更检测,下面是简化源码。
class ApplicationRef {
changeDetectorRefs:ChangeDetectorRef[] = [];
constructor(private zone: NgZone) {
this.zone.onTurnDone
.subscribe(() => this.zone.run(() => this.tick());
}
tick() {
this.changeDetectorRefs
.forEach((ref) => ref.detectChanges());
}
}
Angular应用内部在创建每一个组件实例的同时,也会创建一个对应的检测器实例,用来记录组件的数据变化状态,所以在应用形式组件树的同时,也形成了检测器实例的树型结构,引用两年前那篇文章的一张图可以看的比较清楚。
jianceqi-tree.jpg
二、变更检测策略
在上面,我们看到angular应用会形式自己的检测器树,且每一次的组件的状态变化都会从根组件遍历整个组件树,虽然angular变更检测本身性能很好,在毫秒内可以做成百上千次变化检测。但是随着项目越来越大,很多不必要的变化检测还是会在一定程度上影响性能。
那怎么才能控制默认的这种检测频率呢?
Angular提供了两种组件级的变更检测策略设置:
- default: 每次变更检测都会引起组件的变更检测,包括其他组件的状态变化,以及本组件引用型变量内部属性值变化
- Onpush: 每次变更检测会跳过本组件的变更检查,除非满足一些条件,这个在后面说明
Angular默认的变化检测机制是ChangeDetectionStrategy.Default,每次异步事件callback结束后,NgZone会触发整个组件树至上而下做变化检测;可以修改为OnPush策略,用以跳过某个component以及它下面所有子组件的变化检测,使用示例:
@Component({
selector: 'app-child',
template: `
<h1 (click)="counter()">Child: {{name}}</h1>
<h5>Title: {{name + ' 555'}}</h5>
<p>Today: {{getDate() | date}}</p>
<p>Count: {{count}}</p>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
...
还是可以使用上面那个demo项目angular-pwa来测试,方便起见,还提供了一个此demo的在线版本:angular-tscsya,方便实时修改看效果。
还有在大会上发现的一个开源项目edu-angular-change-detection也很好用,可以动态设置组件的各种策略条件,直观看到不同策略下组件树的变更检测的路径效果。
以上几个项目在下文中都可以使用。
(1)“手动”触发变更检测
通过Onpush策略组织了自动检测的执行,那在需要时候怎么“手动”触发组件的变更检测呢?
其实在设置了OnPush策略以后,还是有许多方法可以触发变更检测的;
1)组件的@Input属性的引用发生变化。
2)组件内的DOM事件,包括它子组件的DOM事件,比如click、submit、mouse down。
3)组件内的Observable订阅事件,同时设置Async pipe。
4)组件内手动使用ChangeDetectorRef.detectChanges()、ChangeDetectorRef.markForCheck()、ApplicationRef.tick()方法
这些都可以在上面两个示例中尝试来观察。
此外,还可以通过detach/reattach组合使用来控制变更检测,可以看一下变更检测类主要类接口:
class ChangeDetectorRef {
markForCheck() : void;
detach() : void;
reattach() : void;
detectChanges() : void;
}
其中:
markForCheck():使用于子组件,将该子组件到根组件之间的路径标记起来,通知angular检测器下次变化检测时一定检查此路径上的组件;
detach():将组件的检测器从检测器数中脱离,不再受检测机制的控制,除非重新attach上;
reattach():把脱离的检测器重新链接到检测器树上;
detectChanges():手动发起该组件到各个子组件的变更检测;
(2)OnPush策略下ngDoCheck的执行
首先看一下组件ngDoCheck的执行时机:
- 在状态发生变化,angular自己本身不能捕获这个变化时会触发NgDoCheck
- 每次变化检测以后,都会触发ngDoCheck钩子函数,紧跟在ngOnChanges和ngOnInit之后运行
在设置了OnPush策略,组件的ngDoCheck钩子仍会触发,属于第1类触发时机
三、单向数据流
以第一节的检测器树来看,整个angualr应用其实是一棵组件树,每个组件都会有一个对应的检测器实例,用来记录组件的数据变化状态,所以在应用形式组件树的同时,也形成了检测器实例的树型结构。
jianceqi-tree.jpg在组件树中,假如ComponentA的状态发生了变化,比如从后台拿到新的渲染数据,这个是ComponentA会触发变更检测。
我们知道,每次触发变更检测,都会从根组件出发,沿着整棵组件树从上到下的执行每个组件的变更检测,默认情况下,直到最后一个叶子component组件完成变更检测达到稳定状态。也就说ComponentA也会触发它的子孙组件执行变更检测,而在从上倒下这个变更检测流中,一旦上层的ComponentA完成变更检测稳定以后,在下一次事件触发变更检测之前,它的子孙组件此时是不允许去更改祖先CompnentA的change detection相关属性状态的,这就是单向数据流。
(1) 违反单向数据流原则
而在我们开发过程中,经常会没怎么注意这个问题,会导致出现类似下面这个ExpressionChangedAfterItHasBeenCheckedError错误信息:
ExpressionChangedAfterItHasBeenCheckedError.png
这个尤其发生某些生命钩子里随意更改父组件状态的情况下,但是并不是每个钩子函数里修改都会引起这个错误的。还是以之前的在线demo:angular-tscsya为例来试试,涉及的代码如下:
import {AfterContentInit,AfterViewInit,ChangeDetectionStrategy,ChangeDetectorRef,Component,DoCheck,Input,OnChanges,OnInit,SimpleChanges} from '@angular/core';
import {ParentComponent} from './parent.component';
import { interval, Observable } from 'rxjs';
import { throttleTime, map, scan } from 'rxjs/operators';
// parent.component.ts
@Component({
selector: 'app-parent',
template: `
<h1 (click)="modTitle()" title="click modify title">Parent: {{name}}</h1>
<h3>Title: {{title}}</h3>
<app-child></app-child>
`
})
export class ParentComponent implements OnInit, OnChanges, DoCheck, AfterContentInit, AfterViewInit {
@Input() name = 'angular parent';
title = 'hello child';
constructor(public cdr: ChangeDetectorRef) {
}
...
modTitle() {
this.title += ' Next!';
}
}
// child.component.ts
@Component({
selector: 'app-child',
template: `
<h1 (click)="counter()" title="click addCount">Child: {{name}}</h1>
<h5>Title: {{name + ' 555'}}</h5>
<p>Today: {{getDate() | date}}</p>
<p>Count: {{count}}</p>
<p>num: {{num$ | async}}</p>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChildComponent implements OnInit, OnChanges, DoCheck, AfterContentInit, AfterViewInit {
@Input() name = '';
count = 0;
num$ : Observable<number>;
constructor(private parentComponent: ParentComponent,
public cdr: ChangeDetectorRef) {
}
ngOnInit(): void {
// this.parentComponent.title = 'angular next!';
console.log('child-----OnInit');
/*setTimeout(() => {
this.count = 10;
this.cdr.detectChanges();
});*/
//this.num$=interval(2000);
}
ngOnChanges(changes: SimpleChanges): void {
// this.parentComponent.title = 'angular next!';
console.log('child-----OnChanges');
}
ngDoCheck(): void {
// this.parentComponent.title = 'angular next!';
console.log('child-----DoCheck');
}
ngAfterContentInit(): void {
// this.parentComponent.title = 'angular next!';
console.log('child-----AfterContentInit');
}
ngAfterViewInit(): void {
this.parentComponent.title = 'angular next!';
console.log('child-----AfterViewInit');
}
...
}
经过测试,在钩子ngAfterViewInit里修改parentComponent.title会报错,
报错的原因主要是:angular强制了单向数据流,在从上倒下执行变更检测流的过程中,ParentComponent完成变更检测以后,任何子孙组件去修改它的change detection相关属性都是不允许的。如果是在生产环境里,也就是启用了enableProdMode()会直接忽略这样的操作,页面也不会显示变化以后的值,也不会报错。但是在开发模式下,在每一次变更检测以后,angular会从上到下再多跑一个变更检测,确保每次改动之后所有的状态是stable的,这个时候发现有子孙级改动上层级的值,就会出现上面那个错误。
然而在在线例子中,试着在ngOnInit、ngDoCheck、ngAfterContentInit、ngAfterContentChecked、ngOnChanges这几个钩子里修改却不会报错,为什么呢?
看一下core.js源码,找到下面这个方法,在检测更新时会用到:
function checkAndUpdateView(view, ...) {
...
// update input bindings on child views (components) & directives,
// call NgOnInit, NgDoCheck, ngOnChanges,ngAfterContentInit, ngAfterContentChecked 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…);
...
}
可以看到检测顺序是下面这样的,这个在ngChina2019阿里执衡大佬也讲到过;
- update bound properties for all child components
- call OnChanges, OnInit, DoCheck and AfterContentInit lifecycle hooks on all child components
- update DOM for the current component
- run change detection for a child component
- call ngAfterViewInit lifecycle hook for all child components
可以看到,在Childcomponent中,AfterViewInit(以及AfterViewChecked)是在它的变更检测之后再执行的,也就是说这个时候从上到下检测完成,状态达到stable之后,这时候去改动上一层级属性,被认为是违反angular的单向数据流;而在变更检测之前修改不会报错。
还有,再来修改下在parent.component传入input属性,
@Component({
selector: 'app-parent',
template: `
...
<app-child [name]="title"></app-child>
`
})
此时在ngOnInit里面修改父组件的name也报错,为什么?
其实这个是因为childcomponent修改上一级组件的属性触发input属性的变化,而这是在childcomponent完成自身变更检测以后的事,违反了单向数据原则。从错误发生的时间也可以看到是出现在ParentComponent和ChildComponent的检测和视图更新以后的。
屏幕快照 2020-01-15 上午1.30.44.png
(2) 去掉检测错误信息
那在上面的两种情况下,怎么去掉报错呢?
试了有两种方法,可以在在线demo里修改试验。
第1种: 使用事件触发下一次变更检测
比如使用定时api修改上一次级的属性,
ngAfterViewInit(): void {
setTimeout(() => {
this.parentComponent.title = 'angular next!';
},0)
}
第2种: 将修改的属性从上一级组件的change detection里去掉
从变更检测机制那节看,我们从ParentComponent模板里把title相关的代码删除,
@Component({
selector: 'app-parent',
template: `
<h1 (click)="modTitle" title="click modify tie">Parent: {{name}}</h1>
<!--p>title: {{title}}</p>
<app-child [name]="title"></app-child-->
<app-child></app-child>
`
})
这样就不会出现错误信息了。
网友评论