头图
这是来自孙啸达 同学的zone.js系列文章第二篇,这篇文章主要为我们介绍了Zone和ZoneTask

zone.js系列往期文章


zone.js中最重要的三个定义为:Zone,ZoneDelegate,ZoneTask。搞清楚了这三个类的API及它们之间关系,基本上对zone.js就通了。而Zone,ZoneDelegate,ZoneTask三者中,Zone,ZoneDelegate其实半差不差的可以先当成一个东西。所以文中,我们集中火力主攻Zone和ZoneTask。

1_jingrui_1676534185813.jfif

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,感兴趣的可以了解一下

1_image_1676534810213.png

通用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
  •  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还会不会执行。

// 创建子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:当在注册回调函数时被触发,简单点理解在调用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打包方式就讲完了,相信大家都有点累,休息一下吧

2_rest_1676538995836.png

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时做了哪些工作。

3_best_1676539850123.png

关于 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站/抖音/小红书/视频号。


OpenTiny社区
58 声望10 粉丝

OpenTiny官网:[链接] 源码:[链接] (欢迎star)我们是华为云体验技术团队~欢迎关注共同交流~