Skip to content

JavaScript 模块化

JavaScript 语言一开始并没有模块化的概念,js 文件直接通过 script 标签引入到页面中。但是学过 Java、Python 等服务端语言都能发现其中问题:

  • 无法实现封装,引入 js 文件会暴露整个文件内的数据
  • 依赖管理混乱,必须按依赖次序引入 js 文件否则报错,一旦文件大了非常逆天
  • 全局变量污染,全挂在 Window 对象上了说不定就重名被覆盖了

CommonJS

概览

CommonJS 最初是给 2009 年出现的服务端环境 Node.js 引入模块化的概念,名为 ServerJS,显然一开始是为了给服务端管理文件模块使用的。

CommonJS 规范如下所示,在 Node.js 环境下外层的 define 是默认自动添加的,不需要自己写。

js
define(function (require, exports, module) {
    //使用event 模块
    var ec = require("event");
});

CommonJS 模块导入是通过文件系统同步完成的,这在服务端是常见且合理的。

规范语法

导入导出语法中,exports是一个对象。

js
// 使用 module.exports 导出
const a = 1, b = 2
module.exports = {a, b}

// 使用 exports 点语法导出
exports.name = 'lucy'

// 这样也行
module.exports.age = 18

导入使用全局函数 require

js
// 导入整个模块
var myModule = require('./myModule.js');
// 对象可以解构
var { myFunction, myVariable } = require('./myModule.js');

// 使用导入的模块
myModule.myFunction();
console.log(myModule.myVariable);

值得注意的是 CommonJS 规范是同步的,这就是其只运用在服务端的原因:浏览器端的 js 模块是通过网络请求异步获得的,由此出现了异步的模块化标准 ACM 以及后来居上的 ES6 模块化标准。

ES6 Module

概览

在 ES6 中,模块内是自动运行在严格模式(意味着顶级 thisundefined)下并且没有办法退出运行的 JavaScript 代码。

在一个模块中定义的变量不会自动被添加到全局共享的作用域之中,这个变量只能作用在这个作用域中。此外模块还必须导出一些外部文件可以访问的元素,以供其他模块或代码使用。

模块只会被加载一次,并且所有对模块的引用都将指向同一个实例。

规范语法

要导入一个模块,指定 type = "module"

js
<script type="module" src="main.js"></script>

值得注意的是,加载一个模块脚本时不需要使用 defer 属性 (see <script> attributes) 模块会自动延迟加载。(HTML 文档解析完成后,DOMContentLoaded 事件之前 )

下面这种写法效果也是一样的:

html
<script type="module">
  import utils from "./utils.js"
  // other code
</script>

ESM 模块之间的导出语法有两种形式

  • 具名导出,对应导入时必须用同名指示导入内容,可以通过 as 设置别名
  • 默认导出,对应导入时可以使用任意命名
js
// 具名导出
export const a = 1

const name = 'lucy'
export name

// 默认导出
export default function() {...}
export default {name, a}

导入语法中同样可以选择性设置别名

js
// 导入具名导出的变量,必须显式指出变量名
import {a, name as outerName} from './outer.js'
// 导入默认导出的变量,任意名字都可,这里用 obj
import obj from './outer.js'
// 使用
console.log(outerName)    // lucy
console.log(obj.name)    // lucy

区别

一些主要的区别可见下列表格

特性CommonJSESM
加载方式require() 函数import 关键字
导出方式export 对象export 关键字
加载模式同步异步
执行模式单例单例
树形摇晃不支持支持

以下是一些对树形摇晃的简单说明,有时间再写 Tree-shaking 的内容,感觉是编译那边的内容…

Tree Shaking在去除代码冗余的过程中,程序会从入口文件出发扫描所有的模块依赖,以及模块的子依赖,然后将它们链接起来形成一个“抽象语法树”(AST)。

随后,运行所有代码,查看哪些代码是用到过的,做好标记。最后,再将“抽象语法树”中没有用到的代码“摇落”。经历这样一个过程后,就去除了没有用到的代码。

DANGER

🚧 其实就是编译里面的死代码消除(Dead Code Elimination),AST 类似 IR 的控制流图。还是得多看看华保健老师的程序设计语言原理…

Webpack 中,Tree-shaking 的实现一是先「标记」出模块导出值中哪些没有被用过,二是使用 Terser 删掉这些没被用到的导出语句。标记过程大致可划分为三个步骤:

  • Make 阶段,收集模块导出变量并记录到模块依赖关系图ModuleGraph 变量中
  • Seal 阶段,遍历 ModuleGraph 标记模块导出变量有没有被使用
  • 生成产物时,若变量没有被其它模块使用则删除对应的导出语句

模块输出

TIP

😅 CommonJS 模块导出输出的是一个值的复制,ES6 模块导出输出的是值的引用

CommonJS 模块导出输出的是值的复制,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。可以通过暴露一个修改模块内变量值的函数发现这一点:

js
// lib.js
var counter = 3;
function incCounter() {
  counter++;
}
module.exports = {
  counter: counter,
  incCounter: incCounter,
};

// main.js
var mod = require('./lib');
 
console.log(mod.counter);   // 3
mod.incCounter();
console.log(mod.counter);   // 3

ESM 模块导出输出的是引用,同样可以测试:

js
// lib.js
export let counter = 3;
export function incCounter() {
  counter++;
}
 
// main.js
import { counter, incCounter } from './lib';
console.log(counter); // 3
incCounter();
console.log(counter); // 4

加载方式

TIP

😅 CommonJS 模块是运行时加载,ES6模块是编译时输出接口

这是因为 CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行结束时才会生成。

而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。参考以下说明:

模块解析和执行分离:在ES6模块系统中,模块的解析(加载和编译)与执行是分开的。模块在解析阶段被加载,并且在这个阶段,模块内部的代码不会被执行。只有在所有依赖的模块都解析完毕后,模块的代码才会按照依赖关系顺序执行。

ES6 模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令import 就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用到被加载的模块中取值。换句话说,ES6 的 import 有点像 Unix 系统的“符号连接”,原始值变了,import 加载的值也会跟着变。因此,ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。

Released under the GNU General Public License v3.0.