作为“为大型前端项目”而设计的前端框架,Angular 其实有许多值得参考和学习的设计,本系列主要用于研究这些设计和功能的实现原理。本文主要围绕 Angular 中的 NgZone 核心能力,这些能力主要基于 zone.js 来实现,因此本文先介绍 zone.js。
在 Angular 中,对于数据变更检测使用的是脏检查(dirty check),这曾经在 AngularJS 版本中被诟病,认为存在性能问题。而在 Angular(2+) 版本之后,通过引入模块化组织,以及 NgZone 的设计,提升了脏检查的性能。
对于 NgZone 的引入,并不只是为了解决脏检查的问题,它解决了很多 Javascript 异步编程时上下文的问题,其中 zone.js 便是针对异步编程提出的作用域解决方案。
# zone.js
Zone 是跨异步任务而持久存在的执行上下文,zone.js 提供以下能力:
- 提供异步操作之间的执行上下文
- 提供异步生命周期挂钩
- 提供统一的异步错误处理机制
# 异步操作的困惑
在 Javascript 中,代码执行过程中会产生堆栈,函数会在堆栈中执行 (opens new window)。
对于异步操作来说,异步代码和函数执行的时候,上下文可能发生了变化,为此可能导致一些难题。比如:
- 异步代码执行时,上下文发生了变更,导致预期不一致
throw Error
时,无法准确定位到上下文- 测试某个函数的执行耗时,但因为函数内有异步逻辑,无法得到准确的执行时间
一般来说,异步代码执行时的上下文问题,可以通过传参或是全局变量的方式来解决,但两种方式都不是很优雅(尤其全局变量)。zone.js 正是为了解决以上问题而提出的,我们来看看。
# zone.js 的设计
zone.js 的设计灵感来自 Dart Zones (opens new window),你也可以将其视为 JavaScript VM 中的 TLS--线程本地存储 (opens new window)。
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()
)也相对简单:
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 还提供了许多方法来运行、计划和取消任务,包括:
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
可让函数(包括函数里的异步逻辑)在指定的区域中运行。举个例子:
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 如EventEmitter
、fs
等)来实现这些功能:
// 为指定的本地模块加载补丁
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:
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 和浏览器环境的检测和处理,其中比较关键的实现逻辑在:
// 检测该函数属性是否可写
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 中,有将各种异步任务拆分为三种:
type TaskType = 'microTask'|'macroTask'|'eventTask';
zone.js 可以支持选择性地打补丁,具体更多的补丁机制可以参考 Zone.js's support for standard apis (opens new window)。
# 任务执行的生命周期
zone.js 提供了异步操作生命周期钩子,有了这些钩子,Zone 可以监视和拦截异步操作的所有生命周期:
onScheduleTask
:此回调将在async
操作为之前被调用scheduled
,这意味着async
操作即将发送到浏览器(或 NodeJS )以计划在以后运行时onInvokeTask
:此回调将在真正调用异步回调之前被调用onHasTask
:当任务队列的状态在empty
和之间更改时,将调用此回调not empty
完整的生命周期钩子包括:
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
中:
class Zone implements AmbientZone {
constructor(parent: Zone|null, zoneSpec: ZoneSpec|null) {
...
this._zoneDelegate = new ZoneDelegate(this, this._parent && this._parent._zoneDelegate, zoneSpec);
}
}
以onFork
为例:
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
进行处理和跟踪- 测试某个函数的执行耗时,但因为函数内有异步逻辑,无法得到准确的执行时间:使用生命周期钩子配合可得到具体的耗时