网络埋伏纪事

JavaScript模块的现状

网络埋伏纪事 · 2017-06-06翻译 · 471阅读 原文链接

ESM、CJS、UMD、AMD - 我到底该选哪个?

最近,关于ES模块的现状,特别是Node.js中决定引入*.mjs作为文件扩展名,在 Twitter 上有不少争论。恐惧和不确定性是可以理解的,因为这个话题很复杂,要跟上讨论需要全神贯注。

古老的恐惧

大多数前端开发人员还记得JavaScript依赖管理的黑暗时期。那时,你得将一个库复制粘贴到一个vendor文件夹中,依靠全局变量,尝试以正确的顺序连接所有的东西,而且仍然需要处理命名空间问题。

在过去的几年中,我们已经了解到了通用模块格式和集中式模块注册表的价值。

今天,发布和使用JavaScript库比以往更容易,一条npm publishnpm install命令就可以把这事搞定。这就是为什么当人们听到不同模块系统之间的兼容性问题时,会感到紧张的原因:他们害怕失去这种舒适。

在下面的文章中,我将解释和总结模块系统实现的现状,以及为什么我认为向ES模块(ESM)的转变不会损害Node.js生态系统。最后,我将概述这些变化对webpack用户和模块作者的意义。

当前实现

现存的ESM 实现有三种:

要理解当前的讨论,必须知道ES2015引入了两种不同的模式:

  • script--针对具有全局命名空间的常规脚本

  • module--针对显式 import 和 export 的模块代码

如果试图在一个script中使用importexport语句,会引发SyntaxError。这些语句在全局上下文中没有任何意义。另一方面,module模式意味着严格模式,它禁止某些语言特性,比如with语句。因此,必须在脚本被解析和执行之前定义模式。

浏览器中的ESM

截至2017年5月,所有主流浏览器都已经开始着手ESM的实现。不过,大多数依然还处于实验阶段。我不会在这里详细介绍,因为杰克·阿奇博尔德已经写了一篇很不错的文章

除了稍有困难以外,实现其实非常简单,因为之前浏览器中并没有模块系统。为了指定module模式,你需要像下面这样,将type="module"属性添加到script标签中:

<script type="module" src="main.js"></script>

在一个模块中,目前只能使用有效的URL作为模块标识符。模块标识符是用于require或import其他模块的字符串。为了确保将来与CJS模块模式符保持兼容,直接导入标识符(如 import "lodash")还不被支持。模块标识符必须要么是绝对URL,要么以/./../开头:

// Supported:
import {foo} from 'https://jakearchibald.com/utils/bar.js';
import {foo} from '/utils/bar.js';
import {foo} from './bar.js';
import {foo} from '../bar.js';

// Not supported:
import {foo} from 'bar.js';
import {foo} from 'utils/bar.js';

// Example from [https://jakearchibald.com/2017/es-modules-in-browsers/](https://jakearchibald.com/2017/es-modules-in-browsers/)

同时必须指出的是,一旦你是在一个模块中,那么每个import也会被解析为moduleimport一个script是不可能的。

ESM与webpack

像Webpack这种构建工具通常会尝试用module模式解析代码。如果有什么问题,就会回退到script模式。这些工具的结果是一个script,通常是在一定程度上模拟CJS和ESM的行为的模块运行时。

下面我们用两个简单的ESM为例:

// a.js
export let number = 42;
export function incr() {
    number++;
}
//test.js
import { number } from "./a";

console.log(number);

webpack使用函数包装器封装模块作用域和对象引用,来模拟ESM实时绑定。每次编译时,它还包括一个模块运行时,负责引导和缓存模块。此外,它将模块标识符转换为数字模块id。这样可以减少打包文件的大小和引导时间。

这意味着什么?下面我们来看看编译后的输出:

//webpack-runtime-example.js

(function(modules) {
    // This is the module runtime.
    // It's only included once per compilation.
    // Other chunks share the same runtime.
    var installedModules = {};
    // The require function
    function __webpack_require__(moduleId) {
        ...
    }
    ...
    // Load entry module and return exports
    return __webpack_require__(__webpack_require__.s = 1);
})
([ // An array that maps module ids to functions
    // a.js as module id 0
    function (module, __webpack_exports__, __webpack_require__) {
        "use strict";
        Object.defineProperty(__webpack_exports__, "a", {
            configurable: false,
            enumerable: true,
            get: () => number
        });

        let number = 42;

        function incr() {
            number++;
        }
    },
    // test.js as module id 1
    function (module, __webpack_exports__, __webpack_require__) {
        "use strict";
        var __WEBPACK_IMPORTED_MODULE_0__a__ = __webpack_require__(0);

        // Object reference as "live binding"
        console.log(__WEBPACK_IMPORTED_MODULE_0__a__["a" /* number */]);
    }
]);

模拟ES模块行为的简化的webpack输出

上面我已经简化并删除了一些与此示例无关的代码。正如你所见,webpack将所有的export语句替换为export对象上的Object.defineProperty。它还将所有对导入值的引用替换为属性访问器。还要注意在每个ESM开头的"user strict"指令。这是由webpack添加的,因为ESM中必须是严格模式。

这个实现只是一个模拟,因为它试图模仿ESM和CJS的行为 - 但是并非是原封不动地复制。例如,它不符合某些边缘情况。假如有如下模块:

console.log(this);

如果你通过用带有babel-preset-es2015的Babel运行的话,会得到:

“use strict”;

console.log(undefined);

从输出判断,貌似Babel默认假定采用ESM,因为module模式就意味着严格模式,并将this初始化为 undefined

不过,如果用webpack的话,会得到:

(function(module, exports) {

console.log(this);

})

当引导时,this会指向export,这与Node.js中CJS的行为相符。这是因为script和模module的语法很容易引起歧义。解析器无法判断该模块是ESM还是CJS。而遇到这种情况时,webpack就会模拟CJS,因为它依然是最受欢迎的模块风格。

这种模拟可以用在很多情况下,因为模块作者通常会避免这种代码。不过,“很多情况”对于像Node.js这样一个平台是不够的,因为所有有效的Javascript都应该运行。

Node.js 中的ESM

Node.js在实现ESM时遇到麻烦,因为它仍然需要支持CJS。虽然二者的语法看起来相似,但运行时行为却是完全不同的。Node.js核心技术委员会(CTC)成员James M Snell撰写了一篇很好的文章,解释了CJS与ESM之间的差异。

归根结底:CJS是一个动态模块系统,而ESM是一个静态模块系统。

CJS

  • 允许动态同步require()

  • export仅在对模块求值后才知道

  • export甚至可以在模块初始化后添加、替换和删除

ESM

  • 只允许静态同步import

  • 在对模块求值之前,import和export就被关联了

  • import和export是不可更改的

由于CJS早于ES2015,它始终以script模式进行解析。封装是通过使用函数包装器来实现的。如果你在Node.js中加载CJS,它实际上会执行与此类似的代码:

//node-function-wrapper.js

const module = {
    exports: {}
};
const require = makeRequireFunction();
const filename = "...";
const dirname = "...";
(function (exports, require, module, __filename, __dirname) {
/* YOUR CODE */
})(module.exports, require, module, filename, dirname);

简化了的Node.js中CommonJS模块的函数包装

当要将两个模块系统集成到同一个运行时时,问题就出现了。例如,ESM和CJS之间的循环依赖可能会迅速导致类似死锁的情况。

不过,由于现有CJS模块数量庞大,因此放弃对CJS的支持也不明智。为了避免破坏Node.js生态系统,很明显:

  • 现有的CJS代码必须继续以相同的方式工作

  • 两种模块系统都必须同时且尽可能无缝地工作

目前的权衡

2017年3月,经过几个月的讨论后,CTC终于找到了实现这一目标的途径。由于在ES规范和引擎不改变的情况下无法进行无缝集成,所以CTC决定带有一些权衡来开始一种实现

1.ESM必须具有* .mjs文件扩展名

由于如上所解释的歧义性语法问题,无法只通过解析来确认你正查看的JavaScript是什么类型。以对Node.js向后兼容的主要目标,作者需要选择加入新的模式。虽然已经有各种关于替代品的讨论,但不同的文件扩展名是解决方案的最佳权衡。

2.CJS只能通过异步import()导入ESM

Node.js会异步加载ESM,以便尽可能接近浏览器的行为。因此,ESM的同步require()将是不可能的。因此,依赖于ESM的每个函数都需要是异步的:

//example.js

const driverPromise = import("dbdriver");

exports.readFromDb = async (query) => {
   return (await driverPromise).read(query);
};

3.CJS公开了一个单一的,不可变的默认export给ESM

使用Babel或Webpack,我们通常像这样,将CJS重构为ESM:

// CJS
const { a, b } = require("c");

// ESM
import { a, b } from "c";

再次,语法看起来很相似,但它忽略了CJS中没有命名的导出的事实。只有一个名为default的导出,它等于CJS模块求值完成后module.exports的不可变快照。在技​​术上讲,可以将module.exports重新构造成命名的导入,但是这将需要对规范做更大变化。这就是为什么CTC决定现在就走这条路线的原因

4.模块作用域的变量(比如modulerequire__filename)在ESM中都没有了

Node.js和浏览器会在ESM中实现它们的对应物,但标准化过程仍在进行中

鉴于将CJS和ESM集成到单个运行时的工程挑战,CTC在评估边缘案例和权衡方面做了非常好的工作。例如,使用不同的文件扩展名是对这个问题的一个很简单的解决方案。

实际上,一个文件扩展名本质上对一个二进制文件如何解释的提示。如果一个module不是一个script,那么我们就应该用一个不同的文件扩展名。像linter或者IDE这类其他工具也可以获取同样的信息。

当然,引入新的文件扩展名也带来了成本,不过,一旦服务器和其他应用程序将*.mjs确认为JavaScript,我们很快就会忘记这个争议。

*.mjs会成为Node.js的Python 3吗?

考虑到所有这些限制,人们可能会问,这种转变会对生态系统造成什么样的损害。虽然CTC已经在努力解决问题,但社区会如何采用它仍然存在很大的不确定性。知名的NPM模块作者声明在他们的模块中永远不会使用*.mjs,再次强调了这种不确定性。

Python 3 is killing Python

很难预测社区如何反应,但我不认为我们会看到对生态系统的大破坏。我甚至认为我们会看到从CJS平稳过渡到ESM。这主要是由于两个原因:

1.与CJS严格向后兼容

对ESM感到不舒服的模块作者仍然可以坚持用CJS,而不会被排除。他们自己的代码不会受到采用ESM的影响,这降低了他们转到另一种运行时的可能性。它也让在NPM这种规模的生态系统中需要花点时间过渡的过程变得平稳一些。从CJS到ESM的重构给包维护者带来了负担,我并不期望所有的人都有时间。

2. CJS在ESM中的无缝整合

在ESM导入CJS模块非常简单。所有你需要记住的是,CJS仅导出一个默认值。一旦你在ESM中,你甚至可能都不会注意到依赖关系使用的模块风格。与CJS中的await import()进行比较试试看。

由于ESM的诸多优点,例如开箱即用的tree shaking和浏览器兼容性,预计在未来几年内,我们可以看到向ESM的缓慢而稳定的过渡。CJS特有的功能,如动态require()和猴子补丁的导出,在Node.js社区一直是有争议的,并且也不会超过ESM的好处。

这对我来说意味着什么?

随着所有最近的争论,我们很容易为所有的选择和限制感到困惑。在下面的部分中,我整理了开发人员会面临的典型问题以及我对此的答案:

现在需要重构现有的代码吗?

不需要。Node.js才刚刚开始实现ESM,还有很多工作要做。James M Snell 认为至少还花一年的时间,而且还有变化的余地,所以现在就重构是不安全的。

应该在新代码中使用ESM吗?

  • 如果你已经有一个像webpack这样的构建工具,或者对正在用的感到很舒服,那么答案就是是。ESM会让你的代码库平稳过渡,并让tree shaking成为可能。不过请小心:一旦Node.js原生支持ESM,你可能就需要重构你代码库中的某些部分。

  • 如果你正在写一个库,那么答案就是是的。你的模块的用户将受益于tree shaking。

  • 如果你不想有构建步骤,或者正在编写Node.js应用程序,那就请继续用CJS

现在应该为ESM使用.mjs吗?

不要。这样做目前没什么好处,工具支持依然薄弱。我建议一旦Node.js开始原生支持ESM,就开始过渡。请记住,浏览器只关心MIME类型,而不是文件扩展名

应该关心原生浏览器兼容性吗?

是的,因为关注到某种程度。你不应该忽略import语句中的.js扩展名,因为浏览器需要完整的URL。浏览器无法像Node.js那样执行路径查找。同样,你应该避免使用index.js文件。不过,我不指望人们会很快在浏览器中开始使用NPM软件包,因为裸import仍然是不可能的。

作为库作者该怎么办?

编写ESM,并用Rollup或Webpack将其转译为单个CJS模块。将package.json中的main字段指向此CJS打包文件。另外,使用module字段指向原始ESM。如果正在用除ESM之外的新语言特性,则应将其编译为ES5,并提供同时一个CJS和一个ESM的打包文件。通过这种方式,你的库用户仍然可以从tree shaking中获利,同时无需转译你的代码。

看看所有这些 tree shaking 过的模块!

总结

关于 ES 模块有很多不确定性。由于当前Node.js实现的权衡,开发人员担心它可能会破坏Node.js生态系统。

不过这不会发生,因为两个原因:CJS的严格的后向兼容性和CJS在ESM中的无缝集成

直到Node.js发布原生ESM支持之前,您应该仍然使用Rollup和Webpack等工具。他们在一定程度上模拟了ESM环境。请注意,它们不完全符合规范。此外,一旦我们可以在浏览器中使用NPM软件包,仍然使用捆绑软件也是很好的理由。

我们webpack团队正在努力让你平稳过渡。为此,我们计划在Node.js的ESM支持成熟后,模拟Node.js导入CJS的方式。

相关文章