[译]JS模块化简史

1,825 阅读9分钟

原文: https://ponyfoo.com/articles/brief-history-of-modularity


对于 JS 来说,模块化是个现代的概念。本文将快速回溯和总结模块化是如何推动了 JS 世界进化的。我们不会罗列各种全面的清单,而只是展示改变 JS 历史的主要范例

Script 标签和闭包

早年间,JS 还只是委身于 HTML <script> 标签中的内联代码;顶不济也就是被封装到专门的脚本文件中调用,也还是得与其他脚本共享一个全局作用域。

在这些文件或内联标签里面定义的任何变量都被全局对象 window 收入囊中,由此可能带来的所有不相关脚本中的互相污染,将导致冲突甚至破坏体验;某个脚本中的变量可能会在无意之间被全局中或者其他脚本中的变量覆盖。

后来,随着 web 应用开始变得越来越庞杂,作用域和全局作用域的危害等概念变得显而易见而深入人心。立即调用函数表达式 (IIFE: Immediately-invoking function expressions)被发明出来并成为中流砥柱。一个 IIFE 就是把整个或部分 JS 文件包裹进一个函数,并在对其求值后立即执行。因为 JS 中的每个函数都会创建一个新一级的作用域,所以用 var 声明的变量就被绑定在所处的 IIFE 中了。归功于 IIFE,尽管作用域中也有变量提升等效果,但不再会变成隐式声明的全局变量了,这避免了定义变量时的脆弱性。

下面的代码片段展示了各种形式的 IIFE。除非用window.foo = 'bar' 这种形式定义一个全局上下文的变量,否则每个 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 的变量并在之后对其重用的方式,来创建一个典型的模块了,这避免了全局命名的空间污染。以下代码片段展示了如何用这些 IIFE 中的一种形式来创建一个包含 sum 方法的 mathlib 库。如果想对 mathlib 库增加更多模块,就可以把每个模块置于一个 IIFE 中,并将其暴露的方法添加到 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

这种模式也在无意之中诱发了 JS 工具的一个萌芽 -- 开发者首次能安全的将所有 IIFE 模块集合到一个文件中,这减轻了网络负担。

IIFE 这种实现方法的问题在于,并没有一个明确的依赖树。这意味着开发者不得不去特意维护组件的明确顺序,以做到模块的依赖必须先于其被加载,还得考虑递归的情况。

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 的第一个数组参数中,并且将其各自的公开接口作为参数传入 define 的第二个回调函数中,注意保持顺序一致。

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

这样就定义好了一个库,并且能借由 require 函数调用了。注意下面片段中是如何处理依赖链的。

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

这就是 RequireJS 和其固有的依赖树的优势 -- 不管应用包含成百还是上千的模块,都不需要小心翼翼的维护一个依赖清单。考虑到我们明确列出了依赖在哪里被需要,也就排除了为每个组件写一份如何关联其他组件的长长的清单的必要性,同时也避免了因此出错。但排除如此之大的复杂度还仅仅是个副作用,而非其主要好处。

在模块层面描述依赖的明确性,使得组件如何关联到应用中其他部分变得显而易见。这种明确性反过来又培育出更大程度的模块化,这在以前是无法做到的,因为难以跟踪依赖链。

RequireJS并非没有问题。

首先,整个模式围绕其异步加载模块的能力运行,如果用在产品部署上,将影响性能。使用异步加载机制,可能会在代码被执行前发起数百个网络请求。亟需对产品构建使用不同的工具实现优化。(译注:可以手工打包或使用官方的 r.js 实现自动打包等避免这个问题)

其次,需要一个 RequireJS 函数、一个可能很冗长的依赖列表、一个可能有同样冗长参数的回调;所有这些只为实现“声明一个有依赖的模块”一件事,这使得其应用复杂化了,其 API 也显得不是很直观。

AngularJS 中的依赖注入(DI - dependency injection)系统有着许多同样的问题。作为当时一个优雅的解决方案,依靠巧妙的字符串解析以避免依赖数组,使用函数参数名来处理依赖。但是这个机制和代码压缩工具不兼容,将导致参数被重新命名成单字符,从而破坏了依赖的注入。

在之后的 AngularJS v1 版本中,引入了一个 build task 来转换如下的代码:

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

会转换为下面这种格式的代码,因为包含了明确的依赖列表,就可以安全的使用压缩工具了。

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

不用说,之后引入的这个鲜为人知的构建工具,作为一个额外的构建步骤有过度设计之嫌,和带来的小小好处相比,无论如何都妨碍了该模式的使用。开发者几乎都会选择继续使用熟悉的类 RequireJS 风格来硬编码依赖数组。

Node.js 和 CommonJS 的降临

在由 Node.js 催生的若干创新中,CommonJS 模块系统算得上一个,也被简称为 CJS。利用 Node.js 程序可以访问文件系统的优势,CommonJS 标准更加贴近传统的模块加载机制。在 CommonJS 中,每个文件都是拥有自己的作用域和上下文的单独模块。使用一个异步的 require 函数来加载依赖项,并且可以在该模块生命周期中的任何时候动态调用,就像下面这样:

const mathlib = require('./mathlib')

和 RequireJS 以及 AngularJS 很像的是,CommonJS 中的依赖也是靠路径名称实现的。主要的区别在于,不再需要样板函数和依赖数组什么的了,而是将模块的接口指派到一个绑定的变量中,或是在任何地方由 JS 表达式使用。

与前面提到的两者不同的是,CommonJS 更加严格。在 RequireJS 和 AngularJS 中,每个文件中可以包含若干个动态定义的模块,而 CommonJS 则限制了每个文件只能一个模块。同时,RequireJS 有多种声明模块的途径,而 AngularJS 则有不同种类的 factories、services、providers 等等 -- 以及幕后和其依赖注入机制紧密耦合的框架本身。相比较而言,CommonJS 描述模块的方式则是唯一的。JS 文件皆模块,调用 require 就加载依赖,并且其接口就是指定给 module.exports 的东西。这带来了良好的工具化,以及更好的代码自省 -- 让工具也能更容易的找出 CommonJS 模块系统中的层次。

最后,Browserify 被发明出来,用于在本为 Node.js 服务器而生的 CommonJS 模块和浏览器之间架起了桥梁。使用 browserify 命令行接口程序,并向其传递入口模块的路径,就能将无论多少个模块打包成一个浏览器适用的单独文件。而 CommonJS 的杀手级特性:npm 包注册器,为其接管模块加载生态系统起到了决定性作用。

的确,npm 没有限制为只能有 CommonJS 的模块,甚至也没规定只能是 JS 包,但 CommonJS 的 JS 包一直并仍将是其主流。指尖轻点之间,数以千计(现在已经有50多万并仍稳定增长)的包就在你的应用中可用了,加上可以在系统的一大部分重用 Node.js 服务器端和每种客户端 web 浏览器中代码的能力,使其极大的保持了对其他模块系统的竞争优势。

ES6、import、Babel 和 Webpack

当 ES6 在 2015 年中标准化,加之在此很久之前就已经可以用 Babel 将 ES6 转换为 ES5 了,一场新的革命旋即展开。ES6 规范包括了一个 JS 原生的模块系统,一般被称为 ECMAScript Modules (ESM)。

ESM 深受 CJS 及其前辈的影响,提供了一个静态声明式 API,以及一个基于 promise 的动态可编程 API。如下所示:

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

在 ESM 中,和 CJS 一样,每个文件都是拥有自己的作用域和上下文的单独模块。EMS 相比 CJS 的一个主要的优势是:其具备并被鼓励使用的静态导入依赖的方式。静态导入极大改善了模块系统的自省能力,使其可以被静态化的分析;并有了从系统中每个模块的抽象语法树(AST - abstract syntax tree)中的词法层面抽取的能力。

在 Node.js v8.5.0 中,引入了 ESM 模块支持。大部分现代浏览器也已经支持。

作为 Browserify 的接班人,Webpack 主要接管了通用模块打包器的角色,这归功于其具备的大量新特性。正如 Babel 之于 ES6,Webpack 也一直支持着 ESM -- 及包括其 import 和 export声明语句,也包括动态 import() 函数。Webpack 采用 ESM 并取得了特别丰富的成果,不仅是其引入的“代码分割(code-splitting)”机制,更是凭借能将应用分为不同部分打包的能力提升了首次加载时的使用体验。

相比于 CJS,考虑到 ESM 作为 JS 这门语言的天然性 -- 在几年后,有理由期待其全面接管模块生态系统。





-------------------------------------

长按二维码或搜索 fewelife 关注我们哦