模块化简史


JavaScript 中,模块化是一个现代的概念。本文将简略总结模块化在 JavaScript 世界中的演变路径。总之本文不是一个完整的列表,而是简要说明几个范式变化的里程碑。

1. Script标签和作用域

早期的JavaScript都是内联在HTML中的<script>标签中。好一些的会把内容放到单独的js文件中,总之所有的内容都全部在全局作用域 global scope

在任意一个文件中声明的变量都是在全局的 window 对象上,这样就会暴露到完全不想干的脚本里,可能会导致冲突甚至导致错误。可能脚本会无意中修改了另外一个脚本需要用到的变量。

随着Web应用程序的规模变大,也变得更复杂。作用域 scope的概念,和全局作用域 global scope 的危险逐渐被更多开发者理解。立即执行函数表达式(或IIFE)Immediately-invoking function expressions 被发明并很快成为当时的主流。这种方法把整个文件,或者文件中的一部分代码包含到一个立即执行的函数中。JavaScript中的每个函数都有自己的作用域,这样在函数内定义的变量不会隐式的变成全局变量,这不仅避免了外界访问此作用域的变量,又不会污染全局作用域。

以下是立即执行函数表达式的几个版本,每段代码的作用域都是独立的,要想传变量到全局作用域只能通过 wiindow.frrmeIIFE = true 这种写法:

(function() {
 console.log('IIFE using parenthesis')
})()

~function() {
 console.log('IIFE using a bitwise operator')
}()

void function() {
 console.log('IIFE using the void operator')
}()

使用立即执行函数表达式模式,代码库通过暴露一个唯一的组件并绑定到全局 window 对象上。这样就避免了全局命名空间污染。下面的代码片段展示了用这种模式创建的 mathlib 组件,它包含了一个sum方法。如果想为 mathlib 添加更多模块,可以把每个模块放到一个单独的立即执行函数表达式中,将新方法添加到 mathlib 公共接口,而模块中定义的其他变量都是私有的。

void 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

这种模式让开发者第一次可以安全的把每个立即执行函数表达式放到一个单独的文件中,让各种 JavaScript 开发工具激增,减轻了网络压力。

这种开发模式的缺点是没没有明确的依赖树,开发者必须精确的按顺序制作组件文件列表,在使用前先加载,而且要递归的加载。

2. RequireJS,AngularJS和依赖注入

在模块系统(比如RequireJS)还有AngularJS的依赖注入机制出现之前,基本上不考虑这个问题。

下面的代码演示了用 RequireJS 的 define 函数定义 mathlib/sum.js 代码库。这样就把函数添加到了全局作用域。define 回调的返回值,就能作为我们模块的公共接口。

define(function() {
 return sum

 function sum(...values) {
   return values.reduce((a, b) => a + b, 0)
}
})

我们可能会有个 mathlib.js 模块聚合了所有需要的功能。上面的例子中,只包含了 mathlib/sum, 但我们可以用同样的方式加载所有需要的依赖项。用数组方式传入依赖项,就会按照顺序得到他们的公共接口,作为参数传入回调中。

define(['mathlib/sum'], function(sum) {
 return { sum }
})

定义好一个库以后,就可以用 require 方法使用了。注意下面代码如何解析依赖关系链。

require(['mathlib'], function(mathlib) {
 mathlib.sum(1, 2, 3)
 // <- 6
})

这就是 RequireJS 和依赖关系树的优势。不管程序是否包含了成百上千的模块,RequireJS都会解析依赖关系树,不需要仔细维护列表。因为已经将依赖关系准确地列在里需要的位置,就不需要再维护长长的组件列表,以及他们的关系了。

复杂的依赖关系问题解决了,更大的好处是,在模块module级别,这种显示的依赖声明让组件component与应用程序其他部分的关系变得更明显,反过来促进了更大程度的模块化。

RequireJS 也不是没有问题,他的整个设计模式都围绕着异步加载模块的能力,部署后会在主要代码执行前瀑布式发出数百个网络请求。需要有个工具提前处理好,才适合部署到生产环境。问题又回来了,还是需要长长的依赖列表。

AngularJS 的依赖注入系统也遇到了相同的问题。但是他们用了一个优雅的解决方案,用字符串解析替代依赖数组,解析函数名来解决依赖关系。但这种机制与压缩工具不兼容,函数名编程单个字母后就不能正确解析了。

在 AngularJS v1 的后期,引入了一个构建任务,把下面的代码:

module.factory('calculator', function(mathlib) {
 // …
})

转换成下面的格式,这样就不怕压缩了,里面明确包含了依赖的列表:

module.factory('calculator', ['mathlib', function(mathlib) {
 // …
}])

毋庸置疑,这个鲜为人知的构建工具,让构建过程更长。再加上过度设计破坏了本不应该被修改的内容,得到的收益几乎可以忽略不计。大多数开发者还是选择去维护一份依赖数组。

3. Node.js和CommonJS的出现

Node.js又诸多创新,其中一个就是 CommonJS 模块系统。利用 Node.js 可以访问文件系统的优势,CommonJS标准更符合传统的模块加载机制。在 CommonJS 中,每个文件都是一个有自己作用域和上下文的模块。使用 require 函数同加载依赖项,就可以走模块的生命周期中随时动态调用。如下代码片段所示:

const mathlib = require('./mathlib')

与 RequireJS 和 AngularJS 类似,CommonJS 依赖关系也由路径名引用。主要区别是 boilerplate 函数和依赖列表都不需要了,并且模块的接口可以赋值给变量,也可以用在 JavaScript 表达式中。

与 RequireJS 和 AngularJS 不同的是 CommonJS 相当严格。RequireJS 和 AngularJS 可以有许多动态定义的模块,而 CommonJS 在文件与模块一一对应。同时,RequireJS 有几种声明模块的方法,AngularJS 有几种 factories、services、providers等,而且它的依赖注入机制与 AngularJS 框架本身紧密耦合。相比之下,并且只有一种声明模块的方式。一个 JavaScript 文件就是一个模块,调用 require 会加载依赖项,赋值给 module.exports 的任何东西都是它的接口。这样可以实现更好的工具,因为工具可以更容易理解 CommonJS 组件系统的层次结构。

最终 Browserify 的出现抹平了 Node.js 服务器和浏览器的 CommonJS 模块的差别。使用 browserify 命令行界面,只需要提供入口模块的路径,就能将难以想象的大量模块打包到一个文件中。终极功能 npm package registry,让 CommonJS 接管了模块加载生态系统。

当然 npm 不仅限于 CommonJS 模块,甚至不限于 JavaScript 包,但是这仍然是它的主要用途。只要敲几下代码,就能在Web应用中获得数千个包(现在总数已经超过50万个,仍在稳步增长中),并且能再 Node.js 服务端和浏览器中复用大部分功能。对其他系统来说这个优势太强大。

4. ES6,import,Babel和Webpack

随着 ES6 在2015年6月开始标准化,并且 Babel 在此之前很久就将 ES6 转换为 ES5,一场新的革命正在迅速逼近。 ES6规范包括 JavaScript 原生的模块系统,通常称为 ECMAScript 模块(ESM)。

ECMAScript 模块很大程度上受到 CommonJS 模块的影响,有静态声明API以及基于 Promise 的动态编程API,如下所示。

import mathlib from './mathlib'
import('./mathlib').then(mathlib => {
 // …
})

ECMAScript 模块中,每个文件都有自己的作用域和上下文。ECMAScript 模块优于 CommonJS 模块的一个主要优点就是它的静态引入依赖关系,可以让第三方工具在运行前得出模块之间的依赖关系,进行代码检查。

静态引入和有选择地引入,使得第三方工具可以有选择地删除未使用的包做优化,这个过程被称为 “tree shaking”

在 Node.js v8.5.0 中也支持了 ECMAScript 模块(需要设置flag)。 大多数现代浏览器也支持 ECMAScript 模块(需要设置flag)。

Webpack 是 Browserify 的继承者,由于具有更广泛的功能,基本上成了通用的模块 bundle 工具。 就像 Babel 和 ES6 一样,Webpack 长期以来一直支持 ECMAScript 模块的importexport 以及动态的 import()函数。 它引入了”code-splitting”机制,它能够将应用程序划分为不同的 bundle 包以提高首次加载体验的性能。

鉴于ECMAScript 模块是该语言的原生实现(对比CommonJS 模块)可以预期在几年内完全接管模块生态系统。

作者:Nicolás Bevacqua
原文:A Brief History of Modularity 发表于2017年9月26日
翻译:https://yukun.im/javascript/815