本文为稀土技术社区首发签约文章,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 中是主动增加的,不需求写。
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 标准中定义模块的方法如下:
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 代码如下所示:
(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 的引证模块的对比如下:
// 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 秒快速搭建一个新库的基础框架,其间现已内置了本文提到的一切模块。