IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    2024 年,该如何写一个全面兼容的 NPM 库

    Innei发表于 2024-03-18 08:41:18
    love 0
    该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/tech/write-a-universally-compatible-js-library-with-fully-types

    最近,把 rc-modal 做成了一个库。写代码倒是不难,无非就是把原本的代码封装抽象一下。倒是发包,和作为使用者如何使用这个包难住了。

    首先,我们知道现在一个 npm 包如果不做 Pure ESM 的话,你需要考虑兼容 CJS/ESM,node 老版本和新版本(吃不吃 package.json 的 exports 字段),和 TypeScript 针对这些情况推出 bundler开启了特征和没有开启 bundler 的项目。那么,我们需要保证使用方不管是哪个版本 node 在 module resolution 没有问题,不管你的项目有没有开启 type: module 都没有问题,不管你的 TypeScript 有没有开启 "moduleResolution": "Bundler" 也都没有问题。

    另外,还需要保证,打包出来的产物,不能丢失源代码中存在的 "use client" directives。

    所以,下面的例子我们来写一个这样的库,需要全面兼容上面的所有场景。

    初始化

    写库首先选择打包工具,目前常见的有:rollup, esbuild。

    rollup 在兼容上最好,同时生态也比较完善。这里我们选择 vite 作为打包工具,相比直接使用 rollup,vite 配置会更加简单一点。

    首先初始化一个项目。

    npm create vite@latest
    # 选择 react-ts

    然后我们调整一下 vite 的配置。

    import { readFileSync } from 'fs'
    
    import react from '@vitejs/plugin-react'
    import { defineConfig } from 'vite'
    
    const packageJson = JSON.parse(
      readFileSync('./package.json', { encoding: 'utf-8' }),
    )
    
    const globals = {
      ...(packageJson?.dependencies || {}),
    }
    export default defineConfig({
      plugins: [react()],
    
      build: {
        lib: {
          entry: 'src/index.ts',
          formats: ['cjs', 'es'],
        },
        rollupOptions: {
          external: [
            'react',
            'react-dom',
            'lodash',
            'lodash-es',
            'react/jsx-runtime',
            ...Object.keys(globals),
          ],
        },
      },
    })

    然后在 src 中,简单的写两个组件进行导出。这里我们定义一个为 Client Component 另一个为 Shared Component。如果你不知道这是什么也没有关系,你只需要知道 "use client" 是什么就行。

    这个内容只能在原文中查看哦

    OK,那么这个简单库的代码就写好了。接下来我们需要考虑如何兼容上面提到的所有场景。

    保留 "use client" directive

    按照上面的代码进行打包之后,产物中并不会存在 "use client" directive。而且所有的模块都被打包成一个文件。

    使用 rollup 插件 rollup-plugin-preserve-directives 来解决这个问题。

    npm i rollup-plugin-preserve-directives -D

    修改一下 vite 配置。

    import { preserveDirectives } from 'rollup-plugin-preserve-directives'
    
    export default defineConfig({
      // ...
      build: {
        lib: {
          entry: 'src/index.ts', // [!code --]
          entry: ['src/index.ts'], // [!code ++]
          formats: ['cjs', 'es'],
        },
        rollupOptions: {
          external: [
            'react',
            'react-dom',
            'lodash',
            'lodash-es',
            'react/jsx-runtime',
            ...Object.keys(globals),
          ],
          output: {
            preserveModules: true, // [!code ++]
          },
          plugins: [preserveDirectives({})], // [!code ++]
        },
      },
    })

    现在 build 之后,dist 目录就这样了。

    .
    ├── components
    │   ├── client.cjs
    │   ├── client.js
    │   ├── shared.cjs
    │   └── shared.js
    ├── index.cjs
    └── index.js

    并且,可以看到 client.js 的产物是:

    'use client'
    const l = () => null
    export { l as Client }

    保留了 "use client" directive。

    生成 d.ts

    这一步是非常重要的,也是为后续兼容 node 老版本的基础。

    现在我们的产物是完全没有类型的,我们需要生成 d.ts 文件。这里可以使用 vite-plugin-dts 插件。

    npm i -D vite-plugin-dts

    修改下配置:

    import dts from 'vite-plugin-dts'
    
    export default defineConfig({
      plugins: [react(), dts({})], // [!code highlight]
    })

    现在我们就有了 d.ts 文件。每一个产物都有对应了一个 d.ts 文件。

    那么,现在我们要如何定义 package.json 的产物导出字段。

    在支持 exports 字段的 node 版本中,我们应该这样定义。假设我们现在的 type: "module"。

    {
      "exports": {
        ".": {
          "import": {
            "types": "./dist/index.d.ts",
            "default": "./dist/index.js"
          }
          "require": {
            "types": "./dist/index.d.ts",
            "default": "./dist/index.cjs"
          }
        },
        "./client": {
          "import": {
            "types": "./dist/components/client.d.ts",
            "default": "./dist/components/client.js"
          },
          "require":  {
            "types": "./dist/components/client.d.ts",
            "default": "./dist/components/client.cjs"
          }
        },
        "./shared": {
          "import": {
            "types": "./dist/components/shared.d.ts",
            "default": "./dist/components/shared.js"
          },
          "require":  {
            "types": "./dist/components/shared.d.ts",
            "default": "./dist/components/shared.cjs"
          }
    
        }
      }
    }

    写起来是相当的麻烦。每一个导出项都要写一个 import 和 require 字段,在内部还需要再写一个 types 字段,types 字段还必须在第一位,不小心写错了写漏了就完了。

    那么这里为什么不能写成这样的形式。

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

    那是因为,这个 types 只对 ESM 有用,对 CJS 是没有用的。既然 CJS/ESM 都分离了,那么 types 也需要分离才行。

    修改 vite.config.ts。

    export default defineConfig({
      plugins: [
        react(),
        dts({
          beforeWriteFile: (filePath, content) => {
            writeFileSync(filePath.replace('.d.ts', '.d.cts'), content) // [!code ++]
            return { filePath, content }
          },
        }),
      ],
    })

    现在我们每个产物都有对应的一个 d.cts 和 d.ts 文件了。

    .
    ├── components
    │   ├── client.cjs
    │   ├── client.d.cts
    │   ├── client.d.ts
    │   ├── client.js
    │   ├── shared.cjs
    │   ├── shared.d.cts
    │   ├── shared.d.ts
    │   └── shared.js
    ├── index.cjs
    ├── index.d.cts
    ├── index.d.ts
    └── index.js

    那么现在 exports 字段只需要修改为:

    {
      "exports": {
        ".": {
          "import": "./dist/index.js",
          "require": "./dist/index.cjs"
        },
        "./client": {
          "import": "./components/client.js",
          "require": "./dist/components/client.cjs"
        },
        "./shared": {
          "import": "./dist/components/shared.js",
          "require": "./dist/components/shared.cjs"
        }
      }
    }

    嗯,甚至我们不再需要 types 字段了。

    我们添加下常规的定义。

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

    现在我们 build 一下,然后 pack 之后去 arethetypeswrong 验证。

    npm run build
    npm pack

    上传 taz 之后,得到了这样的结果。

    Environmentrc-librc-lib/clientrc-lib/shared
    node10✅💀 Resolution failed💀 Resolution failed
    node16 (from CJS)✅✅ (CJS)✅ (CJS)
    node16 (from ESM)🥴 Internal resolution error (2)✅ (ESM)✅ (ESM)
    bundler✅✅✅

    可以看到,在 node10 也就是不支持 exports 字段环境中,后面的非 index 导出都是无法被 resolve 到的。

    其次,在 node16 中,tsconfig 开启了 moduleResolution: "node16" 并且项目是 type: "module" 时候,index 反而是无法正确类型推导的。

    我们先来解决后者的问题。

    起一个上面出现问题的 demo。

    {
      "type": "module",
      "dependencies": {
        "rc-lib": "workspace:*" // 这里使用 pnpm workspace 链接这个包。
      }
    }
    {
      "compilerOptions": {
        "moduleResolution": "node16"
      }
    }

    创建 index.ts

    import { Client } from 'rc-lib'
    import { Share } from 'rc-lib/shared'
    
    console.log(!!Client, !!Share)

    此时使用 tsx index.ts,程序顺利运行,并输出 true true。可见 node 对于这个 module 的 resolve 是没有问题的。但是问题出在了 TypeScript 的类型推导。

    进入类型后,发现这样的报错。

    问题找到了,导出项没有添加后缀。

    现在我们修改包的 index.ts。

    export * from './components/client' // [!code --]
    export * from './components/shared' // [!code --]
    export * from './components/client.js' // [!code ++]
    export * from './components/shared.js' // [!code ++]

    再次打包,在网站上验证。

    Environmentrc-librc-lib/clientrc-lib/shared
    node10✅💀 Resolution failed💀 Resolution failed
    node16 (from CJS)✅ (CJS)✅ (CJS)✅ (CJS)
    node16 (from ESM)✅ (ESM)✅ (ESM)✅ (ESM)
    bundler✅✅✅

    那么现在除了 node10 不支持 exports 之外,我们已经全部解决了。

    解决旧版本 node 的非 index 导出问题

    要解决这个问题,我们首先需要知道 typesVersions。

    typesVersions 是 TypeScript 4.1 引入的一个字段,用于解决不同版本的 TypeScript 对于不同版本的 node 的类型推导问题。

    在 node10 中,我们可以通过这个字段来解决 exports 导出项的推导问题。

    {
      "typesVersions": {
        "*": {
          "*": ["./dist/*", "./dist/components/*", "./*"]
        }
      }
    }

    因为产物导出项存在于 dist 和 dist/components 中,所以我们需要定义两层目录,最后一个根目录经过测试也是必须的。

    现在 pack 之后重新验证兼容性。

    Environmentrc-librc-lib/clientrc-lib/shared
    node10✅✅✅
    node16 (from CJS)✅ (CJS)✅ (CJS)✅ (CJS)
    node16 (from ESM)✅ (ESM)✅ (ESM)✅ (ESM)
    bundler✅✅✅

    现在我们已经完全兼容了所有的环境,大功告成。

    上面代码的完整模板位于:

    https://github.com/innei-template/rc-library-template

    看完了?说点什么呢



沪ICP备19023445号-2号
友情链接