随着JavaScript的代码复杂度日益增加,为了减少开发和维护的难度,模块化的需求出现了,从1999年至今,模块化的发展走过了一些重要的阶段,本篇重点讲解这些比较突出的模块化方式。

命名空间

早期一个比较简单的模块化方式就是通过命名空间,通过约定俗成的全局变量的方式,将一些属性或者方法存放在一个大写的变量对象中,然后去使用。

var MY_MODULE = {
    name: "a",
    fn1: function() {},
    fn2: function() {}
}

这样一来可以减少全局作用域上的变量数量,但是这个实现的本身它还是一个对象,这个就导致它的不安全性,外部可以修改命名空间下的属性,而且有些不希望暴露给外部的属性没办法做到隐藏,比如name属性。

IIFE

为了解决命名空间带来的弊端,发展出利用闭包的方式实现隐藏不必要的属性。

var MY_MODULE = (function() {
    var name = "a";
    function fn1() {};
    function fn2() {};

    return {
        fn1: fn1,
        fn2: fn2
    }
})();

LAB.js

在CommonJS之前,js模块还经历了一些变化,比如开始出现以文件为单位的模块化方式,一个js文件代表一个模块。

<script src="module1.js"></script>
<script src="module2.js"></script>
...

这看起来非常美好,但是真实情况是:

<script src="module1.js"></script>
<script src="module2.js"></script>
<script src="module3.js"></script>
<script src="module4.js"></script>
<script src="module5.js"></script>
<script src="module6.js"></script>
<script src="module7.js"></script>
<script src="module8.js"></script>
<script src="module9.js"></script>
<script src="module10.js"></script>
...

一个稍微大一点的项目,它需要加载特别多的模块,这就导致我们需要彻底理清楚模块的加载顺序,以及它们之间的依赖关系,而且产生了大量的http请求,这很痛苦。

于是2009年有人提出一种做法,封装一个库,专门用来加载模块,从而减少丑陋的script标签,比较有代表意义的就是LAB.js

它提供了一些便捷的用法:

<script src="LAB.js"></script>
<script>
  $LAB
  .script("http://remote.tld/jquery.js").wait()
  .script("/local/plugin1.jquery.js")
  .script("/local/plugin2.jquery.js").wait()
  .script("/local/init.js").wait(function(){
      initMyPage();
  });
</script>

LAB.js还支持一种语法糖,就是script方法支持传入一个数组,数组里是对应的脚本地址,再通过wait方法等待所有模块加载完毕触发对应回调。

$LAB
    .script(["script1.js", "script2.js", "script3.js"])
    .wait(function() { 
        script1Func();
        script2Func();
        script3Func();
    });

此时我们可以发现,形成了一种基于文件的依赖管理关系。

YUI (昔日大佬)

与LAB.js同年,雅虎发布了YUI,它包含了一套模块化的实现,首先模块的加载有点像jq的这套,先引入核心框架,再引入模块,模块会通过YUI().use()方法进行挂载,用户使用的时候可以指定需要的模块,在一个回调函数里接受一个约定俗称的参数Y,这个Y里面挂载着你指定的模块,可以将Y理解为一个沙箱,这样你对Y的修改不会影响到外部。

YUI还会自动加载没有预先script引入的模块,这种方式其实是采用了 依赖注入的思想,我不需要提前将模块声明引入,而是在使用的时候自动注入。

官网:YUI3

YUI().use('node', function (Y) {
    var demo = Y.one('#demo');

    demo.on('click', function (e) {
        demo.set('text', 'You clicked me!');
    });
});

早些时候,雅虎的YUI还是很有影响力的,当时除了用它这套,也只有大厂才有能力自己搓一套类似的。

这里就不得不提一个Combo Handler特性,YUI加载模块时,它们通过服务器资源配合,将多个文件请求合并成一个请求,从而减少了http请求数量,这对当时的网页访问性能有着显著的提升效果。

具体来说,YUI Combo Handler 会将这些模块的路径拼接在一起,然后用一个单一的 HTTP GET 请求将它们一次性请求下来。

例如:

http://yui.yahooapis.com/combo?3.0.0/build/yui/yui-min.js&3.0.0/build/dom/dom-min.js

CommonJS

CommonJS规范最初的发布大概也是2009年,它的初衷就是创建一个可以不依赖浏览器的JavaScript环境,为此CommonJS社区定义了一系列的规范,其中就有模块系统。

而我们耳熟能详的node采用了这个模块系统规范并使其成为自己的模块化标准,也就是从这时开始,JavaScript 才真正意义上解决了模块化这个大难题。

CommonJS模块实际上是一种同步处理方案,它会先将require引入的模块先加载,然后再处理后续调用代码,这对于本地IO来说,其实是没有瓶颈的,相较于网络速度普遍4m的速度,本地磁盘的读取起码都在100mb起步了,即便require是同步阻塞式的,也不影响在服务端使用它,反倒是浏览器环境水土不服。

创建一个模块:

function sum(a, b) {
  return a + b;
}

module.exports = sum;

引入并使用模块:

const sum = require("./module1.js");

const total = sum(1, 3); //4

通过require进行模块的加载,这个同步操作,它会根据模块路径找到对应的文件,将文件的内容读取并存放于内存中,再将文件的内容包裹在一个函数中,这样每个模块都有自己的作用域,变量也不会污染全局,包裹的函数会传入以下参数:exports, require, module, __filename, __dirname,注意此时生成的内容还只是string值,并没有运行,最终这段代码字符串会被传递给new Function() 来创建一个新的函数。

最后执行这个包裹函数,并将 exports, require, module, __filename, __dirname 作为参数传入。

(function(exports, require, module, __filename, __dirname) {
    // 模块代码在这里
});

其中module可以理解成这样的一个新对象:

const module = {
  exports: {}
}

函数的第一个参数exports就是module上的exports属性;require 是一个函数,用于加载模块;__filename 是被执行的 js 文件路径;__dirname 是 js 文件所在的文件夹路径。

事实上node对于相同的模块,其实不会重复加载,虽然IO的速度很快但同时也很宝贵,为了节省资源,node会有一个缓存机制,它会缓存模块的module对象,每次require加载的模块,实际上获得是module.exports的引用。

来看一个伪代码实现:

(function () {
  var modules = {
    "./a.js": function (exports, module, require) {
      function sum(a, b) {
        return a + b;
      }

      module.exports = sum;
    },
    "./index.js": function (exports, module, require) {
      const sum = require("./a.js");

      const total = sum(1, 3);
      console.log(total); //4
    },
  };

  const cache = {};

  function require(moduleId) {
    if (cache[moduleId]) {
      return cache[moduleId].exports;
    }

    var module = { exports: {} };
    modules[moduleId](module.exports, module, require);
    cache[moduleId] = module.exports;

    return module.exports;
  }

  require("./index.js");
})();

大概意思是差不多的,require优先缓存,没有缓存在去读取文件,这里就省略具体的操作,而是将new Function() 的结果展示出来了,通过modules去获取到function并运行,运行后将module.exports赋值,因为是引用对象,所以不需要return外面也能拿到对应的导出结果。

与es6模块最容易混淆的一个区别

在大部分介绍node模块和es6模块的文章中,都会说CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用

这很难去理解,因为大部分的代码示例都没说到点子上,而造成这种说法的原因是因为CommonJS的写法导致。

在node中我们导出一个这样的数据:

var value = 1;

function add() {
  value += 1;
}

module.exports = {
  value: value,
  add: add,
};

但凡知道什么是引用类型和基本类型,都应该知道此时exports对象上的value就是一个基本类型,基本类型的赋值就是完整的复制,此时我们在外部调用add方法,加的是var value的值,而不是exports对象上的value。

const a = require("./a.js");

console.log(a.value); //1
a.add();
console.log(a.value); //1

而如果是es6模块,我们得到的是一个不能被赋值的引用:

export let value = 1;

export function add() {
  value += 1;
}
import { value, add } from "./a.js";

console.log(value); //1
add();
console.log(value); //2

通过这个例子我们才能明白他们说的值的拷贝是什么意思,造成这样原因只是因为CommonJS 导出的是一个对象,对象上赋值基本类型就只能是拷贝的值,所以用准确的说法:CommonJS 模块导出的对象赋值基本类型属性后,无法通过内部的方法改动原值的方式来改变导出对象上的对应属性值

事实上导出的值不会改变吗?是可以的,我们通过修改导出的exports对象属性,一样可以改变,有两种方式:

1.直接修改导出的对象属性

const a = require("./a.js");

console.log(a.value); //1
a.value = 2;
console.log(a.value); //2

2.导出的模块里的方法,改成修改module.exports对象属性

let value = 1;

module.exports = {
  value: value,
  add: add,
};

function add() {
  module.exports.value += 1;
}

所以有什么特殊吗?在了解了其基本原理后其实就没啥特殊的了,什么值的拷贝,说的那么高大上,让人以为导出的值修了别的模块引入也不受影响,其实并不是。

AMD

由于CommonJS 只适用于服务端,而浏览器所有的资源都依赖于http请求,这就导致同步的方式根本不适用,于是2009年又出了一份适用于浏览器的异步加载模块方案。

AMD规范文档:amdjs-api

这个规范定义了一个加载函数require,有一个对此的实现:requirejs

requirejs官网:requirejs

使用示例:

<script data-main="scripts/main" src="scripts/require.js"></script>

<script>
  requirejs(["helper/util"], function(util) {
      ...
  });
</script>

此时就会加载helper/util.js文件,并将模块导出的对象作为形参util传入回调函数使用。

util模块内部示例:

define("util",[],function() {
  return {
    sum: function(a,b) {
      return a+b;
    }
  }
});

可以看到模块内部调用了一个define方法,这个方法是一个模块注册的方法,从这我们可以了解到require.js会在全局注入一个define方法用于模块注册,而require.js自己加载脚本其实就是通过动态创建script标签的方式进行加载,当脚本load完成后,就会自动运行,触发define方法进行注册。

define会将注册的模块挂载在一个modules对象上,当用户通过requirejs()的方式去指定模块并传入回调函数,会先从modules对象上拿取,如果没有再动态创建script标签,拿到后运行回调函数,将指定的模块作为参数传入。

大体上是这么一个流程,我们来看一个伪代码:

var modules = {};

function define(name, deps, factory) {
  var pending = deps.length;
  var depsList = new Array(pending);

  deps.forEach(function (dep, i) {
    if (modules[dep]) {
      depsList[i] = modules[dep];
      pending--;
      if (pending === 0) {
        modules[name] = factory.apply(null, depsList);
      }
    } else {
      loadScript(dep + ".js", function () {
        depsList[i] = modules[dep];
        pending--;
        if (pending === 0) {
          modules[name] = factory.apply(null, depsList);
        }
      });
    }
  });
}

function requirejs(deps, callback) {
  var pending = deps.length;
  var depsList = new Array(pending);

  deps.forEach(function (dep, i) {
    if (modules[dep]) {
      depsList[i] = modules[dep];
      pending--;
      if (pending === 0) {
        callback.apply(null, depsList);
      }
    } else {
      loadScript(dep + ".js", function () {
        depsList[i] = modules[dep];
        pending--;
        if (pending === 0) {
          callback.apply(null, depsList);
        }
      });
    }
  });
}

function loadScript(url, callback) {
  var script = document.createElement("script");
  script.src = url;
  script.onload = callback;
  document.head.appendChild(script);
}

requirejs(["helper/util"], function (util) {});

真实情况是这个函数封装会更加复杂,我们大概了解一下整体流程就行,如果有兴趣可以自己深入研究下。

CMD

大约在2012年左右,由国内开发者玉伯(尤小右)提出的CMD规范,用于浏览器中依赖和模块管理,它是一种异步加载模块,但是写法却是同步的,模块函数体内的调用和CommonJS类似。

简单来说就是,当执行到某个模块时,必须把所有的依赖加载完毕才会执行后续的代码。

为此提供了sea.js工具用于具体实现。

CMD规范文档:CMD

仓库地址:seajs

官网:sea

模块写法示例:

define(function(require, exports, module) {
  // 加载jquery模块
  var $ = require('jquery');
  // 直接使用模块里的方法
  $('#header').hide();
});

这里我们将这个模块理解为业务模块,我们可以看到这个模块里依赖的jquery,但是调用上又是同步的写法,require函数是怎么做到同步加载模块的?

其实这里用到了一个骚操作,我们知道,函数的原型上存在一个toString方法的,它会将函数转成string字符串:

function abc(params) {
    console.log(params);
}

abc.toString();
//'function abc(params) {\n    console.log(params);\n}'

得到这个字符串后利用正则匹配函数体内的require字段,然后根据配置(sea在调用前配置一些内容,比如模块路径)加载指定的js文件,加载方式也是动态创建script标签,当所有的依赖都load的时候,才会真的执行这个回调,此时由于模块都已经加载完成,var $ = require('jquery');就能通过内存中挂载的模块即时返回,达到同步效果。

这种想法不得不说真的很优秀!

UMD

模块化的发展呈现出三种主流模块设计:CommonJSAMDCMD;当然还有一些比较小众的模块化设计,虽然百花齐放,但是却带来了一些痛点:模块的复用

我编写了一个模块,由于此时只在服务端使用,所以我只需要满足CommonJS规范即可,但是随着业务发展,这个模块其实还可以用在非服务端,最简单的办法就是再写一个js文件,满足AMD或者CMD规范即可。

但是,维护起来就非常痛苦,我可能改动一个地方,就需要改动三个js模块文件,有没有什么办法可以只写一个js文件,就能满足CommonJSAMDCMD这几种规范呢?

于是UMD规范出来了,用于一次编写满足多种规范,这个也是目前大部分模块采用的策略,虽然ES6模块已经是大势所趋。

UMD模块能够在任何地方工作,无论是在客户端、服务器还是其他地方。

UMD文档:UMD

基本上umd会满足以下条件:

  1. 支持全局属性挂载
  2. 支持AMD规范
  3. 支持CMD规范
  4. 支持CommonJS规范

我们一步一步来实现这些需求。

全局属性挂载

所有的模块最终都要return出一个内容,这个内容可能是一个方法,一个值,或者一个对象,我们并不关心它的结果,但是它一定会return,所以我们通过封装一个factory函数,只有调用这个工厂函数才会返回出模块的具体内容,这样我们才能方便去操作。

function factory() {
  return {
    name: "module1"
  }
}

然后我们通过一个IIFE函数,传入一个this,这个this在window环境下指向的就是window本身,这样我们通过this就能进行挂载全局属性。

(function(root, factory) {
  //直接挂载
  root["module1"] = factory();
})(this,function() {
  //模块的内容
  return {
    name: "module1"
  }
})

我们看一下axios的这部分处理:

兼容AMD

amd的模块注册是通过全局的define方法进行注册,并且规范规定define方法上存在一个amd属性,一般情况下这个amd属性会被设置成一个空对象,只要amd属性存在,我们就能确定他是在amd环境,基于这个我们就能进行amd的兼容。

(function(root, factory) {
  if(typeof define === "function" && define.amd) {
    define([], factory);
  }else {
    root["module1"] = factory();
  }
})(this,function() {
  //模块的内容
  return {
    name: "module1"
  }
})

这里我们再解释一下amd的define函数,在文档定义中,define函数接收三参数,但是前两个都是可选的:

define(id?, dependencies?, factory);

如果id没有,会自动根据模块的文件位置自动分配,具体就不深入了,如果dependencies没有或者是空数组,表示不需要任何外部依赖,而factory工厂函数是必须的。

所以上面的define([], factory)可以改成更为简洁的define(factory)

兼容CMD

cmd也会在define方法上挂载一个cmd属性,通常来说也是一个空对象。

(function(root, factory) {
  if(typeof define === "function" && define.amd) {
    define([], factory);
  }else if(typeof define === "function" && define.cmd) {
    define(function(require, exports, module){
       module.exports = factory();
    });
  }else {
    root["module1"] = factory();
  }
})(this,function() {
  //模块的内容
  return {
    name: "module1"
  }
})

CMD的define和CommonJS是差不多的,接收一个回调函数callback,然后通过callback.toString的方式抓取函数体内的require,如果存在就先将所有依赖加载完毕,再运行callback,然后如果存在导出,就通过module或者exports

兼容CommonJS

我们在了解CommonJS的实现的时候知道,他会将js文件内容包裹在一个函数体内,这个函数会有一些预设形参:moduleexports,我们判断这两个是否存在且是一个对象,就能判定是一个CommonJS环境。

(function(root, factory) {
  if(typeof exports === 'object' && typeof module === 'object') {
    module.exports = factory();
  }else if(typeof define === "function" && define.amd) {
    define([], factory);
  }else if(typeof define === "function" && define.cmd) {
    define(function(require, exports, module){
       module.exports = factory();
    });
  }else {
    root["module1"] = factory();
  }
})(this,function() {
  //模块的内容
  return {
    name: "module1"
  }
})

上面这段代码已经是一个非常优秀的UMD实现了,但是CommonJS在一些旧版本的实现,并没有module对象,只有exports对象,所以在一些模块打包时,为了兼容旧版还会多做一个处理:

(function(root, factory) {
  if(typeof exports === 'object' && typeof module === 'object') {
    module.exports = factory();
  }else if(typeof define === "function" && define.amd) {
    define([], factory);
  }else if(typeof define === "function" && define.cmd) {
    define(function(require, exports, module){
       module.exports = factory();
    });
  }else if(typeof exports === 'object') {
    exports["module1"] = factory();
  }else {
    root["module1"] = factory();
  }
})(this,function() {
  //模块的内容
  return {
    name: "module1"
  }
})

模块依赖模块

至此基本上一个umd模块就基本完成了,但是还差一点没说,就是模块的依赖关系,比如我这个模块依赖了其他模块。

写法其实也很简单,你只要搞清楚各种规范下依赖是怎么载入的就行,因为模块的依赖加载取决你的实际环境,比如我现在的环境是一个AMD环境,那么插件的加载是通过define函数传入一个依赖数组来实现的,我们只需要在umd里面的amd兼容处加上你要的依赖就行了,至于怎么加载模块,根本不是我们考虑的事情。

(function(root, factory) {
  if(typeof exports === 'object' && typeof module === 'object') {
    var $ = require("jquery");
    module.exports = factory();
  }else if(typeof define === "function" && define.amd) {
    define(["jquery"], factory);
  }else if(typeof define === "function" && define.cmd) {
    define(function(require, exports, module){
       var $ = require("jquery");
       module.exports = factory();
    });
  }else if(typeof exports === 'object') {
    var $ = require("jquery");
    exports["module1"] = factory();
  }else {
    //我都全局了,说明所有的依赖都在全局,我是不能主动载入的,所以啥也不用干
    root["module1"] = factory();
  }
})(this,function() {
  //模块的内容
  return {
    name: "module1"
  }
})

不同的模块打包可能会有细微的不同,但是总体逻辑一致。

NPM模块与浏览器转换

2011 - 2014年,随着模块化趋于稳定,加上node的发力,npm依赖管理也是风生水起,一时间大量模块的出现,但是很多模块可能只有node端兼容,为此出现了一些用于将CommonJS转成浏览器可以用的js文件。

出现这个的原因也有一部分因为模块化文件过多导致,开始有人想着能不能去除模块化的包裹,直接将多个模块文件合并成单个文件,从而减少http请求。

browserify

github: browserify

browserify会递归分析(AST)模块中的所有依赖,然后打包成一个捆绑包,这样就可以通过单个script引入并使用了。

npm install -g browserify

转换:

browserify main.js -o bundle.js

但是这就产生一个问题,每次依赖的代码发生变化,你就需要手动打包一次,能不能让他自动监听变化然后自动打包呢?

watchify

github: watchify

watchify这个插件会监控文件,当文件发生变化,就会调用browserify重新编译生成新的bundle。

npm install -g watchify
watchify main.js -o static/bundle.js

此时我们已经可以看到现代化构建工具的雏形了,配合npm命令实现快速调用:

package.json

{
    "scripts": {
        "build": "browserify main.js -o bundle.js",
        "watch": "watchify main.js -o bundle.js -v"
    }
}

webpack

2014年,webpack出现,将原来的功能都进行了整合,并且更加强大。

module.exports = {
    entry: "./main.js",
    output: {
        filename: "bundle.js"
    }
}

此时打包的流程更加规范,从原来的打包单个模块到现在整合整个项目的模块,后续还有拆分打包处理,配合代码混淆压缩,Source Map等支持,已然成为了一个巨头。

到这其实也不用说太多,一个前端开发者不可能没有接触过webpack的, 它从最开始只能打包js,到后来各种文件类型的支持,将所有内容进行编译处理成了现在项目的标配。

ES6模块

2015年ES6标准发布,同时带来了官方的模块化标准,大一统时代来了,通过importexport关键字,让模块化成为 JavaScript 语言的一部分。

//count.js
export function increment(n) {
  return n + 1;
}

//main.js
import { increment } from './count.js';

console.log(increment(1)); //outputs 2

而ES6模块还有一个优质特性:模块是静态的,这就可以使得我们实现静态分析,从而实现tree-shaking优化,还有一个更棒的效果就是解决了模块之间的循环引入,因为不是运行时,所有的代码在导入导出可以看成在一个第三方环境里处理,是a模块b模块与c环境的关系,而不是a模块和b模块之间直接联系。

官方文档规范:ES6 Modules

详细的说明就不说了,这个部分其实内容还是挺多的,大家自行搜索引擎查找文章学习就行了,特别多文章。

然而,尽管 ES6 模块在 2015 年被标准化,但并不意味着所有现代浏览器立即开始支持 ES6 模块。事实上,浏览器对 ES6 模块的原生支持直到 2017 年才开始变得常见。

为了能让开发者提前体验到新语法的特性,webpack和2015年发布的Babel都开始增加对新语法的支持,让开发者在开发时使用新特性代码,打包后会进行编译,编译成ES5代码之类。

为此出现了一些工具:

  1. babelify
  2. babel-loader

现在都2023年,一些插件可能早就有新的代替品(历史总是洪流滚滚,只有不断向前才能存活)。

生产环境的模块转换

虽然es6模块化我们可以在开发的时候使用,但是并不意味着在生产环境,也就是浏览器上使用,首先是兼容性上的问题,不同的浏览器不一定支持,特别当产品的目标人群是政府或者不发达地区的时候,终端设备很难使用新的特性。

那么该如何做呢?

这里其实要分成2步,首先第一个问题是如何解决之前的模块化和ES6标准模块化的统一?最后才是如何兼容浏览器?

如何解决之前的模块化和ES6标准模块化的统一?

这里webpack利用babel对代码进行转换,babel会将ES6模块转化为CommonJS模块,并且对代码实施降级处理,如将ES6代码转换成ES5可以运行的代码。

babel会将export default转换成exports.default

var name = "module";

export default name;

/// 转换
exports.default = name;

如果是export也是作为exports属性:

var age = 18;

export { age }

/// 转换
exports.age = age;

然后import的引入也会被转换:

import moduleName from "xxx.js";
import { age } from "xxx.js";

/// 转换
var moduleName = require("xxx.js").default;
var age = require("xxx.js").age;

当然实际上转换的代码不单单是这么简单,babel还会有一些降级和安全处理,比如exports对象在赋值真值之前都会先声明一个同名属性,并且赋值void 0用于初始化属性。

通过这种转换方式,保证了旧的模块标准和新模块标准之间的兼容性问题,当然这种做法也存在着因为旧模块量大,没法全部采用ES6模块化标准的原因。

如何兼容浏览器

转换完后,webpack会将CommonJS模块转换成它自己实现的一套模块处理方案。这种方案更像是amd的变种,它会将一些模块进行合并,此时模块的代码通过一个工厂函数包裹,模块自身的代码被转成字符串值并作为实参传入eval函数中,最后这个工厂函数作为一个值存在,而值对应的key则是模块的path路径。

(()=>{
  var __webpack_modules__ = ({
    "./src/a.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__)=> {
      eval("具体的模块代码...")
    }),
    "./src/b.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__)=> {
      eval("具体的模块代码...")
    }),
    "./src/main.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__)=> {
      eval("具体的模块代码...")
    }),
  });

  var __webpack_module_cache__ = {};

  function __webpack_require__(moduleId) {
    var cachedModule = __webpack_module_cache__[moduleId];
    
    if (cachedModule !== undefined) {
      return cachedModule.exports;
    }

    var module = __webpack_module_cache__[moduleId] = {
      exports: {}
    };

    __webpack_modules__[moduleId](module, module.exports, __webpack_require__);

    return module.exports;
  }

  //将modules挂载到全局方法上,方便后续使用
  __webpack_require__.m = __webpack_modules__;


   // 入口
   var __webpack_exports__ = __webpack_require__("./src/main.js");
  
  })();

然后webpack还会去实现__webpack_require__方法用来加载模块,__webpack_module_cache__用于缓存工厂函数运行后导出的模块,

这块除了__webpack_modules__ 的处理和amd略有不同,其他都是差不多的,如果你是动态加载的:

const c = import("./c");
c.then((res) => {
  console.log(res);
});

/// 转换
const c = __webpack_require__.e("./c.js").then(__webpack_require__.bind(__webpack_require__,"./src/c.js"));
c.then((res) => {  console.log(res); });

首先c模块会被单独拆分成一个js文件,在调用import("./c")地方将import转换成__webpack_require__.e("./c.js")的方式,然后e方法里面会调用__webpack_require__.f上挂载的j方法来加载js文件,j方法里面会创建模块加载完成的回调之类的处理函数,然后触发__webpack_require__.l来创建script标签,开始正式加载。

当脚本和脚本的依赖都加载完毕后,e方法的Promise.all返回的promise实例状态发生变化,此时第一个then触发。

当脚本和依赖全部加载完毕后,会先触发__webpack_require__.bind,bind会改变this指向和传参,此时./src/c.js作为moduleId参数传递给__webpack_require__

然后触发模块的同步加载,因为此时src_c_js已经加载完成了,通过某种方式它会将模块中的:

{
  "./src/c.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
    eval("模块的内容...");
   })
}

这个对象挂载到__webpack_modules__上,这样在通过__webpack_require__.bind(__webpack_require__,"./src/c.js")就能同步拿到模块的内容然后return返回。

最后在c.then中正确拿到模块内容。

至于src_c_js是怎么挂载到__webpack_modules__上的,首先先看模块转义后成了什么?

(self["webpackChunk_"] = self["webpackChunk_"] || []).push([["src_c_js"], {
 "./src/c.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
    eval("模块的内容...");
  })
}]);

self["webpackChunk_"]/数组push了一个数据,但是这个数组在被webpack全局创建后,改写了它的push方法:

var chunkLoadingGlobal = self["webpackChunk_"] = self["webpackChunk_"] || [];
chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0));
chunkLoadingGlobal.push = webpackJsonpCallback.bind(null,chunkLoadingGlobal.push.bind(chunkLoadingGlobal));

它将push变成webpackJsonpCallback,在这个方法中内部会将模块对象通过:

__webpack_require__.m[moduleId] = moreModules[moduleId];

这种方式挂载,而__webpack_require__.m就是__webpack_modules__对象。

至此一个相对简化的流程概述完毕。

基于以上实现方式,我们对于浏览器的模块兼容就已经处理完毕了!

展望

前端三剑客中的JavaScript ES6模块化即便至2023年,其实还是没有得到很好的支持,就算是支持了,首先就会因为模块后js文件开始变得碎片化,一个站点也许需要加载几十个模块,这对现存的http协议来说,带来的体验是极差的,也许只有当HTTP 2.0被广泛支持的时候原生模块化才能大显神通,现在的天下还是将模块合并成单文件的天下,并且短时间内无法撼动。

那么html和css能否也能实现模块化呢?

事实上已有有很多人在探寻这种可能性,比如css的模块化,我们开发中scoped特性,但是原生的支持目前还是没看到,期待以后能有吧!

至此对于JavaScript模块化的介绍已经结束,希望你有所收获!

分类: JavaScript 标签: cmd模块javascript命名空间webpackIIFELAB.jsYUICommonJSAMDUMDbrowserifywatchifyES6模块

评论

全部评论 2

  1. wu先生
    wu先生
    Google Chrome Windows 10
    不明觉历,但还是评论一发。[呲牙]
    1. 木灵鱼儿
      木灵鱼儿
      FireFox Windows 10
      @wu先生其实还没写完,本来还想把webpack和babel对于es6模块的处理原理说一些的,得空再弄[doge]

目录