Li Mei

个人技术博客

嗨,我是李梅,一名前端开发者。


主要是记录个人对技术的理解和开发过程中遇到的问题,欢迎了解更多。

Angular Change Detection:变化检测机制

Angular 中的变化检测机制是当 component 状态有变化的时候,Angular 都能检测到这些变化,并且能够将这些变化反应到页面上。

比如有这样一个 component,代码如下:

@Component({
  template:`<h1>I am <span [textContent]="data.name"></span><h1>`
})
export class CDParentComponent {
  data : any = { name:'meii', address: 'ShangHai'};
}

当把 cdParentComponent 中data.name的值改成 limeii,页面会直接把 meii 更新成 limeii,看似很简单的一个改动,其实在 Angular 内部涉及到很多复杂的操作,包括变化检测 脏数据检查 数据绑定 单向数据流 更新DOM NgZone等等。

单向数据流在这篇【Angular:单向数据流】文章有详细介绍,也提到 Angular 应用其实就是组件树,变化检测都是沿着组件树从 root component 开始至上而下执行的。我们都知道在Angular 里,每个 component 都有一个 html 模板,在 Angular 内部,编译器在 component 和模板之间会生成一个 component view。数据绑定、脏数据检查和更新 DOM 都是由这个 component view 实现的。变化检测机制也可以说就是沿着 component view 的树状结构从上到下执行的。

Component View 到底是什么?

data.name值改成 limeii,会触发变化检测,同时 Angular 会做数据脏检查,也就是对比当前值(limeii)和之前的值(oldvalue:meii)是否一样,如果发现两者不一致,会把当前的值(limeii)更新到页面上。同时也会把当前的值保持为 oldvalue。

在文章【Angular:深入理解Angular编译机制】中介绍了 AoT 和 ngc,提到过 ngc 会生成*.ngfactory.js,其实 ngfactory 就是 Component View。在*.ngfactory.js过程中,ngc 会把所有可能发生变化的DOM Nodes/Elements都找出来,然后给这些DOM Nodes/Elements生成Bindings,这些Bindings里会记录Element Name/Expression/OldValue,一旦有异步事件发生(Click事件或者是HttpRequest)就会被ngZone捕获到,然后触发Change Detection,也就是会从Root Component开始,从上到下检查所有组件的Bindings也就是前面提到的Component View,对比NewVauleOldValue,如果不一致就会把新值更新到页面,同时把新值更新为旧值(这也就是我们经常提到的脏检查机制Dirty Checking)。我们来看下 CDParentComponent 通过 ngc 编译以后的 cd-parent.component.ngfactory.js 文件里有什么:

/**
 * @fileoverview This file was generated by the Angular template compiler. Do not edit.
 *
 * @suppress {suspiciousCode,uselessCode,missingProperties,missingOverride,checkTypes}
 * tslint:disable
 */
import * as i0 from "@angular/core";
import * as i1 from "../cd-child/cd-child.component.ngfactory";
import * as i2 from "../cd-child/cd-child.component";
import * as i3 from "./cd-parent.component";
var styles_CDParentComponent = [];
var RenderType_CDParentComponent = i0.ɵcrt({ encapsulation: 2, styles: styles_CDParentComponent, data: {} });
export { RenderType_CDParentComponent as RenderType_CDParentComponent };

export function View_CDParentComponent_0(_l) {
    return i0.ɵvid(0,
        [(_l()(), i0.ɵeld(0, 0, null, null, 1, "h1", [], null, null, null, null, null)), (_l()(), i0.ɵted(1, null, ["I am ", " and I live in ", ""])),

        (_l()(), i0.ɵeld(2, 0, null, null, 1, "cd-child", [], null, null, null, i1.View_CDChildComponent_0, i1.RenderType_CDChildComponent)),
        i0.ɵdid(3, 638976, null, 0, i2.CDChildComponent, [i0.ChangeDetectorRef, i0.ApplicationRef], { data: [0, "data"] }, null),

        (_l()(), i0.ɵeld(4, 0, null, null, 1, "button", [], null, [[null, "click"]],
            function (_v, en, $event) {
                var ad = true; var _co = _v.component; if (("click" === en)) {
                    var pd_0 = (_co.changeInfo() !== false);
                    ad = (pd_0 && ad);
                } return ad;
            }, null, null))

            , (_l()(), i0.ɵted(-1, null, ["Change Info"]))
        ],
        
        function (_ck, _v) {
            var _co = _v.component; var currVal_2 = _co.data; _ck(_v, 3, 0, currVal_2);
        },
        function (_ck, _v) {
            var _co = _v.component; var currVal_0 = _co.data.name;
            var currVal_1 = _co.data.address; _ck(_v, 1, 0, currVal_0, currVal_1);
        });
}


export function View_CDParentComponent_Host_0(_l) {
    return i0.ɵvid(0, [(_l()(), i0.ɵeld(0, 0, null, null, 1, "ng-component", [], null, null, null, View_CDParentComponent_0, RenderType_CDParentComponent)),
    i0.ɵdid(1, 49152, null, 0, i3.CDParentComponent, [], null, null)], null, null);
}


var CDParentComponentNgFactory = i0.ɵccf("ng-component", i3.CDParentComponent, View_CDParentComponent_Host_0, {}, {}, []);
export { CDParentComponentNgFactory as CDParentComponentNgFactory };
//# sourceMappingURL=cd-parent.component.ngfactory.js.map

各段代码的作用如下: angular-change-detection

  • View_CDParentComponent_0(internal component):是 CDParentComponent,里面有每个 DOM 节点引用,并且给每个节点做数据绑定;还有两个 change detection 方法,分别对应 [data]="data" { { data.name } }{ { data.address } }

  • View_CDParentComponent_Host_0(internal host component):负责渲染出宿主元素 < CDParentComponent > < / CDParentComponent > ,并且使用 “internal component” 管理组件的内部视图,也是通过这个构建整个组件树。

当编译器分析组件模板的时候,知道每次变化检测有可能需要更新页面上 DOM 元素属性的值,对于这些属性,编译器都会给它创建绑定,绑定里至少有这个属性名称和取值的表达式。

有了 component view、绑定、脏数据检查,组件状态变化就可以触发变化检测从而更新页面 DOM 属性的值。

那什么会触发组件状态的变化?

最常见的一种方式,在页面按钮的click事件更新data.name的值,代码如下:

@Component({
  template:`<h1>I am <span [textContent]="data.name"></span><h1>
            <button (click)="changeName()">Change Name</button>`
})
export class CDParentComponent {
  data : any = { name:'meii', address: 'ShangHai'};
  changeName(){
    this.data.name = "limeii";
  }
}

还有一种常见的方式是,通过 http request 拿到 data 的值,如下:

  ngOnInit() {
    this.http.get('/contacts')
      .map(res => res.json())
      .subscribe(contacts => this.data.name = contacts.name;);
  }

angular通常有如下三种方式会导致组件数据变化:

  1. 事件:页面 click、submit、mouse down……
  2. XHR:从后端服务器拿到数据
  3. Timers:setTimeout()、setInterval()

Angular 又怎么通知各个组件做变化检测?

前面那三种方式会导致 Angular 状态变化,那又是谁知道状态已经发生改变,需要通知 Angular 触发变化检测从而更新页面 DOM 呢?NgZone(zone.js)充当了这个角色。

NgZone 可以简单的理解为是一个异步事件拦截器,它能够 hook 到异步任务的执行上下文,然后就可以来处理一些操作,比如每个异步任务 callback 以后就会去通知 Angular 做变化检测。

Angular 源码中有一个ApplicationRef,可以监听 NgZones onTurnDone事件,每当onTurnDone被触发后,它会立马执行tick()方法,tick()会从上到下沿着组件树触发变化检测。ApplicationRef简洁版代码如下:

// very simplified version of actual source
class ApplicationRef {
  changeDetectorRefs:ChangeDetectorRef[] = [];

  constructor(private zone: NgZone) {
    this.zone.onTurnDone
      .subscribe(() => this.zone.run(() => this.tick());
  }

  tick() {
    this.changeDetectorRefs
      .forEach((ref) => ref.detectChanges());
  }
}

每个 component 都有自己的变化检测器,负责检查它们各自的绑定,结构如下:

angular-change-detection

有了 NgZone 上述三种异步事件都会导致整个 Angular 应用发生变化检测,虽然 Angular 变化检测本身性能已经很好了,在毫秒内可以做成百上千次变化检测。但是随着项目越来越大,其实很多不必要的变化检测还是会在一定程度上影响性能。

在这篇文章【Angular Change Detection:变化检测策略】介绍了如何通过 OnPush 来跳过一些不必要的变化检测,从而优化整个应用的性能。