作为“为大型前端项目”而设计的前端框架,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); } public fork(zoneSpec: ZoneSpec): AmbientZone { if (!zoneSpec) throw new Error('ZoneSpec required!'); 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 { 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 { ... runTask<T>(task: Task, applyThis?: any, applyArgs?: any): T; scheduleMicroTask(source: string, callback: Function, data?: TaskData, customSchedule?: (task: Task) => void): MicroTask; scheduleMacroTask(source: string, callback: Function, data?: TaskData, customSchedule?: (task: Task) => void, customCancel?: (task: Task) => void): MacroTask; scheduleEventTask(source: string, callback: Function, data?: TaskData, customSchedule?: (task: Task) => void, customCancel?: (task: Task) => void): EventTask; scheduleTask<T extends Task>(task: T): T; 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); } function b() { console.log(Zone.current.name); setTimeout(c, 2000); } function a() { console.log(Zone.current.name); zoneBC.run(b); }
a();
|
执行的效果如图:
实际上,每个异步任务的调用堆栈会以根区域开始。因此,在 zone.js 中该区域会使用与任务关联的信息来还原正确的区域,然后调用该任务:
对于Zone.fork()
和Zone.run()
的作用和实现,上面已经介绍过了。那么,zone.js 是如何识别出异步任务的呢?其实 zone.js 主要是通过猴子补丁拦截异步 API(包括 DOM 事件、XMLHttpRequest
和 NodeJS 的 API 如EventEmitter
、fs
等)来实现这些功能:
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; mark(perfName); patches[name] = fn(global, Zone, _api); 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]); } }
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 { } } }; const task = scheduleMacroTaskWithCurrentZone(setName, args[0], options, scheduleTask, clearTask); if (!task) { return task; } 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 { onFork?: (parentZoneDelegate: ZoneDelegate, currentZone: Zone, targetZone: Zone, zoneSpec: ZoneSpec) => Zone; 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) { ... 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(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
进行处理和跟踪
- 测试某个函数的执行耗时,但因为函数内有异步逻辑,无法得到准确的执行时间:使用生命周期钩子配合可得到具体的耗时
参考
查看Github有更多内容噢:https://github.com/godbasin
更欢迎来被删的前端游乐场边撸猫边学前端噢
如果你想要关注日常生活中的我,欢迎关注“牧羊的猪”公众号噢