ESLint 插件支持 native ESM 踩坑实践记录

578 阅读4分钟
  • 什么是 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 现状

    • 目前只支持 commonjs
    • 开始推进往 ESM 迁移,详见 RFC
    • 这导致如果 ESLint 插件的部分依赖升级到了 ESM only,那么这个插件几乎只能等待 ESLint 先支持 ESM 才能升级相关的依赖
    • 目前可能的 workaround 选项:
      • esm 包支持直接在 commonjs 中使用 ESM
      • synckit 包支持将异步函数转为同步执行,使在 commonjs 调用 await import() 变成同步操作
  • eslint-mdx 的选择

    • synckit 由于其异步转同步的能力在 eslint-mdx@1.13.0 被引入,当时是为了解决部分 remark 插件要求异步执行的问题,而 esm 没有能力解决同步调用异步函数的问题,因此显而易见地我们继续使用 synckit 来解决 ESM 的问题
  • 基于 synckit 的改造思路

    • 我们将所有 ESM only 的 npm 包的加载都转移到 synckitworker 中,并使用 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 中完成代码的 parseprocess 流程,我们将这两个操作也封装在同一个 worker 中按入参分别进行并返回可以被结构化克隆算法序列化的结构,不出意外的话我们的改造流程就能顺利运行了
  • 意外踩坑

    • v-file-message 定义的 VFileMessage 无法被正确序列化(暂时没有去深究原因,可能与它继承了 Error 有关),使用 JSON.parse(JSON.stringify()) 深度克隆一次即可
    • remark-mdx 内部使用 acorn 对 ES 和 jsx 语法进行 parse,而 acorn 解析出的 tokeneslint 默认使用的 parser espree 有差异(实际上 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 时我们需要传入 acornacorn-jsx 中定义的 tokTypes,而 acorn 是一个双格式混合包,acorn-jsx 是一个纯 commonjs 包,这本来无所谓,但是 acorn-jsx 里定义 tokTypes 的方式是 getJsxTokens(require("acorn")).tokTypesgetter,这导致 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