1

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

 1 year ago
source link: https://blog.rxliuli.com/p/73331967c1814df480811eee598e714b/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

JavaScript _
2022年8月10日 下午

7.6k 字

64 分钟

本文最后更新于:2022年8月14日 凌晨

可能许多前端开发者都知道,自从去年 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 模块运行。

{
  "type": "module"
}

TypeScript 支持

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

{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "NodeNext"
  }
}

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

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

import { helper } from "./foo.js"; // works in ESM & CJS
helper();

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

jest/wallaby 支持

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

import { uniq } from "lodash-es";

it("uniq", () => {
  console.log(uniq([1, 2, 1]));
});
Jest encountered an unexpected token

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

  1. 配置 ts-jest

    {
      "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/lodash.test.ts

  3. 配置 wallaby(这里就可以运行 __tests__ 目录下的测试文件了,奇怪。。。)

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

    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 为例:

之前一般会这样写

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

现在需要修改为

import fsExtra from "fs-extra";
import path from "path";

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

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

import fsExtra = require("fs-extra");
import path from "path";

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

使用 __dirname

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

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 字段区分模块

{
  "main": "dist/index.js",
  "module": "dist/index.esm.js",
  "types": "dist/index.d.ts"
}

但在 esm 项目中引用会报错

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

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

{
  "exports": {
    ".": {
      "import": "./dist/index.esm.js",
      "require": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  }
}

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

esbuild 支持

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

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

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));
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
[ERROR] Top-level await is currently not supported with the "cjs" output format

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

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));
})();

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

node dist/bin.js

首先是第一个错误

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 后缀。

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

然后出现了第二个错误

TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string or an instance of URL. Received undefined
// 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 中作者的回答,修改命令

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 的代码如下

// 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

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 呢?
也会出现错误

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

Error: Dynamic require of "fs" is not supported

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

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 产物可能会是 cjs,例如在浏览器中可能是 iife,在 nodejs 中是 cjs,但绝大多数的应用层开发者并不关心这些,只有 lib 的维护者才会关心,esm only 则将使用包的复杂度也转移给了使用者,而且在 cjs 中引用 esm only 的包并没有真正可用的方案。相比于 esbuild/vite 这种解决实际问题的项目而言,esm only 运动更像是一场 web 前端圈内的狂欢。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK