Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
JounQin opened this issue Mar 28, 2022 · 0 comments
Open

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

JounQin opened this issue Mar 28, 2022 · 0 comments

Comments

@JounQin
Copy link
Owner

JounQin commented Mar 28, 2022

  • 什么是 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant