[译] JS 模块化历史简介
in 码农技术宅翻译点文章 with 2 comments

[译] JS 模块化历史简介

in 码农技术宅翻译点文章 with 2 comments

对于 JavaScript 来说,模块化是一个相对现代的概念,这篇文章会带你在 JavaScript 的世界里快速浏览模块化的历史进程~

Script 标签和闭包

在早些年间,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 和 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) { 
  // … 
}])

然鹅不得不提的是,用工程师思维添加了这么一个构建步骤,解决了这个本不应该出现的问题,但是这本身性价比实在是不高,于是大部分开发者还是选择自己手写所有的依赖数组(我当年就是这样,哈哈)。

Node.js 和 CommonJS

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, import, Babel, 和 Webpack

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 的 importexport 语法以及 import() 动态加载函数。并且在 ESM 的基础上,添加了 code-splitting 功能,可以将应用程序代码分割成多个文件来提升首屏加载体验。

鉴于 ESM 是原生的模块加载规范,它一统江湖也指日可待了!


英文原文链接

Responses
京ICP备15030655号-1