前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >如何实现一个异步模块加载器

如何实现一个异步模块加载器

作者头像
winty
发布2021-01-05 10:49:20
5990
发布2021-01-05 10:49:20
举报
文章被收录于专栏:前端Q前端Q

作者:youngwind

原文链接:https://github.com/youngwind/blog/issues/98

引言

异步是前端中的一个重点。

今天就结合模块,和大家讲分享一下:如何实现一个简单的模块加载器

正文

最近参考 require.js 的API,自己动手实现了一个简单的异步模块加载器: fake-requirejs

为什么要做这样一个东西呢?

原因是:我一直觉得自己对模块化这方面的理解不够深入。

即便用了很长时间的 webpack,看了很多模块化相关的资料,比如:

  • 模块化的发展历史
  • amd
  • commonjs 和 cmd规范之争

等等。

然而,我依然觉得自己的理解流于表面,所以决定自己动手实现一个。

目标的选择

本来一开始的目标是webpack的,但是后来考虑到:

webpack是建立在模块化基础上的一个构建工具。

且webpack的实现也相当的复杂,而我希望能够刻意区分开模块化构建这两个概念。

因为这有助于我集中有限的精力研究模块化这一个概念,所以后来决定实现requirejs,这是一个相对来说比较简单的异步模块加载器。

虽然现在使用它的人已经越来越少了,但是正因为其简单和纯粹,倒是非常适合现在的我。

注:请确保掌握了requirejs的基本用法再往下阅读。

刚开始敲代码的时候,我就在想如何实现require函数和define函数,但是后来我发现我错了,因为这陷入了面向过程编程的误区,正确的方式应该是面向对象编程

所以,我重新进行了思考。

问题:这里都有哪些类型的对象呢?

答案:至少有模块(Module)这一类对象

那模块类对象有哪些数据呢?

代码语言:javascript
复制
Module.id       // 模块id
Module.name     // 模块名字
Module.src      // 模块的真实的uri路径
Module.dep      // 模块的依赖
Module.cb       // 模块的成功回调函数
Module.errorFn  // 模块的失败回调函数
Module.STATUS   // 模块的状态(等待中、正在网络请求、准备执行、执行成功、出现错误……)

又有哪些对应的操作这些数据的方法呢?

代码语言:javascript
复制
Module.prototype.init           // 初始化,用来赋予各种基本值
Module.prototype.fetch          // 通过网络请求获取模块
Module.prototype.analyzeDep     // 分析、处理模块的依赖
Module.prototype.execute        // 运算该模块

依赖分析与处理

顺着上面的思路一步步写,我碰到了一个难点:

如何分析和处理模块的依赖?

举个例子:

代码语言:javascript
复制
// 入口main.js
require(['a', 'b'], function (a, b) {
    a.hi();
    b.goodbye();
}, function () {
    console.error('Something wrong with the dependent modules.');
});

我们的目标是:

当模块a和b都准备好之后,再执行成功回调函数;一旦a或b有任意一个失败,都执行失败回调函数。

这个跟使用Promise.allPromise.race很像,但这一次我们是要实现它们。

怎么办呢?

我想了一个方法:记数法, 分两步走

Module原型新增Module.depCount属性,初始值为该模块依赖模块数组的长度。

假如 depCount === 0,说明该模块依赖的模块都已经运算好了,通过setter触发执行该模块。

某模块执行成功之后,Module.STATUS === 5,通过setter触发下一步。

通过对象mapDepToModule,查找到依赖与该模块的所有模块,那么让那些模块都执行depCount--。

注:对象mapDepToModule的作用是:

映射被依赖模块到依赖模块之间的关系。

结构如下图所示:

举个例子:

当模块a准备好之后,我们就遍历mapDepToModule['a']对应的数组,里面的每一项都执行depCount--。

下面是一些关键的代码:

代码语言:javascript
复制
Module.prototype.analyzeDep = function () {
    // ...
    let depCount = this.dep ? this.dep.length : 0;
    Object.defineProperty(this, 'depCount', {
        get() {
            return depCount;
        },
        set(newDepCount) {
            depCount = newDepCount;
            if (newDepCount === 0) {
                console.log(`模块${this.name}的依赖已经全部准备好`);
                this.execute();  // 如果depCount===0,执行该模块
            }
        }
    });
    this.depCount = depCount;
    // ...
};

Object.defineProperty(this, 'status', {
    get () {
        return status;
    },
    set (newStatus) {
        status = newStatus;
        if (status === 5) {
             // 假如某个模块已经准备好了(STATUS===5),
             // 那么找出依赖于这个模块的所有模块,让他们都执行depCount--
            let depedModules = mapDepToModule[this.name];
            if (!depedModules) return;
            depedModules.forEach((module) => {
                setTimeout(() => {
                    module.depCount--;
                });
            });
        }
    }
})

虽然我们都说循环依赖是一种不好的现象,应该在设计之初尽量避免。

但是,随着项目越滚越大,谁又能保证一定不会出现?

所以:

作为一个合格的模块加载器,必须解决循环依赖的问题。

那么,让我们先来看看别人是怎么处理的吧。

  • Commonjs和ES6的循环依赖 http://www.ruanyifeng.com/blog/2015/11/circular-dependency.html
  • seajs的循环依赖 https://github.com/seajs/seajs/issues/732
  • requirejs的循环依赖 http://requirejs.cn/docs/api.html#circular

http://www.ruanyifeng.com/blog/2015/11/circular-dependency.html

这里我们不讨论各种处理方式孰优孰劣,我们只关注:

如何实现requireJS API文档中那样的功能?

仔细观察下面的例子:

a 与 b 出现循环依赖:

代码语言:javascript
复制
// main.js
require(['a','b'], function (a, b) {
    a.hi();
    b.goodbye();
}, function () {
    console.error('Something wrong with the dependent modules.');
});

// a.js
define(['b'],function (b) {
    var hi = function () {
        console.log('hi');
    };

    b.goodbye();
    return {
        hi: hi
    }
});

// b.js
define(['require', 'a'], function (require) {
    var goodbye = function () {
        console.log('goodbye');
    };
    // 因为在运算b的时候,a还没准备好,所以不能直接拿到a,只能用require再发起一次新的任务
    require(['a'], function (a) {
        a.hi();
    });

    return {
        goodbye: goodbye
    }
});

我们能看到:

模块b的回调函数中,并不能直接引用到a,需要使用require方法包住。

那么问题来了:

在原先的设计中, 每一个define是跟一个模块一一对应的,require只能用一次,用于主入口模块(如:main.js)的加载。

但是,现在在模块b的回调函数中,又出现require(['a']),这显然是乱套了。

至此,我发现require不应该仅仅是用于主入口模块的加载,require应该对应更高层次的抽象概念:我将它命名为:任务(Task),这是一个有别于Module的新的类。

每一次调用require,相当于新建一个Task(任务)。

这个任务的功能是:当任务的所有依赖都准备好之后,执行该任务的成功回调函数。

有没有发现这个Task原型与Module很像?

它们都有依赖、回调、状态,都需要分析依赖、执行回调函数等方法。

但是又有些不同,比如Task没有网络请求,所以不需要fetch这样的方法。

所以,我让Task继承了Module,然后重写某些方法。

关键代码如下:

代码语言:javascript
复制
// before
require = function (dep, cb, errorFn) {
    // mainEntryModule是主入口模块
    modules[mainEntryModule.name] = mainEntryModule;
    mainEntryModule.dep = dep;
    mainEntryModule.cb = cb;
    mainEntryModule.errorFn = errorFn;
    mainEntryModule.analyzeDep();
};

// after
require = function (dep, cb, errorFn) {
    let task = new Task(dep, cb, errorFn);
    task.analyzeDep();
};

// 引入新的类: Task(任务)
function Task(dep, cb, errorFn) {
    this.tid = ++tid;
    this.init(dep, cb, errorFn);
}

// Task类继承于Module类
Task.prototype = Object.create(Module.prototype);

至此,我们就完成了一个简单的异步模块加载器。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2020-12-31,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 前端Q 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 引言
    • 为什么要做这样一个东西呢?
      • 目标的选择
        • 依赖分析与处理
        领券
        问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档