写点什么

zone.js 由入门到放弃之三——zone.js 源码分析【setTimeout 篇】

作者:OpenTiny社区
  • 2023-09-06
    中国香港
  • 本文字数:8371 字

    阅读完需:约 27 分钟

Delegate 是个好东西,看看孙啸达 同学对 ZoneDelegate 的介绍吧,这是他关于 zone.js 系列文章的第三篇~

zone.js 系列往期文章

zone.js 源码分析

接下来的全是干货,从头到尾,一干到底


一点前置:Zone 和 ZoneDelegate

在前文中,我们一直在回避讲解 Zone 和 ZoneDelegate 之间的区别。尤其在上篇文章讲 API 的时候,我甚至让大家把这两者当成一回事。其实这两者并不是完全相等的。单从 Delegate 这个单词你也能看出,虽然 Zone 和 ZoneDelegate 的 API 很像,但是真正干活的是 ZoneDelegate。我简单节选几段 Zone 的源码,大家不难发现,大多数 Zone 的 API 都直接或间接通过代理中相对应的 API 完成的。


public fork(zoneSpec: ZoneSpec): AmbientZone {    // 此处省略成吨源码    return this._zoneDelegate.fork(this, zoneSpec);}
public run<T>(callback: (...args: any[]) => T, applyThis?: any, applyArgs?: any[], source?: string): T { // 此处省略成吨源码 return this._zoneDelegate.invoke(this, callback, applyThis, applyArgs, source);}
runTask(task: Task, applyThis?: any, applyArgs?: any): any { // 此处省略成吨源码 return this._zoneDelegate.invokeTask(this, task, applyThis, applyArgs);}
复制代码


我把上篇文章讲到的 API 和 ZoneDelegate 之间的调用关系简单梳理了一下。下文在分析源码的时候,会有大量 Zone、ZomeDelegate、ZomeTask 三者之间相互调用的场景,实在理不清的地方可以返回这里看下。



虽然 ZoneDelegate 实际承担了大量的工作,但是 Zone 也不是甩手掌柜,啥活儿也不干。在我个人看来,Zone 其实主要只负责两件事:


  • 维护 Zone 的上下文栈:我们知道 Zone 是个具有继承关系的链式结构。zone.js 在全局会维护一个 Zone 栈帧,每当我们在某个 Zone 中执行代码时,Zone 要负责将当前的 Zone 上下文置于栈帧中;当代码执行完毕,又要负责将 Zone 栈帧恢复回去。


public run<T>(callback: (...args: any[]) => T, applyThis?: any, applyArgs?: any[], source?: string): T {    // 将当前的Zone上下文置于栈帧中    _currentZoneFrame = {parent: _currentZoneFrame, zone: this};    try {        ...    } finally {        // 恢复Zone栈帧        _currentZoneFrame = _currentZoneFrame.parent!;    }}
复制代码


  • Zone 还负责 ZoneTask 的状态切换。上文说过,Zone 可以对宏任务、微任务、事件进行管理。那么每个任务在 Zone 中处于何种阶段、何种状态也是由 Zone 负责的。Zone 会在适当时候调用 ZoneTask 的_transitionTo 方法切换 ZoneTask 的状态。




接下来会把 zone.js 对 setTimeout 的 Patch 过程进行详细的说明,为了方便理解,其中涉及的大量源码都是我简化之后。



第一阶段:zone.js 打包 setTimeout

Patch 第一站

zone.js 提供一个静态方法用于 Patch 我们常见的 API,对 setTimeout 的 Patch 位于zone.js/lib/browser/browser.ts下:其中这个patchTimer(global, set, clear, 'Timeout');就是本次源码分析的起点。


代码传送门


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

虽然 patchTimer 是打包 setTimeout 的关键代码,但是为了先理清框架,我先把一些当下没那么重要的代码都省略掉。通过下面的代码我们发现,patchTimer 中最核心的一句就是:


setNative = patchMethod(...)


setNative 从命名上不难理解,其实就是用来保存原生的 setTimeout。除了保存原生 setTimeout 之外,我们在下一节中一起看下 patchMethod 对 setTimeout 还做了什么。


代码传送门


export function patchTimer(window: any, setName: string, cancelName: string, nameSuffix: string) {    let setNative: Function|null = null;
function scheduleTask(task: Task) { // 战术式忽略 }
setNative = patchMethod(window, setName, (delegate: Function) => function(self: any, args: any[]) { // 战术式忽略 });}
复制代码

只会甩锅的 patchMethod


下面是简化后的代码,不难发现 patchMethod 就做了两件事:


  1. 将原生 setTimeout 方法保存起来,保存在 windiw.__zone_symbol__setTimeout 中

  2. 通过 patchFn 方法打包 setTimeout,并替换原 windiw.setTimeout


patchFn这个函数看起来有点繁琐,其实这是对函数柯里化的应用,是一种高阶函数。如果对这块知识不了解的可以简单理解为它就是一个返回函数的函数。patchFn的执行会返回一个打包后的 setTimeout,而对patchFn的定义来自于上一节的patchTimer方法中。所以我说patchMethod甩锅,说好的要打包 setTimeout 方法,结果打包工具还得patchTimer函数提供。


代码传送门


export function patchMethod(    ...,    patchFn: (delegate: Function, delegateName: string, name: string) => (self: any, args: any[]) => any): Function|null {
let delegate: Function|null = null; // __zone_symbol__xxx 是 zone.js 的特色产物,专门用来保存原生API的 delegate = windiw.__zone_symbol__setTimeout = windiw.setTimeout; const patchDelegate = patchFn(delegate!, delegateName, name); 👈
windiw.setTimeout = function() { return patchDelegate(this, arguments as any); }; return delegate;}
复制代码

看看 zone.js 对 setTimeout 到底干了什么:


再回到patchTimer方法中,patchTime 在调用 patchMethod 的时候传入了一个patchFn方法。这个方法对 setTimeout 干了两件事:


  • 通过 timer 方法把真实回调包装了一下,实际上就是想保留 this 指针

  • 调用 scheduleMacroTaskWithCurrentZone 方法封装出一个 task 【重点】


看到这里是不是有点似曾相识的感觉,这个 task 会不会是 ZoneTask?scheduleMacroTaskWithCurrentZone 会不会和 scheduleMacroTask 有关系?


这里可以很负责的告诉你,两个的问题的答案都是肯定的哈!至于 scheduleMacroTaskWithCurrentZone 的源码分析,我们稍作调整再继续分析。


代码传送门


const patchFn = (delegate: Function) => function(self: any, args: any[]) {    const options = {        delay: args[1] || 0,        args: args    };
const callback = args[0]; // 封装timer方法,保存this指针 args[0] = function timer(this: unknown) { return callback.apply(this, arguments); }; // 通过调用scheduleMacroTask封装异步Task const task = scheduleMacroTaskWithCurrentZone(setName, args[0], options, scheduleTask, clearTask); 👈 return task;};
复制代码

第一阶段小结:

我把第一阶段称作为打包阶段,此处一般都在应用初始化的时候执行的,zone.js 正是利用这段时间对各式各样的 API 进行了 Monkey Patch 操作。截止目前为止,zone.js 对 setTimeout 的 Patch 操作其实并没有什么特别。最核心的函数是 patchTimer,虽然在这个阶段中,该函数大部分功能都被战术性阉割了,但是它将 setTimeout 的原生实现替换成了patchFn。从patchFn的实现我们可以看出,每当我们触发 window.setTimeout 时,就会有一个名为 task 的任务被创建出来。上一遍文章说过,zone.js 可以把诸多异步操作封装成 ZoneTask,然后就可以对每个异步任务的生命周期进行监控、跟踪。看到这里,是不是大致有点轮廓了。


下面这个图,是我根据 zone.js 第一阶段的动作描述的,方便大家配合源码进行理解。



我看很多文章都说过 zone.js 的 Patch 过程如何残暴,光听别人说有什么意思,不如自己来看看

第二阶段:触发 setTimeout

上一阶段中,zone.js 强势 hack 了 setTimeout,让 setTimeout 被调用时创建一个 task。接下来,我们看下,当一个打包的 setTimeout 被调用后的流程。

创建 Task

先填个坑,上一节我说 scheduleMacroTaskWithCurrentZone 和 scheduleMacroTask 有关系,此处以源码为证哈:代码传送门


export function scheduleMacroTaskWithCurrentZone(source: string, callback: Function, data?: TaskData, customSchedule?: (task: Task) => void,customCancel?: (task: Task) => void): MacroTask {    return Zone.current.scheduleMacroTask(source, callback, data, customSchedule, customCancel);  👈}
复制代码


scheduleMacroTask非常简单,创建一个 ZoneTask 后帅锅给scheduleTask函数。


代码传送门


scheduleMacroTask(source: string, callback: Function, data?: TaskData, customSchedule?: (task: Task) => void,customCancel?: (task: Task) => void): MacroTask {    return this.scheduleTask(        new ZoneTask(macroTask, source, callback, data, customSchedule, customCancel));}
复制代码


在这里,这个新建的 ZoneTask 非常重要,它除了一些初始化操作以外,有 3 个值得大家注意的地方(其它作用暂时不大的代码已经被省略):


  • scheduleFn 是 zoneTask 调度的关键代码,这里具体的代码在patchTimer中。但在之前被我战术性阉割了,后续用到的时候我再展开解释。这里先记住,task 有个 scheduleFn 方法,方法来自patchTimer请死记!

  • ZoneTask 有个 invoke 方法,该方法实际是对 zone.runTask 的调用。zone.runTask 后面会介绍,但是这里是 ZoneTask 和 Zone 之间联系的一个桥梁,请死记!

  • _transitionTo是 ZoneTask 状态切换函数,Zone 就是通过这个函数来改变 Task 的状态,并对 Task 实施跟踪监控的,还是 bi~~~ 请死记!


代码传送门


class ZoneTask<T extends TaskType> implements Task {    // 战术性省略    constructor(        type: T, source: string, callback: Function, options: TaskData|undefined,        scheduleFn: ((task: Task) => void)|undefined, cancelFn: ((task: Task) => void)|undefined) {                this.type = type;        this.source = source;        this.data = options;        this.scheduleFn = scheduleFn;  👈        this.cancelFn = cancelFn;        this.callback = callback;        const self = this;
// invoke最总会被封装成setTimeout的回调函数 this.invoke = function() { return this.zone.runTask.call(global, self, this, <any>arguments); 👈 }; }
// Task的状态切换函数 _transitionTo(toState: TaskState, fromState1: TaskState, fromState2?: TaskState) { 👈 // 战术性省略 }}
复制代码

调度 Task

Task 创建后,Zone 会通过代理执行 scheduleTask 完成对 Task 的调度。Zone 只在 ZoneDelegate 调度前后分别去修改一下 Task 的状态而已,真的是干啥全凭一张嘴。



代码传送门


scheduleTask<T extends Task>(task: T): T {    // 赵立冬:情侣大街这个项目给你了    (task as any as ZoneTask<any>)._transitionTo(scheduling, notScheduled);
// 高启强:撸起袖子干 task = this._zoneDelegate.scheduleTask(this, task) as T; 👈
// 赵立冬:这个项目做得不错 if ((task as any as ZoneTask<any>).state == scheduling) { (task as any as ZoneTask<any>)._transitionTo(scheduled, scheduling); } return task;}
复制代码


ZoneDelegate.scheduleTask 主要工作:


  • 上篇文章中我们讲到的 onScheduleTask 这个勾子会在此时被调用,这是 zone.js 跟踪异步任务时触发的第一个勾子。代码中this._scheduleTaskZS.onScheduleTask的执行就是这块的体现。由于 Zone 有着一层层的继承关系,所以源码中其实还有很多父级代理中 onScheduleTask 勾子的调用逻辑。我为了方便理解,在下面代码中把这部分代码省略了,实际上 scheduleTask 这个方法会在这个过程中被递归调用多次。

  • 调度的核心是调用了 task.scheduleFn 方法,在上文中,我说这里是重点,要死记的。


代码传送门


scheduleTask(targetZone: Zone, task: Task): Task {    // 战术性省略:此处代码跟源码略有出入,这么做只是为了方便理解    this._scheduleTaskZS.onScheduleTask !(    this._scheduleTaskDlgt !, this._scheduleTaskCurrZone !, targetZone, task) as ZoneTask<any>;    task.scheduleFn(task);  👈
return returnTask;}
复制代码


scheduleTask函数的代码不多,但是要了解它需要前面很多的铺垫:


  1. setNative 方法被调用,前面讲了这个方法是原生的 setTimeout,也就是说执行到这里,真正的 setTimeout 方法才刚被调用。

  2. setTimeout 的回调被重新封装,封装以后变成了 task.invoke。从这一刻,zone.js 正式改写了 setTimeout 的回调,并开始正式接管 setTimeout。

  3. task.invoke 这个方法之前强调了要死记的,因为它会间接调用 zone.runTask 方法。通过这样的办法,zone.js 可以将 setTimeout 的回调方法限定在 Zone 的上下文中执行。别看这里只有几行,这是 zone 跨调用栈维持上下文统一的核心所在!


代码传送门


function scheduleTask(task: Task) {    const data = <TimerOptions>task.data;    data.args[0] = function() {        // 将setTimeout回调替换成task.invoke        return task.invoke.apply(this, arguments);  👈    };        // 执行原生setTimeout    data.handleId = setNative!.apply(window, data.args);  👈    return task;}
复制代码

第二阶段小结:

对接上一阶段,当 setTimeout 被触发后,zone 会根据 patch 后的 setTimeout 新建一个 Task(MacroTask)。这个 task 有个三个重要知识点:


  • 保存了该 task 的调度方法scheduleFn

  • 定义 task 的 invoke 方法

  • 存在一个切换 task 状态的方法_transitionTo


接下来 Zone 把调度 Task 的工作承包给高启强,啊不对不对,是承包给 ZoneDelegate,然后 ZoneDelegate 通过调用 ZoneTask 中 scheduleFn 完成任务调度。


scheduleFn这个函数实际上 hack 掉了原生 setTimeout 方法上的回调函数,将回调函数改写成 task 的 invoke 方法。到此形成一个逻辑上的闭环,一句话总结:setTimeout 的回调实际调用的是 task.invoke 函数。


下图是到目前为止的调用关系图:


第三阶段:回调执行

由于原生的 setTimeout 被触发,所以改写后的回调被送进循环队列的 Timer 队列中,待计时器计算延时到达后,将改写后的回调放入执行队列等待执行。这部分内容是 V8 引擎的循环队列的知识,这里就不展开讲了。我们最关心的是,当执行栈开始执行这个回调的时候又会发生什么?


Task 运行

回调函数执行的时候,实际执行的是 task.invkoe 方法;又由于 task.invkoe 绑定 Zone.runTask。当然,一看到 Zone 上方法,那我们可以毫无波澜地判断,此时 Zone 除了改一改 Task 状态之外又又又把活承包给 ZoneDelegate,而这次的承办单位是ZoneDelegate.invokeTask


ZoneDelegate.invokeTask相对比较简单,我就不阉割它了。别看前面一堆判断逻辑,都是虚张声势(也不全是,至少 onInvokeTask 这个勾子是此时被调用的)。ZoneDelegate.invokeTask最重要的实际就是最后这句task.callback.apply(applyThis, applyArgs)。这里的 callback 是 setTimeout 真实的回调函数,从此出我们可以看出,这个回调函数确实是执行在 Zone 的上下文中的。


代码传送门


invokeTask(targetZone: Zone, task: Task, applyThis: any, applyArgs?: any[]): any {    return this._invokeTaskZS ? this._invokeTaskZS.onInvokeTask!        (this._invokeTaskDlgt!, this._invokeTaskCurrZone!, targetZone,        task, applyThis, applyArgs) :        task.callback.apply(applyThis, applyArgs);}
复制代码

你以为这就完了?

最后,这篇文章还差一个坑没有填,那就是第三个要死记的_transitionTo方法。之前只是说_transitionTo可以改变 Task 的状态,那么一个 Task 到底有些状态呢?都是什么时候改变的?下面这些是 Task 所有可能的状态,那我们对上面讲的封装逻辑只涉及到其中的几个。


const notScheduled: 'notScheduled' = 'notScheduled', scheduling: 'scheduling' = 'scheduling',            scheduled: 'scheduled' = 'scheduled', running: 'running' = 'running',            canceling: 'canceling' = 'canceling', unknown: 'unknown' = 'unknown';
复制代码


  • Task 在刚刚初始化的时候是notScheduled

  • scheduleFn 调度函数执行之前,Task 状态会被改为scheduling

  • scheduleFn 调度函数执行之后,Task 状态会被改为scheduled

  • 当回调函数被置于调用栈中准备执行时,Task 状态会被改为running

  • 回调函数执行完毕后,Task 状态会被改为notScheduled

调用关系图

最后,奉上我对源码分析的调用关系图


总结

分析 zone.js 源码的过程是痛苦的,光从思维图上就可以看出,zone.js 的绝大多数逻辑都是围绕 Zone、ZoneDelegate、ZoneTask 展开的。这兄弟三个之间相互引用、相互依赖,即使在我省略掉很多代码之后还是存在很多错综复杂的调用关系。如果你是一个颈椎病患者,那么建议你可以深度体验一下,你的脖子大概率会问候一下 zone.js 的全体研发团队。


今天这篇文章其实只分析 setTimeout 的 Patch 逻辑,zone.js 其实对很多其它 API 也都下手了。setTimeout 只是一个宏任务的代表,后续希望可以再选一个微任务和事件继续分析一下 zone.js 的打包流程。当前,前提是 if necessary


最近已经梳理完 NgZone 的源码逻辑,个人觉得可能会更贴近大家的实际开发,分析过程也更有趣。喜欢的可以继续蹲个后续~~~

OpenTiny 社区招募贡献者啦

OpenTiny Vue 正在招募社区贡献者,欢迎加入我们🎉


你可以通过以下方式参与贡献:


  • 在 issue 列表中选择自己喜欢的任务

  • 阅读贡献者指南,开始参与贡献


你可以根据自己的喜好认领以下类型的任务:


  • 编写单元测试

  • 修复组件缺陷

  • 为组件添加新特性

  • 完善组件的文档


如何贡献单元测试:


  • packages/vue目录下搜索it.todo关键字,找到待补充的单元测试

  • 按照以上指南编写组件单元测试

  • 执行单个组件的单元测试:pnpm test:unit3 button


如果你是一位经验丰富的开发者,想接受一些有挑战的任务,可以考虑以下任务:


  • ✨ [Feature]: 希望提供 Skeleton 骨架屏组件

  • ✨ [Feature]: 希望提供 Divider 分割线组件

  • ✨ [Feature]: tree 树形控件能增加虚拟滚动功能

  • ✨ [Feature]: 增加视频播放组件

  • ✨ [Feature]: 增加思维导图组件

  • ✨ [Feature]: 添加类似飞书的多维表格组件

  • ✨ [Feature]: 添加到 unplugin-vue-components

  • ✨ [Feature]: 兼容 formily


参与 OpenTiny 开源社区贡献,你将收获:


直接的价值:


  1. 通过参与一个实际的跨端、跨框架组件库项目,学习最新的Vite+Vue3+TypeScript+Vitest技术

  2. 学习从 0 到 1 搭建一个自己的组件库的整套流程和方法论,包括组件库工程化、组件的设计和开发等

  3. 为自己的简历和职业生涯添彩,参与过优秀的开源项目,这本身就是受面试官青睐的亮点

  4. 结识一群优秀的、热爱学习、热爱开源的小伙伴,大家一起打造一个伟大的产品


长远的价值:


  1. 打造个人品牌,提升个人影响力

  2. 培养良好的编码习惯

  3. 获得华为云 OpenTiny 团队的荣誉和定制小礼物

  4. 受邀参加各类技术大会

  5. 成为 PMC 和 Committer 之后还能参与 OpenTiny 整个开源生态的决策和长远规划,培养自己的管理和规划能力

  6. 未来有更多机会和可能

关于 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 源码分析【setTimeout篇】_JavaScript_OpenTiny社区_InfoQ写作社区