JS 模块化历史演变

142 阅读7分钟

JS 从设计之初就没有考虑到模块化。模块化是将一个大型项目拆分成一个个独立的小模块,这样做便于项目的代码结构组织,每个模块只专注于自己的模块内容,同时也能够按需加载模块内容。

在 ESM 出现之前社区有很多模块化方案,这里将会介绍几种主流方案,了解它们的原理、解决什么样的问题以及有什么的缺点。

原始模式

// moduleA.js
var name = 'module'
var sayHello = function() {
  console.log('say Hello: ' + this.name);
}

// moduleB.js
var sayHello = function() {
  console.log('say Hello: ' + this.name);
}

// main.js
sayHello();
<!-- index.html  -->
<!-- ...省略其他 -->
<body>

  <script src="moduleA.js"></script>
  <script src="moduleB.js"></script>
  <script src="main.js"></script>
</body>
</html>

最初的模块化代码,sayHello 输出值受 <script> 标签加载顺序的影响,两个模块之间存在冲突覆盖的问题。

命名空间

为了解决两个模块之间的冲突问题,引入了"命名空间"的方法:

// moduleA.js
var moduleA = {
  name: 'moduleA'
};
moduleA.sayHello = function() {
  console.log('say Hello: ' + this.name);
}

// moduleB.js
var moduleB = {
  name: 'moduleB'
};
moduleB.sayHello = function() {
  console.log('say Hello: ' + this.name);
}

// main.js
moduleA.sayHello();

命名空间解决了变量名冲突覆盖的问题。但是外部可以很轻易的修改模块内的内容:

// moduleA.js
var moduleA = {
  name: 'moduleA'
};
moduleA.sayHello = function() {
  console.log('say Hello: ' + this.name);
}

// main.js
moduleA.name = 'main';
moduleA.sayHello();

IIFE

为了解决代码隔离问题,可以使用闭包来封装数据,外部用户无法修改模块内的数据( *这种方式只能封装数据,外部依然可以很轻易的修改函数内容):

// moduleA.js
var moduleA = (function() {
  var name = 'moduleA';

  return {
    sayHello: function() {
      console.log('say Hello: ' + name);
    }
  }
})();

// main.js
moduleA.name = 'main';
moduleA.sayHello();

假设 moduleA 依赖于 moduleB,则需要将 moduleB 传递给立即执行函数,那么 moduleB 必须在 moduleA 前面加载。

// moduleB.js
var moduleB = (function() {
  var name = 'moduleB';

  return {
    sayHello: function() {
      console.log('say Hello: ' + name);
    }
  }
})();

// moduleA.js
var moduleA = (function(modB) {
  var name = 'moduleA';

  return {
    sayHello: function() {
      modB.sayHello();
      console.log('say Hello: ' + name);
    }
  }
})(moduleB);

// main.js
moduleA.name = 'main';
moduleA.sayHello();
<!-- index.html  -->
<!-- ...省略其他 -->
<body>

  <!-- moduleB 必须在 moduleA 前面 -->
  <script src="moduleB.js"></script>
  <script src="moduleA.js"></script>
  <script src="main.js"></script>
</body>
</html>

CommonJS

随着 Node 的出现,JS 服务端模块化标准 CommonJS 来了。

// moduleA.js
var name = 'moduleA';
var sayHello = function() {
	console.log('say Hello:' + name );
}
module.exports.name = name;
module.exports.sayHello = sayHello;

// main.js
var moduleA = require('moduleA.js');
moduleA.sayHello();

加载原理

Node 提供了一个 Module 类,每个文件就是一个模块,其中 exports 属性就是对外开放的内容:

// class Module
function Module(id, parent){
    this.id = id;
    this.exports = {};
    this.parent = parent;
    this.filename = null;
    this.loaded = false;
    this.children = []
}

Node 提供的 require 的基本功能就是读取文件并执行,最终返回 module.exports。

模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。

// 在执行模块代码之前,Node.js 将使用如下所示的函数封装器对其进行封装:
(function(exports, require, module, __filename, __dirname) {
	// 模块代码实际存在于此处
});

特性

  • CommonJS 是同步加载
    • 同步加载就导致在浏览器端无法使用,会阻塞 JS 运行。而服务端由于所有资源都在一起,所以没有这个问题。
  • CommonJS 是运行时加载
    • CommonJS 加载的是个对象(module.exports),对象只有在脚本运行时才能生成。
  • CommonJS 模块输出的是值的拷贝
    • 一旦输出一个值,模块内部的变化就影响不到这个值
// moduleA.js
var counter = 3;
function incCounter() {
  counter++;
}
module.exports = {
  counter: counter,
  incCounter: incCounter,
};

// main.js
var moduleA = require('moduleA.js');
moduleA.counter; // 3
moduleA.incCounter();
moduleA.counter; // 3

AMD

既然服务端有了 CommonJS 规范,自然会想在浏览器端设计出一套规范。

AMD(Async Module Definition)是浏览器端模块化开发规范的一种,Require.js 库就是 AMD 的一种具体实现,下面用 Require.js 举例。

<!-- index.html  -->
<!-- ...省略其他 -->
<body>

        <!--   data-main属性的作用是,指定网页程序的主模块。 -->
      <script src="js/require.js" data-main="js/main"></script>
</body>
</html>
// main.js
require(['moduleA', 'moduleB', 'moduleC'], function (moduleA, moduleB, moduleC){
  // some code here
});

// moduleA.js
define(['moduleB'], function(modB) {
  var name = 'moduleA';
  var sayHello = function() {
    console.log('say Hello:' + name);
  }

  return {
    sayHello
  }
})

加载原理

  • require.js 定义了两个全局函数 requiredefine
    • require 会根据入口文件的依赖关系,手动插入 script 标签来加载对应模块
    • 模块加载完后,会根据模块内 define 的依赖关系继续加载其余模块
  • 层层递进,逐级执行
    • 假设 moduleA 依赖 moduleB,moduleB 依赖 moduleC。那么当这三个模块都加载完成后,会先执行 moduleC 的内容,然后是 moduleB 最后是 moduleA(* 这个机制有点类似于事件的捕获/冒泡)。

特性

  • 先执行依赖模块,拿到返回内容后,在父模块中使用(在示例中就是会执行 moduleB 拿到返回结果后,在 moduleA 中使用)
    • 缺陷:这样的执行时机也被人诟病,如果 moduleA 中依赖了 moduleB 但是并没有使用 moduleB,moduleB 一样会被执行。
  • 缺陷:全局声明了 requiredefine 这样的关键词。

CMD

基于 AMD 的缺陷,于是有了 CMD(Common Module Definition),而 Sea.js 是这个规范的具体实现。

与 AMD 相比,CMD 在 API 设计上与 AMD 不太一样,但是最主要的还是它们的执行时机不同:

假设 moduleA 依赖 moduleB,moduleB 依赖 moduleC。那么会先执行 moduleA,遇到 moduleB 调用时,再去执行 moduleB。

UMD

UMD 是为了让模块能够同时在浏览器和服务器上运行,即兼容 AMD 和 CommonJS 而设计出来的一种模块格式写法,了解即可。

(function (root, factory) {
    if (typeof define === 'function' && define.amd) {
        //AMD
        define(['jquery'], factory);
    } else if (typeof exports === 'object') {
        //Node, CommonJS之类的
        module.exports = factory(require('jquery'));
    } else {
        //浏览器全局变量(root 即 window)
        root.returnExports = factory(root.jQuery);
    }
}(this, function ($) {
    //方法
    function myFunc(){};
    //暴露公共方法
    return myFunc;
}));

ESM

ESM(ECMA Script Module)是 ES6 语言标准层面的模块加载方案,同时兼容浏览器和服务端,是目前 JS 模块化的标准化方案。

// moduleA.js
var name = 'moduleA';
var sayHello = function() {
  console.log('say Hello:' + name);
}

export { sayHello };

// moduleB.js
var name = 'moduleB';
var sayHello = function() {
  console.log('say Hello:' + name);
}

export { sayHello };

// main.js
import { sayHello as sayHelloA} from 'moduleA.js';
import { sayHello as sayHelloB} from 'moduleB.js';

sayHelloA(); // say Hello: moduleA
sayHelloB(); // say Hello: moduleB
<!-- index.html  -->
<!-- ...省略其他 -->
<body>

  <!--   data-main属性的作用是,指定网页程序的主模块。 -->
  <script src="main.js" type="module"></script>
</body>
</html>

加载原理

使用 import 和 export 两个关键字进行模块的导入和导出,引擎解析模块主要分为三个阶段:

  • 构建阶段
    • 这个阶段会从入口文件(main.js)开始,根据 import 关键字查找、下载所有文件,并解析成模块记录(module record)
  • 实例化阶段
    • 为所有模块分配内存空间(此时还没有内容),然后依据 import、export 语句将模块指向对应地址,这个过程称为 "链接(Link)"。
    • 实例化模块关系图,这个过程使用 "深度优先后续遍历" 的方式 它会顺着关系图到达最底端没有任何依赖的模块,然后回到上一层把模块的导入链接起来。
  • 运行阶段
    • 运行模块代码,并将结果填充进分配到的内存空间。
    • 这个过程使用 "深度优先后续遍历" 的方式。

特性

  • ESM 输出的是值的引用
    • 与 CommonJS 输出值的拷贝不用,JS 引擎对脚本静态分析的时候,遇到模块加载命令 import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。
  • ESM 是编译时输出接口
    • ESM 模块对象,它对外输出内容是静态定义的,在代码静态解析时就会生成。
  • ESM 异步加载
    • ESM 异步加载,有独立的依赖分析阶段。

循环依赖

循环依赖是指两个模块之间有相互引用的关系,例如 a 模块引入了 b 模块,b 模块也引入了 a 模块。这在大型复杂项目是很常见的,那么这个时候要如何加载模块呢?

这里只介绍 ESM 的方式:

ES6 处理“循环加载”与 CommonJS 有本质的不同。ES6 模块是动态引用,如果使用import从一个模块加载变量(即import foo from 'foo'),那些变量不会被缓存,而是成为一个指向被加载模块的引用,需要开发者自己保证,真正取值的时候能够取到值。

在 a.mjs 的第1行,发现引用了 b 模块的内容,于是运行 b.mjs。由于 a.mjs 已经运行一次了,所以不会重新运行,于是在 b.mjs 的第四行会报错,因为此时 a 模块的内存空间还没有 foo 的值。

// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar);
export let foo = 'foo';

// b.mjs
import {foo} from './a';
console.log('b.mjs');
console.log(foo); // ReferenceError: foo is not defined
export let bar = 'bar';

解决这个问题的方法,就是让b.mjs运行的时候,foo已经有定义了。这可以通过将foo写成函数来解决。

这是因为函数具有提升作用,在执行import {bar} from './b'时,函数foo就已经有定义了,所以b.mjs加载的时候不会报错。

// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar());
function foo() { return 'foo' }
export {foo};

// b.mjs
import {foo} from './a';
console.log('b.mjs');
console.log(foo());
function bar() { return 'bar' }
export {bar};

这里就有疑问了,为什么使用 function 变量提升后就会在 import 之前就定义了呢?那在 import 之前定义内容行不行?答案是不行。

// a.mjs
// 在 import 之前定义行不行?
var foo = 1;
import {bar} from './b';
console.log('a.mjs');
console.log(bar());
export {foo};

// b.mjs
import {foo} from './a';
console.log('b.mjs');
console.log(foo); // undefined
function bar() { return 'bar' }
export {bar};

即使在 import 前面定义变量,依然不行。这和引擎的执行机制有关:

// modualA.js
console.log('in modualA');
const a = 1;
export { a };

// main.js
console.log('in main');
import { a } from 'modualA';
console.log(a);

// 执行 main.js
// in modualA
// in main
// 1

即使打印在 import 前面,引擎仍然会去先执行被引用模块内容,再执行当前内容。