手写js模块加载器

前言

前端模块化已经成为了当下主流的开发模式.模块化能将不同页面的逻辑分别写在对应的文件中管理,其次还能将一些公共的函数或插件单独封装成模块.页面要调用相关功能只需在代码中引用一遍就可以使用.通过模块化可以使大型复杂的项目拆分成不同的模块管理,有助于后期维护和拓展.

本文将手写一遍模块加载器的核心代码,整个过程要实现以下功能.

  • 设计模块加载器的整体架构
  • 父模块如何一步步加载子模块和后代
  • 子模块加载完毕了如何通知父模块

调用方式

先从用户的角度设计好模块加载器的调用方式,再从结果反推实现过程.假设当前页面需要引用a模块,代码如下.

 <script src="./lib/mload.js"></script>
 <script>
    Mload.use(['/js/a.js'], function (a) {
      console.log(a);//输出a模块的导出内容
    });
 </script>

Mload是最终暴露给用户调用的对象,运行use方法后,浏览器会开始请求项目目录下js文件夹下的a.js.脚本加载成功后触发回调函数,a.js返回的数据将作为回调函数的参数参与执行.

/js/a.js 文件

define(function (require, exports, module) {
  var c = require("./c.js"); //引用c模块的数据.引用后就可以调用c模块对外暴露的属性和方法
  exports.data = {
    value: 123
  };
});

a.js中约定所有代码全部写在define函数包裹的函数体内部.define包裹的函数会对外暴露三个参数:requireexportsmodule.

  • require能够获取其他模块的数据.通过传入其他模块的文件路径,就可以返回对外导出的数据.
  • exports是本模块对外导出数据和方法的实现机制.exports可以写多条导出代码,那么其他模块引用当前模块时就可以获取exports导出的数据.
  //a模块
  exports.data = 123;
  exports.test = function(){console.log("hello world")};
  ...
  //其他模块引用a模块
  var data = require("./a.js");
  console.log(data); // {data:123,test:function(){console.log("hello world")}
  • module的功能和exports一样,都是导出数据供外部调用.不过module的调用方式和exports有区别,module在模块中只能导出一次,导出语句如下.
 //a模块
 module.exports = {
   value:123
 }
 ...
 //其他模块
 var data = require("./a.js");
 console.log(data); // {value:123}

define函数的实现过程是模块加载器的重点,了解清楚define函数具体在用户端如何使用后,接下来就可以一探其底层的代码逻辑.

页面引用a模块后,最终输出结果.

{
 data:{
     value:123
   }
}

实现

在页面入口调用以下代码,启动模块加载器.随后模块加载器整个执行过程分为三部分.

  • 加载子模块
  • 子模块全部加载完毕后一层层向上通知父模块,直到入口文件处的根模块
  • 根模块收到所有脚本文件加载完毕的通知后,开始获取子模块的数据供外部使用
 Mload.use(['/js/a.js'], function (a) {
      console.log(a);//输出a模块的导出内容
 });

加载子模块

上面代码执行后,模块加载器便会去请求a.js的脚本,脚本返回后开始执行.此时在a.js中又可能引用多个其它的子模块,那么又要去加载其他子模块.其他子模块里又可能引用子模块,就这样一直往下加载,直到最后一个子模块没有外部引入时为止.

上面的执行过程可以拆分成以下步骤一步步实现.

  • 创建根模块和一个callback回调函数.将a.js划到根模块的子级依赖数组中存储起来.回调函数要等到所有子级全部加载完毕后再执行.
  • 根模块开始循环遍历依赖数组,依次加载子模块.此时数组中只有一个a.js,针对这个脚本文件创建一个a模块.随后让浏览器请求a.js的脚本,请求成功后执行脚本内容.
  • a.js的脚本执行完毕后,将a.js里面使用了require语法引用其他子模块的路径收集存储在a模块的依赖数组里.再按照上述第一步的步骤嵌套加载子模块,直到最后一个不再有引用的子模块为止.

加载根模块

首先先创建3个全局变量.缓存对象cache_data、状态码code和构造函数Mload.

  var cache_data = {}; //缓存对象

  /**
   * 状态码
   */
  var code = {
    created: 1, //创建模块
    load:2, //开始加载依赖
    feching: 3, //开始加载远程脚本文件
    feched:4, //脚本文件加载完毕
    loaed:5, //模块加载完毕
  };

  /**
   * 模块构造函数
   * @param {*} params
   */
  function Mload(params) {
    this.id = params.id;
    this.libs = params.libs || [];
    this.status = code.created;
    this.cnum = 0; //有几个子级没有加载完
    this.parent_lib = {}; //有哪些父级引用我
    this.exports = {}; //对外导出的数据
  }
  • Mload是创建模块的构造函数.它里面会存储与模块构建相关的数据.每个模块的id都是唯一的,子模块的id就是请求该模块脚本文件的绝对路径.比如"/js/a.js"文件对应着a模块,那么它的id就等于请求该文件的绝对路径,形如http://www.xxx.com/js/a.js.另外libs里面存储着该模块依赖的子级组成的id数组.
  • code里面的状态标志着模块处于生命周期的各个阶段.
  • cache_data是全局缓存模块的对象.它的结构形如{'http://www.xxx.com/js/a.js':a模块实例}.cache_data会将每一个模块的id作为key值,实例作为值缓存起来.
 Mload.use = function (libs, callback) {
    var root_id = getPath() + getModuleId(); //生成一个随机id
    libs = pathHandler(libs); //对路径格式做规范处理
    var m = getModule(root_id);
    m.callback = function () {
      //所有子模块加载完毕后再执行
    };
    m.libs = libs;
    m.cnum = libs.length; //依赖的数量
    m.loadModule();//加载子模块
  };
  
  /**
   * 获取module
   */
  function getModule(id) {
    return cache_data[id]
      ? cache_data[id]
      : (cache_data[id] = new Mload({ id }));
  } 

Mload.use是模块加载器的入口函数.它会完成创建根模块和callback函数的工作.随后就开始加载子模块.

加载子模块

根模块通过执行loadModule方法开始加载子模块.遍历循环根模块的依赖数组libs的过程中,这行代码很关键.

seed.parent_lib[m.id] = 1;

子模块要把根模块的id记录下来,为了后面子模块一旦加载完毕可以通知到父模块.

 Mload.prototype.loadModule = function () {
    var m = this;
    m.status = code.load; //准备加载依赖
    var libs = m.libs;
    for (var i = 0; i < libs.length; i++) {
      var seed = getModule(libs[i]); //获取子模块
      seed.parent_lib[m.id] = 1; //将父模块全部注册进来
      if (seed.status < code.feching) {
        //开始加载子模块
        seed.fetchFile();
      }
    }
  };

从下面代码可以看出,加载子模块的脚本文件是通过动态创建script标签实现的.

创建script标签后,将请求地址赋给src再挂载在body下,这时候浏览器就开始去请求脚本了.等到脚本加载完毕了,浏览器默认会去执行脚本的代码.

脚本执行完毕后才会触发下面的onload方法进而执行callback函数.


 /**
   * 请求子文件
   */
  Mload.prototype.fetchFile = function () {
    var m = this;
    m.status = code.feching; //加载脚本文件
    var callback = function () {
      //脚本加载完毕后的工作
    };
    requestFile(m.id, callback);
  };
  
  function requestFile(id, callback) {
    var script = document.createElement('SCRIPT');
    script.src = id;
    document.body.appendChild(script);
    script.onload = function () {
      script.remove();
      callback();
    };
  }

子模块继续加载子模块

脚本文件从远程加载到body下面开始执行.此时脚本的文件的内容如下,define函数被执行,它包裹了一个函数体将作为参数传递进去.

define(function (require, exports, module) {
  var c = require("./c.js"); //引用c模块的数据.引用后就可以调用c模块对外暴露的属性和方法
  exports.data = {
    value: 123
  };
});

define函数里会将函数体factory转化成字符串,再使用正则表达式匹配出所有require包裹的参数.

这样该模块依赖的子模块的路径都能获取到,随后将结果存在一个全局变量tmp_data中.

  function define(factory) {
    var factory_string = factory.toString();
    //匹配出 require("c.js") 里面的 c.js
    var reg = /\brequire\((['"])([a-zA-Z\d\.-_]+)\1\)/g;
    var libs = [];
    factory_string.replace(reg, function (match, $1, $2) {
      libs.push($2);
      return match;
    });
    var obj = {
      factory: factory,
      libs: libs,
    };
    tmp_data = obj; //tmp_data是一个全局变量
  }

此时远程请求的脚本文件已经执行完了,线程又会回到上面介绍过的fetchFile方法执行callback函数.

Mload.prototype.fetchFile = function () {
    var m = this;
    m.status = code.feching; //加载脚本文件
    var callback = function () {
      m.factory = tmp_data.factory; //工厂函数
      var result = childPathHandler(m.id, tmp_data.libs); //将依赖数组里面的路径做一下规范处理
      var libs = [];
      for (var i = 0; i < result.length; i++) {
        //为了防止a文件引入b,b里面又引入a造成的问题.
        if (getModule(result[i]).status < code.feched) {
          libs.push(result[i]);
        }
      }
      m.libs = libs;
      m.cnum = libs.length; //依赖的数量
      m.status = code.feched; // 脚本文件加载完毕
      if (m.cnum == 0) {
        //加载完毕,没有依赖了
        m.loaded();
      } else {
        m.loadModule();
      }
    };

    requestFile(m.id, callback);
  };

callback函数里,拿到刚才缓存的全局变量tmp_data,将该模块依赖的数组libs存储起来.

此时要对模块的依赖数量做一下判断,如果它没有再引入子级了,就执行loaded方法.如果还有子级继续回到上面流程,嵌套加载子模块.

通知父模块

在走完了上面加载子模块流程之后,最终所有的子级都加载完毕.此时最后一个子级因为没再引入其他子级了,它便会执行loaded方法.

  /**
   * 加载完毕
   */
  Mload.prototype.loaded = function () {
    var m = this;
    m.status = code.loaed; //模块加载完毕
    if (m.callback) {
      m.callback();
      return false;
    }
    var parent_lib = m.parent_lib;

    for (var key in parent_lib) {
      var parent_module = getModule(key);
      parent_module.cnum--;
      if (parent_module.cnum == 0) {
        //子依赖全部加载完毕
        parent_module.loaded();
      }
    }
  };

parent_lib在前面提到过,子级可以通过它找到父级模块.

当父级模块下面的所有子级模块全部加载完毕后,它的cnum依赖数量会减到0,它又会继续嵌套调用loaded通知它的父级.

子级一层层往上通知,直到通知到了根模块.在入口函数里根模块绑定了一个callback函数,代码跑到此处便会执行callback函数.

 //入口函数
 Mload.use = function (libs, callback) {
    var root_id = getPath() + getModuleId();
    libs = pathHandler(libs); //对路径处理
    var m = getModule(root_id);
    m.callback = function () {
      var result = [];
      for (var i = 0; i < libs.length; i++) {
        var seed = getModule(libs[i]);
        result.push(seed.exec()); //获取模块的导出数据
      }
      callback.apply(null, result);
    };
    m.libs = libs;
    m.cnum = libs.length; //依赖的数量
    m.loadModule();
  };

在上面入口函数定义的callback中,代码首先会获取根模块的依赖数组libs,通过遍历libs得到子模块的导出数据.最终将这份数据传递给外部编写的回调函数里.

如此用户就可以通过模块加载器在外部得到任意模块返回的数据了.

至此大功就告成了,但上面父模块是怎么获取子模块的导出数据呢?答案是通过调用exec方法就得到子模块导出的数据,实现细节后面详述.

  Mload.use(['/js/a.js'], function (a) {
      console.log(a);
  });

获取数据

根模块遍历循环它下面的子级依赖,想得到子级的导出数据.

于是子模块开始执行exec方法返回数据.

  /**
   * 获取模块的导出数据
   */
  Mload.prototype.exec = function () {
    var m = this;

    if (JSON.stringify(m.exports)!=="{}") {
      //已经将导出数据缓存了
      return m.exports;
    }

    var factory = m.factory;

    var require = function (path) {
      path = childPathHandler(m.id, path); //规范路径格式
      var seed = getModule(path);
      if (seed.libs.indexOf(m.id) !== -1) {
        return seed.exports;
      }
      return seed.exec();
    };

    var module = {
      exports: {},
    };

    factory.apply(null, [require, module.exports, module]);

    for(var key in module.exports){
      m.exports[key] = module.exports[key];
    }

    return m.exports;
  };

exec函数中,最关键的部份就是会去执行define包裹的函数体factory.在执行这个factory之前,会定义好三个变量requiremodule.exportsmodule作为参数传递进去.

factory的函数体形如下面代码.

define(function (require, exports, module) {
  var c = require("./c.js");
  exports.data = {
    value: 123,
    c:c
  };
});
  • module.exportsmodule其实指向的数据源是相同的.factory执行后会将脚本文件导出的数据赋予module.exports.将它存储在模块实例的exports属性里,最终这个数据将会作为该模块的对外导出数据.
  • require函数通过嵌套调用exec函数获取下一个子级的导出数据.如果层级比较深,它就会一直往下获取,直到最后一层没有引入为止.
    exec函数通过这样嵌套调用的机制,最终会把属于该模块的数据悉数导出供给外部调用.

总结

模块加载器整个流程还是挺复杂的,从宏观上可以将其过程分成三步.

  • 从根模块开始,嵌套加载所有子模块和后代.
  • 子模块加载完毕后开始层层往上通知父模块,直到最后通知到根模块.
  • 根模块知道所有后代模块全部加载完毕后,又开始向下一层一层嵌套获取子模块的导出数据.最终将数据返回给用户端使用.

源码

完整代码

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值