苦逼前端

ESM(ES6 Module) 的前世今生

Javascript2020-11-11 06:29

翻看很多第三方库的代码,会发现有一些库的 package.json 中有个 module 字段:

{
    "name": "vue",
    "version": "2.5.17",
    "main": "dist/vue.runtime.common.js",
    "module": "dist/vue.runtime.esm.js",
}

那么这个字段是干嘛的呢?翻看了最新的 package.json 文档也没找到这个字段,逛了一会 Google 发现了这么一篇有意思的讨论:In Defense of .js

在正式讨论这个问题之前,我们需要先了解一些背景:

Node.js 最初的模块设计是基于 CommonJS 规范的,并且该规范并未考虑浏览器端的 js 运行场景,即使后来出现了 RequireJS 能让我们在浏览器端使用类似 CommonJS 规范的AMD 规范(支持模块异步加载)来组织 js 代码,但浏览器本身仍未对此做任何支持。

CommonJS is known from Node.js. It’s mostly dedicated for servers and it supports synchronous loading. It also has a compact syntax focused on export and require keywords.
AMD and the most popular implementation - RequireJS are dedicated for browsers. AMD supports asynchronous loading, but has more complicated syntax than CommonJS.

并且这两种规范使得 Node.js 模块和浏览器端 js 代码不能很好的兼容,既然都是 js, 如果 Node.js 模块能被浏览器环境下的 js 代码随意引用,那真是一大幸事。

本着这个目的,ES6(ECMAScript 6th Edition, 后来被命名为 ECMAScript 2015) 于 2015年6月17日 横空出世,主要被人熟知的其中一个特性就是 es6 module, 下文简称为 ESM

背景到这里结束,我们回到正题。


既然 Node.js 也要遵循新的 ESM 规范,那就不可避免的涉及到了历史包袱,那些使用了 CommonJS 规范的 Node.js 模块将何去何从?所以产生了上面提到的这个讨论:In Defense of .js, 针对两种不同的场景分别有以下方案:

  1. 单个文件,使用 CommonJS 规范的文件不做任何改变,只是使用了 ESM 规范的文件需要使用 .mjs 的后缀,类似 module-name.mjs
  2. npm 模块,需要在 package.json 中声明,哪些文件是 CommonJS 规范,哪些文件是 ESM 规范

综合考虑了各种用户群体的诉求和兼容性等问题,得出了以下结论:

  1. 已有的模块,不会有任何的改变,仍然可以正常工作
  2. 如果要声明使用了 ESM, 可以在 package.json 中声明一个叫做 module 的key, value 自然就是使用了 ESM 规范的模块入口文件
  3. 模块可以通过在 package.json 中只声明 module 、不声明传统的 main 的方式来标记该模块是一个 ESM 规范的「完全体」模块
  4. 所有的模块,都应该可以通过 require 或是 import 来正常引入

此外我们可能还会看到 package.json 中其他两种声明:

modules: 一些复杂的大型模块,可能会包含很多目录和文件,我们可以用它来指定具体的目录或文件为 ESM 规范,如:"modules": ["app/routes/", "config/"],要注意,如果一个模块只声明了 module 而未声明 main(上面结论3),就可以不需要这种指定方式了,只需通过 module 声明一个入口,然后整个模块里的代码都会被当做 ESM 规范来处理

modules.root: 一些由老模块迭代过来的模块,可能同时存在两种规范的代码,则可以通过该声明来指定代码的处理方式

For example, let's say that lodash specifies "modules.root": "src". Then, require("lodash/array.js") will work in older versions of Node.js, where "lodash/array.js" points at array.js in the root of lodash, as today. In newer versions of Node.js, which support standard modules, "lodash/array.js" will point at lodash/src/array.js.

用了这种声明方式的模块,也就没有必要再使用 modules 来声明了,我们可以把原来 modules 中声明的目录和文件直接放进 modules.root 所声明的目录中,他们都会被当做 ESM 模块来处理


我们再来看看 Node.js 在实际的迭代过程中是如何支持 ESM 的:

2017年9月12日发布的 8.5.0 率先支持了这一特性 module: Allow runMain to be ESM, 意味着使用该版本或高于该版本的 Node.js 就可以通过 node --experimental-modules x.mjs 来体验了

2019年4月23日发布的 12.0.0 更新了 ESM 的实现并支持了几个重要特性 new ESM implementation,其中之一便是新增了 package.json 中的 type 字段,它有如下含义:

  • type: "commonjs":
    • .js is parsed as commonjs
    • default for entry point without an extension is commonjs
  • type: "module":
    • .js is parsed as esm
    • does not support loading JSON or Native Module by default
    • default for entry point without an extension is esm

即使声明了该字段,.cjs 文件仍然遵循 CommonJS 规范,.mjs 文件仍然遵循 ESM 规范,即文件格式高于该配置项。使用 --type=[mode] 参数可以覆盖该字段

2019年7月23日发布的 12.7.0 新增了 package.json 中的 exports 字段,它提供了更友好的模块导出方式:

{
    "name": "pkg",
    "main": "./feature.js",
    "exports": {
        "./feature": {
            "node": {
                "import": "./feature-node.mjs",
                "require": "./feature-node.cjs"
            },
            "browser": "./feature-browser.js",
            "default": "./feature.js"
        }
    }
}

以上声明支持针对不同场景导出不同模块:

当在 Node.js 环境下使用 import feature from 'pkg/feature' 时,导入的是 feature-node.mjs 文件

当在 Node.js 环境下使用 const feature = require('pkg/feature') 时,导入的是 feature-node.cjs 文件

当在 browser 环境下使用 import feature from 'pkg/feature' 时,导入的是 feature-browser.js 文件,当然这需要 browser 环境自己去做支持,webpack5 承诺会对此作出支持 Respect "exports" field in "package.json", 但就该 issue 提供的信息来看,截止目前(2020-11-11)应该是仍未支持

当在以上环境以外使用 import feature from 'pkg/feature' 时,导入的是默认的 feature.js 文件,同样需要对应的环境自己去做支持

2020-05-26 发布的 12.17.0 移除了 --experimental-modules, 也就是说,不使用该参数也可以体验 ESM 模块了,但是官方特意说明了即使如此,ESM 模块在 Node.js 中仍然处于实验阶段。而在此之前的所有版本,如果不做以上的参数声明将会报错(12.16.2):

Error [ERR_REQUIRE_ESM]: Must use import to load ES Module: xxxx

所以 ESM 经历了这3年多的发展,已经到了非常成熟的阶段,可以考虑在生产环境全面使用。回顾这个过程,为 Node.js Modules Team 的这一壮举感到骄傲,来自世界各地的 31 个核心开发者,按照这份计划将 Node.js 从 require 时代完美平滑的过渡到了 import 时代


我们再来看看 browser 端的 javascript 是如何实现这一标准的:

这篇文章提到了我们最开始说的 module 字段,它的作用是告诉前端构建工具,类似 webpackrollup, 这个模块是支持 ESM 的,我们可以直接通过 import 来引入代码,但它只是个 proposal 并不是标准

并且它的存在可能会引起其他的问题,比如常用前端构建工具默认会忽略 node_modules 中的文件编译,但如果你在 npm 模块中声明了 module 字段,这时候构建工具将会直接将该 npm 模块中的代码打入你的代码中,而他们是没有被编译的,大量的高级 API 会使代码在低版本浏览中报错,所以就有了诸如 vue-cli 中蹩脚的 transpileDependencies 配置,用来指定哪些 npm 模块是需要编译的。假如你的 npm 模块中又引用了其他声明 module 字段的模块,那么这个被引入的模块也得指定需要编译,比较典型的是早期的swiper@4, 它还依赖了同样声明了 module 字段的 dom7ssr-window,所以需要将这三个模块共同声明为需要编译


综合来看,还是 Node.js 的 ESM 比较合理一些,希望 webpack5 和其他前端构建工具早日拥抱这一标准,为 javascript 统一全宇宙做好准备 😄

评论(2)
  • 一一: 大神,如果没有声明,打包工具是怎么确定是否是es6模块的嘞?10个月前
  • 邢文亮: 没有声明,就默认你是编译后的代码,不参与 tree-shaking 等操作,直接包装成模块引入你的代码10个月前
还可输入200个字