编写兼容 nodejs/浏览器的库

本文最后更新于:2021年6月19日 下午

问题

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

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

不同的模块化规范

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

GitHub 示例项目

形如

1
2
3
4
5
6
7
8
9
// 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 中指定即可

1
2
3
4
5
{
"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 示例项目

1
2
3
4
5
6
7
8
9
// 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()],
});
1
2
3
4
5
6
7
8
9
{
"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 而已

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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);
}
}
1
2
3
4
5
6
// 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 项目示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 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 项目示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 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

遇到的一些子问题

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

    1
    typeof fetch === "undefined";
  • 怎么为不同环境的全局变量写入 ployfill

    1
    2
    3
    4
    5
    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/#inlinedynamicimports

内联 => 外联

1
2
3
4
5
6
7
8
// 内联
export default {
output: {
file: "dist/extension.js",
format: "cjs",
sourcemap: true,
},
};
1
2
3
4
5
6
7
8
// 外联
export default {
output: {
dir: "dist",
format: "cjs",
sourcemap: true,
},
};

平台限定的类型定义

以下解决方案本质上都是多个 bundle

  • 混合类型定义。例如 axios
  • 打包不同的出口文件和类型定义,要求使用者自行指定需要的文件。例如通过 module/node/module/browser 加载不同的功能(其实和插件系统非常接近,无非是否分离多个模块罢了)
  • 使用插件系统将不同环境的适配代码分离为多个子模块。例如 remark.js 社区
对比 多个类型定义文件 混合类型定义 多模块
优点 环境指定更明确 统一入口 环境指定更明确
缺点 需要使用者自行选择 类型定义冗余 需要使用者自行选择
dependencies 冗余 维护起来相对麻烦(尤其是维护者不是一个人的时候)

打包不同的出口文件和类型定义,要求使用者自行指定需要的文件

GitHub 项目示例

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

1
2
3
4
5
6
7
8
9
10
// 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;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 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()],
},
]);

使用者示例

1
2
3
4
5
6
7
8
9
10
11
12
13
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