这是来自 @孙啸达 同学的 zone.js 系列文章第二篇,这篇文章主要为我们介绍了 Zone 和 ZoneTask
zone.js 系列往期文章
zone.js 中最重要的三个定义为:Zone,ZoneDelegate,ZoneTask。搞清楚了这三个类的 API 及它们之间关系,基本上对 zone.js 就通了。而 Zone,ZoneDelegate,ZoneTask 三者中,Zone,ZoneDelegate 其实半差不差的可以先当成一个东西。所以文中,我们集中火力主攻 Zone 和 ZoneTask。
Zone
传送门
interface Zone {
// 通用API
name: string;
get(key: string): any;
getZoneWith(key: string): Zone|null;
fork(zoneSpec: ZoneSpec): Zone;
run<T>(callback: Function, applyThis?: any, applyArgs?: any[], source?: string): T;
runGuarded<T>(callback: Function, applyThis?: any, applyArgs?: any[], source?: string): T;
runTask(task: Task, applyThis?: any, applyArgs?: any): any;
cancelTask(task: Task): any;
// Wrap类包装API
wrap<F extends Function>(callback: F, source: string): F;
// Task类包装API
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;
}
复制代码
Zone 中的 API 大致分了三类:通用 API、Wrap 类和 Task 类。Wrap 和 Task 类分别对应 zone.js 对异步方法的两种打包方式(Patch),不同的打包方式对异步回调提供了不同粒度的"监听"方式,即不同的打包方式会暴露出不同的拦截勾子。你可以根据自身对异步的控制精度选择不同的打包方式。
Wrap 方式:
Task 方式
onScheduleTask
onInvokeTask
onCancelTask
onHasTask
上文说到了,zone.js 在初始化的时候已经把大多数常见的异步 API 都打包过了(就是用的上面这些 API 打包的),除了这些默认被打包的 API 以外,zone.js 也支持用户对一些自研的 API 或是一些依赖中 API 自行打包。下图展示了一些已经被 zone.js 默认打包的 API,感兴趣的可以了解一下:
通用 API
zone.js 的 current 和 get 在上一篇文章中已经介绍过了,因为本身也不太难,这里就不专门举例了。
[ ] current:获取当前的 zone 上下文
[ ] get:从 properties 中获取当前 zone 中的属性。properties 属性其实是 immutable 的,上一篇文章中直接对 properties 进行修改其实是不推荐的。同时,由于 zone 之间是可以通过 fork 嵌套的,所以子 zone 可以继承父 zone 的 properties。
[ ] fork(zoneSpec: ZoneSpec):fork 方法可以给当前 Zone 创建一个子 Zone,函数接受一个 ZoneSpec 的参数,参数规定了当前 Zone 的一些基本信息以及需要注入的勾子。下面展示了 ZoneSpec 的所有属性:
传送门
interface ZoneSpec {
name: string;
properties?: {[key: string]: any};
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;
}
复制代码
run(callback: Function, applyThis?: any, applyArgs?: any[], source?: string): T;
runGuarded(callback: Function, applyThis?: any, applyArgs?: any[], source?: string): T;
runTask(task: Task, applyThis?: any, applyArgs?: any;
runXXX 方法可以指定函数运行在特定的 zone 上,这里可以把该方法类比成 JS 中的 call 或者 apply,它可以指定函数所运行的上下文环境;而 zone 在这里可以类比成特殊的 this,只不过 zone 上下文可以跨执行栈保存,而 this 不行。与此同时,runXXX 在回调执行结束后,会自动地恢复 zone 的执行环境。
Demo1:zone 的一些基操
看过一篇的对这个例子应该不陌生了,这个例子主要演示了如何通过 zone.js 的通用 API 创建 zone,并在特定的 zone 上下文中执行函数。
// 创建子zone
const apiZone = Zone.current.fork({
name: 'api',
// 通过ZoneSpec设置属性
properties: {
section: 'section1',
},
});
apiZone.run(() => {
const currentZone = Zone.current;
assert.equal(currentZone.name, 'api');
assert.equal(currentZone.get('section'), 'section1');
});
复制代码
Demo2:runXXX
前文说了 runXXX 方法类似于 call 和 apply 的作用,那么 wrap 方法类似于 JS 中的 bind 方法。wrap 可以将执行函数绑定到当前的 zone 中,使得函数也能执行在特定的 zone 中。下面是我简化以后的 wrap 源码:
public wrap<T extends Function>(callback: T, source: string): T {
// 省略若干无关紧要的代码
const zone: Zone = Zone.current;
return function() {
return zone.runGuarded(callback, (this as any), <any>arguments, source);
} as any as T;
}
复制代码
wrap 本身却是什么也没做,只是维护了对 runGuarded 方法的调用。runGuarded 方法其实也没有什么神奇之处,它内部就是对 run 方法的一个调用,只不过 runGuarded 方法会尝试捕获一下 run 方法执行过程中抛出的异常。下面是 run 和 runGuarded 的源码比较,看下 runGuarded 对比 run 是不是就多了一个 catch?
传送门
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!;
}
}
public runGuarded<T>(
callback: (...args: any[]) => T, applyThis: any = null, applyArgs?: any[],
source?: string) {
_currentZoneFrame = {parent: _currentZoneFrame, zone: this};
try {
try {
return this._zoneDelegate.invoke(this, callback, applyThis, applyArgs, source);
} catch (error) {
if (this._zoneDelegate.handleError(this, error)) {
throw error;
}
}
} finally {
_currentZoneFrame = _currentZoneFrame.parent!;
}
}
复制代码
Demo3:onHandleError
上面介绍,run 和 runGuarded 就只差一个 catch,那么这个 catch 中调用的 handleError 方法又是做什么的?其实 handleError 实际触发的是 zone 中的一个钩子函数 onHandleError。我们可以在定义一个 zone 的时候将其定义在 zoneSpec 中,此时,当函数运行过程中出现了未捕获异常的时候,该钩子函数会被触发。注意,这里是未捕获的异常,如果异常已经被捕获,则该钩子不会触发。感兴趣的可以在 reject 后面直接 catch 异常,看下此时 onHandleError 还会不会执行。
// 创建子zone
const apiZone = Zone.current.fork({
name: 'api',
// 通过ZoneSpec设置属性
properties: {
section: 'section1',
},
onHandleError: function (parentZoneDelegate, currentZone, targetZone, error) {
console.log(`onHandleError catch: ${error}`);
return parentZoneDelegate.handleError(targetZone, error);
}
});
apiZone.run(() => {
Promise.reject('promise error');
});
// onHandleError catch: Error: Uncaught (in promise): promise error
// Unhandled Promise rejection: promise error ; Zone: api ; Task: null ; Value: promise error undefine
复制代码
Demo4: onIntercept & onInvoke
onIntercept 一般用的很少,我也没有想到特别好的使用场景。下面这个例子通过 onIntercept 勾子“重定义”了回调函数,在回调函数之前又加了一段打印。所以个人认为,onIntercept 可以用来对包装函数做一些通用的 AOP 增强。
onInvoke 会在下一篇源码分析中大量出现,每当包装函数要执行时就会触发 Zone(实际是 ZoneDelegate)的 invoke 方法时,介时 onInvoke 勾子方法就会被调用。
下面的例子中,先通过 wrap 函数将 setTimeout 的回调包装,并将回调的执行绑定到 apiZone 上。当回调函数执行时,onInvoke 被调用。这里通过 onInvoke 勾子打印了一下回调执行时间,从而侧面说明了 onInvoke 的执行时机。
const apiZone = Zone.current.fork({
name: 'api',
onIntercept: function (_parentZoneDelegate, currentZone, targetZone, delegate, source) {
console.log('Enter onIntercept', currentZone.name, Date.now() - start);
// 修改原回调实现
function newCb() {
console.log('hacking something in main');
delegate.call(this);
}
return newCb;
},
onInvoke: function (parentZoneDelegate, currentZone, targetZone, delegate, applyThis, applyArgs, source) {
console.log('Enter onInvoke', currentZone.name, Date.now() - start);
parentZoneDelegate.invoke(targetZone, delegate, applyThis, applyArgs, source);
},
});
const cb = function() {
console.log('cb called', Zone.current.name, Date.now() - start);
};
function main() {
setTimeout(apiZone.wrap(cb), 1000);
}
const start = Date.now();
main();
// Enter onIntercept api 0
// Enter onInvoke api 1010
// hacking something in main
// cb called api 1010
复制代码
讲到这里 Zone 的通用 API 和 Wrap 打包方式就讲完了,相信大家都有点累,休息一下吧
ZoneTask
zone.js 打包了大多数你见到过的异步方法,其中有很大一部分被打包成 Task 的形式。Task 形式比 Wrap 形式有更丰富的生命周期勾子,使得你可以更精细化地控制每个异步任务。好比 Angular,它可以通过这些勾子决定在何时进行脏值检测,何时渲染 UI 界面。
zone.js 任务分成 MacroTask、MicroTask 和 EventTask 三种:
MicroTask:在当前 task 结束之后和下一个 task 开始之前执行的,不可取消,如 Promise,MutationObserver、process.nextTick
MacroTask:一段时间后才执行的 task,可以取消,如 setTimeout, setInterval, setImmediate, I/O, UI rendering
EventTask:监听未来的事件,可能执行 0 次或多次,执行时间是不确定的
Demo5:SetTimeout Task
zone.js 对 Task 的生命周期勾子:
单看对着三个勾子函数的介绍,很难清楚地认识到他们的意思和触发时机。我以 setTimeout 为例,介绍一下我对这几个勾子的理解,这里会涉及到一些源码逻辑,这些会在第三篇文章中详细说明,这里了解个大概即可。
首先,zone 初始化的时候会 monkey patch 原生的 setTimeout 方法。之后,每当 setTimeout 被调用时,patch 后的方法都会把当前的异步操作打包成 Task,在调用真正的 setTimeout 之前会触发 onScheduleTask。
将 setTimeout 打包成 Task 后,这个异步任务就会进入到 zone 的管控之中。接下来,Task 会将 setTimeout 回调通过 wrap 打包,所以当回调执行时,zone 也是可以感知的。当回调被执行之前,onInvokeTask 勾子会被触发。onInvokeTask 执行结束后,才会执行真正的 setTimeout 回调。
onHasTask 这个勾子比较有意思,它记录了任务队列的状态。当任务队列中有 MacroTask、MicroTask 或 EventTask 进队或出队时都会触发该勾子函数。
下图是一个 onHasTask 中维护队列状态的示例,该状态表明了有一个 MacroTask 任务进入了队列。
{
microTask: false,
macroTask: true, // macroTask进入队列
eventTask: false,
change: 'macroTask' // 本次事件由哪种任务触发
}
复制代码
这是一个 MacroTask 出队的示例:
{
microTask: false,
macroTask: false, // macroTask 出队列
eventTask: false,
change: 'macroTask' // 本次事件由哪种任务触发
}
复制代码
下面这个示例 onHasTask 被调用两次,第一次是 setTimeout 时间进入任务队列;第二次是 setTimeout 执行完毕,移出任务队列。同时在 onScheduleTask 和 onInvokeTask 中,也可以通过 task.type 获取到当前的任务类型。
const apiZone = Zone.current.fork({
name: 'apiZone',
onScheduleTask(delegate, current, target, task) {
console.log('onScheduleTask: ', task.type, task.source, Date.now() - start);
return delegate.scheduleTask(target, task);
},
onInvokeTask(delegate, current, target, task, applyThis, applyArgs) {
console.log('onInvokeTask: ', task.type, task.source, Date.now() - start);
return delegate.invokeTask(target, task, applyThis, applyArgs);
},
onHasTask(delegate, current, target, hasTaskState) {
console.log('onHasTask: ', hasTaskState, Date.now() - start);
return delegate.hasTask(target, hasTaskState);
}
});
const start = Date.now();
apiZone.run(() => {
setTimeout(function() {
console.log('setTimeout called');
}, 1000);
});
// onScheduleTask: macroTask setTimeout 0
// onHasTask: {
// microTask: false,
// macroTask: true,
// eventTask: false,
// change: 'macroTask'
// } 4
// onInvokeTask: macroTask setTimeout 1018
// setTimeout called
// onHasTask: {
// microTask: false,
// macroTask: false,
// eventTask: false,
// change: 'macroTask'
// } 1018
复制代码
Demo6:多任务跟踪
为了能认清 zone.js 对跟异步任务的跟踪能力,我们模拟多个、多种异步任务,测试一下 zone.js 对这些任务的跟踪能力。下面例子,zone.js 分别监控了 5 个 setTimeout 任务和 5 个 Promise 任务。从结果上看,zone 内部可以清楚地知道各种类型的任务什么时候创建、什么时候执行、什么时候销毁。Angular 正是基于这一点进行变更检测的,ngZone 中的 stable 状态也是由此产生的,这个我们会在系列的第四篇中介绍。
// 宏任务计数
let macroTaskCount = 0;
// 微任务计数
let microTaskCount = 0;
const apiZone = Zone.current.fork({
name: 'apiZone',
onScheduleTask: (delegate, currZone, target, task) => {
if (task.type === 'macroTask') {
macroTaskCount ++;
console.log('A new macroTask is scheduled: ' + macroTaskCount);
} else if (task.type === 'microTask') {
microTaskCount ++;
console.log('A new microTask is scheduled: ' + microTaskCount);
}
return delegate.scheduleTask(target, task);
},
onInvokeTask: (delegate, currZone, target, task, applyThis, applyArgs) => {
const result = delegate.invokeTask(target, task, applyThis, applyArgs);
if (task.type === 'macroTask') {
macroTaskCount --;
console.log('A macroTask is invoked: ' + macroTaskCount);
} else if (task.type === 'microTask') {
microTaskCount --;
console.log('A microTask is invoked: ' + microTaskCount);
}
return result;
},
});
apiZone.run(() => {
for (let i = 0; i < 5; i ++) {
setTimeout(() => {
});
}
for (let i = 0; i < 5; i ++) {
Promise.resolve().then(() => {
});
}
});
// A new macroTask is scheduled: 1
// A new macroTask is scheduled: 2
// A new macroTask is scheduled: 3
// A new macroTask is scheduled: 4
// A new macroTask is scheduled: 5
// A new microTask is scheduled: 1
// A new microTask is scheduled: 2
// A new microTask is scheduled: 3
// A new microTask is scheduled: 4
// A new microTask is scheduled: 5
// A microTask is invoked: 4
// A microTask is invoked: 3
// A microTask is invoked: 2
// A microTask is invoked: 1
// A microTask is invoked: 0
// A macroTask is invoked: 4
// A macroTask is invoked: 3
// A macroTask is invoked: 2
// A macroTask is invoked: 1
// A macroTask is invoked: 0
复制代码
Demo7:手动打包 setTimeout
我们最后还有 3 个 API 没有讲:scheduleMacroTask、scheduleMicroTask、scheduleEventTask。zone.js 通过这三个方法将普通的异步方法打包成异步任务。这三个方法属于比较底层的 API,一般很少会用,因为大部分 API 的打包 zone 已经帮我们实现了。为了介绍一下这个 API 的使用,今天就头铁😎😎😎一次,使用 scheduleMacroTask 打包一个我们自己的 setTimeout。
准备
我们知道,zone.js 默认会打包 setTimeout 的,打包后的 setTimeout 变成 Task 被管控起来。所以,我们可以通过 Task 的勾子有没有触发判断 setTimeout 有没有被打包。下面代码为例,当 onHasTask 事件触发,我们才能断定 setTimeout 已经被打包成 Task。
const apiZone = Zone.current.fork({
name: 'api',
onHasTask: function (parentZoneDelegate, currentZone, targetZone, hasTaskState) {
console.log(hasTaskState);
parentZoneDelegate.onHasTask(parentZoneDelegate, currentZone, targetZone, hasTaskState);
}
});
apiZone.run(() => {
setTimeout(() => {
console.log(Zone.current.name);
});
});
// {
// microTask: false,
// macroTask: true,
// eventTask: false,
// change: 'macroTask'
// }
复制代码
Step1:取消 zone.js 原生打包
zone.js 初始化时会自动打包 setTimeout 函数,所以我们第一步要做的就是禁止 zone.js 自动打包 setTimeout。自 zone.js v0.8.9 以后,zone.js 支持用户通过配置自主选择需要打包的函数。比如本例中,只需要对__Zone_disable_timers 进行设置就可以关闭 zone.js 对 setTimeout 的打包。
global.__Zone_disable_timers = true;
Step2:偷梁换柱
改造 setTimeout 的第一步就是要保存原始的 setTimeout:
const originalSetTineout = global.setTimeout;
Step3:scheduleMacroTask
scheduleMacroTask 用来将异步方法打包成 Task。值得注意的是 scheduleMacroTask 的最后一个参数,要求传入一个 Task 的调度方法。这个方法返回了原生的 setTimeout 方法,只是把回调函数换成了 task.invoke。看到这,你对 zone.js 的认识应该越来越清晰了,task.invoke 中就是 zone 对回调函数的打包。打包的结果就是让回调可以在正确地 zone 上下文中被执行。
Zone.current.scheduleMacroTask('setTimeout', cb, taskOptions, function(task) {
return originalSetTineout(task.invoke, delay);
});
复制代码
最后,完整代码奉上:
// 禁止zone.js的默认打包行为
global.__Zone_disable_timers = true;
require('zone.js');
function myPatchTimer() {
// 保存原有setTimeout函数
const originalSetTineout = global.setTimeout;
global.setTimeout = function(cb, delay) {
const taskOptions = {
isPeriodic: false, // 是否是间歇性的,类似setInterval
};
// 将异步函数打包成Task
Zone.current.scheduleMacroTask('setTimeout', cb, taskOptions, function(task) {
// task.invoke可以跨调用栈保存zone上下文
return originalSetTineout(task.invoke, delay);
});
};
}
myPatchTimer();
const apiZone = Zone.current.fork({
name: 'api',
onHasTask: function (parentZoneDelegate, currentZone, targetZone, hasTaskState) {
console.log(hasTaskState);
parentZoneDelegate.onHasTask(parentZoneDelegate, currentZone, targetZone, hasTaskState);
}
});
apiZone.run(() => {
setTimeout(() => {
console.log(Zone.current.name);
}, 1000);
});
复制代码
总结
本文重点介绍了 zone.js 中各个 API 的使用方式及相互间关系,通过大量的实验 demo 简单演示了一下这些 API 的用法。最后,还通过一个较底层的 API 打包了自己的 Task。当然,本文最后这个对 setTimeout 的打包还是太过粗糙,原生的打包要比这个复杂的多。即便如此,我相信看到这里的童鞋们应该已经对 zone.js 背后的逻辑有了一定的认识了。
下一篇文章,我准备对 zone.js 的打包原理做更深的分析。大家可以跟我一起深入到源码看看 zone.js 在打包 setTimeout 时做了哪些工作。
关于 OpenTiny
OpenTiny 是一套企业级组件库解决方案,适配 PC 端 / 移动端等多端,涵盖 Vue2 / Vue3 / Angular 多技术栈,拥有主题配置系统 / 中后台模板 / CLI 命令行等效率提升工具,可帮助开发者高效开发 Web 应用。
核心亮点:
跨端跨框架
:使用 Renderless 无渲染组件设计架构,实现了一套代码同时支持 Vue2 / Vue3,PC / Mobile 端,并支持函数级别的逻辑定制和全模板替换,灵活性好、二次开发能力强。
组件丰富
:PC 端有 100+组件,移动端有 30+组件,包含高频组件 Table、Tree、Select 等,内置虚拟滚动,保证大数据场景下的流畅体验,除了业界常见组件之外,我们还提供了一些独有的特色组件,如:Split 面板分割器、IpAddress IP 地址输入框、Calendar 日历、Crop 图片裁切等
配置式组件
:组件支持模板式和配置式两种使用方式,适合低代码平台,目前团队已经将 OpenTiny 集成到内部的低代码平台,针对低码平台做了大量优化
周边生态齐全
:提供了基于 Angular + TypeScript 的 TinyNG 组件库,提供包含 10+ 实用功能、20+ 典型页面的 TinyPro 中后台模板,提供覆盖前端开发全流程的 TinyCLI 工程化工具,提供强大的在线主题配置平台 TinyTheme
联系我们:
更多视频内容也可以关注 OpenTiny 社区,B 站/抖音/小红书/视频号。
评论