Angular 源码解析 - Zone.js
Angular 源码解析系列。这篇文章有关于 Zone.js 的用途,实现和 NgZone 的实现,以及 Angular 如何使用 Zone.js 实现自动变更检测。
这篇文章是 Angular 源码解析系列的第一篇,分为以下三个小节:
- 为什么 Angular 需要 Zone.js?
- Zone.js 如何工作?
- Zone.js 核心的实现
- 举一个例子介绍 Zone.js 如何打补丁
- Angular 如何使用 Zone.js?
阅读这篇文章你需要熟悉 JavaScript 的事件循环 (Event Loop) 机制 (opens in a new tab)。
现在 Zone.js 的源码已经被放到 Angular 底下以 mono repo 的形式管理,你可以通过下面的链接阅读 Zone.js 的源码。
angular/angular (opens in a new tab)
为什么 Angular 需要 Zone.js?
所有前端框架需要解决的一个共同问题就是:应该何时将应用状态的变化反映到视图中,即变更检测。React 的方案是交给用户自行决定,即让用户通过 setState 方法告诉 React 应用的状态发生了改变;Vue 通过拦截对象的赋值操作来监测状态改变(即所谓响应式);而 Angular 的方案就是 Zone.js。Zone.js 通过给一些会触发异步事件 API 打补丁(monkey patch),比如 XHR、DOM event、定时器等来监听异步事件的编排(比如调用 setTimeout)和触发(比如 setTimeout 到时),而应用状态的变化一定是某个异步事件的结果,这样 Angular 就可以借助 Zone.js 实现变更检测。
体现在代码中:Angular 应用会在 zone 的 onMicrotaskEmpty 回调中调用 tick 方法 (opens in a new tab),而 tick 方法会调用顶层组件的 detectChanges 方法执行变更检测,就是下面这行代码:
this._zone.onMicrotaskEmpty.subscribe({
next: () => {
this._zone.run(() => {
this.tick()
})
}
})可以通过看代码注释 (opens in a new tab)来了解官方对 Zone.js 作用的描述。
Zone.js 如何工作?
编程模型
把 Zone.js 中的 zone 想象成 JavaScript VM 线程里的 mini 线程。
JavaScript 是单线程,基于事件循环的,而通过 Zone.js,我们可以把处理事件的回调函数放到不同的 zone 里面执行,而且在当前回调函数内触发的异步事件也会在当前 zone 里面得到处理,即我们给事件的回调函数提供了执行环境。而且,Zone.js 还提供了钩子,允许我们在回调函数执行前后执行额外一些代码(还有其他的一些钩子)。
总而言之——
zone 提供了 JavaScript 异步函数的执行环境。
核心代码
核心部分实现了 Zone.js 的机制,而不关心各种 patch 该如何实现,代码都在 zone.ts (opens in a new tab) 当中,前面几百行都是接口声明,请自行阅读,本文主要聚焦于其实现 (opens in a new tab)。
重要类型和方法
这个文件主要声明和实现了如下几个类:
Zone,JavaScript 事件的执行环境,和线程一样,它们可以带一些数据,并且可能拥有父子 zone。ZoneTask,包装后的异步事件,这些 task 有三种子类:MicroTask,由Promise创建,我们知道 native 的Promise是在当前事件循环结束前就要执行的,所以打过补丁的 Promise 也应该在事件循环结束前执行。MacroTask,由setTimeout等创建,native 的setTimeout会在将来某个时间被处理,而且会被处理一到多次。EventTask,由addEventListener等创建,这些 task 可能被触发多次,也可能一直不会被触发。
ZoneSpec,创建一个 zone 时给它提供的参数,除了name是必须的外,还可以传入如下的钩子:onFork,创建新 zone 的钩子。onIntercept,包装某个回调函数时触发的钩子。onInvoke,调用某个回调函数时触发的钩子。onHandleError,调用某个回调函数出错时触发的钩子。onScheduleTask,ZoneTask被安排时触发的钩子,比如调用了setTimeout。onInvokeTask,ZoneTask被触发时触发的钩子,比如setTimeout到时。onCancelTask,ZoneTask被取消时触发的钩子,比如用clearTimeout取消了计时器。onHasTask,检测到有或无ZoneTask时触发的钩子(即对第一个 schedule 的 zone 和最后一个 invoke 或 cancel 的 task 触发)。
ZoneDelegate,负责调用钩子。
官方文档中对这些类所实现的接口有非常详细的注释,可以比较下面的内容阅读。
Zone 类
这些代码是对 Zone 的实现 (opens in a new tab)。
有几个值得关注的静态方法:
get root(),该方法返回根 zone,所有其他 zone 都是该 zone 的子孙,类似于操作系统的第一个进程。根 zone 是 Zone.js 初始化时自行创建的,相关代码在这里 (opens in a new tab)。根 zone 确保了所有的异步函数都在 Zone.js 的机制内运行。get current(),返回当前 zone,类似于单线程 CPU 中正在占用 CPU 的进程,它本质上是返回闭包内的一个变量_currentZoneFrame的引用。get currentTask,返回当前正被 invoke 的 task。__load_patch,这是 Zone.js 加载补丁的方法,后面讲解 patch 的加载时会详细说明。
这个类的构造函数 (opens in a new tab),要点如下:
- 必须要有名字作为 zone 的标识符。
parent变量保存了父 zone,这样 zone 就可以形成一个树型结构。- 可以挂一些变量到
_properties上作为函数运行的上下文,而get()和getZoneWith()方法分别用于取得某个 key 所对应的变量和上下文中有某个 key 的 zone。 - 构建了一个
ZoneDelegate并赋值给_zoneDelegate属性。
Zone 类还有如下的实例方法:
fork,创建一个子 zone,相当于 fork 一个进程。wrap,包裹一个函数,这个被包裹的函数执行时,首先会通过runGurad把该函数运行的上下文置换为原来包裹它的 zone,然后通过ZoneSpec去执行钩子。run,立即在当前 zone 执行函数,并调用ZoneSpec执行invoke钩子。runGuarded,和 run 做的事情基本相同,不同点在于如果执行过程出错,错误会被ZoneSpec注册的 error 钩子先处理,如果ZoneSpecerror 钩子不能处理,再抛出。runTask,运行一个ZoneTask:- 看到这里请先去阅读
ZoneTask类的实现。 - 未被安排的
MacroTask和EventTask两种类型的 task 是不需要执行的。 - 调用
ZoneDelegate的方法来执行 task。 - 执行完毕之后调整 task 的状态,对于周期性触发的 task 来说,需要通过
reEntryGuard进行状态保护。
- 看到这里请先去阅读
scheduleTask,用来安排一个 task 的执行环境。
在阅读代码的时候我们经常能看到
_currentZoneFrame这个变量,这实际上上记录了一个 zone 的栈(以链表的形式),如果某个 zone 中执行了函数调用,该 zone 就进入这个栈中,这样就将函数的调用栈与进入和离开 zone 的先后顺序对应起来了。该变量被声明在创建 Zone.js 全局对象的函数里(即在一个闭包中),外部没有办法直接访问。
ZoneDelegate 类
要点:
- 它的构造函数中前面一坨代码实际上干的事都可以概括为:我有没有这个回调,没有我就用父
ZoneDelegate的(当然父级也有可能是空的)。这样父级 zone 可以通过 delegate 干预子 zone 的行为。 - 大部分方法都是在尝试调用钩子,不成的话再进行磨人的简单处理。
- 特别注意
scheduleTask()(opens in a new tab),该方法对于MicroTask会调用scheduleMicroTask。
ZoneTask
这个类的代码在这里 (opens in a new tab)。
要点:
_state,记录了这个 task 的状态。- 在构造函数的最后要为当前 task 设置 invoke 方法 (opens in a new tab),通常走的是 else 的分支,可以看到这里绑定了 invoke 函数中的
task为当前 task。 static invokeTask(),该函数执行一个 task:- 执行 task 的时候会增加
_numberOfNestedTaskFrames计数器的值。 - 之后通过 zone 执行 task。
- 当执行完毕的时候调用
drainMicroTaskQueue尝试清空所有的MicroTask。 - 然后减少计数器的值。
- 执行 task 的时候会增加
_transitionTo,改变 task 的状态,task 可以从两个源状态转移到一个目标状态,当 zone 的状态不属于两个源状态中的任何一个时,这个方法会抛出错误。
除了上面几个重要的类,下面两个方法也值得关注:
scheduleMicroTask (opens in a new tab),这个方法是对 Promise 这样的所谓 micro task 的处理。
- 我们知道根据 JavaScript 的规范,Promise 是要在当前 VM 时间循环结束前触发的,所以 Zone.js 会尝试在恰当的时机释放所有的 Promise,即通过调用
drainMicroTaskQueue。
drainMicroTaskQueue (opens in a new tab)。该方法内容十分简单,即尝试对 _microTaskQueue 中的每一个 MicroTask run 一下。
- 除了 schedule micro task 的时候有可能调用该方法,还有一种可能就是
_numberOfNestedTaskFrames === 1时 (opens in a new tab)。
补丁的实现
我们之前提到了 __load_patch 方法是 Zone.js 用来加载补丁的,这一小节我们将以 setTimeout 为例介绍 Zone.js 如何加载补丁。同时还会结合上一小节的内容,讲解 setTimeout 在 Zone.js 执行的全过程。因为各个 JavaScript runtime 对异步函数的支持情况不尽相同(比如在 Node.js 环境里不可能有 DOM 事件相关的异步函数,如 addEventListener),所以 Zone.js 会给不同的 runtime 提供不同的 dist 包,patch 不同的异步函数。好在不管在任何环境中,setTimeout 都是存在的。
patch 的具体实现在这里 (opens in a new tab),下面我们将会仔细讲解这部分代码。
首先准备好要 patch 的函数的名称:
setName += nameSuffix // setTimeout
cancelName += nameSuffix // clearTimetout接下来,调用 patchMethod 方法,传入的三个参数分别是目标对象(被 patch 后的函数应当挂载在目标对象上,因为 setTimeout 其实是 window 的一个属性,所以这里的形参叫做 window),被 patch 函数的名字,以及一个回调函数,请注意这个回调函数直接返回了另外一个函数(如果你了解什么叫做柯里化,应该很容易理解,其实就是让该函数的执行时能够访问到 delegate 参数):
patchMethod(window, setNmae, (delegate: Function) => function(self: any, args: any[]): void);来看 patchMethod (opens in a new tab) 方法:
export function patchMethod(
target: any,
name: string,
patchFn: (
delegate: Function,
delegateName: string,
name: string
) => (self: any, args: any[]) => any
): Function | null首先 (opens in a new tab),该方法从 target 的原型链上找到 name 代表的方法的具体位置(不要忘记 JavaScript 访问对象属性是通过原型链机制进行的),如果找不到这个方法,就直接在 target 上创建 patch 过的方法.
然后 (opens in a new tab),检查 patch 过的方法是否存在,不存在才进行 patch。
在进行 patch 时:
- 首先,让变量
delegate先指向原生方法。 - 然后,确保该方法是可复写的 (通过检查
PropertyDescriptor的writable字段),不然就用原来的,相当于没有 patch。 - 然后,调用
patchFn(这里是一个类似于柯里化的过程),将patchDelegate变量指向 patch 过后的函数,然后再将proto[name]指向一个新的函数,这里同时确保了this能够绑定在正确的对象上(对于setTimeout的例子就是window)。 - 到这里 patch 过程就已经完成了,函数返回的是 patch 之前的方法,即原生方法。
用户执行 setTimeout 后会发生什么?
我在这里给读者准备了一个简单的例子 (opens in a new tab),通过给 console.log('z') 这一行打断点,就能够看到整个调用栈。

- 用户执行
setTimeout时,实际上执行的是这一行的匿名函数 (opens in a new tab),最终执行了这一行 (opens in a new tab)的function(self: any, args: any[]) - 准备 task 的参数
- 调用
scheduleMacroTaskWithCurrentZone(opens in a new tab) 方法,这个函数直接将参数透传给Zone.current.scheduleTaskMacroTask - 回到我们先前讲过的
Zone.current.scheduleTaskMacroTask,最终是调用了ZoneDelegate的scheduleTask方法 (opens in a new tab)。 scheduleTask方法 (opens in a new tab)将被调用,而这一句task.scheduleFn(task);实际上调用了scheduleTask函数 (opens in a new tab),最终调用原生的setTimeout。- 返回
handle,即原生setTimeout的返回值。 - 当原生的
setTimeout触发时,task.invoke(opens in a new tab) 被调用,最终ZoneDelegate.invokeTask被调用。
Angular 如何使用 Zone.js?
Angular 应用初始化过程中,实例化了一个 NgZone 然后将所有逻辑都跑在该对象的 _inner 对象中 (opens in a new tab),该 _inner 即为 Angular zone (opens in a new tab)。
Angular 创建该 zone 的过程中传入的 ZoneSpec 的部分如下所示:
onHasTask:
(delegate: ZoneDelegate, current: Zone, target: Zone, hasTaskState: HasTaskState) => {
delegate.hasTask(target, hasTaskState);
if (current === target) {
// We are only interested in hasTask events which originate from our zone
// (A child hasTask event is not interesting to us)
if (hasTaskState.change == 'microTask') {
zone.hasPendingMicrotasks = hasTaskState.microTask;
checkStable(zone);
} else if (hasTaskState.change == 'macroTask') {
zone.hasPendingMacrotasks = hasTaskState.macroTask;
}
}
},在 checkStable 这个方法中你可以看到这样一行:
zone.onMicrotaskEmpty.emit(null)这就联系到了开篇提到的代码:
this._zone.onMicrotaskEmpty.subscribe({
next: () => {
this._zone.run(() => {
this.tick()
})
}
})当然 checkStable 方法 (opens in a new tab)还有可能在其他时机被调用,主要是通过 Zone.js 的 onInvokeTask 和 onInvoke 两个钩子,即在异步事件触发时,交给读者自行验证,这里就不赘述了。
结论
这篇文章介绍了 Zone.js 的实现,包括 Zone.js 核心和 patch 的实现,还讲解了 Angular 对 Zone.js 的使用。
- Zone.js 提供的 zone 是 JavaScript 函数的执行环境,Zone.js patch 了几乎所有的异步函数。
- Angular 应用在初始化时创建了一个 Angular zone,Angular 的代码都运行在该 Zone 当中。
- 经过 patch 的异步函数所触发的异步事件会被 Zone.js 所捕捉,在事件回调函数执行后触发 Zone.js 的钩子。
- Angular 在部分钩子中进行整个应用的变更检测。
- Angular 的变更检测默认依赖于 Zone.js,但如果开发者完全了解应该在什么时候做变更检测,就可以抛弃 Zone.js。实际上,开发者可以通过给
bootstrapModule方法的第二个参数传参{ ngZone: 'noop' }来使用一个在钩子里不做任何事情的ZoneSpec(opens in a new tab)。