編寫兼容 nodejs/瀏覽器的庫

博客原文: https://blog.rxliuli.com/p/b8...

問題

兼容問題是因爲使用了平臺特定的功能致使,會致使下面幾種狀況node

  • 不一樣的模塊化規範:rollup 打包時指定
  • 平臺限定的代碼:例如包含不一樣平臺的適配代碼
  • 平臺限定的依賴:例如在 nodejs 須要填充 fetch/FormData
  • 平臺限定的類型定義:例如瀏覽器中的 Blob 和 nodejs 中的 Buffer

不一樣的模塊化規範

這是很常見的一件事,如今就已經有包括 cjs/amd/iife/umd/esm 多種規範了,因此支持它們(或者說,至少支持主流的 cjs/esm)也成爲必須作的一件事。幸運的是,打包工具 rollup 提供了相應的配置支持不一樣格式的輸出文件。ios

GitHub 示例項目

形如git

// rollup.config.js
export default defineConfig({
  input: 'src/index.ts',
  output: [
    { format: 'cjs', file: 'dist/index.js', sourcemap: true },
    { format: 'esm', file: 'dist/index.esm.js', sourcemap: true },
  ],
  plugins: [typescript()],
})

而後在 package.json 中指定便可github

{
  "main": "dist/index.js",
  "module": "dist/index.esm.js",
  "types": "dist/index.d.ts"
}
許多庫都支持 cjs/esm,例如 rollup,但也有僅支持 esm 的庫,例如 unified.js 系列

平臺限定的代碼

  • 經過不一樣的入口文件打包不一樣的出口文件,並經過 browser 指定環境相關的代碼,例如 dist/browser.js/dist/node.js:使用時須要注意打包工具(將成本轉嫁給使用者)
  • 使用代碼判斷運行環境動態加載
對比 不一樣出口 代碼判斷
優勢 代碼隔離的更完全 不依賴於打包工具行爲
最終代碼僅包含當前環境的代碼
缺點 依賴於使用者的打包工具的行爲 判斷環境的代碼可能並不許確
最終代碼包含全部代碼,只是選擇性加載
axios 結合以上兩種方式實現了瀏覽器、nodejs 支持,但同時致使有着兩種方式的缺點並且有點迷惑行爲,參考 getDefaultAdapter。例如在 jsdom 環境會認爲是瀏覽器環境,參考 detect jest and use http adapter instead of XMLHTTPRequest

經過不一樣的入口文件打包不一樣的出口文件

GitHub 示例項目
// rollup.config.js
export default defineConfig({
  input: ['src/index.ts', 'src/browser.ts'],
  output: [
    { dir: 'dist/cjs', format: 'cjs', sourcemap: true },
    { dir: 'dist/esm', format: 'esm', sourcemap: true },
  ],
  plugins: [typescript()],
})
{
  "main": "dist/cjs/index.js",
  "module": "dist/esm/index.js",
  "types": "dist/index.d.ts",
  "browser": {
    "dist/cjs/index.js": "dist/cjs/browser.js",
    "dist/esm/index.js": "dist/esm/browser.js"
  }
}

使用代碼判斷運行環境動態加載

GitHub 示例項目

基本上就是在代碼中判斷而後 await import 而已typescript

import { BaseAdapter } from './adapters/BaseAdapter'
import { Class } from 'type-fest'

export class Adapter implements BaseAdapter {
  private adapter?: BaseAdapter
  private async init() {
    if (this.adapter) {
      return
    }
    let Adapter: Class<BaseAdapter>
    if (typeof fetch === 'undefined') {
      Adapter = (await import('./adapters/NodeAdapter')).NodeAdapter
    } else {
      Adapter = (await import('./adapters/BrowserAdapter')).BrowserAdapter
    }
    this.adapter = new Adapter()
  }
  async get<T>(url: string): Promise<T> {
    await this.init()
    return this.adapter!.get(url)
  }
}
// rollup.config.js
export default defineConfig({
  input: 'src/index.ts',
  output: { dir: 'dist', format: 'cjs', sourcemap: true },
  plugins: [typescript()],
})
注: vitejs 沒法捆綁處理這種包,由於 nodejs 原生包在瀏覽器環境確實不存在,這是一個已知錯誤,參考: Cannot use amplify-js in browser environment (breaking vite/snowpack/esbuild)

平臺限定的依賴

  • 直接 import 依賴使用:會致使在不一樣的環境炸掉(例如 node-fetch 在瀏覽器就會炸掉)
  • 在代碼中判斷運行時經過 require 動態 引入依賴:會致使即使用不到,也仍然會被打包加載
  • 在代碼中判斷運行時經過 import() 動態引入依賴:會致使代碼分割,依賴做爲單獨的文件選擇性加載
  • 經過不一樣的入口文件打包不一樣的出口文件,例如 dist/browser.js/dist/node.js:使用時須要注意(將成本轉嫁給使用者)
  • 聲明 peerDependencies 可選依賴,讓使用者自行填充:使用時須要注意(將成本轉嫁給使用者)
對比 require import
是否必定會加載
是否須要開發者注意
是否會屢次加載
是否同步
rollup 支持

在代碼中判斷運行時經過 require 動態引入依賴

GitHub 項目示例
// src/adapters/BaseAdapter.ts
import { BaseAdapter } from './BaseAdapter'

export class BrowserAdapter implements BaseAdapter {
  private static init() {
    if (typeof fetch === 'undefined') {
      const globalVar: any =
        (typeof globalThis !== 'undefined' && globalThis) ||
        (typeof self !== 'undefined' && self) ||
        (typeof global !== 'undefined' && global) ||
        {}
      // 關鍵在於這裏的動態 require
      Reflect.set(globalVar, 'fetch', require('node-fetch').default)
    }
  }

  async get<T>(url: string): Promise<T> {
    BrowserAdapter.init()
    return (await fetch(url)).json()
  }
}

1624018106300

在代碼中判斷運行時經過 import() 動態引入依賴

GitHub 項目示例
// src/adapters/BaseAdapter.ts
import { BaseAdapter } from './BaseAdapter'

export class BrowserAdapter implements BaseAdapter {
  // 注意,這裏變成異步的函數了
  private static async init() {
    if (typeof fetch === 'undefined') {
      const globalVar: any =
        (typeof globalThis !== 'undefined' && globalThis) ||
        (typeof self !== 'undefined' && self) ||
        (typeof global !== 'undefined' && global) ||
        {}
      Reflect.set(globalVar, 'fetch', (await import('node-fetch')).default)
    }
  }

  async get<T>(url: string): Promise<T> {
    await BrowserAdapter.init()
    return (await fetch(url)).json()
  }
}

打包結果json

1624018026889

遇到的一些子問題

  • 怎麼判斷是否存在全局變量axios

    typeof fetch === 'undefined'
  • 怎麼爲不一樣環境的全局變量寫入 ployfill瀏覽器

    const globalVar: any =
      (typeof globalThis !== 'undefined' && globalThis) ||
      (typeof self !== 'undefined' && self) ||
      (typeof global !== 'undefined' && global) ||
      {}
  • TypeError: Right-hand side of 'instanceof' is not callable: 主要是 axios 會判斷 FormData,而 form-data 則存在默認導出,因此須要使用 (await import('form-data')).default(吾輩總有種在給本身挖坑的感受)
    1622828175546

使用者在使用 rollup 打包時可能會遇到兼容性的問題,實際上就是須要選擇內聯到代碼仍是單獨打包成一個文件,參考:https://rollupjs.org/guide/en...app

內聯 => 外聯dom

// 內聯
export default {
  output: {
    file: 'dist/extension.js',
    format: 'cjs',
    sourcemap: true,
  },
}
// 外聯
export default {
  output: {
    dir: 'dist',
    format: 'cjs',
    sourcemap: true,
  },
}

平臺限定的類型定義

如下解決方案本質上都是多個 bundle

  • 混合類型定義。例如 axios
  • 打包不一樣的出口文件和類型定義,要求使用者自行指定須要的文件。例如經過 module/node/module/browser 加載不一樣的功能(其實和插件系統很是接近,無非是否分離多個模塊罷了)
  • 使用插件系統將不一樣環境的適配代碼分離爲多個子模塊。例如 remark.js 社區
對比 多個類型定義文件 混合類型定義 多模塊
優勢 環境指定更明確 統一入口 環境指定更明確
缺點 須要使用者自行選擇 類型定義冗餘 須要使用者自行選擇
dependencies 冗餘 維護起來相對麻煩(尤爲是維護者不是一我的的時候)

打包不一樣的出口文件和類型定義,要求使用者自行指定須要的文件

GitHub 項目示例

主要是在覈心代碼作一層抽象,而後將平臺特定的代碼抽離出去單獨打包。

// src/index.ts
import { BaseAdapter } from './adapters/BaseAdapter'

export class Adapter<T> implements BaseAdapter<T> {
  upload: BaseAdapter<T>['upload']

  constructor(private base: BaseAdapter<T>) {
    this.upload = this.base.upload
  }
}
// rollup.config.js

export default defineConfig([
  {
    input: 'src/index.ts',
    output: [
      { dir: 'dist/cjs', format: 'cjs', sourcemap: true },
      { dir: 'dist/esm', format: 'esm', sourcemap: true },
    ],
    plugins: [typescript()],
  },
  {
    input: ['src/adapters/BrowserAdapter.ts', 'src/adapters/NodeAdapter.ts'],
    output: [
      { dir: 'dist/cjs/adapters', format: 'cjs', sourcemap: true },
      { dir: 'dist/esm/adapters', format: 'esm', sourcemap: true },
    ],
    plugins: [typescript()],
  },
])

使用者示例

import { Adapter } from 'platform-specific-type-definition-multiple-bundle'

import { BrowserAdapter } from 'platform-specific-type-definition-multiple-bundle/dist/esm/adapters/BrowserAdapter'
export async function browser() {
  const adapter = new Adapter(new BrowserAdapter())
  console.log('browser: ', await adapter.upload(new Blob()))
}

// import { NodeAdapter } from 'platform-specific-type-definition-multiple-bundle/dist/esm/adapters/NodeAdapter'
// export async function node() {
//   const adapter = new Adapter(new NodeAdapter())
//   console.log('node: ', await adapter.upload(new Buffer(10)))
// }

使用插件系統將不一樣環境的適配代碼分離爲多個子模塊

簡單來講,若是你但願將運行時依賴分散到不一樣的子模塊中(例如上面那個 node-fetch),或者你的插件 API 很是強大,那麼即可以將一些官方適配代碼分離爲插件子模塊。

選擇

兼容 nodejs 與瀏覽器的庫的技術方案選擇.drawio.svg

相關文章
相關標籤/搜索