写点什么

zone.js 由入门到放弃之二——zone.js API 大练兵

作者:OpenTiny社区
  • 2023-08-30
    中国香港
  • 本文字数:9455 字

    阅读完需:约 31 分钟

zone.js由入门到放弃之二——zone.js API大练兵

这是来自 @孙啸达 同学的 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 方式:


  • onInvoke

  • onIntercept


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 上下文中执行函数。


// 创建子zoneconst 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

  • wrap(callback: F, source: string): F;


前文说了 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 还会不会执行。


// 创建子zoneconst 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:当在注册回调函数时被触发,简单点理解在调用 wrap 的时候,该勾子被调用

  • onInvoke: 当通过 wrap 包装的函数调用时被触发


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 的生命周期勾子:


  • onScheduleTask:当一步操作被探测出的时候调用

  • onInvokeTask:当回调执行时被调用

  • onHasTask:当队列状态发生改变时被调用


单看对着三个勾子函数的介绍,很难清楚地认识到他们的意思和触发时机。我以 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 应用。


核心亮点:


  1. 跨端跨框架:使用 Renderless 无渲染组件设计架构,实现了一套代码同时支持 Vue2 / Vue3,PC / Mobile 端,并支持函数级别的逻辑定制和全模板替换,灵活性好、二次开发能力强。

  2. 组件丰富:PC 端有 100+组件,移动端有 30+组件,包含高频组件 Table、Tree、Select 等,内置虚拟滚动,保证大数据场景下的流畅体验,除了业界常见组件之外,我们还提供了一些独有的特色组件,如:Split 面板分割器、IpAddress IP 地址输入框、Calendar 日历、Crop 图片裁切等

  3. 配置式组件:组件支持模板式和配置式两种使用方式,适合低代码平台,目前团队已经将 OpenTiny 集成到内部的低代码平台,针对低码平台做了大量优化

  4. 周边生态齐全:提供了基于 Angular + TypeScript 的 TinyNG 组件库,提供包含 10+ 实用功能、20+ 典型页面的 TinyPro 中后台模板,提供覆盖前端开发全流程的 TinyCLI 工程化工具,提供强大的在线主题配置平台 TinyTheme


联系我们:



更多视频内容也可以关注 OpenTiny 社区,B 站/抖音/小红书/视频号。

用户头像

还未添加个人签名 2023-06-06 加入

我们是华为云的 OpenTiny 开源社区,会定期为大家分享一些团队内部成员的技术文章或华为云社区优质博文,涉及领域主要涵盖了前端、后台的技术等。

评论

发布
暂无评论
zone.js由入门到放弃之二——zone.js API大练兵_前端_OpenTiny社区_InfoQ写作社区