Skip to main content

JavaScript 模块化

一、什么是模块

好的作者把他们的书分为一些章和节,好的程序员把代码分为一些模块。

好的模块,是高度独立的,它可以被随时加入或者移除,而不会损害系统。

使用模块的好处:

  • 可维护性:因为模块是独立的,一个设计良好的模块会让外面的代码对自己的依赖越少越好,这样自己就可以独立去更新和改进。

  • 命名空间:在 JavaScript 中,如果一个变量在最顶级的函数之外声明,它就直接变成全局可用。因此,常常不小心出现命名冲突的情况。使用模块化开发来封装变量,可以避免污染全局环境。

  • 重用代码:我们有时候会从之前写过的项目中拷贝代码到新的项目,这没有问题,但更好的方法是,通过模块引用的方式,来避免重复的代码库。可以在更新了模块之后,让引用了该模块的所有项目都同步更新,还能指定版本号,避免 API 变更带来的麻烦。

二、如何实现模块化

1、原始写法

原始的模块化写法是把不同名字的函数放在一起作为不同的模块。

function m1() {
//...
}

function m2() {
//...
}

但这种写法污染了全局变量,很容易导致命名冲突,而且模块成员之间看不出直接关系。

2、对象写法:Namespace 模式

为了解决上面的缺点,可以把模块写成一个对象,所有的模块成员都放到这个对象里面。

var module1 = new Object({
_count: 0,

m1: function () {
//...
},

m2: function () {
//...
}
})

上面的函数 m1() 和 m2() 都封装在 module1 对象中,使用时就是调用这个对象的属性:

module1.m1();

但种写法会暴露所有模块成员,内部状态可以被外部改写。比如,外部代码可以直接改变内部计数器的值:

module1._count = 5;

3、匿名闭包:IIFE 模式

使用立即执行函数(Immediately-Invoked Function Expression,IIFE),可以达到不暴露私有成员的目的。

var module1 = (function () {
var _count = 0

var m1 = function () {
//...
}

var m2 = function () {
//...
}

return {
m1: m1,
m2: m2
}
})()

使用上面的写法,外部代码无法读取内部的 _count 变量。

console.info(module1._count); // undefined

module1 就是 Javascript 模块的基本写法。

4、优化 IIFE:注入依赖

独立性是模块的重要特点,模块内部最好不与程序的其他部分直接交互。

为了在模块内部调用全局变量,必须显式地将其他变量输入模块。

var module1 = (function ($) {
//...
})(jQuery)

上面的 module1 模块需要使用 jQuery 库,就把这个库当作参数输入 module1。

这样做除了保证模块的独立性,还使得模块之间的依赖关系变得明显,而且性能更好,这就是模块模式,也是现代模块实现的基石。

5、放大模式

如果一个模块很大,必须分成几个部分,或者一个模块需要继承另一个模块,就需要采用"放大模式"(augmentation)

var module1 = (function (mod) {
mod.m3 = function () {
//...
}

return mod
})(module1)

上面的代码为 module1 模块添加了一个新方法 m3(),然后返回新的 module1 模块。

6、宽放大模式

在浏览器环境中,模块的各个部分通常都是从网上获取的,有时无法知道哪个部分会先加载。

如果采用放大模式,第一个执行的部分有可能加载一个不存在空对象,这时就要采用"宽放大模式"(Loose augmentation)

var module1 = (function (mod) {
//...

return mod
})(window.module1 || {})

与"放大模式"相比,"宽放大模式"就是"立即执行函数"的参数可以是空对象。

三、模块规范

有了模块,就可以更方便地使用他人的代码,想要什么功能,就加载什么模块。

但有一个前提,就是大家必须以同样的方式编写模块,目前,常见的 Javascript 模块规范共有两种:CommonJS 和 AMD。

1、CommonJS

2009 年,Ryan Dahl 创造了 node.js 项目,将 Javascript 语言用于服务器端编程。

在 CommonJS 的规范中,每个 JavaScript 文件就是一个独立的模块上下文(module context),在这个上下文中默认创建的属性都是私有的,对其他文件不可见。

node.js 的模块系统,就是参照 CommonJS 规范实现的。在 CommonJS 中,有一个全局性方法 require(),用于加载模块。举个例子:

创建 math.js 模块:

function math() {
// ...
}

module.exports = math

用 require() 加载 math.js 模块:

var math = require('math');

然后,就可以调用模块提供的方法:

var math = require('math')

math.add(2, 3) // 5

这种做法有两个明显的优势:

  • 避免全局命名空间污染,require 进来的模块可以被赋值到自己随意定义的局部变量中,所以即使是同一个模块的不同版本也可以完美兼容;
  • 让各个模块的依赖关系变得很清晰。

然而,由于一个重大的局限,使得 CommonJS 规范不适用于浏览器环境。在上面的代码中,第二行 math.add(2, 3) 在第一行 require('math') 之后运行,因此必须等 math.js 加载完成。如果加载时间很长,整个应用就会停在那里等。

这对服务器端不是一个问题,因为所有的模块都存放在本地硬盘,可以同步加载完成,等待时间就是硬盘的读取时间。但对于浏览器,因为模块都放在服务器端,等待时间取决于网速的快慢,可能要等很长时间,使得浏览器处于"假死"状态。

因此,浏览器端的模块,不能采用"同步加载"(synchronous),只能采用"异步加载"(asynchronous)。这就是 AMD 规范诞生的背景。

2、AMD

AMD(Asynchronous Module Definition),即异步模块定义。它采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等加载完成之后,这个回调函数才会运行。

AMD 也采用 require() 语句加载模块,但是不同于 CommonJS,它要求两个参数:

require([module], callback)
  • [module]:一个数组,里面的成员就是要加载的模块;
  • callback:加载成功之后的回调函数。

如果将前面的代码改写成 AMD 形式,就是下面这样:

// CommonJS
var math = require('math')
math.add(2, 3)

// AMD
require(['math'], function (math) {
math.add(2, 3)
})

math.add()math 模块加载不是同步的,浏览器不会发生假死。所以很显然,AMD 比较适合浏览器环境。

目前,主要有两个 Javascript 库实现了 AMD 规范:require.jscurl.js

3、CMD

CMD(Common Module Definition)是阿里玉伯在开发 SeaJS 的时候提出的,与 CommonJS 和 Node.js 的 Modules 规范保持了很大的兼容性。

MD 与 AMD 挺相近,二者区别如下:

  • 对于依赖的模块 AMD 是提前执行(不过 RequireJS 从 2.0 开始也改成可以延迟执行),而 CMD 是延迟执行;
  • AMD 推崇依赖前置,而 CMD 推崇依赖就近(as lazy as possible)
  • AMD 的 api 默认是一个当多个用,CMD 严格的区分推崇职责单一,其每个 API 都简单纯粹。例如:AMD 里 require 分全局的和局部的。CMD 里面没有全局的 require,提供 seajs.use() 来实现模块系统的加载启动。
define(function (require, exports, module) {
// TODO...
// 一:使用 exports 暴露模块接口
define(function (require, exports) {
// 对外提供 name 属性
exports.name = 'hangge'
// 对外提供 hello 方法
exports.hello = function () {
console.log('Hello hangge.com')
}
})

// 二:使用 modul.exports 暴露模块对象
define(function (require, exports, module) {
// 对外提供接口
module.exports = {
name: 'hangge',
hello: function () {
console.log('Hello hangge.com')
}
}
})
})

Sea.js 与 RequireJS 的区别

  • 定位有差异RequireJS 想成为浏览器端的模块加载器,同时也想成为 Rhino / Node 等环境的模块加载器。而 Sea.js 则专注于 Web 浏览器端,同时通过 Node 扩展的方式可以很方便跑在 Node 环境中。

  • 遵循的规范不同RequireJS 遵循 AMD(异步模块定义)规范,Sea.js 遵循 CMD (通用模块定义)规范。规范的不同,导致了两者 API 不同。Sea.js 更贴近 CommonJS Modules/1.1 和 Node Modules 规范。

  • 推广理念有差异RequireJS 在尝试让第三方类库修改自身来支持 RequireJS,目前只有少数社区采纳。Sea.js 不强推,采用自主封装的方式来“海纳百川”,目前已有较成熟的封装策略。

  • 对开发调试的支持有差异Sea.js 非常关注代码的开发调试,有 nocache、debug 等用于调试的插件。RequireJS 无这方面的明显支持。

  • 插件机制不同RequireJS 采取的是在源码中预留接口的形式,插件类型比较单一。Sea.js 采取的是通用事件机制,插件类型更丰富。

4、UMD

对于需要同时支持 AMD 和 CommonJS 的模块而言,可以使用 UMD(Universal Module Definition)

在执行 UMD 规范时,会优先判断当前环境是否支持 AMD 环境,然后再检验是否支持 CommonJS 环境,否则认为当前环境为浏览器环境。

UMD 的缺点:

  • 代码量:兼容需要额外的代码,而且是每个文件都要写这么一大段代码;
  • 代码合并:requireJS 合不了 UMD 的代码。

5、ES6 Modules

2015 年 6 月,ES2015(即 ECMAScript 6)正式发布,提出了 ES6 的模块规范,即一个模块就是一个独立的文件,该文件内部的所有变量,外部无法获取。

ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。

点击查看 ES6 模块规范详情

四、总结

  • 早期阶段:
    • 直接定义依赖(1999年):通过全局方法定义、引用模块,与现在的 CommonJS 神似,但不与文件关联。
    • 闭包模块化模式(2003年):用闭包解决变量污染问题,只需对外暴露一个全局变量。
    • 模版依赖定义(2006年):通过后端模版语法聚合 JS 文件,实现依赖加载,但可维护性差。
    • 注释依赖定义(2006年):以文件为单位定义模块,通过 lazyjs 加载文件,读取注释递归加载剩余文件。
    • 外部依赖定义(2007年):将依赖抽出单独文件定义,不利于项目管理。
  • 中期阶段:
    • Sandbox模式(2009年):将所有模块塞到一个 sandbox 变量中,存在命名冲突问题。
    • 依赖注入(2009年):angular1.0 采用,现已广泛运用在 react、vue 等框架中。
    • CommonJS(2009年):真正解决模块化问题,从 node 端逐渐发力到前端,前端需使用构建工具模拟。
    • Amd(2009年):解决前端动态加载依赖,相比 CommonJS 体积更小,按需加载。
  • 后期阶段:
    • Umd(2011年):兼容 CommonJS 与 Amd。
    • Labeled Modules(2012年):与 CommonJS 很像,但生不逢时。
    • YModules(2013年):使用 provide 取代 return,有拓展性但难以管理。
  • 现代阶段:
    • ES2015 Modules(2015年):现代模块化方案,大部分项目已通过 babel 或 typescript 提前体验。

从 JS 模块化的发展,可以看到 HTML、CSS 模块化方面的落后,原生支持的模块化,解决 HTML、CSS 模块化正是以后的方向。

对于 HTML 模块化,Chrome 小组正在调研 HTML Modules,如果 HTML 得到了浏览器、编辑器的模块化支持,未来可能会取代 JSX 成为最强大的模块化、模板语言。

对于 CSS 模块化,目前不依赖预编译的方式是 styled-component,通过 JS 动态创建 class。

对于 JS 模块化,最近出现的 <script type="module"> 方式虽然还没有得到浏览器原生支持,但也是比较好的未来趋势,这样就连 webpack 的拆包都不需要了,直接把源代码传到服务器,配合 http2.0 完美抛开预编译的枷锁。