编写兼容 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

相关文章
相关标签/搜索