JavaScript ESM 很好,但它现在也许没那么好

本文最后更新于:2022年10月1日 下午

前言

可能许多前端开发者都知道,自从去年 sindresorhus 发表 esm only 的宣言 一年多以来,许多项目开始转向了 esm only,即仅支持 esm 而不支持 cjs,以此来迫使整个生态更快的迁移到 esm only。

一些流行的项目已经这样做了

  • sindresorhus 维护的上千个 npm 包
  • node-fetch
  • remark 系列
  • 更多。。。

它们声称:你可以仍然使用现有版本而不升级到最新版,大版本更新不会影响到你。事实如何?

吾辈之前碰到过几次无法使用 esm only 包的问题,每当吾辈想尝试 esm only 时,总是还有一些问题,最痛苦的是,一些包是 esm only,而另一些是 cjs only,总要选择放弃一边,fuck esm only。主要问题一些是 cjs only 的包,以及必须兼容的包 typescript/jest/ts-jest/wallaby 未能正确支持 esm。当然,吾辈可以选择寻找 esm only 包的替代品,例如 globby => fast-glob、remark => markdown-it、node-fetch => node-fetch@2,lodash-es => lodash,但这终究不是一个长久的选择,更何况有些包很难真正找到替代品,例如 remark 系列。

那么,使用旧版本的包有什么问题呢?
主要问题是很难找到正确的版本,当然,如果使用的是相对独立的包,例如 node-fetch 这个,就可以直接使用 v2 版本即可。但如果使用的是 vuepress/remark 这种 monorepo 中包含许多小型包的项目,你很难找到每个子项目正确的版本。

吾辈最近在做 epub 生成器的时候需要从 markdown 并操作 ast 做一些转换,最后转换为 html,因此再次使用 remark,也决定真正尝试使用 esm,下面是一些尝试的过程。

目标

使用 esm 必须解决以下几个问题,否则在生产环境中使用是不可能的

  • typescript 支持 – 基本上全部的 web 项目都使用了 ts,不支持的话是不可接受的
  • jest 支持 – 同样大量使用的测试工具
    • wallaby 支持 – 一个付费的所见即所得的测试工具
  • 允许引用 cjs 模块 – 需要支持现有包
  • 双模块包仍然能支持两种 esm/cjs 的项目 – 需要支持 cjs 项目引用
  • 支持不打包的模块 – monorepo 中有些私有模块不会 bundle
  • esbuild 支持 – esbuild 正在成为 lib bundle 标准

修改 package 声明

第一步是修改包的模块类型,修改 "type": "module" 即可将包声明为 esm,所有 js 代码将默认以 esm 模块运行。

1
2
3
{
"type": "module"
}

TypeScript 支持

从 ts4.7 开始支持 NodeNext,所以需要更改 tsconfig.json

1
2
3
4
5
6
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "NodeNext"
}
}

另外,在 ts 文件中导入其他 ts 文件必须使用 .js 后缀

这是一个奇怪的限制,参考 ts 4.7 发布文档

1
2
import { helper } from './foo.js' // works in ESM & CJS
helper()

看起来是否会很奇怪,但现在只能这样写,typescript 甚至会这样提示

jest/wallaby 支持

例如使用 pnpm jest src/__tests__/lodash.test.ts 命令运行以下代码

1
2
3
4
5
import { uniq } from 'lodash-es'

it('uniq', () => {
console.log(uniq([1, 2, 1]))
})

出现错误

1
Jest encountered an unexpected token

从 jest 28 开始支持实验性的 esm 支持,wallaby/ts-jest 也都可以通过配置支持,按照以下步骤即可处理

  1. 配置 ts-jest

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    {
    "jest": {
    "preset": "ts-jest/presets/default-esm",
    "globals": {
    "ts-jest": {
    "useESM": true
    }
    },
    "moduleNameMapper": {
    "^(\\.{1,2}/.*)\\.js$": "$1"
    },
    "testMatch": ["<rootDir>/src/**/__tests__/*.test.ts"]
    }
    }
  2. 修改命令为 node --experimental-vm-modules node_modules/jest/bin/jest.js src/__tests__/lodash.test.ts

  3. 配置 wallaby

    1
    2
    3
    4
    5
    6
    7
    8
    9
    {
    "wallaby": {
    "env": {
    "params": {
    "runner": "--experimental-vm-modules"
    }
    }
    }
    }
  4. 由于 esm 的导入是静态的,所以还需要卸载 @types/jest 使用 @jest/globals 包导入测试需要的函数,例如 it/expect/describe/beforeEach 等等

    1
    2
    3
    4
    5
    import { it, expect } from '@jest/globals'

    it('basic', () => {
    expect(1 + 2).toBe(3)
    })

nodejs 支持

nodejs 自从 14 就开始支持 esm,但直到目前 18 为止迁移仍然不是平滑的,主要遇到了以下问题。

导入 cjs only 模块

遗憾的是,现存的大量包都是 cjs only 模块的,不可能短时间迁移,而 nodejs 中 esm 与 cjs 的互操作性并不太好,所以需要处理一下。下面以 fs-extra 为例:

之前一般会这样写

1
2
3
4
import { readdir } from 'fs-extra'
import path from 'path'

console.log(await readdir(path.resolve()))

使用 tsx 运行时会出现错误 SyntaxError: The requested module 'fs-extra' does not provide an export named 'readdir',这似乎是一个已知错误,参考:https://github.com/esbuild-kit/tsx/issues/38

现在需要修改为

1
2
3
4
import fsExtra from 'fs-extra'
import path from 'path'

console.log(await fsExtra.readdir(path.resolve()))

或者修改为以下代码使用 ts-node --esm <file> 运行(tsx 不支持这种方式)

1
2
3
4
import fsExtra = require('fs-extra')
import path from 'path'

console.log(await fsExtra.readdir(path.resolve()))

使用 __dirname

是的,你没看错,在 esm 模块下 __dirname 不可用了,取而代之的是 import.meta.url,总而言之,现在的使用方式是

1
2
3
4
5
6
import path from 'path'
import { fileURLToPath } from 'url'

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

参考文章 https://flaviocopes.com/fix-dirname-not-defined-es-module-scope/,之后在谈到 esbuild 时再说打包 cjs bundle 如何处理 import.meta.url(在 cjs 中不支持,又是二选一)。

lib 维护与使用

新的 esm 与 cjs 双包支持配置

之前,我们通过 main/module 字段区分模块

1
2
3
4
5
{
"main": "dist/index.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts"
}

但在 esm 项目中引用会报错

1
SyntaxError: The requested module 'cjs-and-esm-lib' does not provide an export named 'hello'

esm 项目不认这个,它新定义了 exports 字段,所以需要增加(注意 main 字段仍需保留兼容旧版本 node)exports 字段

1
2
3
4
5
6
7
8
9
{
"exports": {
".": {
"import": "./dist/index.esm.js",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
}
}
}

参考该回答:https://stackoverflow.com/a/70020984

esbuild 支持

原以为 esbuild 天生支持 esm 所以应该会很简单,但实际上也遇到了相当多的问题。

捆绑以下代码为 cjs 会出现错误

1
2
3
4
5
6
7
8
9
10
import path from 'path'
import { fileURLToPath } from 'url'
import fsExtra from 'fs-extra'

const { readdir } = fsExtra

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
console.log(__dirname)
console.log(await readdir(__dirname))

命令

1
2
esbuild src/bin.ts --platform=node --outfile=dist/bin.esm.js --bundle --sourcemap --format=esm
esbuild src/bin.ts --platform=node --outfile=dist/bin.js --bundle --sourcemap --format=cjs

错误

1
[ERROR] Top-level await is currently not supported with the "cjs" output format

这里是因为 cjs 不能包含顶级 await,修改为

1
2
3
4
5
6
7
8
9
10
11
12
import path from 'path'
import { fileURLToPath } from 'url'
import fsExtra from 'fs-extra'

const { readdir } = fsExtra

;(async () => {
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
console.log(__dirname)
console.log(await readdir(__dirname))
})()

捆绑没有问题,但运行会出错

1
node dist/bin.js

首先是第一个错误

1
2
3
4
5
6
var import_path = __toESM(require("path"), 1);
^

ReferenceError: require is not defined in ES module scope, you can use import instead
This file is being treated as an ES module because it has a '.js' file extension and 'esm-demo\packages\esm-include-cjs-lib\package.json' contains "type": "module". To
treat it as a CommonJS script, rename it to use the '.cjs' file extension.

它说这是一个 esm 包,默认代码是 esm 模块,如果希望是以 cjs 模块执行,需要修改为 cjs 后缀。

修改命令

1
esbuild src/bin.ts --platform=node --outfile=dist/bin.cjs --bundle --sourcemap --format=cjs

然后出现了第二个错误

1
TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string or an instance of URL. Received undefined

相关代码

1
2
3
4
5
6
7
8
9
10
11
12
// src/bin.ts
var import_path = __toESM(require('path'), 1)
var import_url = require('url')
var import_fs_extra = __toESM(require_lib(), 1)
var import_meta = {}
var { readdir } = import_fs_extra.default
;(async () => {
const __filename = (0, import_url.fileURLToPath)(import_meta.url) // 这里是关键,因为 import.meta.url 在 cjs 代码中是空的
const __dirname = import_path.default.dirname(__filename)
console.log(__dirname)
console.log(await readdir(__dirname))
})()

根据这个 issue 中作者的回答,修改命令

1
esbuild src/bin.ts --platform=node --outfile=dist/bin.cjs --inject:./import-meta-url.js --define:import.meta.url=import_meta_url --bundle --sourcemap --format=cjs

遗憾的是,这不再生效,bundle 的代码如下

1
2
3
4
5
6
7
8
9
10
11
12
// import-meta-url.js
var import_meta_url2 = require('url').pathToFileURL(__filename)
console.log(import_meta_url2)

// src/bin.ts
var import_path = __toESM(require('path'), 1)
var import_url = require('url')
;(async () => {
const __filename2 = (0, import_url.fileURLToPath)(import_meta_url)
const __dirname = import_path.default.dirname(__filename2)
console.log(__dirname)
})()

可以明显看到,注入的脚本的变量名被修改了,从 import_meta_url => import_meta_url2,这是奇怪的问题。。。

或许可以替换 --inject => --banner

1
esbuild src/bin.ts --platform=node --outfile=dist/bin.cjs --define:import.meta.url=import_meta_url --bundle --sourcemap --banner:js="var import_meta_url = require('url').pathToFileURL(__filename)" --format=cjs

这样就生效了


那么,运行 esm bundle 呢?
也会出现错误

1
2
3
throw new Error('Dynamic require of "' + x + '" is not supported')

Error: Dynamic require of "fs" is not supported

按照这里的解决方法修改命令

1
esbuild src/bin.ts --platform=node --outfile=dist/bin.esm.js --bundle --sourcemap --banner:js="import { createRequire } from 'module';const require = createRequire(import.meta.url);" --format=esm

现在,bundle 后的代码可以终于运行了。

结语

或许 esm only 看起来很好,也有 tree shaking 看起来很棒的想法,但现在,它都还没有真正在生产中可用。包括一系列重要的项目都没有迁移,包括 react/vscode/electron/vite 等等。实际上,在此之前,许多人(吾辈亦然)也都使用 esm 模块来编写代码,只是最终的 bundle 产物可能不是 esm,例如在浏览器中可能是 iife,在 nodejs 中是 cjs,但绝大多数的应用层开发者并不关心这些,只有 lib 的维护者才会关心,esm only 则将包的复杂度也转移给了使用者,而且在 cjs 中引用 esm only 的包并没有真正可用的方案。相比于 esbuild/vite 这种解决实际问题的项目而言,esm only 运动更像是一场 web 前端圈内的狂欢。


JavaScript ESM 很好,但它现在也许没那么好
https://blog.rxliuli.com/p/73331967c1814df480811eee598e714b/
作者
rxliuli
发布于
2022年8月10日
许可协议