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

1,771 阅读12分钟

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

zone.js系列往期文章

zone.js源码分析

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

1_c7a1bf843b07d791257f5ac973e0e335_780x366.webp@900-0-90-f.webp

一点前置: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三者之间相互调用的场景,实在理不清的地方可以返回这里看下。

2_fc528515f5ab052e66d103f5992de86d_1757x900.png@900-0-90-f.png

虽然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

3_5184efe3c11c669dba01f2c2e56728fe_720x527.jpg@900-0-90-f.jpg

下面是简化后的代码,不难发现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到底干了什么:

4_532e10cf83143e4e8e5ae72448531587_350x500.jpg@900-0-90-f.jpg

再回到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第一阶段的动作描述的,方便大家配合源码进行理解。

5_b96563ffa991a034eb5484746962636b_961x324.png@900-0-90-f.png

我看很多文章都说过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的状态而已,真的是干啥全凭一张嘴。

6_d7178ffc5c9634daa7cfdb98a38741ef_640x853.webp@900-0-90-f.webp

代码传送门

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函数。

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

7_6d17aed27e917086ad2d5a96f2613e06_1373x1053.png@900-0-90-f.png

第三阶段:回调执行

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

8_c87962bda7660c5ea78d46aea35a33c1_645x506.png@900-0-90-f.png

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

调用关系图

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

9_8df17df2701fedff80d46666f0bed695_1485x1118.png@900-0-90-f.png

总结

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

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

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

联系我们

如果你对这些WEB前沿技术也有兴趣,欢迎你对我们的文章一键三连,以及关注我们的开源项目——OpenTiny。欢迎微信搜索我们的小助手:opentiny-official,拉你进群,了解OpenTiny最新动态。

官网:opentiny.design/
GitHub:github.com/opentiny