36
阅读 Angular 6/RxJS 最新教程,请访问前端修仙之路

Change Detection (变化检测) 是 Angular 2 中最重要的一个特性。当组件中的数据发生变化的时候,Angular 2 能检测到数据变化并自动刷新视图反映出相应的变化。

在介绍变化检测之前,我们要先介绍一下浏览器中渲染的概念,渲染是将模型映射到视图的过程。模型的值可以是 JavaScript 中的原始数据类型、对象、数组或其他数据对象。然而视图可以是页面中的段落、表单、按钮等其他元素,这些页面元素内部使用 DOM (Document Object Model) 来表示。

图片描述

为了更好地理解,我们来看一个具体的示例:

<h4 id="greeting"></h4>
<script>
    document.getElementById("greeting").innerHTML = "Hello World!";
</script>

这个例子很简单,因为模型不会变化,所以页面只会渲染一次。如果数据模型在运行时会不断变化,那么整个过程将变得复杂。因此为了保证数据与视图的同步,页面将会进行多次渲染。接下来我们来考虑一下以下几个问题:

  • 什么时候模型会发生变化
  • 模型产生了什么变化
  • 变化后需要更新的视图区域在哪里
  • 怎么更新对应视图区域

而变化检测的基本目的就是解决上述问题。在 Angular 2 中当组件内的模型发生变化的时候,组件内的变化检测器就会检测到更新,然后通知视图刷新。因此变化检测器有两个主要的任务:

  • 检测模型的变化
  • 通知视图刷新

接下来我们来分析一下什么是变化,变化是怎么产生的。

变化和事件

变化是旧模型与新模型之间的区别,换句话说变化产生了一个新的模型。让我们来看一下下面的代码:

import { Component } from '@angular/core';

@Component({
  selector: 'exe-counter',
  template: `
  <p>当前值:{{ counter }}</p>
  <button (click)="countUp()"> + </button>`
})
export class CounterComponent {
  counter = 0;

  countUp() {
    this.counter++;
  }
}

页面首次渲染完后,计数器的当前值为0。当我们点击 + 按钮时,计数器的 counter 值将会自动加1,之后页面中当前值也会被更新。在这个例子中,点击事件引起了 counter 属性值的变化。

我们继续看下一个例子:

import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'exe-counter',
  template: `
    <p>当前值:{{ counter }}</p>
  `
})
export class CounterComponent implements OnInit {
  counter = 0;

  ngOnInit() {
    setInterval(() => {
      this.counter++;
    }, 1000);
  }
}

该组件通过 setInterval 定时器,实现每秒钟 counter 值自动加1。在这种情况下,它是定时器事件引起了属性值的变化。最后我们再来看个例子:

import { Component, OnInit } from '@angular/core';
import { Http } from '@angular/http';

@Component({
  selector: 'exe-counter',
  template: `
    <p>当前值:{{ counter }}</p>
  `
})
export class CounterComponent implements OnInit {
  counter = 0;
  constructor(private http: Http) {}

  ngOnInit() {
    this.http.get('/counter-data.json')
        .map(res => res.json())
        .subscribe(data => {
          this.counter = data.value;
        });
  }
}

该组件在进行初始化的时候,会发送一个 HTTP 请求去获取初始值。当请求成功返回的时候,组件的 counter 属性的值会被更新。在这种情况下,它是由 XHR 回调引起了属性值的变化。

现在我们来总结一下,引起模型变化的三类事件源:

  • Events:click, mouseover, keyup ...
  • Timers:setInterval、setTimeout
  • XHRs:Ajax(GET、POST ...)

这些事件源有一个共同的特性,即它们都是异步操作。那我们可以这样认为,所有的异步操作都有可能会引起模型的变化。

非常好,你已经了解了引起模型变化的事件源和触发变化的时机点。但是你还不知道,是由谁来负责通知相应的变化给视图。接下来,我们将讨论一种允许 Angular 随时检测到变化的机制,它被称为 Zone

Zones

Zone 是下一个 ECMAScript 规范的建议之一。Angular 团队实现了 JavaScript 版本的 zone.js ,它是用于拦截和跟踪异步工作的机制。

Zone 是一个全局的对象,用来配置有关如何拦截和跟踪异步回调的规则。Zone 有以下能力:

  • 拦截异步任务调度
  • 提供了将数据附加到 zones 的方法
  • 为异常处理函数提供正确的上下文
  • 拦截阻塞的方法,如 alert、confirm 方法

我们来看一个简单的示例:

Zone.current.fork({}).run(function () {
    Zone.current.inTheZone = true;
  
    setTimeout(function () {
        console.log('in the zone: ' + !!Zone.current.inTheZone); 
    }, 0);
});

console.log('in the zone: ' + !!Zone.current.inTheZone);

以上代码运行后的结果是:

in the zone: false
in the zone: true

是不是感觉很神奇!在Angular 2 中,有一个 NgZone,它是专门为 Angular 2 定制的 zone。在正式介绍它之前,我们先来看一下 Angular 1.x 中的一个例子:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Angular 1.x Demo</title>
    <script src="//cdn.bootcss.com/angular.js/1.6.3/angular.min.js"></script>
</head>
<body ng-app="exeApp">
<div ng-controller="MainCtrl">
    <h4>Hello {{ name }}</h4>
</div>
<script type="text/javascript">
    angular.module('exeApp', [])
            .controller('MainCtrl', ['$scope', function ($scope) {
                $scope.name = 'Angular';

                setTimeout(function () {
                    $scope.name = 'Angular 2';
                }, 2000);
            }]);
</script>
</body>
</html>

以上代码运行后的输出结果:

图片描述

用过 Angular 1.x 的同学,应该很清楚可以通过 Angular 1.x 中的 $timeout 服务或手动调用 $scope.$digest() 方法来通知视图刷新。这对初学者来说,是很麻烦的一件事情。你们应该还记得前面计数器组件的例子,我们通过 setInterval 定时器,实现每秒钟 counter 值自动加1,页面就自动刷新了。不需要再使用 Angular 1.x 中的 $timeout 服务或手动调用 $scope.$digest() 方法来刷新视图。

为什么我们都是使用定时器,而在 Angular 2 中模型发生变化后,却能自动通知视图进行刷新呢 ?我们来分析一下,首先在浏览器中新开一个 Tab 页,在控制台输入:

window.setTimeout.toString() 
"function setTimeout() { [native code] }"

然后再打开一个 Angular 2 应用的页面,在控制台同样输入:

window.setTimeout.toString()
"function setTimeout(){return f(this, arguments)}"

我们发现在 Angular 2 中,setTimeout 方法已经被重写了,最简单的实现方式如下:

var originSetTimeout = window.setTimeout;
window.setTimeout = function(fn, delay) {
  console.log('setTimeout has been called');
  originSetTimeout(fn, delay); 
}

其实在 Angular 2 应用程序启动之前,Zone 采用猴子补丁 (Monkey-patched) 的方式,将 JavaScript 中的异步任务都进行了包装,这使得这些异步任务都能运行在 Zone 的执行上下文中,每个异步任务在 Zone 中都是一个任务,除了提供了一些供开发者使用的钩子外,默认情况下 Zone 重写了以下方法:

  • setInterval、clearInterval、setTimeout、clearTimeout
  • alert、prompt、confirm
  • requestAnimationFrame、cancelAnimationFrame
  • addEventListener、removeEventListener

Zone 内部源码片段:

var set = 'set';
var clear = 'clear';
var blockingMethods = ['alert', 'prompt', 'confirm'];
var _global = typeof window === 'object' && window || 
     typeof self === 'object' && self || global;
patchTimer(_global, set, clear, 'Timeout');
patchTimer(_global, set, clear, 'Interval');
patchTimer(_global, set, clear, 'Immediate');
patchTimer(_global, 'request', 'cancel', 'AnimationFrame');
patchTimer(_global, 'mozRequest', 'mozCancel', 'AnimationFrame');
patchTimer(_global, 'webkitRequest', 'webkitCancel', 'AnimationFrame');

NgZone

NgZone 是基于 Zone 实现的,它是Zone派生出来的一个子Zone,在 Angular 环境内注册的异步事件都运行在这个子 Zone 内 (因为NgZone拥有整个运行环境的执行上下文),它扩展了自有的一些 API 并添加了一些功能性的方法到它的执行上下文中。

在 Angular 源码中,有一个 ApplicationRef_ 类,其作用是用来监听 NgZone 中的 onMicrotaskEmpty 事件,无论何时只要触发这个事件,那么将会执行一个 tick 方法用来告诉 Angular 去执行变化检测,简化版的代码如下:

class ApplicationRef { 
  private _views: InternalViewRef[] = [];

  constructor(private zone: NgZone) {
        this.zone.onMicrotaskEmpty.subscribe(() => {
            this.zone.run(() => { 
              this.tick();
            }); 
        });
  }
  
  tick() { 
    if (this._runningTick) {
      throw new Error('ApplicationRef.tick is called recursively');
    }
    this._views.forEach((view) => view.detectChanges());
   }
}

现在我们先来总结一下前面所讲的内容:

  • 异步操作被安排为任务
  • Zones 跟踪任务的执行
  • Angular 处理由执行异步操作引起的事件
  • Angular 对所有组件执行变化检测,若发生变化则更新视图

要完全理解 Zone 的工作原理是比较困难的,对我们大部分的人来说,只要知道 Angular 内部是通过它来跟踪异步任务,然后执行变化检测任务就可以了。

我有话说

1.在 Angular 2 项目中怎么访问 Zone 打补丁前的方法,如 setTimeout、clearTimeout 等

因为 Zone 内部通过内建的 __symbol__ 函数来模拟 Symbol

function __symbol__(name) {
  return '__zone_symbol__' + name;
}

因此我们可以在浏览器的控制台中运行:

Object.keys(window).forEach((key) => {
 if(key.indexOf('zone_symbol') > 0) {
    console.log(key);
 }  
});

运行后控制台的输出结果如下:

图片描述

2.前面介绍 Zone 使用的示例,为什么控制台会输出那样的结果 ?

// 加载Zone.js给浏览器中的一些异步操作打上补丁
// 创建Root Zone
// 调用Zone.current对象上的fork方法创建新的zone,我们称之为childZone
Zone.current.fork({}).run(function () { 
      // 运行run方法,Zone.current被设置为函数被执行时所属的Zone,即childZone
    Zone.current.inTheZone = true;
  
      // 这里注册了一个定时器。由于被打过了猴子补丁,这里调用的并不是
    // 浏览器"默认"的setTimeout方法。因此,这里实际上是在配置代理。这里
    // 要重点指出的是这个代理会保留一个指向创建时所属Zone的引用即childZone,
    // 稍后会用到这个引用。
    setTimeout(function () {
        // 定时时间到,此时的Zone.current的值会被重置为childZone
        console.log('in the zone: ' + !!Zone.current.inTheZone); 
    }, 0);
  
      // 代码执行完 Zone.current属性被重置为Root Zone
    // Zone的生命周期里的钩子函数会被触发
});

console.log('in the zone: ' + !!Zone.current.inTheZone);

如果还是不好理解的话,我们可以想象一下同步的过程:

const rootZone = Zone.current;
// 创建一个新的Zone
const childZone = Zone.current.fork({});
// 设置当前的zone
Zone.current = zone;
// 为当前的zone添加inTheZone属性
Zone.current.inTheZone = true;
console.log('in the zone: ' + !!Zone.current.inTheZone);
// 退出当前的zone
Zone.current = rootZone;
console.log('in the zone: ' + !!Zone.current.inTheZone);

总结

这篇文章我们先介绍了浏览器中渲染的概念,然后通过三个示例引出了引起模型变化的事件源并总结了它们之间的共性,此外我们还介绍了 Angular 1.x 项目中初学者容易遇到的问题,并基于该问题引入了 Zone 和 NgZone 的概念,最后我们简单介绍了 Zone.js 的内部工作原理。下一篇文章我们将详细介绍 Angular 2 组件中的变化检测器。


阿宝哥
15.8k 声望10.2k 粉丝

聚焦全栈,专注分享 Angular、TypeScript、Node.js/Java 、Spring 技术栈等全栈干货