关于前端:zonejs由入门到放弃之三zonejs-源码分析setTimeout篇

6次阅读

共计 11257 个字符,预计需要花费 29 分钟才能阅读完成。

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

zone.js 系列往期文章

  • zone.js 由入门到放弃之一——通过一场游戏意识 zone.js
  • zone.js 由入门到放弃之二——zone.js API 大练兵

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
  • OpenTiny 官网:https://opentiny.design/
  • OpenTiny 代码仓库:https://github.com/opentiny/
  • Vue 组件库:https://github.com/opentiny/tiny-vue(欢送 Star)
  • Angluar 组件库:https://github.com/opentiny/ng(欢送 Star)
  • CLI 工具:https://github.com/opentiny/tiny-cli(欢送 Star)

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

正文完
 0