本文为稀土掘金技术社区首发签约文章,30 天内禁止转载,30 天后未获授权禁止转载,侵权必究!
如果你看过某个现代 JavaScript 库的代码,一定会困惑其复杂的模块适配,下图目前主流的 JavaScript 库模块适配方案,截取自我的新书《现代 JavaScript 库开发:技术、原理与实战》。
本文梳理 JavaScript 模块化的历史和现状,不仅介绍不同模块系统是什么,而是深入介绍不同模块系统诞生的原因和解决的问题,阅读本文将为你解开很多 JavaScript 模块化的疑惑。
传统 JavaScript
JavaScript 诞生之初,是一门在浏览器使用的脚本语言,并没有提供模块系统,站在当时的视角来看,确实也并不需要模块系统。
在浏览器中,如果想引用一个脚本文件,只需要使用 script 标签引入即可,非常简单直观,如下所示即可可入 jQuery。
script 的方式,并没有解决依赖的问题,依赖关系的解决,需要我们手动保证引入的顺序问题,2013 年我曾写过一个较为复杂的绘图程序Painter,手动维护依赖关系,如下图所示,曾经让我非常痛苦。
JavaScript 缺少模块带来两个问题,一个是封装的问题,一个是依赖的管理问题,关于如何支持模块的问题,浏览器社区和 Node.js 社区分别给出了不同的探索和方案,下面介绍其中影响比较大的模块系统。
Node.js 模块方案
Node.js 是浏览器之外的另一个运行时,其创建之初,为了弥补 JavaScript 缺失模块的问题,其带来了commonjs 规范,在 Node.js 中模块是强制的,commonjs 的模块定义和使用示例如下,需要注意外面的 define 在 Node.js 中是自动添加的,不需要写。
js
define(function (require, exports, module) {
//使用event 模块
var ec = require("event");
});
随着 Node.js 的发展,commonjs 影响力也越来越大,社区中很多库都提供了 commonjs 的引入方式,在当前这个时间点(2024-03-11),社区中依然存在大量仅支持 commonjs 的库。
浏览器模块方案
当浏览器社区考虑引入模块系统时,发现 commonjs 并不适合浏览器,这是因为 commonjs 是为同步导入设计的模块系统,在 Node.js 中引入一个模块是通过文件系统,同步非常合理,也非常简单。但在浏览器环境中,都是基于网络加载 js 文件的,需要设计一套异步加载规范。
其中最突出的异步加载规范是AMD(Asynchronous Module Definition),如果要使用 AMD 模块,还需要加载器,其中 RequireJS 是使用最为广泛的 AMD 模块加载器。
AMD 规范中定义模块的方式如下:
js
define(["beta"], function (beta) {
bata.***//调用模块
});
笔者早年间写的变色方块小游戏,就是使用 RequireJS 作为加载器的,其源代码中只加载一个入口文件。
其依赖的其他模块都通过 RequireJS 异步导入,示例如下:
目前 AMD 已经很少使用,仅作为了解即可,但在当时 AMD 也有很多用户,很多 JS 库都提供了 AMD 的引入方式。
割裂的社区
在 AMD 和 commonjs 双雄并存的年代,不得不面临一个巨大的问题,很多 JS 库,都只提供一种引入方式,这让社区割裂开来,如何在一种模块中使用另一种模块的库,成了模块加载器的急需解决的问题。
AMD 如何给 Node.js 使用
RequireJS 提供了在 Node.js 中使用 AMD 模块的方案,其使用方式如下所示:
为了实现这个功能,给 RequireJS 中添加了冗余代码,其部分源码实现如下所示,即便今天来看,RequireJS 的实现也颇为巧妙。
commonjs 如何给浏览器使用
那么大量的 commonjs 模块如何让浏览器使用呢?最早开始探索的先驱是# Browserify,其通过预编译的方式,将 commonjs 编译为传统 script 的方式,其使用方式如下所示:
Browserify 的这种方式被后来的工具借鉴并发扬光大,今天我们常用的工具都是基于这种方式,比如 webpack,rollup,pracel,vite 等。
如何融合 AMD 和 commonjs?
两套模块系统的另一个困扰来自库开发者,我到底该提供哪个模块给大家使用呢?有什么办法可以融合 AMD 和 commonjs 呢?
这个问题最终被 UMD 解决,UMD的全称是 Universal Module Definition。和它名字的意思一样,这种规范基本上可以在任何一个模块环境中工作。
UMD 的设计非常精巧,其支持传统 JavaScript,AMD 和 commonjs,对于传统 JavaScript,它设置支持了类似 jQuery 中的 noConflict 方法,一段典型的 UMD 代码如下所示:
js
(function (root, factory) {
var Data = factory(root);
if ( typeof define === 'function' && define.amd) {
// AMD
define('data', function() {
return Data;
});
} else if ( typeof exports === 'object') {
// Node.js
module.exports = Data;
} else {
// Browser globals
var _Data = root.Data;
Data.noConflict = function () {
if (root.Data === Data) {
root.Data = _Data;
}
return Data;
};
root.Data = Data;
}
}(this, function (root) {
var Data = ...
//自己的代码
return Data;
}));
其实故事到此本该就结束了,一些主要问题,已经基本解决了,没想到半路杀出个程咬金------ESM,对 AMD 和 commonjs 实现了降维打击。
ESM
2015 年 ECMAScript6 发布,也被称为 ECMAScript2015,其为 JavaScript 语言带来了原生的模块系统 ECMAScript6 Module,下文我们简称为 ESM。
网上有很多文章介绍 ESM,ESM 的科普不是本文的重点,这里不再展开介绍,commonjs 和 ESM 的引用模块的对比如下:
js
// commonjs
let { stat, exists, readfile } = require("fs");
// ESM
import { stat, exists, readFile } from "fs";
打包工具如何支持 ESM
ES6 虽然带来了 ESM,但并未提供实际的使用方式,并没有环境支持 ESM,为了使用 ESM 需要借助打包工具,最早支持 ESM 的打包工具应该是 rollup,rollup 给的方案是库提供两个入口,一个是 esm,一个是 commonjs,这样让库同时在 rollup 和其他打包工具中使用。
rollup 建议库中增加module
字段,来标记 ESM 的入口文件,rollup 有一篇文档详细介绍这个字段 pkg.module,值得一提的是由于不同工具的原因,实现同样的诉求,存在两个字段,一个是module
,一个是jsnext:main
。
如果库中存在如下字段,rollup 会加载module
字段文件,其他打包工具则加载main
。
后来 webpack 也提供了支持,其是通过增加配置来实现的,mainFields
中的main
前面添加module
配置即可,如下所示:
你可能会好奇最前面的browser
字段是啥,那就继续往下看吧。
如何解决 Node.js 和浏览器差异的问题
一个库同时支持 Node.js 和浏览器,理想很美好,然后现实却可能遇到挑战,由于环境的不一致问题,同一个库,在不同环境中,可能存在不同的实现方式。
举个例子,我们熟悉的 axios 库,其功能是提供发送请求的友好接口,同时支持在 Node.js 和浏览器中使用,其在浏览器中基于 xhr 实现,但在 Node.js 由于没有 xhr,其基于 http 模块实现。
对于这个问题,可以通过提供两个 npm 包的方式来解决,但这并不优美,库的开发者可能希望只提供一个 npm 包。还有一种解决办法,就是在一个 npm 包中,写分支代码,但这种方式会让浏览器环境中多出来 Node.js 中的代码,虽然可以通过打包工具,避免将 http 模块打包进来,但仍然有冗余代码。
为了解决这个问题,package-browser-field-spec诞生了,其通过在 package.json 中增加 browser 字段的方式,来区分不同的环境,对于浏览器环境来说,打包工具会自动引用 browser 字段的内容。
其使用方式如下所示,即支持整个入口替换,也支持部分文件的替换。
webpack 对于 browser 的支持也通过增加配置的方式,上面我们看到 webpack 配置中的 browser 字段,就是实现这个功能的。
浏览器如何支持 ESM
除了借助打包工具,浏览器也对 ESM 提供了原生支持,其通过给 script 标签增加type="module"
属性的方式,来区分传统加载,还是模块化加载。
举个例子,我们有main.js
和hello.js
两个文件,其中main.js
依赖hello.js
,内容如下所示:
如果通过传统 script 标签直接加载存在import
和export
的 js 文件会报错,如下所示:
只需简单添加type="module"
即可,示例如下,现在我们使用 vite 在 dev 模式下,就是基于浏览器原生 ESM 加载模块的。
如下
Node.js 如何支持 ESM
Node.js 对 ESM 的支持比较坎坷,其中实现方案也修改过,导致其比较复杂,Node.js 从 18 版本开始提供了较为稳定的 ESM 支持。
Node.js 支持 ESM 的挑战是,如何兼容大量的存量 commonjs 模块,Node.js 提供了两种方式,一种是通过后缀名区分,一种是通过给 package.json 增加 type 字段来区分。
在一个 npm 包中,可以同时存在这种量情况,归纳起来,可以分为如下情况,.mjs
是 ESM,.cjs
是 commonjs,.js
要看 package.json 的type
字段。
- .mjs
- .js
- package.json 没有 type
- package.json type=commonjs
- package.json type=module
- .cjs
有一点需要注意,在 Node.js 中 ESM 中可以引用 commonjs,在 commonjs 中不能引用 ESM,如果想了解背后的原因,以及更多细节,可以查看 Node.js 官方的文档:package 包模块
目前 webpack 中也支持.mjs,可以通过如下配置来实现:
exports
那么一个库,如何给旧版本 Node.js 提供 commonjs,给新版本 Node.js 提供 ESM 呢,Node.js 提供的答案是引入新的 exports 字段,exports 是重新设计的接口,其支持我们前面提到的全部功能,比如 browser。
下面是 exports 的示例:
- 对于不支持 exports 的环境,会继续读取 main 字段
- 支持 exports,但不支持 ESM 的环境,会使用 require 字段
- 支持 exports,且支持 ESM 的环境,会使用 import 字段
exports 本身的规则也比较复杂,如果想正确使用 exports 建议仔细阅读规范,下面是工具库 axios 的的 exports 配置,其中 types 是给 typescript 使用的,browser 是支持我们前面提到的 browser 功能。
目前 webpack 也支持 exports 导入,可以通过如下配置来实现:
双包问题
对于 Node.js 来说,支持 ESM 并不简单,这里题一个双包的问题,假如我们的 npm 包通过前面的 exports 字段,提供了 ESM 和 commonjs 两种入口,在实际使用中,两种入口可能会被同时使用,导致我们的代码被执行两边。
举个例子,我们的包是 A,项目中使用 A 的 ESM,项目依赖另一个包 B,B 依赖 A 的 commonjs 时,就会存在双包的问题。
如果我们的包是无副作用的代码,则执行两次问题不到,如果是希望单例的包,这样则会造成严重问题。
解决双包的问题,目前有两个思路:
一种是保守方法,由于 ESM 可以引入 commonjs,在 commonjs 中实现功能代码,在 ESM 中提供一个包装代码,调用我们的 commonjs 即可。
一种是激进办法,只提供 ESM 的包,放弃支持 commonjs 的旧环境。
Deno
仔细观察你会发现,Node.js 加载 ESM 是基于文件路径,而我们的浏览器是基于 URL,这并不统一,世界上也并不只有 Node.js,比如 Deno 就只支持 URL 加载,如下所示:
那么 Deno 如何使用我们的 npm 包呢,答案是通过unpkg和esm这种的平台来实现。
不过这要在我们的 package.json 中添加新的字段,如下所示,unpkg 会自动识别我们的字段,并提供相应的替换和包装功能:
需要注意,在 Node 17 以后,也支持通过 URL 来加载 ESM。
总结 & JavaScript 库的破局
本文介绍了 JavaScript 中主要模块方案和其背后的原因,希望对您有帮助,在日后的工作中看到任何模块,都不再困扰。
对于 JavaScript 库开发者来说,上面介绍的内容都需要掌握,在实际开发中,可能需要同时支持上面的这些模块系统,或者按自己的库的特性,选择支持部分。可以看到还是比较麻烦和繁琐的,为此我专门开发了jslib-base,支持 10 秒快速搭建一个新库的基础框架,其中已经内置了本文提到的所有模块。