谈到前端模块化的倒退历史,就肯定不会漏掉 ESM,除此之外,还有大家熟知的 CommonJS、AMD、CMD 以及 ES6 等。目前,ESM 曾经逐渐失去各大浏览器厂商以及 Node.js 的原生反对,正在成为支流前端模块化计划。
而 Vite 自身就是借助浏览器原生的 ESM 解析能力 (type=”module”) 实现了开发阶段的 no-bundle,即不必打包也能够构建 Web 利用。不过咱们对于原生 ESM 的了解仅仅停留在 type=”module” 这个个性下面未免有些狭窄了,一方面浏览器和 Node.js 各自提供了不同的 ESM 应用个性,如 import maps、package.json 的 imports 和 exports 属性等等,另一方面前端社区开始逐步向 ESM 过渡,有的包甚至仅留下 ESM 产物,Pure ESM 的概念随之席卷前端圈,而与此同时,基于 ESM 的 CDN 基础设施也如雨后春笋般不断涌现,诸如 esm.sh、skypack、jspm 等等。
因而你能够看到,ESM 曾经不仅仅局限于一个模块标准的概念,它代表了前端社区生态的走向以及各项前端基础设施的将来,不论是浏览器、Node.js 还是 npm 上第三方包生态的倒退,无一不在印证这一点。
接下来,咱们一起来看一下浏览器和 Node.js 中基于 ESM 实现的一些高级个性,而后剖析一下 Pure ESM 模式,以及这种模式下存在哪些痛点,以及如何解决这些痛点。
一、高阶个性
1.1 import map
在浏览器中咱们能够应用蕴含 type=”module” 属性的 script 标签来加载 ES 模块,而模块门路次要蕴含三种:
- 绝对路径,如 https://cdn.skypack.dev/react
- 相对路径,如./module-a
- bare import 即间接写一个第三方包名,如 react、lodash
对于前两种模块门路浏览器是原生反对的,而对于 bare import,在 Node.js 能间接执行,因为 Node.js 的门路解析算法会从我的项目的 node_modules 找到第三方包的模块门路,然而放在浏览器中无奈间接执行。而这种写法在日常开发的过程又极为常见,除了将 bare import 手动替换为一个绝对路径,还有其它的解决方案吗?
答案是有的。古代浏览器内置的 import map 就是为了解决上述的问题,咱们能够用一个简略的例子来应用这个个性:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="root"></div>
<script type="importmap">
{
"imports": {"react": "https://cdn.skypack.dev/react"}
}
</script>
<script type="module">
import React from 'react';
console.log(React)
</script>
</body>
</html>
在浏览器中执行这个 HTML,如果失常执行,那么你能够看到浏览器曾经从网络中获取了 React 的内容,如下图所示:
在反对 import map 的浏览器中,在遇到 type=”importmap” 的 script 标签时,浏览器会记录下第三方包的门路映射表,在遇到 bare import 时会依据这张表拉取近程的依赖代码。如上述的例子中,咱们应用 skypack 这个第三方的 ESM CDN 服务,通过 https://cdn.skypack.dev/react 这个地址咱们能够拿到 React 的 ESM 格局产物。
import map 个性尽管简洁不便,但浏览器的兼容性却是个大问题,在 CanIUse 上的兼容性数据如下:
它只能兼容市面上 68% 左右的浏览器份额,而反观 type=”module” 的兼容性(兼容 95% 以上的浏览器),import map 的兼容性实属不太乐观。但侥幸的是,社区曾经有了对应的 Polyfill 解决方案——es-module-shims,残缺地实现了蕴含 import map 在内的各大 ESM 个性,还包含:
- dynamic import。即动静导入,局部老版本的 Firefox 和 Edge 不反对。
- import.meta 和 import.meta.url。以后模块的元信息,相似 Node.js 中的 __dirname、__filename。
- modulepreload。以前咱们会在 link 标签中加上 rel=”preload” 来进行资源预加载,即在浏览器解析 HTML 之前就开始加载资源,当初对于 ESM 也有对应的 modulepreload 来反对这个行为。
- JSON Modules 和 CSS Modules,即通过如下形式来引入 json 或者 css。
<script type="module">
// 获取 json 对象
import json from 'https://site.com/data.json' assert {type: 'json'};
// 获取 CSS Modules 对象
import sheet from 'https://site.com/sheet.css' assert {type: 'css'};
</script>
值得一提的是,es-module-shims 基于 wasm 实现,性能并不差,相比浏览器原生的行为没有显著的性能降落。大家能够去这个地址查看具体的 benchmark 后果。
由此可见,import map 尽管并没有失去宽泛浏览器的原生反对,然而咱们依然能够通过 Polyfill 的形式在反对 type=”module” 的浏览器中应用 import map。
1.2 Nodejs 包导入导出策略
在 Node.js 中 (>=12.20 版本) 有个别如下几种形式能够应用原生 ES Module:
- 文件以 .mjs 结尾;
- package.json 中申明 type: “module”。
那么,Node.js 在解决 ES Module 导入导出的时候,如果是解决 npm 包级别的状况,其中的细节可能比你设想中更加简单。
首先来看看如何导出一个包,你有两种形式能够抉择: main 和 exports 属性。这两个属性均来自于 package.json,并且依据 Node 官网的 resolve 算法(查看详情),exports 的优先级比 main 更高,也就是说如果你同时设置了这两个属性,那么 exports 会优先失效。并且,main 的应用比较简单,设置包的入口文件门路即可。
"main": "./dist/index.js"
须要重点梳理的是 exports 属性,它蕴含了多种导出模式: 默认导出、子门路导出和条件导出,这些导出模式如以下的代码所示。
// package.json
{
"name": "package-a",
"type": "module",
"exports": {
// 默认导出,应用形式: import a from 'package-a'
".": "./dist/index.js",
// 子门路导出,应用形式: import d from 'package-a/dist'
"./dist": "./dist/index.js",
"./dist/*": "./dist/*", // 这里能够应用 `*` 导出目录下所有的文件
// 条件导出,辨别 ESM 和 CommonJS 引入的状况
"./main": {
"import": "./main.js",
"require": "./main.cjs"
},
}
}
其中,条件导出能够包含如下常见的属性:
- node: 在 Node.js 环境下实用,能够定义为嵌套条件导出,如
{
"exports": {
{
".": {
"node": {
"import": "./main.js",
"require": "./main.cjs"
}
}
}
},
}
- import: 用于 import 形式导入的状况,如 import(“package-a”);
- require: 用于 require 形式导入的状况,如 require(“package-a”);
- default,兜底计划,如果后面的条件都没命中,则应用 default 导出的门路。
当然,条件导出还蕴含 types、browser、develoment、production 等属性。在介绍完 ” 导出 ” 之后,咱们再来看看 ” 导入 ”,也就是 package.json 中的 imports 字段,个别是这样申明的:
{
"imports": {
// key 个别以 # 结尾
// 也能够间接赋值为一个字符串: "#dep": "lodash-es"
"#dep": {
"node": "lodash-es",
"default": "./dep-polyfill.js"
},
},
"dependencies": {"lodash-es": "^4.17.21"}
}
而后,能够在本人的包中应用 import 语句进行导入:
// index.js
import {cloneDeep} from "#dep";
const obj = {a: 1};
// {a: 1}
console.log(cloneDeep(obj));
Node.js 在执行的时候会将 #dep 定位到 lodash-es 这个第三方包,当然,你也能够将其定位到某个外部文件。这样相当于实现了门路别名的性能,不过与构建工具中的 alias 性能不同的是,”imports” 中申明的别名必须全量匹配,否则 Node.js 会间接抛错。
二、Pure ESM
什么是 Pure ESM ? Pure ESM 最后是在 Github 上的一个帖子中被提出来的,其中有两层含意,一个是让 npm 包都提供 ESM 格局的产物,另一个是仅留下 ESM 产物,摈弃 CommonJS 等其它格局产物。
当这个概念被提出来之后社区当中呈现了很多不同的声音,有人赞成,也有人不满。但不论怎么样,社区中的很多 npm 包曾经呈现了 ESM First 的趋势,能够预感的是越来越多的包会提供 ESM 的版本,来拥抱社区 ESM 大一统的趋势,同时也有一部分的 npm 包做得更加激进,间接采取 Pure ESM 模式,如赫赫有名的 chalk 和 imagemin,最新版本中只提供 ESM 产物,而不再提供 CommonJS 产物。
不过,对于没有下层封装需要的大型框架,如 Nuxt、Umi,在保障能上 Pure ESM 的状况下,间接上不会有什么问题;但如果是一个底层根底库,最好提供好 ESM 和 CommonJS 两种格局的产物。
接下来,咱们看一下如何应用 ESM,咱们能够间接导入 CommonJS 模块,如:
// react 仅有 CommonJS 产物
import React from 'react';
console.log(React)
Node.js 执行以上的原生 ESM 代码并没有问题,但反过来,如果你想在 CommonJS 中 require 一个 ES 模块,就行不通了。
其根本原因在于 require 是同步加载的,而 ES 模块自身具备异步加载的个性,因而两者人造互斥,即咱们无奈 require 一个 ES 模块。
那是不是在 CommonJS 中无奈引入 ES 模块了呢? 也不尽然,咱们能够通过 dynamic import 来引入:
不晓得你留神到没有,为了引入一个 ES 模块,咱们必须要将原来同步的执行环境改为异步的,这就带来如下的几个问题:
- 如果执行环境不反对异步,CommonJS 将无奈导入 ES 模块;
- jest 中不反对导入 ES 模块,测试会比拟艰难;
- 在 tsc 中,对于 await import()语法会强制编译成 require 的语法 (详情),只能靠 eval(‘await import()’) 绕过去。
总而言之,CommonJS 中导入 ES 模块比拟艰难。因而,如果一个根底底层库应用 Pure ESM,那么你依赖这个库时产物最好为 ESM 格局。也就是说,Pure ESM 是具备传染性的,底层的库呈现了 Pure ESM 产物,那么下层的应用方也最好是 Pure ESM,否则会有上述的种种限度。
但从另一个角度来看,对于大型框架 (如 Nuxt) 而言,根本没有二次封装的需要,框架自身如果可能应用 Pure ESM,那么也能带动社区更多的包 (比方框架插件) 走向 Pure ESM,同时也没有上游调用方的限度,反而对社区 ESM 标准的推动是一件好事件。
不过,npm 上大部分的包还是属于根底库的领域,那对于大部分包,咱们采纳导出 ESM/CommonJS 两种产物的计划,会不会对我的项目的语法产生限度呢?
咱们晓得,在 ESM 中无奈应用 CommonJS 中的 __dirname、__filename、require.resolve 等全局变量和办法,同样的,在 CommonJS 中咱们也没方法应用 ESM 专有的 import.meta 对象,那么如果要提供两种产物格局,这些模块标准相干的语法怎么解决呢?
在传统的编译构建工具中,咱们很难逃开这个问题,但新一代的根底库打包器 tsup 给了咱们解决方案。
三、新一代的根底库打包器
tsup 是一个基于 Esbuild 的根底库打包器,主打无配置 (no config) 打包。借助它咱们能够轻易地打出 ESM 和 CommonJS 双格局的产物,并且能够任意应用与模块格局强相干的一些全局变量或者 API,比方某个库的源码如下:
export interface Options {data: string;}
export function init(options: Options) {console.log(options);
console.log(import.meta.url);
}
因为代码中应用了 import.meta 对象,这是仅在 ESM 下存在的变量,而通过 tsup 打包后的 CommonJS 版本却被转换成了上面这样:
var getImportMetaUrl = () =>
typeof document === "undefined"
? new URL("file:" + __filename).href
: (document.currentScript && document.currentScript.src) ||
new URL("main.js", document.baseURI).href;
var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
// src/index.ts
function init(options) {console.log(options);
console.log(importMetaUrl);
}
能够看到,ESM 中的 API 被转换为 CommonJS 对应的格局,反之也是同理。最初,咱们能够借助之前提到的条件导出,将 ESM、CommonJS 的产物别离进行导出,如下所示。
{
"scripts": {
"watch": "npm run build -- --watch src",
"build": "tsup ./src/index.ts --format cjs,esm --dts --clean"
},
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.js",
// 导出类型
"types": "./dist/index.d.ts"
}
}
}
tsup 在解决了双格局产物问题的同时,自身利用 Esbuild 进行打包,性能十分强悍,也能生成类型文件,同时也补救了 Esbuild 没有类型零碎的毛病,还是十分举荐大家应用的。
当然,回到 Pure ESM 自身,我感觉这是一个将来能够预感的趋势,但对于根底库来说,当初并不适宜切到 Pure ESM,现在作为过渡时期,还是发 ESM/CommonJS 双格局的包较为靠谱,而 tsup 这种工具能升高根底库构建上的老本。当所有的库都有 ESM 产物的时候,咱们再来落地 Pure ESM 就轻而易举了。