惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

WordPress大学
WordPress大学
大猫的无限游戏
大猫的无限游戏
酷 壳 – CoolShell
酷 壳 – CoolShell
博客园 - 叶小钗
月光博客
月光博客
Last Week in AI
Last Week in AI
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
人人都是产品经理
人人都是产品经理
阮一峰的网络日志
阮一峰的网络日志
罗磊的独立博客
IT之家
IT之家
美团技术团队
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Hugging Face - Blog
Hugging Face - Blog
博客园_首页
S
SegmentFault 最新的问题
宝玉的分享
宝玉的分享
博客园 - Franky
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Jina AI
Jina AI
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
The Cloudflare Blog
博客园 - 司徒正美
爱范儿
爱范儿

ALBERTAZ

How I Built a Scannable 3D QR Code Generator | Every QR Code OpenStreetMap Data Pipeline for Canvas Maps | EveryCityMap 每日关灯:今天这题理论最少 8 步 OpenStreetMap + Canvas 城市地图实践:EveryCityMap 的五个工程取舍 OpenStreetMap + Canvas 城市地图实践:EveryCityMap 的五个工程取舍 博客 CDN 迁移到 Cloudflare R2 博客 CDN 迁移到 Cloudflare R2 Migrate my website to SvelteKit2 Migrate my website to SvelteKit2 漫谈编辑器 漫谈编辑器 Pure ESM package 又折腾博客了 又折腾博客了 Rewrite my website with Svelte Rewrite my website with Svelte 字体漫谈-网站字体最佳实践 字体漫谈-网站字体最佳实践 你不知道的 Web Components - 现状 你不知道的 Web Components - 现状 你不知道的 Web Components - 过去和未来 你不知道的 Web Components - 过去和未来 Node 应用本地 HTTPS Node 应用本地 HTTPS docker 部署 Gatsby 应用 docker 部署 Gatsby 应用 再提示『您的磁盘几乎已满』算我输 再提示『您的磁盘几乎已满』算我输 G6-Mobile 的前世今生 G6-Mobile 的前世今生
Pure ESM package
https://www.albertaz.com/about · 2022-07-22 · via ALBERTAZ

最近升级依赖,发现 execainquirer 的最新版本都已经变成了 pure ESM package,这也导致一些项目报错 ERR_REQUIRE_ESM,构建失败。

CommonJS -> ESM

我们知道由于早期 JavaScript 缺少模块规范,Node.js 和 NPM package 一直采用 CommonJS 规范,直到 2015 年,ECMAScript 给出了标准的模块规范 ECMAScript Modules ,社区工具开始陆续转向 ESM。

Node.js 对 ESM 的实验性支持 从目前已经不再维护的 v12 开始,到 v12.22.0 和 v 14.13.0 后(2020年底)稳定支持:在 package.json 配置了 type: module 的情况下,对 .mjs 后缀的文件 以 ESM 处理,同时通过 package.exports 语法糖导出模块。

之后随着 Node.js v10.x 下线,看起来在 Node.js 和 NPM 生态下全面使用 ESM 应该问题不大。

2021 年中开始,前端轮子哥 sindresorhus 开始呼吁大家使用 Pure ESM package,并率先把自己维护的一堆轮子(比如 execa)迁移到了 Pure ESM。随后越来越多的包开始 Pure ESM 化,在国内外社区都引起了很多激烈讨论,主要争论点在于生态中大量现有的 CommonJS 如何兼容 ESM 是个难题,尤其对于大型框架和应用。

Modern JavaScript with ESM

在 2022 年,超过 95% 的浏览器已经能支持 ES6/ESNEXT 语法,所谓 Modern JavaScript 是一个泛指,指的是这 95% 的现代浏览器支持的 JavaScript 语法。尽管如此,目前绝大多数网站在生产环境都会打包转译到 ES5 来支持那些 5% 的老版本浏览器,比如死而不僵的 IE 11。这也导致:

  • ES5 通常比等效的 Morden JavaScript 代码体积大 20% 左右,速度更慢,如果有工具缺陷、错误配置,这差距会进一步扩大。
  • 通常生产代码中的 90% 来自依赖库。库代码会由于 polyfill 和 helper 重复而产生更高的开销。

直接使用 Modern JavaScript 会避免这些问题,从长远看全面使用 ESM 是必然,这也让 Pure ESM package 有了更重要的意义。

如何使用 Pure ESM package

根据 Pure ESM package 的设计,一个 Pure ESM 包的 package.json 中会有 typeexports 配置的调整:

Diff
{
+  	"type": "module",
-  	"main": "./index.js",
+  	"exports": "./index.js",
    "engines": {
        "node": "^12.20.0 || ^14.14.0 || >=16.0.0"
    }
    ...
}

在使用时,Node.js v12 以下不再支持,require不再支持,需要用 import(包括动态 import)。

但事实上,实际使用场景不通,还有很多坑要填,sindresorhus 的这篇 FAQ 给的比较全面的,下面记录一些我遇到的:

Node 内置和全局对象

__dirname__filename在 ESM 中不可直接访问,因为他们并不是真的 globals,而是CommonJS 规范封装进来的

JavaScript
(function(exports, require, module, __filename, __dirname) { 
  ... 
});

因此需要通过 pathurl 获取文件目录:

JavaScript
import { dirname } from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(fileURLToPath(import.meta.url));

require有个 main属性,经常被用来判断模块是直接执行还是被调用执行。比如 require.main === module 为 true 说明是 CLI 直接执行 node foo.js,否则就是 require('./foo')调用执行。这在 ESM 中由于 require 用不了,需要换种方式:

JavaScript
import { fileURLToPath } from 'url';
import process from 'process';

if (import.meta.url.startsWith('file:')) {
  if (process.argv[1] === fileURLToPath(import.meta.url)) {
    // The script was run directly.
  }
}

或者通过 es-main 包去判断。

CLI 参数

命令行跑 ESM 要加参数:

  • node 命令
    • -r(—require)启动 CommonJS 模块
    • —experimental-specifier-resolution=node, 执行 ESM

TypeScript 和 ESM

浏览器和 Node.js 都有强制的文件拓展名规范,需要包含 js。
面向浏览器端的时候,开发应用,我们习惯了 webpack 之类的构建工具处理 ts 文件, 通常不会有感觉。
但在 Node.js 中,如果不借助其他处理工具,想直接引用 TypeScript 也只能改成 .js 拓展名。
再复习下 TS 的几个配置:

  • target: 编译后的JS 遵循什么规范
  • module: 文件中的模块采用何种方式实现
  • moduleResolution: 如何找依赖的模块位置
JSON
// tsconfig.json  ->  CommonJs 模式
"moduleResolution": "node",
"module": "commonjs",


// tsconfig.json   ->  ESM 模式
"moduleResolution": "node",
"module": "esnext",

如果配置了 ESM 模式,就需要调整拓展名:

JavaScript
// ./foo.ts
export function helper() {
    // ...
}

// ./bar.ts
import { helper } from "./foo"; // only works in CommonJS, Fail in ESM

// ./bar.ts
import { helper } from "./foo.js"; // works in ESM & CommonJS

常见的报错Error: ERR_MODULE_NOT_FOUND ... Cannot find module '.../src/foo'
import { bar } from './src/foo'import { bar } from './src/foo.js' 即可。

当然在 Node.js 中.cjs 会被识别为 CommonJs ,.mjs 会被识别为 ESM,所以也可以通过后缀 .cts(以 .cjs引入)、 .mts(以 .mjs引入),但感觉看起来更乱,还是统一保持 .js用文件夹区分比较好。

ts-node 和 ESM

实际项目中我们通常还是会用一些工具,比如 ts-node/ts-eager 来在 Node.js 中运行 TypeScript。
我习惯用 ts-node,ts-node 有一份对 ESM 的支持计划 ts-node/issues/1007,虽然没有完全实现,但通过配置 ESM loader 基本可以解决问题:

Bash
# only works for CommonJS, Fail in ESM
ts-node ./index.ts

# works for ESM
node --loader ts-node/esm ./index.ts

# works for ESM
ts-node --esm ./index.ts

一些报错记录:

  • TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts"
    • 对 ESM 没用 ts-node/esmloader
    • 对 CommonJS 没配置 "module": "commonjs"
  • ReferenceError: exports is not defined in ES module scope
    • 用了 ts-node/esmloader ,但配置了 "module": "commonjs"
  • Error [ERR_REQUIRE_ESM]: Must use import to load ES Module
    • 我们开头提到的,引用了 Pure ESM package,把他换成 CommonJS 版本,或者把当前项目改造成 Pure ESM。

Dual Mode package

相比 Pure ESM package 这种完全 ESM 化,社区里还有很多包采用双模式 npm 包 Dual CommonJS/ES module package,同时支持两种规范。
我理解面向浏览器的包,本质是直面用户的包,采用 Pure ESM 是可以接受的,因为 webpack 之类的工具都能处理。
而面向 Node.js 的包,直面用户的包用 Pure ESM 可行,但定位就是要被二次封装、存在上下游 CommonJS 依赖的包,其实更适合采用 Dual package。
同时要面向浏览器和 Node.js 的包,也应该用 Dual package。

Dual package 的改造根据 Node.js 支持的 conditional_exports,通过同时给出 requireimport的入口,导出两套规范代码, Node.js 会自行根据父模块的运行模式决定应当加载哪个文件:

JSON
{
  // package.json
  "name": "foo",
  "exports": {
    "require": "./main.cjs",
    "import": "./main.mjs"
  }
}

mjscjs的后缀名其实也不是必须,只要保证各自的文件符合相应的规范即可。
比如 domhandler就做了 Dual package 配置 https://github.com/fb55/domhandler/blob/master/package.json#L13

JSON
"exports": {
  "require": "./lib/index.js",
  "import": "./lib/esm/index.js"
 },

通过配置 exports 还有个好处就是可以避免不想暴露的目录被外部引用,比如在上面 domhandler 的 exports 配置下,如果直接引用 domhandler/lib/node,构建工具一般会报错 Module Not Found : Package path ./lib/node is not exported from package …/node_modules/domhandler

All Mode package

再进一步考虑前面提到的 Modern JavaScript,如果一个包同时要面向 Node.js 和浏览器,同时要兼顾现代和传统浏览器,其实可以把包目录结构和 package.json 设计成这样:

JSON
{
  "name": "my-pkg",
  "type": "module",
  "exports": {
    "types": "legacy/lib/index.d.ts",
    "require": "legacy/lib/index.js",
    "import": "legacy/esm/index.js",
    "modern": "modern/index.js",
	 }
  "main": "legacy/lib/index.js",
  "types": "legacy/lib/index.d.ts",
  "module": "legacy/esm/index.js",
  "browser": "./dist/index.umd.js",

}
Bash
├── dist
│   └── index.umd.js
├── legacy           
│   └── esm
│   └── lib
├── modern
├── package.json
├── src
│   └── index.ts
└── tsconfig.json

现代 JavaScript 产物在 modern 目录下,通过 exports导出。
具有传统 ES5 回退产物在 legacy目录下,其中:

  • legacy/lib下是传统 ES5 + CommonJS 的回退,通过 main导出,兼容老版本 Node.js
  • legacy/esm下是传统 ES5 + ESM 的回退,通过 module导出,注意即便对于传统 ES5,也可以加上 ESM 也就是 import 和 export,可以让 webpack 之类的构建工具做 treeshaking。

bundle 产物(umd)在 dist目录下,通过 browser导出,这也可以用来跟 bundless 区分。