Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

如何实现一个异步模块加载器--以requireJS为例 #98

Open
youngwind opened this issue Jan 17, 2017 · 6 comments
Open

如何实现一个异步模块加载器--以requireJS为例 #98

youngwind opened this issue Jan 17, 2017 · 6 comments
Labels

Comments

@youngwind
Copy link
Owner

youngwind commented Jan 17, 2017

为什么

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

为什么要做这样一个东西呢?
原因是:我一直觉得自己对模块化这方面的理解不够深入,即便用了很长时间的webpack,看了很多模块化相关的资料,比如模块化的发展历史,比如amd,commonjs和cmd规范之争等等。然而,我依然觉得自己的理解流于表面,所以决定自己动手实现一个。

目标的选择:本来一开始的目标是webpack的,但是后来考虑到webpack是建立在模块化基础上的一个构建工具,且webpack的实现也相当的复杂,而我希望能够刻意区分开模块化和构建这两个概念。因为这有助于我集中有限的精力研究模块化这一个概念,所以后来决定实现requirejs,这是一个相对来说比较简单的异步模块加载器。虽然现在使用它的人已经越来越少了,但是正因为其简单和纯粹,倒是非常适合现在的我。

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

Module原型的设计

刚开始敲代码的时候,我就在想如何实现require函数和define函数,但是后来我发现我错了,因为这陷入了面向过程编程的误区,正确的方式应该是面向对象编程。所以,我重新进行了思考。
问题:这里都有哪些类型的对象呢?
答案:至少有模块(Module)这一类对象

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

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

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

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

依赖分析与处理

顺着上面的思路一步步写,我碰到了一个难点:如何分析和处理模块的依赖?
举个例子:

// 入口main.js
require(['a', 'b'], function (a, b) {
    a.hi();
    b.goodbye();
}, function () {
    console.error('Something wrong with the dependent modules.');
});

我们的目标是:当模块ab都准备好之后,再执行成功回调函数;一旦ab有任意一个失败,都执行失败回调函数。
这个跟使用Promise.allPromise.race很像,但这一次我们是要实现它们。怎么办呢?

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

  1. 为Module原型新增Module.depCount属性,初始值为该模块依赖模块数组的长度。
  2. 假如depCount===0,说明该模块依赖的模块都已经运算好了,通过setter触发执行该模块。
  3. 某模块执行成功之后,Module.STATUS===5,通过setter触发下一步。
  4. 通过对象mapDepToModule,查找到依赖与该模块的所有模块,那么让那些模块都执行depCount--

注:对象mapDepToModule的作用是映射被依赖模块到依赖模块之间的关系。
结构如下图所示。举个例子:当模块a准备好之后,我们就遍历mapDepToModule['a']对应的数组,里面的每一项都执行depCount--
mapDepToModule

下面是一些关键的代码:

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的循环依赖
seajs的循环依赖
requirejs的循环依赖

这里我们不讨论各种处理方式孰优孰劣,我们只关注:
如何实现requireJS API文档中那样的功能?

仔细观察下面的例子:ab出现循环依赖

// 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**,然后重写某些方法。

关键代码如下:

// 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);

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

参考资料

  1. 学着写一个异步模块加载器
  2. 动手实现一个简单的浏览器端js模块加载器
  3. requireJS源码学习--叶小钗
  4. 别人实现的MyRequireJS

------完-----------

@youngwind youngwind added the JS label Jan 17, 2017
@Thinking80s
Copy link

mark

@F3n67u
Copy link

F3n67u commented Mar 28, 2017

depedModules.forEach的回调函数中depCount--为什么要加上setTimeout呢?

@aaawhz
Copy link

aaawhz commented Oct 11, 2017

a模块加载完, 所有依赖a的小伙伴们的depCount--; 当depCount为0的时候, 就执行这个回调。

@answer518
Copy link

不错

@leijunping
Copy link

文中,“比如Task没有网络请求,所以不需要fetch这样的方法“,请教下这里,感觉这里说的不太准确,例如在模块a中通过require['c'], 这样的方式(虽然建议在依赖中加入,但是不能排除这种使用)加载模块c,因为之前没有对c的依赖,所以会new Module, 并且执行fetch. 只有在循环依赖模块中的时候,文中所描述的才是对的。这是我的理解。。。不知道有问题木有。。。

@lizhongzhen11
Copy link

nice

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

7 participants