原生 JavaScript 模块的现在与未来

1,079 阅读7分钟
原文链接: zhuanlan.zhihu.com

原生 JavaScript 模块的现在与未来

ECMAScript 2015 为原生 JavaScript 新增了模块体系,自其发布以来便引起了开发者们广泛的讨论和积极的实践。经过一年多的发展,原生 JavaScript 模块目前处于什么状态?它的未来又将如何?本文试图围绕这两个问题,对原生 JavaScript 模块做一个全面的介绍。

JavaScript 中的模块

诞生之初的 JavaScript 没有内建模块化支持。当然,在那个时代,对于一个用来编写表单校验和页面上浮动公告栏的语言来说,「模块化」确实显得有些大材小用。

但是随着互联网的发展,尤其是 2006 年 ajax 技术的出现和之后 Web 2.0 的兴起,越来越多的业务逻辑向前端转移,前端开发的复杂程度和代码量逐渐提升。这时,由于缺乏模块化概念,JavaScript 的一些问题便凸显出来:代码难以复用、容易出现全局变量污染和命名冲突、依赖管理难以维护。开发者们使用诸如暴露全局对象、自执行函数等方法来规避这些问题,但仍无法从根本上解决问题。

CommonJS

2009 年,基于将 JavaScript 应用于服务端的尝试,ServerJS 诞生了。之后 ServerJS 更名为 CommonJS,并逐步发展为一个完整的模块规范。

CommonJS 为模块的使用定义了一套 API。比如,它定义了全局函数 require,通过传入模块标识来引入其他模块,如果被引入的模块又依赖了其他模块,那么会依次加载这些模块;通过 module.exports 向外部暴露 API,以便其他的模块引入。

由于 CommonJS 加载模块是同步的,即只有加载完成才能进行接下来的操作,因此当应用于浏览器端时会受到网速的限制。

AMD

之后,在 CommonJS 组织的讨论中,AMD(Asynchronous Module Definition)应运而生。和前者不同的是,它使用异步方式加载模块,因此更适合被浏览器端采用。AMD 用全局函数 define 来定义模块,它需要三个参数:模块名称、模块的依赖数组、所有依赖都可用之后执行的回调函数(该函数按照依赖声明的顺序,接收依赖作为参数)。

UMD

如果需要同时支持 CommonJS 和 AMD 两种格式,那么可以使用 UMD(Universal Module Definition)。事实上,UMD 通过一系列 if/else 判断来确定当前环境支持的模块体系,因此多数情况下 UMD 格式的模块会占用更大的体积。

ES6 Modules

无论是 CommonJS,AMD 还是 UMD,它们都不是标准的 JavaScript 模块解决方案。换句话说,它们都没有被写进 ECMA 的规范中。直到 2015 年 6 月,TC39 委员会终于将 Modules 写进 ECMAScript 2015 中,标志着原生模块新时代的到来。至此,JavaScript 文件有了两种形式:脚本(自 JavaScript 诞生起我们就在使用的)和模块(即 ECMAScript 2015 Modules)。下面就让我们来一起探索 ECMAScript 2015 Modules(以下简称 ES6 Modules)。

ES6 Modules 现状

规范方面,在 2015 年的早些时候,ES6 Modules 的语法就已经设计完毕并且蓄势待发,但是模块在语义方面的实现,比如具体怎样加载和执行等,却仍然悬而未决,因为这牵扯到大量与现有 JavaScript 引擎和宿主环境(浏览器和 Node.js 等)的整合工作。随着最后期限的临近,委员会不得不进行妥协,即标准只定义 Modules 的语法,而具体的实现则交由宿主环境负责。

使用 Babel 和 webpack

由于绝大多数浏览器都不支持 ES6 Modules,所以目前如果想使用它的语法,需要借助 Babel 和 webpack,即通过 Babel 将代码编译为 ES5 的语法,然后使用 webpack 打包成目标格式。一个精简后的 webpack 配置为:

module.exports = {
  entry: './main.js',
  output: {
    filename: 'bundle.js',
    path: '/'
  },
  module: {
    rules: [{test: /\.js$/, use: 'babel-loader'}]
  }
}

以上配置告诉 webpack,项目入口为 ./main.js,用 babel-loader 处理所有 js 文件,然后将结果打包至 bundle.js

如何开启 ES6 Modules

时至今日,几大主流浏览器都在积极推进支持原生 ES6 Modules 的工作,部分浏览器的技术预览版也已经初步完成了这一使命。可以通过 caniuse.com 查看目前浏览器的支持情况。

FireFox 的 Nightly 版本已经实现了对 ES6 Modules 的支持。想在浏览器中体验的话,需要执行以下步骤:

  1. 首先在 FireFox 网站下载新版 Nightly
  1. 执行安装后,在标签页打开 about:config,并点击 I accept the risk! 按钮
  1. 找到 dom.moduleScripts.enabled 选项,并双击将其开启

小试 ES6 Modules

既然已经在 FireFox Nightly 中开启了支持,那么下面就让我们从一个例子开始,详细介绍 ES6 Modules 的特点。

一个例子

首先,新建一个 HTML 文件 index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Title</title>
</head>
<body>
  <script type="module" src="main.js"></script>
</body>
</html>

值得注意的是,在 script 标签上我们增加了 type="module" 这条属性,目的是告诉浏览器我们引入的 js 文件会包含其他的模块。为何浏览器无法自行判断一个 js 文件是一般的脚本还是 ES6 模块?我们会在下一节具体说明这个问题,现在暂时放在一边。

接下来,编写以下两个 js 文件:

// main.js
import utils from "./utils.js"

console.log(utils.add(3, 4))
// utils.js
export default {
  add(a, b) {
    return a + b
  }
}

在 FireFox Nightly 中打开 index.html,就能在控制台看到 utils.add(3, 4) 的结果被打印出来。

发生了什么

在 utils.js 中,我们使用 export 关键字导出了模块,它具有一个名为 add 的方法,返回两个参数的和;在 main.js 中,我们使用 import 关键字导入了 utils 模块,并调用其中的 add 方法,将结果打印出来。

相信大家对 importexport 都不陌生,因为 webpack、Rollup 等打包工具早已支持了这种写法。但是和打包工具的处理不同的是,原生 ES6 Modules 要求在引入时提供完整路径,包括文件的扩展名。因此,在 main.js 中,如果将第一行代码改为 import utils from "./utils",那么是无法在浏览器中正常运行的。基于同样的原因,如果我们需要引入 node_modules 目录下的第三方包,现有打包工具支持的 import Packages from 'package' 也是不能被 ES6 Modules 识别的,必须要写为:

import Package from './node_modules/package/dist/lib.js'

命名空间

ES6 Modules 是如何解决命名冲突的问题的?试试把上述 main.js 的内容修改为:

var x = 1

console.log(x === window.x)
console.log(this === undefined)

如果将这段代码直接复制进浏览器的控制台并运行,那么会依次打印出 truefalse。但是再次打开我们的 index.html,会发现控制台依次打印出了 falsetrue,和前者完全相反。

这是因为 ES6 Modules 执行在一个独立于全局的、只属于自己的作用域中(module-local scope)。由于这种机制,模块之间的命名冲突不复存在,并且同时也避免了变量污染全局作用域的问题。

严格模式强制开启

在 ES6 Modules 中,严格模式是默认开启并且无法关闭的。现在将 main.js 的内容修改为:

var x = 1
delete x

再次运行时,浏览器就会抛出一个 SyntaxError 错误,这正是严格模式下试图删除一个变量时的浏览器行为。

异步加载

ES6 Modules 默认是异步加载的,并且在页面渲染完毕后才会执行,这等同于打开了 script 标签的 defer 属性。为了验证这一点,可以将 index.html 改写为:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Title</title>
</head>
<body>
  <script src="./script1.js" type="module"></script>
  <script src="./script2.js"></script>
</body>
</html>

然后新建两个 js 文件:

// script1.js
console.log(1)
// script2.js
console.log(2)

在浏览器中打开 index.html,能够看到控制台分别打印出了 2 和 1。

对照 WHATWG HTML 规范里的这幅图我们可以看出,对于 defer 的 script 标签,它的加载与后续文档元素的加载会并行执行,并且它的执行要等到所有元素解析完成之后。因此在上面的例子中,script2.js 会在 script1.js 之前执行。

总结

综上所述,ES6 Modules 具有以下特点:

  • 可使用 import 引入其他模块(模块路径需书写完整,并且不能省略扩展名);使用 export 导出对外接口
  • 拥有独立于全局作用域的命名空间
  • 严格模式强制开启
  • 标签 <script type="module"> 默认是 defer 的

ES6 Modules 的解析问题

上一节中我们提到,浏览器中运行的 ES6 Modules 对应的 script 标签上需要增加 type="module" 属性。为何浏览器不自行判断某个 script 是脚本还是模块?第一眼看上去,似乎只要在代码中找到 importexport 就能够说明是 ES6 Modules 了。但是事情并没有这么简单。

挑战

我们首先假设浏览器能够在解析时通过检测代码中是否包含importexport 来判断 js 文件是脚本还是模块。对于一个模块来说,一种可能的情况是,整个文件只有最后一行出现了一个 export。由于这种情况的存在,浏览器必须解析整个文件才有可能得出最终的结论。

但是从上一节我们了解到,模块是强制运行在严格模式下的。如果浏览器在解析到最后一行时才发现 importexport,那么就需要以严格模式将整个文件重新进行解析。这样一来,第一次非严格模式下的解析就浪费了。

除此之外,真正的问题是,一个不含有 importexport 的 js 文件也有可能是一个模块。比如下面的两段代码:

// main.js
import './onload.js'

console.log('onload.js is loaded')
// onload.js
window.addEventListener('load', _ => {
  console.log('Window is loaded')
})

虽然 onload.js 中没有出现 importexport,但是 main.js 以 import 关键字引入了它,所以它也是一个模块。只解析它本身是没有办法知道这一点的。

总而言之,虽然文件中包含 importexport 预示了该文件是模块,但是不包含这两个关键字却不能说明它不是模块。

浏览器端的解决方案

浏览器端的解决方案很简单,就是在 script 标签上显式地注明一个文件是模块:type="module"。这样浏览器就会以模块的方式解析这个文件,并且加载它所依赖的模块。

围绕 Node.js 的讨论

对于 Node.js 而言,浏览器的解决方法显然是行不通的。Node.js 社区对此进行了激烈的讨论,目前主要有四个方案:

  • 在文件头部增加 use module 来标识该文件是模块
  • package.json 中添加相应的元数据字段
  • 每个模块至少包含一个 importexport(Unambiguous JavaScript Grammar 提案)
  • 为模块文件定义一个新的后缀:jsm

讨论还没有最终的结论,目前看来后两者更有希望。

最新进展与展望

ES6 Modules 进入标准以来,开发者们对它进行了充分的研究和积极的探索,以下就是两个例子。

动态加载方案 import()

目前 ES6 Modules 采用的是静态声明和静态解析,即在编译时就能确定模块的依赖关系,完成模块的加载。这不仅提高了加载效率,也使得 tree shaking 成为可能(Rollup 和 webpack 2 都基于此实现了 tree shaking)。

但是另一方面,某些时候仍然有动态加载的需求。举例来说,在一些场景下,直到运行时才能确定是否需要引入一个模块(比如根据用户的语言引入不同的模块)。为应对动态加载的需求,TC39 整理出了一套所谓「类函数」的模块加载语法提案:import(),目前已经处于规范发布流程的 stage 3 阶段。一个典型的用例如下:

const main = document.querySelector('main')

main.addEventListener('click', event => {
  event.preventDefault()

  import(`./section-modules/${ main.dataset.entryModule }.js`)
    .then(module => {
      module.loadPageInto(main)
    })
    .catch(err => {
      main.textContent = err.message
    })
})

从这个例子可以看出,import() 允许我们动态地引入模块。此外,和 ES6 Modules 相比,它还有以下特点:

  • 它可以出现在代码的任意层级,并且不会被提升
  • 它可以接收任意字符串作为参数(本例中是一个运行时确定的模板字符串),而 ES6 Modules 只能接收字符串字面量
  • 它返回一个 Promise,并且会将加载的模块作为参数传递给 resolve 回调

我们有理由相信,如果这个提案最终被写进标准,对 ES6 Modules 来说将是一个很好的补充。

基于 ES6 Modules 的 module-pusher 尝试

来自挪威奥斯陆的工程师 Marius Gundersen 在一篇博客里结合 ES6 Modules、HTTP/2、Service Worker 和 Bloom Filter,进行了从服务器将未经打包的模块推送至客户端的尝试。

他首先列举了现有打包策略的弊病:要么会造成浏览器下载一些用不到的代码,要么会造成同一个模块被多次重复下载。为了达到模块加载的最优解,他进行了以下尝试:

  1. ES6 Modules 是可以被静态解析的,这使得服务端能够找到给定模块的所有依赖模块。重复这个过程就可以构建出整个应用的依赖关系树
  2. 利用 HTTP/2 的 server push,服务端可以在客户端发出请求之前主动向其推送文件。一旦客户端请求了一个模块,服务端就可以将这个模块的所有依赖连同这个模块本身一起推送给客户端。当客户端需要加载某个依赖时,就会发现这个依赖已经存在于它的缓存中
  3. 一个潜在的问题是,如果模块 A 和模块 B 都依赖了模块 C,那么当客户端请求模块 A 时,服务端会同时将模块 C 推送;之后若客户端请求模块 B,服务端由于并不知道客户端的缓存中已经存在模块 C,因此会再次推送模块 C,这样就造成了网络资源的浪费
  4. 解决方案是,客户端发送请求时在请求头带上一个 Bloom Filter,它携带了客户端缓存的信息,服务端接收请求后对照依赖关系树和 Bloom Filter,确定需要推送哪些模块。不在请求头写入完整的已缓存模块的列表的原因是,这样做会导致请求头变得很大,而一个 Bloom Filter 通常只占用 100 字节
  5. 客户端如何知道自己缓存了哪些模块?这里需要用到 Service Worker:当客户端发送一个模块请求时,Service Worker 首先拦截这个请求,查看缓存中是否有这个模块,如果有就直接返回;如果没有,那么就根据缓存中的已有模块建立一个 Bloom Filter,并且写入请求头,将请求发送出去;当服务端返回一个模块时,Service Worker 将其写入缓存并响应给客户端

整个过程的流程图如下:

从这个例子可以看出,ES6 Modules 的新特性为前端工程化打开了更多的可能性。

ES6 Modules 未来展望

截至目前,在 JavaScript 的各种宿主环境中,只有少数浏览器的技术预览版实现了对 ES6 Modules 的支持;即使主流浏览器都支持了,由于要考虑旧浏览器的兼容性问题,在今后的很长一段时间里,开发者们在编写代码时仍然需要像现在一样使用打包工具将模块打包成需要的格式,而不是使用真正的 ES6 Modules。

事实上,浏览器和 Node.js 支持只是 ES6 Modules 迈向实用的第一步。除此之外,JavaScript 生态链上的许多环节都需要进行相应的改变。比如,目前 npm 上的大量模块都是 CommonJS 格式的,它们是不能直接被 ES6 Modules 引用的。

由此可见,ES6 Modules 离我们还有一段距离。不过,我们相信它终究会到来。因为「一个烂的标准比没有标准好上千万倍」,更何况 ES6 Modules 并不是一个烂的标准。

参考文献

  1. Understanding ES6 Modules via Their History (www.sitepoint.com/understandi…)
  2. ES6 module loading: More complicated than you think (www.nczonline.net/blog/2016/0…)
  3. proposal-dynamic-import (github.com/tc39/propos…)
  4. Efficient module loading without bundling (mariusgundersen.net/module-push…)