本文最后更新于:2022年8月26日 早上
问题 兼容问题是由于使用了平台特定的功能导致,会导致下面几种情况
不同的模块化规范: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 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 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 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 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 ) || {} Reflect .set (globalVar, 'fetch' , require ('node-fetch' ).default ) } } async get<T>(url : string ): Promise <T> { BrowserAdapter .init () return (await fetch (url)).json () } }
在代码中判断运行时通过 import()
动态引入依赖
GitHub 项目示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 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 () } }
打包结果
遇到的一些子问题
使用者在使用 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 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 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 ())) }
使用插件系统将不同环境的适配代码分离为多个子模块 简单来说,如果你希望将运行时依赖分散到不同的子模块中(例如上面那个 node-fetch
),或者你的插件 API 非常强大,那么便可以将一些官方 适配代码分离为插件子模块。
选择