-
什么是 native ESM?
- ESM 即 ECMAScript modules,是 JavaScript 最新官方标准格式
- native ESM 指的是 Node.js 在 v14.0.0, v13.14.0, v12.20.0 后删除实验模块的 flag 和相关警告,开始直接原生支持 ESM。
-
Node.js 模块历史简单回顾
- commonjs 是 Node.js 自诞生之日就一直在使用的标准模块格式,缺点是浏览器不支持
- 为了方便模块在 Node.js 和浏览器之间复用,基于 commonjs/amd(requirejs)/global 上下文的 umd 模块格式开始流行,缺点是无法支持摇树优化 (tree shaking)
- ESM 是 ES6 规范制定的 JavaScript 官方标准格式,但与之前的所以格式语法都无法兼容,导致推进缓慢
- 随着 Node.js 原生支持 ESM,由 sindresorhus 发起的 Pure ESM package 行动受到越来越多的推崇,但同时也有很多流行的 npm 包只发布 commonjs 格式或同时发布 commonjs + ESM 的双格式混合包 (Dual packages)。
-
ESLint 现状
-
eslint-mdx的选择synckit由于其异步转同步的能力在eslint-mdx@1.13.0被引入,当时是为了解决部分remark插件要求异步执行的问题,而esm没有能力解决同步调用异步函数的问题,因此显而易见地我们继续使用synckit来解决 ESM 的问题
-
基于
synckit的改造思路- 我们将所有 ESM only 的 npm 包的加载都转移到
synckit的worker中,并使用await import()加载,因为我们的 worker 代码也是 commonjs 的 - 由于我们的源码使用 TypeScript 编写,部分代码构建由
tsc执行完成,而目前 TypeScript 输出 commonjs 时总是会将await import()转化为类似Promise.resolve().then(() => require())的结构,这直接导致我们在 commonjs 中使用await import()加载 ESM 失效了。恰好这两天在@angular-builders/custom-webpack升级支持 ESM 的 PR(Angular 13 也已经变成 pure ESM 了)看到了相关的 workaround:
/** * This uses a dynamic import to load a module which may be ESM. * CommonJS code can load ESM code via a dynamic import. Unfortunately, TypeScript * will currently, unconditionally downlevel dynamic import into a require call. * require calls cannot load ESM code and will result in a runtime error. To workaround * this, a Function constructor is used to prevent TypeScript from changing the dynamic import. * Once TypeScript provides support for keeping the dynamic import this workaround can * be dropped. * * @param modulePath The path of the module to load. * @returns A Promise that resolves to the dynamically imported module. */ function loadEsmModule<T>(modulePath: string | URL): Promise<T> { return new Function('modulePath', `return import(modulePath);`)(modulePath) as Promise<T>; }- 除了加载 ESM 模块,我们还需要在 worker 中完成代码的
parse和process流程,我们将这两个操作也封装在同一个 worker 中按入参分别进行并返回可以被结构化克隆算法序列化的结构,不出意外的话我们的改造流程就能顺利运行了
- 我们将所有 ESM only 的 npm 包的加载都转移到
-
意外踩坑
v-file-message定义的VFileMessage无法被正确序列化(暂时没有去深究原因,可能与它继承了Error有关),使用JSON.parse(JSON.stringify())深度克隆一次即可remark-mdx内部使用acorn对 ES 和 jsx 语法进行parse,而acorn解析出的token和eslint默认使用的parserespree有差异(实际上espree内部也是使用的acorn),因此我们需要在将remark-mdx调用acorn解析出的tokens进行转换,而这个转换的部分espree内部已经有了相关实现TokenTranslator,我们『直接复用即可』。- 复用
TokenTranslator使用过程中发现TokenTranslator并没有被导出,导致await import('espree/lib/token-translator')不可用,我们需要使用绝对路径加载的方式越过这个限制:
TokenTranslator = ( await loadEsmModule<typeof import("espree/lib/token-translator")>( path.resolve( require.resolve("espree/package.json"), "../lib/token-translator.js" ) ) ).default- 调用
TokenTranslator#onToken时我们需要传入acorn和acorn-jsx中定义的tokTypes,而acorn是一个双格式混合包,acorn-jsx是一个纯 commonjs 包,这本来无所谓,但是acorn-jsx里定义tokTypes的方式是getJsxTokens(require("acorn")).tokTypes的getter,这导致acorn-jsx里引用的acorn是 commonjs 格式的,而remark-mdx是纯 ESM 包,引用的acorn是 ESM 格式的,最终导致两个包定义的TokenType虽然内容是一样,但引用/指针却不同。因此我们只能查看acorn-jsx源码后使用如下的方式获取正确的jsxTokTypes:
jsxTokTypes = ( await loadEsmModule<{ default: typeof import("acorn-jsx") }>("acorn-jsx") ).default( { allowNamespacedObjects: true, } // @ts-expect-error )(acorn.Parser).acornJsx.tokTypes; -
至此,
eslint-mdx基本就能完整支持 native ESM 了,剩下的就是一些常规的由于依赖破坏性变更导致的改动,此次踩坑实践详见相关 PR。