对于 JavaScript 来说,模块化是一个相对现代的概念,这篇文章会带你在 JavaScript 的世界里快速浏览模块化的历史进程~
在早些年间,JavaScript 就是直接写在 HTML 的 <script>
标签里的,最多也就是放在独立的文件里面,而它们也都共享一个全局作用域。
任何 JS 文件里面声明的变量都会被附加在全局的 window
对象上,并且还有可能意外覆盖掉第三方库中的变量。
随着 web 应用越来越复杂,共享全局作用域这种方式的弊端开始显现,于是 IIFE(立即调用函数表达式)就被发明了出来,并且广为使用。IIFE 就是将一整段代码包裹在一个函数中,然后立即执行这个函数。在 JavaScript 中,每个函数都有一个作用域,所以在函数中声明的变量就只在这个函数中可见。即使有变量提升,变量也不会污染到全局作用域中。
下面让我们看几个 IIFE 的写法,每个 IIFE 的作用域都是独立的,其中第一种写法比较常见:
(function() {
console.log('IIFE using parenthesis')
})()
~function() {
console.log('IIFE using a bitwise operator')
}()
void function() {
console.log('IIFE using the void operator')
}()
使用 IIFE 这种方式,某个库如果想要暴露全局变量,可以在 window
上绑定一个对象作为命名空间,这样就避免了污染全局作用域。看下面的代码,假如我们要建立一个 mathlib
工具,它有一个 sum
方法。假如这个工具有多个模块,也可以建立多个文件,每个文件里都是一个 IIFE,然后向 window.mathlib
对象中添加方法就可以了:
(function() {
window.mathlib = window.mathlib || {}
window.mathlib.sum = sum
function sum(...values) {
return values.reduce((a, b) => a + b, 0)
}
})()
mathlib.sum(1, 2, 3)
// <- 6
IIFE 这种方式可以说是模块化的先河,它让开发者可以将模块放在单独的文件中,并且不污染全局作用域。
当然 IIFE 也有缺点,它并没有一个明确的依赖树,这使得开发者只能自己确保 JS 文件的加载顺序。
RequireJS 和 AngularJS 的出现,让我们知道了依赖注入是什么,即需要用哪个模块,就注入哪个模块。
下面的例子我们先用 RequireJS 的 define
方法定义一个没有依赖的 mathlib/sum.js
模块:
define(function() {
return sum
function sum(...values) {
return values.reduce((a, b) => a + b, 0)
}
})
然后我们可以创建一个入口模块 mathlib.js
用来集合所有子模块。我们的例子中只有 mathlib/sum
一个子模块,但是你可以在 mathlib
文件夹中随意扩展。下面我们声明 mathlib
模块的依赖,并将依赖作为形参按顺序传入工厂方法,并返回 mathlib
模块对象:
define(['mathlib/sum'], function(sum) {
return { sum }
})
好了我们已经定义了一个 mathlib
库,下面就可以用 require
引入并使用它:
require(['mathlib'], function(mathlib) {
mathlib.sum(1, 2, 3)
// <- 6
})
RequireJS 在内部维护了一个依赖树,让开发者不用关心依赖之间的顺序,只需要在需要的地方声明要加载的模块即可使用。
这种明确地声明依赖的写法让各个模块间的依赖都非常清晰,并且反过来促进了模块化的发展。
但是 RequireJS 并不是没有缺点。它的整个模式专注于解决异步加载模块,却忽略了在生产环境下,异步加载多个模块造成的网络请求过多等性能影响。如果依赖过多,开发者也将面临一个很长的依赖数组和回调里面的形参列表。同时它的 API 也不够直观,就拿声明一个含有依赖的模块来说,就有很多种不同的写法。
AngularJS 的依赖注入系统也面临同样的问题。有一个方法可以根据形参名字来解析模块,让开发者不用再写那个依赖数组,但是却对代码压缩工具不友好,因为压缩后变量名就变短了,也就找不到相应的依赖。
直到 AngularJS v1 之后,可以通过一种构建任务,将以下代码:
module.factory('calculator', function(mathlib) {
// …
})
转换成可压缩的带依赖数组的代码:
module.factory('calculator', ['mathlib', function(mathlib) {
// …
}])
然鹅不得不提的是,用工程师思维添加了这么一个构建步骤,解决了这个本不应该出现的问题,但是这本身性价比实在是不高,于是大部分开发者还是选择自己手写所有的依赖数组(我当年就是这样,哈哈)。
CommonJS 模块系统是 Node.js 中众多革新的一个,也叫 CJS。得力于 Node.js 可以直接访问文件系统,CommonJS 规范更贴近的是传统的模块加载方式。在 CommonJS 中,每个文件都是一个模块,并具有自己独立的作用域。依赖的加载使用一个同步的 require
函数,这个函数可以在模块的任意地方调用:
const mathlib = require('./mathlib')
与 RequireJS 和 AngularJS 相似的是, CommonJS 依赖也是与文件路径相关联。但是与它们最大的区别,就是 CommonJS 完全抛弃了包装函数和依赖数组,并且require
函数可以像 JS 表达式一样,在模块的任何地方使用。
在 RequireJS 和 AngularJS 中,你可能有很多动态定义的模块,然而 CommonJS 中的文件和模块是一一对应的。与此同时,RequireJS 众多的模块定义方式,与 AngularJS 中的 factory、service、provider 都让人头大。与之相反的是,CommonJS 只有一种模块加载方式,一个 JS 文件就是一个模块,加载依赖只需要用 require
,导出模块只需要将要导出的值赋给 module.exports
。这些优点都让 CommonJS 模块系统更简洁和易于使用。
终于,Browserify 作为桥梁,打通了 CommonJS 在 Node.js 和浏览器端的鸿沟。它可以将众多模块打包成一个可在浏览器中运行的文件。而 npm 源的出现,作为 CommonJS 的杀手级功能,基本上确立了模块加载生态中的事实标准。
诚然,npm 主要服务于 CommonJS 模块和 JavaScript 包,由于简单的模块化语法和可复用性,大量 Node.js 和 web 浏览器的包出现在 npm 上,npm 也成为世界上最大的包管理器。
ES6 是在 2015 年被标准化,在此之前 Babel 一直承担着将 ES6 转换为 ES5 的角色,一场新的革命正在袭来。ES6 规范中包含了一个原生的模块化系统,一般称之为 ECMAScript Modules(ESM)。
ESM 受到 CommonJS 和先烈们的影响,提供了一个静态的声明式的 API 和一个基于 Promise 的动态加载的 API:
import mathlib from './mathlib'
import('./mathlib').then(mathlib => {
// …
})
在 ESM 中,每个文件同样是一个模块,并且具有自己独立的作用域和执行环境。ESM 相对 CJS 来说有一个重要的优点:即 ESM 是静态加载依赖的。静态加载极大地提高了模块系统的自我检查能力,使得模块系统可以基于抽象语法树(AST)作静态分析,同时 ESM 限制了加载语句必须置于模块顶部,也大大简化了语法解析和语法检查。
在 Node.js v8.5.0 中,ESM 已经可以通过一个 flag 开启。大部分主流的浏览器也都可以支持 ESM。
Webpack 作为 Browserify 的继任者,由于功能强大,基本上坐稳了通用模块打包器老大的位置。像 Babel 支持转换 ES6 那样,Webpack 很早就支持了 ESM 的 import
和 export
语法以及 import()
动态加载函数。并且在 ESM 的基础上,添加了 code-splitting
功能,可以将应用程序代码分割成多个文件来提升首屏加载体验。
鉴于 ESM 是原生的模块加载规范,它一统江湖也指日可待了!