第一次接触Angular时候,一方面为它的框架设计所震撼,另一方面为它的性能感到担忧。为什么说我会为它的性能感到担忧呢?是因为我发现Angular 会自动更新 dom, 按照我的想法应该是有个定时循环去不停的触发检测,如果发现数据更改了,那就同步到dom中。事实证明我想多了,angular 内部数据更新并不会一直触发,而是需要达到某个条件,这个自动变更机制术语称为变更检测(change detection)。

在介绍变更检测原理之前,先介绍一个优秀的JS库--Zone。关于Zone我查看了很多资料,这里主要解释我对它的理解。Zone 如其名称一样,区域又或者说是一个执行上下文,只要是在同一个Zone下面执行的代码,无论是异步还是同步都在同一个执行上下文中。 Zone 中可以保存变量,这些变量被同一个Zone下的上下文共享,同时Zone 也提供了很多钩子,用来监控Zone 内部中的任务状态(微任务,宏任务)。 Zone 的使用与多线程非常的相似,线程启动的时候回有个主线程,从主线程中可以分出一个或者多个子线程,子线程也可以继续分子子线程。Zone 也是如此,Zone的启动会有个Root Zone ,从root 中可以继续分 子 Zone ,子 Zone可以分子子Zone.

Zone Thread
根环境 Root Zone Main Thread
可分裂 Child Zone Child Thread
变量存储 Zone Spec Thread Local

在我看了Zone就是一种环境,这个环境里面可以存变量,变量可供在这个环境下所有task使用,并且这个环境下还有很多钩子,这些钩子能够检测task状态。 如果想要不同的任务使用不同的环境,如何将他们分离开?那么只需要把当前的Zone 环境,分裂即可。并且对于变量,子Zone是可以继承父Zone的,而且对于兄弟Zone,他们之间是相互隔离分开的。

值得关注的是,只要在Zone的环境,无论是异步还是同步,他们都共享当前Zone 的上下文,这么做的意义是什么呢?我们都知道,Js是单线程的(这里指的是JS引擎线程,有兴趣的可查阅JS线程模型相关资料),因此也就不会存在多线程,所以Js中的异步编程,无论是 SetTimeout,SetInterval,Promise,Observable等都是基于Event Loop实现的,也就是一个消息队列,把所有的Task都先塞到队列中,然后以先进先出的规则,执行这些Task。这就造成了一种好像是异步的感觉。如果基于这种队列模型的话,那么各个Task之间很难建立沟通关联。你也无法感知这些Task到底执行的怎么样了。

Zone可以用来做什么

JavaScript虚拟机嵌入到宿主环境中,例如浏览器或Node,负责通过任务调度JavaScript执行。Zone是跨异步任务持久存在的执行上下文,并允许Zone的创建者观察和控制该区域内代码的执行。 Angular 的变更检测是基于Zone 的,但并不代表这Zone只能用来做变更检测,Zone 还可以做如写很多事:

  • Zone对于调试、测试和分析非常有用。
  • Zone对于框架知道何时渲染非常有用。
  • Zone对于跟踪跨异步操作持久化的资源非常有用,并且可以自动释放/清除资源。
  • Zone是可组合的 以上是从 官方文档 中摘抄的。从文档描述中,我们可以看出,Zone主要针对异步任务的执行上下文。
  • Zone 如何使用

    以上叙述你应该对Zone有个大概的了解认知,那么这个强大的Zone库如何使用?以及如何异步管理持久化资源?

  • 获取Zone引用,以及创建子Zone
  • //获取当前Root Zone,
    let rootZone = Zone.current;
    //基于当前的Root Zone ,分流出子Zone.
    zoneA = rootZone.fork({name: 'zoneA'});\
    //Root Zone 的默认名称为<root>
    expect(rootZone.name).toEqual('<root>');
    //获取子Zone 的名称
    expect(zoneA.name).toEqual('zoneA');
    //通过parent引用获取父Zone的引用
    expect(zoneA.parent).toBe(rootZone);
    
  • 异步task在Root Zone中执行
  • let rootZone = Zone.current;
    let zoneA = rootZone.fork({name: 'A'});
    expect(Zone.current).toBe(rootZone);
    //如果没有配置Zone 的话,默认实在Root Zone中执行
    setTimeout(function timeoutCb1() {
      // 这个callback 在Root Zone中执行
      expect(Zone.current).toEqual(rootZone);
    }, 0);
    
  • 异步task在指定的子Zone中执行-- zoneA.run()
  • //在指定的子Zone中执行
    zoneA.run(function run1() {
      expect(Zone.current).toEqual(zoneA);
      setTimeout(function timeoutCb2() {
        // 在ZoneA中执行代码
        expect(Zone.current).toEqual(zoneA);
      }, 0);
    

    基于以上Zone 的介绍,我们了解了什么是Zone ,如何创建Zone,以及如何在指定Zone中执行代码。

    Task 分类

    在JS中有任务分类有以下几种: Main Task、Micro Task、Macro Task、Event Task。

    Main Task:就是普通的代码块,比如说赋值,四则运算这些操作,特点是立马执行,不像其他task 会先塞到 event loop 队列中。

    Micro Task:主要是指 promise,在event loop 队列中,特点是尽可能快的执行,而且只会被执行一次不能被取消。

    Macro Task: 就是比较常见的 setTimeout,setInterval,在event loop 队列中它是排在Micro Task 之后执行。

    Event Task: 比如 click , mouse move/over/enter 等, Event Task 特点是 Task 的执行可能永远不会发生,或许在不可预测的时间发生,并且可能发生不止一次,因此无法知道它将执行多少次。 Task 的内容远远不止于此,这里只进行一个简单的介绍。

    为什么要区分这么多Task?了解这些任务执行顺序之后,你就能了解到,dom会在什么时候渲染,已经什么时候更新dom的值。 试想一下,一个页面中,会发送http请求获得页面数据,如果渲染dom的代码与http请求的代码都在Main Task中,也就是说会按照顺序执行,那么http请求将会阻塞js线程执行下面的渲染代码,很多需要渲染的代码并不需要http请求的值,也就是应该被提前渲染。所以应该把http请求,放在Macro Task中,当所有的I/O或者渲染页面操作结束之后,再进行http请求,最后得到数据之后更新dom,所以一般打开一个网页,你会先看到页面,然后页面的值才会被更新。这给人的感觉就好似异步操作,这才是正确的交互逻辑,用户是没有耐心等待的。

    这个例子,我们了解到,数据改变应该放到Macro Task 中,换个角度思考是不是说,只要发生了Macro Task 我就认为需要更新页面数据了。所以你Get到了没?这就是Angular 自动更新页面,执行变更检测的依据。 问题是Angular 如何知道,哪些任务执行完了,哪些任务正在执行,谁来通知Angular,应该去做变更检测?答案就是Zone。

    NgZone

    zone 是一个跨异步任务的,持久存在的执行上下文,你可以认为它是对于JS VM 来说的线程本地存储。下面我们将介绍NgZone是如何自动的执行变更检测来更新html 页面。

    在Angular中展示与更新数据

    在angular中你可以将属性绑定到HTML页面中,像 title 或者 myHero 那样。

    import { Component } from '@angular/core';
    @Component({
      selector: 'app-root',
      template: `
        <h1>{{title}}</h1>
        <h2>My favorite hero is: {{myHero}}</h2>
    export class AppComponent {
      title = 'Tour of Heroes';
      myHero = 'Windstorm';
    

    你也可以给angular 组件绑定一个Click 事件,通过这个事件更新组件属性

    @Component({
        selector: 'app-click-me',
        template: `
          <button (click)="onClickMe()">Click me!</button>
          {{clickMessage}}`
      export class ClickMeComponent {
        clickMessage = '';
        onClickMe() {
          this.clickMessage = 'You are my hero!';
    

    就上面两个例子而言,只是更新了组件的属性,但是HTML却能够自动的更新,下面来介绍Angular 何时,如何的根据属性来渲染Angular html 页面。

    使用纯javascript 来检测变化

    为了阐明如何检测已经更新数据,下面只使用javascript来描述

    <div id="dataDiv"></div> <button id="btn">updateData</button> <canvas id="canvas"></canvas> <script> let value = 'initialValue'; // 初始化渲染 detectChange(); function renderHTML() { document.getElementById('dataDiv').innerText = value; function detectChange() { const currentValue = document.getElementById('dataDiv').innerText; if (currentValue !== value) { renderHTML(); // Example 1: 通过click button 更新数据 document.getElementById('btn').addEventListener('click', () => { // 更新数据 value = 'button update value'; // 手动的调用detectChange detectChange(); // Example 2: HTTP Request const xhr = new XMLHttpRequest(); xhr.addEventListener('load', function() { // 从sever 拿到响应数据 value = this.responseText; // 手动的调用detectChange detectChange(); xhr.open('GET', serverUrl); xhr.send(); // Example 3: setTimeout 宏任务更新数据 setTimeout(() => { // setTimeout 回调更新数据 value = 'timeout update value'; // 手动的调用detectChange detectChange(); }, 100); // Example 4: Promise.then 微任务更新数据 Promise.resolve('promise resolved a value').then(v => { // Promise thenCallback 内部更新数据 value = v; // 手动的调用detectChange detectChange(); }, 100); // Example 5: 其他的一些异步API 更新数据 document.getElementById('canvas').toBlob(blob => { // 当blob data从canvas上被创造的时候更新数据 value = `value updated by canvas, size is ${blob.size}`; // 手动的调用detectChange detectChange(); </script> </html>

    从上面的代码来看,当数据被更新后,你需要手动的调用detectChange 来检查数据是否发生变化,如果发生变化就重新渲染dom。而在Angular中,这些手动调用检查更新都是是可以避免的,无论数据什么时候更新,dom都会被自动的更新。

    什么时候应该更新HTML

    为了了解变更检测的工作原理,首先应该想到的是,这些页面应该什么时候被更新,一般来说更新发生在下面几种情况:

    组件初始化 比如启动一个Angular应用,Angular会加载引导组件,并通过调用 ApplicationRef.tick() 调用变更检测,然后渲染视图。

    dom事件能够更新 Angular 组件中的数据,也能够触发变更检测

    @Component({
        selector: 'app-click-me',
        template: `
          <button (click)="onClickMe()">Click me!</button>
          {{clickMessage}}`
      export class ClickMeComponent {
        clickMessage = '';
        onClickMe() {
          this.clickMessage = 'You are my hero!';
    
  • HTTP 请求,你可以通过server得到数据
  • @Component({
        selector: 'app-root',
        template: '<div>{{data}}</div>';
      export class AppComponent implements OnInit {
        data = 'initial value';
        serverUrl = 'SERVER_URL';
        constructor(private httpClient: HttpClient) {}
        ngOnInit() {
          this.httpClient.get(this.serverUrl).subscribe(response => {
            // user does not need to trigger change detection manually
            this.data = response.data;
    
  • MacroTasks 宏任务,比如 setTimeout() or setInterval()
  • @Component({
        selector: 'app-root',
        template: '<div>{{data}}</div>';
      export class AppComponent implements OnInit {
        data = 'initial value';
        ngOnInit() {
          setTimeout(() => {
            // user does not need to trigger change detection manually
            this.data = 'value updated';
    
  • MicroTasks 微任务,Promise.then 或者其他异步方法返回Promise对象,然后通过调用 then 来更新数据
  • @Component({
        selector: 'app-root',
        template: '<div>{{data}}</div>';
      export class AppComponent implements OnInit {
        data = 'initial value';
        ngOnInit() {
          Promise.resolve(1).then(v => {
            //用户没有必要手动的调用变更检测
            this.data = v;
    
  • 其他的异步操作,除了 addEventListener(), setTimeout() and Promise.then(),之外还有一些其他的操作可以自动更新,比如:WebSocket.onmessage() and Canvas.toBlob() 先前的例子都是一些angular可能会更新数据的通用场景,所以只要有可能会更新数据的操作,Angular就会发生变更检测。 Anuglar 使用不同的方式触发变更检测,一种是组件初始化的时候,angular会直接触发,另一种是对于一些 异步操作 ,angualr会使用zone 在可能会发生数据改变的地方触发变更检测。
  • Zones 与执行上下文

    zone 提供了一个持久的跨异步任务的执行上下文,执行上下文是一个抽象的概念,在当前执行的代码里面它保存了关于环境的信息。如下例子:

    const callback = function() {
      console.log('setTimeout callback context is', this);
    const ctx1 = { name: 'ctx1' };
    const ctx2 = { name: 'ctx2' };
    const func = function() {
      console.log('caller context is', this);
      setTimeout(callback);
    func.apply(ctx1);
    func.apply(ctx2);
    

    回调函数setTimeout()中的this值,可能是不同的,它依赖setTimeout()何时被调用,因此你可能在异步任务中丢失上下文信息。js中的apply方法是一种可以改变上下文的的代码,分别把上下文改为ctx1与ctx2,不难发现this上下文,由ctx1改为ctx2,不难发现这两个异步setTimeout任务很难建立联系。

    zone提供了一个新的上下文,与this无关,这个zone的上下文是持久的跨异步任务的,在下面的例子中我们称这个上下文是zoneThis,不难发现异步任务外部与内部都是同一个zone上下文。

    zone.run(() => {
      //现在你在一个zone中
      expect(zoneThis).toBe(zone);
      setTimeout(function() {
        //  zoneThis内容与zone一致
        // 当setTimeout被调用的时候
        expect(zoneThis).toBe(zone);
    

    Zones与异步生命周期钩子

    Zone不仅提供了一个持久的跨异步任务的上下文,也提供了相关的异步任务生命周期钩子

    const zone = Zone.current.fork({
      name: 'zone',
      onScheduleTask: function(delegate, curr, target, task) {
        console.log('new task is scheduled:', task.type, task.source);
        return delegate.scheduleTask(target, task);
      onInvokeTask: function(delegate, curr, target, task, applyThis, applyArgs) {
        console.log('task will be invoked:', task.type, task.source);
        return delegate.invokeTask(target, task, applyThis, applyArgs);
      onHasTask: function(delegate, curr, target, hasTaskState) {
        console.log('task state changed in the zone:', hasTaskState);
        return delegate.hasTask(target, hasTaskState);
      onInvoke: function(delegate, curr, target, callback, applyThis, applyArgs) {
        console.log('the callback will be invoked:', callback);
        return delegate.invoke(target, callback, applyThis, applyArgs);
    zone.run(() => {
      setTimeout(() => {
        console.log('timeout callback is invoked.');
    

    上面的例子中不仅创建了一个子Zone,并且定义了一个异步任务的钩子,当异步任务状态改变的时候onXXXTask钩子将会被触发。

    Zone任务的概念与Javascript VM任务概念类似。

    Javascript VM tasks:

  • macroTask: such as setTimeout()
  • microTask: such as Promise.then()
  • eventTask: such as element.addEventListener()
  • 在下面几种情况下,钩子将会被触发:

  • onScheduleTask: 当异步任务被调度的时候,比如当你调用 setTimeout() 时候,这个钩子将会被触发。
  • onInvokeTask: 当异步任务被执行的时候,比如当 setTimeout()里面的 callback 执行的时候。
  • onHasTask: 当在Zone内部中,任务状态发生改变,由稳定到不稳定,或者由不稳定到稳定的时候就会调用,这里稳定指的是在Zone中没有任何tasks,不稳定指的是Zone中有一个新的task被调度。
  • onInvoke:当同步方法在Zone中将要被执行的时候会被调用。 有了这些钩子,在Zone中就可以监控同步和异步任务的状态 上面的例子将会输出下面的内容:
  • zone.run执行的时候 输出内容:

    //ngzone中的同步内容执行
    the callback will be invoked: () => {
      setTimeout(() => {
        console.log('timeout callback is invoked.');
    //当执行setTimeout 的时候,这个时候回调函数并没有调用只是放到任务队列中了,调用onScheduleTask
    new task is scheduled: macroTask setTimeout
    //由不稳定状态到稳定状态,调用onHasTask
    task state changed in the zone: { microTask: false,
      macroTask: true,
      eventTask: false,
      change: 'macroTask' }
    //setTimeout中的回调函数被调用,调用onInvokeTask
    task will be invoked macroTask: setTimeout
    //console.log 打印出内容
    timeout callback is invoked.
    //异步任务执行完成,由稳定过渡到不稳定
    task state changed in the zone: { microTask: false,
      macroTask: false,
      eventTask: false,
      change: 'macroTask' }
    

    Zone中的所有方法,都由zone.js库提供,这个库通过猴子补丁拦截异步api来实现这些特性。猴子补丁是在运行时修改或添加一个方法的默认行为且不改变源码。

    NgZone

    虽然zone.js能够监控所有异步或同步操作的状态,但是angular还是提供了NgZone service。这个service创建了一个名叫angular的zone,当满足如下条件的时候,能够自动的触发变更检测:

  • 异步或同步任务被执行
  • 当没有microTask被调度的时候
  • NgZone run() and runOutsideOfAngular()

    Zone能够处理大部分异步任务,像setTimeout()Promise.then(), and addEventListener(),能够处理的所有的异步任务列表文档。因此就这些异步API来说,不需要手动调用变更检测。但是有的时候你可能会调用第三方库的API,这些API没有被Zone处理,因此就不会引发变更检测。NgZone service提供了一个run()方法,这个方法运行你在Angular zone中执行回调方法。这样的话当第三方API函数被调用的时候,就能够自动的触发变更检测。举个例子:ResizeObserver 这个函数你可能没听过,但是你一定遇到过需要监控窗口变化的需求onResize,但是监控window.size一方面不是很方便,另一方面也不高效,如果有一个能够监控dom大小变化的函数就好了,ResizeObserver正是解决这个问题的。所以我在项目中用到这个函数时候页面数据不刷新,引入NgZone完美解决。

    export class AppComponent implements OnInit {
      constructor(private ngZone: NgZone) {}
      ngOnInit() {
        // 新的异步API不会被Zone处理,所以你需要使用 ngZone.run() 是的异步任务在Angular zone中执行,
        //并自动触发变更检测
        this.ngZone.run(() => {
          someNewAsyncAPI(() => {
            //更新组件数据
    

    默认情况下,所有的异步任务都会在angular zone中执行,但是也有一些场景并不想当这些异步任务执行的时候去触发变更检查,这种情况下可以使用另外一个方法:runOutsideAngular()

    export class AppComponent implements OnInit {
      constructor(private ngZone: NgZone) {}
      ngOnInit() {
        // 你自己确定不会发生数据改变
        // 因此你不想在这个执行的方法执行完成后,触发变更检测
        // 调用 ngZone.runOutsideAngular()
        this.ngZone.runOutsideAngular(() => {
          setTimeout(() => {
            // 更新组件数据但是不想触发变更检测
    

    本文首先对Zone.js做了详细的介绍,然后介绍了在纯Javascript下如何实现变更检测,这部分是想让读者了解到什么情况下应该去根据更新的组件数据去更新dom,然后引入NgZone,有了NgZone之后就不需要手动的调用变更检测。本文关于NgZone的介绍主要是翻译官方文档,文档写的太明了缺点是没有中文的,关于zone以及NgZone还有很多知识点,如果想更进一步了解,可以翻阅下面的引用链接。

    developer.mozilla.org/zh-CN/docs/…

    angular.io/guide/zone

    zhuanlan.zhihu.com/p/50835920

    blog.angular-university.io/how-does-an…

    zhupc Angular.js
    私信