第一次接触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
let rootZone = Zone .current ;
zoneA = rootZone.fork ({name : 'zoneA' });\
expect (rootZone.name ).toEqual ('<root>' );
expect (zoneA.name ).toEqual ('zoneA' );
expect (zoneA.parent ).toBe (rootZone);
异步task在Root Zone中执行
let rootZone = Zone.current ;
let zoneA = rootZone.fork ({name: 'A'});
expect (Zone.current).toBe (rootZone);
setTimeout (function timeoutCb1() {
expect (Zone.current).toEqual (rootZone);
}, 0 );
异步task在指定的子Zone中执行-- zoneA.run()
zoneA.run (function run1() {
expect (Zone.current).toEqual (zoneA);
setTimeout (function timeoutCb2() {
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 ();
document .getElementById ('btn' ).addEventListener ('click' , () => {
value = 'button update value' ;
detectChange ();
const xhr = new XMLHttpRequest ();
xhr.addEventListener ('load' , function () {
value = this .responseText ;
detectChange ();
xhr.open ('GET' , serverUrl);
xhr.send ();
setTimeout (() => {
value = 'timeout update value' ;
detectChange ();
}, 100 );
Promise .resolve ('promise resolved a value' ).then (v => {
value = v;
detectChange ();
}, 100 );
document .getElementById ('canvas' ).toBlob (blob => {
value = `value updated by canvas, size is ${blob.size} ` ;
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 => {
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 (() => {
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 (() => {
expect (zoneThis).toBe (zone);
setTimeout (function () {
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
执行的时候
输出内容:
the callback will be invoked : () => {
setTimeout (() => {
console .log ('timeout callback is invoked.' );
new task is scheduled : macroTask setTimeout
task state changed in the zone : { microTask : false ,
macroTask : true ,
eventTask : false ,
change : 'macroTask' }
task will be invoked macroTask : setTimeout
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 () {
this .ngZone .run (() => {
someNewAsyncAPI (() => {
默认情况下,所有的异步任务都会在angular zone中执行,但是也有一些场景并不想当这些异步任务执行的时候去触发变更检查,这种情况下可以使用另外一个方法:runOutsideAngular()
export class AppComponent implements OnInit {
constructor (private ngZone: NgZone ) {}
ngOnInit () {
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
493
OpenTiny社区
Angular.js
458
EscapePlan
Angular.js
430
tc9011
Angular.js
JavaScript
8884
zhupc
搬砖工 @ 思科系统(中国)研发中心