文章目录
  1. 1. zone.js
    1. 1.1. 异步操作的困惑
    2. 1.2. zone.js 的设计
    3. 1.3. 让异步逻辑运行在指定区域中
    4. 1.4. 任务执行的生命周期
  2. 2. 总结
    1. 2.1. 参考

作为“为大型前端项目”而设计的前端框架,Angular 其实有许多值得参考和学习的设计,本系列主要用于研究这些设计和功能的实现原理。本文主要围绕 Angular 中的 NgZone 核心能力,这些能力主要基于 zone.js 来实现,因此本文先介绍 zone.js。

在 Angular 中,对于数据变更检测使用的是脏检查(dirty check),这曾经在 AngularJS 版本中被诟病,认为存在性能问题。而在 Angular(2+) 版本之后,通过引入模块化组织,以及 NgZone 的设计,提升了脏检查的性能。

对于 NgZone 的引入,并不只是为了解决脏检查的问题,它解决了很多 Javascript 异步编程时上下文的问题,其中 zone.js 便是针对异步编程提出的作用域解决方案。

zone.js

Zone 是跨异步任务而持久存在的执行上下文,zone.js 提供以下能力:

  • 提供异步操作之间的执行上下文
  • 提供异步生命周期挂钩
  • 提供统一的异步错误处理机制

异步操作的困惑

在 Javascript 中,代码执行过程中会产生堆栈,函数会在堆栈中执行

对于异步操作来说,异步代码和函数执行的时候,上下文可能发生了变化,为此可能导致一些难题。比如:

  • 异步代码执行时,上下文发生了变更,导致预期不一致
  • throw Error时,无法准确定位到上下文
  • 测试某个函数的执行耗时,但因为函数内有异步逻辑,无法得到准确的执行时间

一般来说,异步代码执行时的上下文问题,可以通过传参或是全局变量的方式来解决,但两种方式都不是很优雅(尤其全局变量)。zone.js 正是为了解决以上问题而提出的,我们来看看。

zone.js 的设计

zone.js 的设计灵感来自 Dart Zones,你也可以将其视为 JavaScript VM 中的 TLS–线程本地存储

zone 具有当前区域的概念:当前区域是随所有异步操作一起传播的异步上下文,它表示与当前正在执行的堆栈帧/异步任务关联的区域。

当前上下文可以使用Zone.current获取,可比作 Javascript 中的this,在 zone.js 中使用_currentZoneFrame变量跟踪当前区域。每个区域都有name属性,主要用于工具和调试目的,zone.js 还定义了用于操纵区域的方法:

  • zone.fork(zoneSpec): 创建一个新的子区域,并将其parent设置为用于分支的区域
  • zone.run(callback, ...):在给定区域中同步调用一个函数
  • zone.runGuarded(callback, ...):与run捕获运行时错误相同,并提供了一种拦截它们的机制。如果任何父区域未处理错误,则将其重新抛出。
  • zone.wrap(callback):产生一个新的函数,该函数将区域绑定在一个闭包中,并在执行zone.runGuarded(callback)时执行,与 JavaScript 中的Function.prototype.bind工作原理类似。

我们可以看到Zone的主要实现逻辑(new Zone()/fork()/run())也相对简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class Zone implements AmbientZone {
// 获取根区域
static get root(): AmbientZone {
let zone = Zone.current;
// 找到最外层,父区域为自己
while (zone.parent) {
zone = zone.parent;
}
return zone;
}
// 获取当前区域
static get current(): AmbientZone {
return _currentZoneFrame.zone;
}
private _parent: Zone|null; // 父区域
private _name: string; // 区域名字
private _properties: {[key: string]: any};
// 拦截区域操作时的委托,用于生命周期钩子相关处理
private _zoneDelegate: ZoneDelegate;

constructor(parent: Zone|null, zoneSpec: ZoneSpec|null) {
// 创建区域时,设置区域的属性
this._parent = parent;
this._name = zoneSpec ? zoneSpec.name || 'unnamed' : '<root>';
this._properties = zoneSpec && zoneSpec.properties || {};
this._zoneDelegate =
new ZoneDelegate(this, this._parent && this._parent._zoneDelegate, zoneSpec);
}
// fork 会产生子区域
public fork(zoneSpec: ZoneSpec): AmbientZone {
if (!zoneSpec) throw new Error('ZoneSpec required!');
// 以当前区域为父区域,调用 new Zone() 产生子区域
return this._zoneDelegate.fork(this, zoneSpec);
}
// 在区域中同步运行某段代码
public run(callback: Function, applyThis?: any, applyArgs?: any[], source?: string): any;
public run<T>(
callback: (...args: any[]) => T, applyThis?: any, applyArgs?: any[], source?: string): T {
// 准备执行,入栈处理
_currentZoneFrame = {parent: _currentZoneFrame, zone: this};
try {
// 使用 callback.apply(applyThis, applyArgs) 实现
return this._zoneDelegate.invoke(this, callback, applyThis, applyArgs, source);
} finally {
// 执行完毕,出栈处理
_currentZoneFrame = _currentZoneFrame.parent!;
}
}
...
}

除了上面介绍的,Zone 还提供了许多方法来运行、计划和取消任务,包括:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
interface Zone {
...
// 通过在任务区域中恢复 Zone.currentTask 来执行任务
runTask<T>(task: Task, applyThis?: any, applyArgs?: any): T;
// 安排一个 MicroTask
scheduleMicroTask(source: string, callback: Function, data?: TaskData, customSchedule?: (task: Task) => void): MicroTask;
// 安排一个 MacroTask
scheduleMacroTask(source: string, callback: Function, data?: TaskData, customSchedule?: (task: Task) => void, customCancel?: (task: Task) => void): MacroTask;
// 安排一个 EventTask
scheduleEventTask(source: string, callback: Function, data?: TaskData, customSchedule?: (task: Task) => void, customCancel?: (task: Task) => void): EventTask;
// 安排现有任务(对重新安排已取消的任务很有用)
scheduleTask<T extends Task>(task: T): T;
// 允许区域拦截计划任务的取消,使用 ZoneSpec.onCancelTask​​ 配置拦截
cancelTask(task: Task): any;
}

让异步逻辑运行在指定区域中

在 zone.js 中,通过zone.fork可以创建子区域,通过zone.run可让函数(包括函数里的异步逻辑)在指定的区域中运行。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const zoneBC = Zone.current.fork({name: 'BC'});
function c() {
console.log(Zone.current.name); // BC
}
function b() {
console.log(Zone.current.name); // BC
setTimeout(c, 2000);
}
function a() {
console.log(Zone.current.name); // <root>
zoneBC.run(b);
}

a();

执行的效果如图:

实际上,每个异步任务的调用堆栈会以根区域开始。因此,在 zone.js 中该区域会使用与任务关联的信息来还原正确的区域,然后调用该任务:

对于Zone.fork()Zone.run()的作用和实现,上面已经介绍过了。那么,zone.js 是如何识别出异步任务的呢?其实 zone.js 主要是通过猴子补丁拦截异步 API(包括 DOM 事件、XMLHttpRequest和 NodeJS 的 API 如EventEmitterfs等)来实现这些功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 为指定的本地模块加载补丁
static __load_patch(name: string, fn: _PatchFn, ignoreDuplicate = false): void {
// 检查是否已经加载补丁
if (patches.hasOwnProperty(name)) {
if (!ignoreDuplicate && checkDuplicate) {
throw Error('Already loaded patch: ' + name);
}
// 检查是否需要加载补丁
} else if (!global['__Zone_disable_' + name]) {
const perfName = 'Zone:' + name;
// 使用 performance.mark 标记时间戳
mark(perfName);
// 拦截指定异步 API,并进行相关处理
patches[name] = fn(global, Zone, _api);
// 使用 performance.measure 计算耗时
performanceMeasure(perfName, perfName);
}
}

setTimeout等定时器为例子,通过拦截和捕获特定 API:

1
2
3
4
5
6
7
Zone.__load_patch('timers', (global: any) => {
const set = 'set';
const clear = 'clear';
patchTimer(global, set, clear, 'Timeout');
patchTimer(global, set, clear, 'Interval');
patchTimer(global, set, clear, 'Immediate');
});

patchTimer做了很多兼容性的逻辑处理,包括 Node.js 和浏览器环境的检测和处理,其中比较关键的实现逻辑在:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 检测该函数属性是否可写
if (isPropertyWritable(desc)) {
const patchDelegate = patchFn(delegate!, delegateName, name);
// 修改函数默认行为
proto[name] = function() {
return patchDelegate(this, arguments as any);
};
attachOriginToPatched(proto[name], delegate);
if (shouldCopySymbolProperties) {
copySymbolProperties(delegate, proto[name]);
}
}
// patchFn 用于使用当前的区域创建 MacroTask 任务
const patchFn = function(self: any, args: any[]) {
if (typeof args[0] === 'function') {
...
const callback = args[0];
args[0] = function timer(this: unknown) {
try {
// 执行该函数
return callback.apply(this, arguments);
} finally {
// 一些清理工作,比如删除任务的引用等
}
}
};
// 使用当前的区域创建 MacroTask 任务,调用 Zone.current.scheduleMacroTask
const task = scheduleMacroTaskWithCurrentZone(setName, args[0], options, scheduleTask, clearTask);
if (!task) {
return task;
}
// 一些兼容性处理工作,比如对于nodejs 环境,将任务引用保存在 timerId 对象中,用于 clearTimeout
return task;
} else {
// 出现异常时,直接返回调用
return delegate.apply(window, args);
}
};

在这里,计时器相关的 Timer 会被创建 MacroTask 任务并添加到 Zone 的任务中进行处理。在 zone.js 中,有将各种异步任务拆分为三种:

1
type TaskType = 'microTask'|'macroTask'|'eventTask';

zone.js 可以支持选择性地打补丁,具体更多的补丁机制可以参考 Zone.js’s support for standard apis

任务执行的生命周期

zone.js 提供了异步操作生命周期钩子,有了这些钩子,Zone 可以监视和拦截异步操作的所有生命周期:

  • onScheduleTask:此回调将在async操作为之前被调用scheduled,这意味着async操作即将发送到浏览器(或 NodeJS )以计划在以后运行时
  • onInvokeTask:此回调将在真正调用异步回调之前被调用
  • onHasTask:当任务队列的状态在empty和之间更改时,将调用此回调not empty

完整的生命周期钩子包括:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
interface ZoneSpec {
// 允许拦截 Zone.fork,对该区域进行 fork 时,请求将转发到此方法以进行拦截
onFork?: (parentZoneDelegate: ZoneDelegate, currentZone: Zone, targetZone: Zone, zoneSpec: ZoneSpec) => Zone;
// 允许拦截回调的 wrap
onIntercept?: (parentZoneDelegate: ZoneDelegate, currentZone: Zone, targetZone: Zone, delegate: Function, source: string) => Function;
// 允许拦截回调调用
onInvoke?: (parentZoneDelegate: ZoneDelegate, currentZone: Zone, targetZone: Zone, delegate: Function, applyThis: any, applyArgs?: any[], source?: string) => any;
// 允许拦截错误处理
onHandleError?: (parentZoneDelegate: ZoneDelegate, currentZone: Zone, targetZone: Zone, error: any) => boolean;
// 允许拦截任务计划
onScheduleTask?: (parentZoneDelegate: ZoneDelegate, currentZone: Zone, targetZone: Zone, task: Task) => Task;
// 允许拦截任务回调调用
onInvokeTask?: (parentZoneDelegate: ZoneDelegate, currentZone: Zone, targetZone: Zone, task: Task, applyThis: any, applyArgs?: any[]) => any;
// 允许拦截任务取消
onCancelTask?: (parentZoneDelegate: ZoneDelegate, currentZone: Zone, targetZone: Zone, task: Task) => any;
// 通知对任务队列为空状态的更改
onHasTask?: (parentZoneDelegate: ZoneDelegate, currentZone: Zone, targetZone: Zone, hasTaskState: HasTaskState) => void;
}

这些生命周期的钩子回调会在zone.fork()时,通过new Zone()创建子区域并创建和传入到ZoneDelegate中:

1
2
3
4
5
6
class Zone implements AmbientZone {
constructor(parent: Zone|null, zoneSpec: ZoneSpec|null) {
...
this._zoneDelegate = new ZoneDelegate(this, this._parent && this._parent._zoneDelegate, zoneSpec);
}
}

onFork为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class ZoneDelegate implements AmbientZoneDelegate {
constructor(zone: Zone, parentDelegate: ZoneDelegate|null, zoneSpec: ZoneSpec|null) {
...
// 管理 onFork 钩子回调
this._forkZS = zoneSpec && (zoneSpec && zoneSpec.onFork ? zoneSpec : parentDelegate!._forkZS);
this._forkDlgt = zoneSpec && (zoneSpec.onFork ? parentDelegate : parentDelegate!._forkDlgt);
this._forkCurrZone =
zoneSpec && (zoneSpec.onFork ? this.zone : parentDelegate!._forkCurrZone);
}
// fork 调用时,会检查是否有 onFork 钩子回调注册,并进行调用
fork(targetZone: Zone, zoneSpec: ZoneSpec): AmbientZone {
return this._forkZS ? this._forkZS.onFork!(this._forkDlgt!, this.zone, targetZone, zoneSpec) : new Zone(targetZone, zoneSpec);
}
}

这便是 zone.js 中生命周期钩子的实现。有了这些钩子,我们可以做很多其他有用的事情,例如分析、记录和限制函数的执行和调用。

总结

本文我们主要介绍了 zone.js,它被设计用于解决异步编程中的执行上下文问题。

在 zone.js 中,当前区域是随所有异步操作一起传播的异步上下文,可比作 Javascript 中的this。通过zone.fork可以创建子区域,通过zone.run可让函数(包括函数里的异步逻辑)在指定的区域中运行。

zone.js 提供了丰富的生命周期钩子,可以使用 zone.js 的区域能力以及生命周期钩子解决前面我们提到的这些问题:

  • 异步代码执行时,上下文发生了变更,导致预期不一致:使用 Zone 来执行相关代码
  • throw Error时,无法准确定位到上下文:使用生命周期钩子onHandleError进行处理和跟踪
  • 测试某个函数的执行耗时,但因为函数内有异步逻辑,无法得到准确的执行时间:使用生命周期钩子配合可得到具体的耗时

参考

码生艰难,写文不易,给我家猪囤点猫粮了喵~

B站: 被删

查看Github有更多内容噢:https://github.com/godbasin
更欢迎来被删的前端游乐场边撸猫边学前端噢

如果你想要关注日常生活中的我,欢迎关注“牧羊的猪”公众号噢

作者:被删

出处:https://godbasin.github.io

本文版权归作者所有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。

文章目录
  1. 1. zone.js
    1. 1.1. 异步操作的困惑
    2. 1.2. zone.js 的设计
    3. 1.3. 让异步逻辑运行在指定区域中
    4. 1.4. 任务执行的生命周期
  2. 2. 总结
    1. 2.1. 参考