最近,把 rc-modal 做成了一個庫。寫代碼倒是不難,無非就是把原本的代碼封裝抽象一下。倒是發包,和作為使用者如何使用這個包難住了。
首先,我們知道現在一個 npm 包如果不做 ESM Pure 的話,你需要考慮兼容 CJS/ESM,node 老版本和新版本(吃不吃 package.json 的 exports 字段),和 TypeScript 針對這些情況推出 bundler
開啟了特徵和沒有開啟 bundler
的項目。那么,我們需要保證使用方不管是哪个版本 node 在 module resolution 沒有問題,不管你的項目有沒有開啟 type: module
都沒有問題,不管你的 TypeScript 有沒有開啟 "moduleResolution": "Bundler"
也都沒有問題。
另外,還需要保證,打包出來的產物,不能丟失源代碼中存在的 "use client"
directives。
所以,下面的例子我們來寫一個這樣的庫,需要全面兼容上面的所有場景。
初始化#
寫庫首先選擇打包工具,目前常見的有:rollup, esbuild, swc。
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" 是什麼就行。
export const Client = () => null
```
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',
entry: ['src/index.ts'],
formats: ['cjs', 'es'],
},
rollupOptions: {
external: [
'react',
'react-dom',
'lodash',
'lodash-es',
'react/jsx-runtime',
...Object.keys(globals),
],
output: {
preserveModules: true,
},
plugins: [preserveDirectives({})],
},
},
})
現在 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({})],
})
現在我們就有了 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)
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
之後,得到了這樣的結果。
Environment | rc-lib | rc-lib/client | rc-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'
export * from './components/shared'
export * from './components/client.js'
export * from './components/shared.js'
再次打包,在網站上驗證。
Environment | rc-lib | rc-lib/client | rc-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 之後重新驗證兼容性。
Environment | rc-lib | rc-lib/client | rc-lib/shared |
---|---|---|---|
node10 | ✅ | ✅ | ✅ |
node16 (from CJS) | ✅ (CJS) | ✅ (CJS) | ✅ (CJS) |
node16 (from ESM) | ✅ (ESM) | ✅ (ESM) | ✅ (ESM) |
bundler | ✅ | ✅ | ✅ |
現在我們已經完全兼容了所有的環境,大功告成。
上面代碼的完整模板位於:
此文由 Mix Space 同步更新至 xLog 原始鏈接為 https://innei.in/posts/tech/write-a-universally-compatible-js-library-with-type-support