问题

兼容问题是由于使用了平台特定的功能导致,会导致下面几种情况

  • 不同的模块化规范:rollup 打包时指定
  • 平台限定的代码:例如包含不同平台的适配代码
  • 平台限定的依赖:例如在 nodejs 需要填充 fetch/FormData
  • 平台限定的类型定义:例如浏览器中的 Blob 和 nodejs 中的 Buffer

不同的模块化规范

这是很常见的一件事,现在就已经有包括 cjs/amd/iife/umd/esm 多种规范了,所以支持它们(或者说,至少支持主流的 cjs/esm)也成为必须做的一件事。幸运的是,打包工具 rollup 提供了相应的配置支持不同格式的输出文件。

GitHub 示例项目open in new window

形如

// 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 中指定即可

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

许多库都支持 cjs/esm,例如 rollupopen in new window,但也有仅支持 esm 的库,例如 unified.js 系列open in new window

平台限定的代码

  • 通过不同的入口文件打包不同的出口文件,并通过 browser 指定环境相关的代码,例如 dist/browser.js/dist/node.js:使用时需要注意打包工具(将成本转嫁给使用者)
  • 使用代码判断运行环境动态加载
对比不同出口代码判断
优点代码隔离的更彻底不依赖于打包工具行为
最终代码仅包含当前环境的代码
缺点依赖于使用者的打包工具的行为判断环境的代码可能并不准确
最终代码包含所有代码,只是选择性加载

axios 结合以上两种方式实现了浏览器、nodejs 支持,但同时导致有着两种方式的缺点而且有点迷惑行为,参考 getDefaultAdapteropen in new window。例如在 jsdom 环境会认为是浏览器环境,参考 detect jest and use http adapter instead of XMLHTTPRequestopen in new window

通过不同的入口文件打包不同的出口文件

GitHub 示例项目open in new window

// 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 示例项目open in new window

基本上就是在代码中判断然后 await import 而已

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)open in new window

平台限定的依赖

  • 直接 import 依赖使用:会导致在不同的环境炸掉(例如 node-fetch 在浏览器就会炸掉)
  • 在代码中判断运行时通过 require 动态 引入依赖:会导致即便用不到,也仍然会被打包加载
  • 在代码中判断运行时通过 import() 动态引入依赖:会导致代码分割,依赖作为单独的文件选择性加载
  • 通过不同的入口文件打包不同的出口文件,例如 dist/browser.js/dist/node.js:使用时需要注意(将成本转嫁给使用者)
  • 声明 peerDependencies 可选依赖,让使用者自行填充:使用时需要注意(将成本转嫁给使用者)
对比requireimport
是否一定会加载
是否需要开发者注意
是否会多次加载
是否同步
rollup 支持

在代码中判断运行时通过 require 动态引入依赖

GitHub 项目示例open in new window

// 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 项目示例open in new window

// 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()
  }
}

打包结果

1624018026889

遇到的一些子问题

  • 怎么判断是否存在全局变量

    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/#inlinedynamicimportsopen in new window

内联 => 外联

// 内联
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 项目示例open in new window

主要是在核心代码做一层抽象,然后将平台特定的代码抽离出去单独打包。

// 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