Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Angular 源码解析 Zone.js #9

Closed
wzhudev opened this issue Oct 16, 2019 · 2 comments
Closed

Angular 源码解析 Zone.js #9

wzhudev opened this issue Oct 16, 2019 · 2 comments
Labels

Comments

@wzhudev
Copy link
Owner

wzhudev commented Oct 16, 2019

Angular 源码解析系列。这篇文章有关于 Zone.js 的用途,实现和 NgZone 的实现,以及 Angular 如何使用 Zone.js 实现自动变更检测。


这篇文章是 Angular 源码解析系列的第一篇,分为以下三个小节:

  • 为什么 Angular 需要 Zone.js?
  • Zone.js 如何工作?
    • Zone.js 核心的实现
    • 举一个例子介绍 Zone.js 如何打补丁
  • Angular 如何使用 Zone.js?

阅读这篇文章你需要熟悉 JavaScript 的事件循环 (Event Loop) 机制

现在 Zone.js 的源码已经被放到 Angular 底下以 mono repo 的形式管理,你可以通过下面的链接阅读 Zone.js 的源码。

angular/angular

为什么 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 方法,而 tick 方法会调用顶层组件的 detectChanges 方法执行变更检测,就是下面这行代码:

this._zone.onMicrotaskEmpty.subscribe({
  next: () => {
    this._zone.run(() => {
      this.tick()
    })
  },
})

可以通过看代码注释来了解官方对 Zone.js 作用的描述。

Zone.js 如何工作?

编程模型

把 Zone.js 中的 zone 想象成 JavaScript VM 线程里的 mini 线程。

JavaScript 是单线程,基于事件循环的,而通过 Zone.js,我们可以把处理事件的回调函数放到不同的 zone 里面执行,而且在当前回调函数内触发的异步事件也会在当前 zone 里面得到处理,即我们给事件的回调函数提供了执行环境。而且,Zone.js 还提供了钩子,允许我们在回调函数执行前后执行额外一些代码(还有其他的一些钩子)。

总而言之——

zone 提供了 JavaScript 异步函数的执行环境。

核心代码

核心部分实现了 Zone.js 的机制,而不关心各种 patch 该如何实现,代码都在 zone.ts 当中,前面几百行都是接口声明,请自行阅读,本文主要聚焦于其实现

重要类型和方法

这个文件主要声明和实现了如下几个类:

  • 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,调用某个回调函数出错时触发的钩子。
    • onScheduleTaskZoneTask 被安排时触发的钩子,比如调用了 setTimeout
    • onInvokeTaskZoneTask 被触发时触发的钩子,比如 setTimeout 到时。
    • onCancelTaskZoneTask 被取消时触发的钩子,比如用 clearTimeout 取消了计时器。
    • onHasTask,检测到有或无 ZoneTask 时触发的钩子(即对第一个 schedule 的 zone 和最后一个 invoke 或 cancel 的 task 触发)。
  • ZoneDelegate,负责调用钩子。

官方文档中对这些类所实现的接口有非常详细的注释,可以比较下面的内容阅读。

Zone

这些代码是对 Zone 的实现

有几个值得关注的静态方法:

  • get root(),该方法返回根 zone,所有其他 zone 都是该 zone 的子孙,类似于操作系统的第一个进程。根 zone 是 Zone.js 初始化时自行创建的,相关代码在这里。根 zone 确保了所有的异步函数都在 Zone.js 的机制内运行。
  • get current(),返回当前 zone,类似于单线程 CPU 中正在占用 CPU 的进程,它本质上是返回闭包内的一个变量 _currentZoneFrame 的引用。
  • get currentTask,返回当前正被 invoke 的 task。
  • __load_patch,这是 Zone.js 加载补丁的方法,后面讲解 patch 的加载时会详细说明。

这个类的构造函数,要点如下:

  • 必须要有名字作为 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 钩子先处理,如果 ZoneSpec error 钩子不能处理,再抛出。
  • runTask,运行一个 ZoneTask
    • 看到这里请先去阅读 ZoneTask 类的实现。
    • 未被安排的 MacroTaskEventTask 两种类型的 task 是不需要执行的。
    • 调用 ZoneDelegate 的方法来执行 task。
    • 执行完毕之后调整 task 的状态,对于周期性触发的 task 来说,需要通过 reEntryGuard 进行状态保护。
  • scheduleTask,用来安排一个 task 的执行环境。

在阅读代码的时候我们经常能看到 _currentZoneFrame 这个变量,这实际上上记录了一个 zone 的栈(以链表的形式),如果某个 zone 中执行了函数调用,该 zone 就进入这个栈中,这样就将函数的调用栈与进入和离开 zone 的先后顺序对应起来了。

该变量被声明在创建 Zone.js 全局对象的函数里(即在一个闭包中),外部没有办法直接访问。

ZoneDelegate

代码在这里

要点:

  • 它的构造函数中前面一坨代码实际上干的事都可以概括为:我有没有这个回调,没有我就用父 ZoneDelegate 的(当然父级也有可能是空的)。这样父级 zone 可以通过 delegate 干预子 zone 的行为。
  • 大部分方法都是在尝试调用钩子,不成的话再进行磨人的简单处理。
  • 特别注意 scheduleTask(),该方法对于 MicroTask 会调用 scheduleMicroTask

ZoneTask

这个类的代码在这里

要点:

  • _state,记录了这个 task 的状态。
  • 在构造函数的最后要为当前 task 设置 invoke 方法,通常走的是 else 的分支,可以看到这里绑定了 invoke 函数中的 task 为当前 task。
  • static invokeTask(),该函数执行一个 task:
    1. 执行 task 的时候会增加 _numberOfNestedTaskFrames 计数器的值。
    2. 之后通过 zone 执行 task。
    3. 当执行完毕的时候调用 drainMicroTaskQueue 尝试清空所有的 MicroTask
    4. 然后减少计数器的值。
  • _transitionTo,改变 task 的状态,task 可以从两个源状态转移到一个目标状态,当 zone 的状态不属于两个源状态中的任何一个时,这个方法会抛出错误。

除了上面几个重要的类,下面两个方法也值得关注:

scheduleMicroTask,这个方法是对 Promise 这样的所谓 micro task 的处理。

  • 我们知道根据 JavaScript 的规范,Promise 是要在当前 VM 时间循环结束前触发的,所以 Zone.js 会尝试在恰当的时机释放所有的 Promise,即通过调用 drainMicroTaskQueue

drainMicroTaskQueue。该方法内容十分简单,即尝试对 _microTaskQueue 中的每一个 MicroTask run 一下。

补丁的实现

我们之前提到了 __load_patch 方法是 Zone.js 用来加载补丁的,这一小节我们将以 setTimeout 为例介绍 Zone.js 如何加载补丁。同时还会结合上一小节的内容,讲解 setTimeout 在 Zone.js 执行的全过程。因为各个 JavaScript runtime 对异步函数的支持情况不尽相同(比如在 Node.js 环境里不可能有 DOM 事件相关的异步函数,如 addEventListener),所以 Zone.js 会给不同的 runtime 提供不同的 dist 包,patch 不同的异步函数。好在不管在任何环境中,setTimeout 都是存在的。

patch 的具体实现在这里,下面我们将会仔细讲解这部分代码。

首先准备好要 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 方法:

export function patchMethod(
  target: any,
  name: string,
  patchFn: (
    delegate: Function,
    delegateName: string,
    name: string
  ) => (self: any, args: any[]) => any
): Function | null

首先,该方法从 target 的原型链上找到 name 代表的方法的具体位置(不要忘记 JavaScript 访问对象属性是通过原型链机制进行的),如果找不到这个方法,就直接在 target 上创建 patch 过的方法.

然后,检查 patch 过的方法是否存在,不存在才进行 patch。

在进行 patch 时:

  • 首先,让变量 delegate 先指向原生方法。
  • 然后,确保该方法是可复写的 (通过检查 PropertyDescriptorwritable 字段),不然就用原来的,相当于没有 patch。
  • 然后,调用 patchFn (这里是一个类似于柯里化的过程),将 patchDelegate 变量指向 patch 过后的函数,然后再将 proto[name] 指向一个新的函数,这里同时确保了 this 能够绑定在正确的对象上(对于 setTimeout 的例子就是 window)。
  • 到这里 patch 过程就已经完成了,函数返回的是 patch 之前的方法,即原生方法。

用户执行 setTimeout 后会发生什么?

我在这里给读者准备了一个简单的例子,通过给 console.log('z') 这一行打断点,就能够看到整个调用栈。

debugger

Angular 如何使用 Zone.js?

Angular 应用初始化过程中,实例化了一个 NgZone 然后将所有逻辑都跑在该对象的 _inner 对象中_inner 即为 Angular zone

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 方法还有可能在其他时机被调用,主要是通过 Zone.js 的 onInvokeTaskonInvoke 两个钩子,即在异步事件触发时,交给读者自行验证,这里就不赘述了。

结论

这篇文章介绍了 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

参考资料

@DaiHangLin
Copy link

ZoneTask里笔误了,两个都是MicroTask了

@wzhudev
Copy link
Owner Author

wzhudev commented May 16, 2020

ZoneTask里笔误了,两个都是MicroTask了

已修改,十分感谢指正!

@wzhudev wzhudev closed this as completed Sep 19, 2020
@wzhudev wzhudev reopened this Mar 2, 2021
@wzhudev wzhudev closed this as completed Dec 2, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

2 participants