DEV Community

loading...

Write nodejs/browser compatible libraries

rxliuli
・6 min read

Question

Compatibility problems are caused by the use of platform-specific functions, which can lead to the following situations

  • Different modular specifications: specify when rollup is packaged
  • Platform-specific code: For example, it contains adaptation codes for different platforms
  • Platform-specific dependencies: For example, nodejs needs to fill in fetch/FormData
  • Platform-specific type definitions: such as Blob in the browser and Buffer in nodejs

Different modular specifications

This is a very common thing. There are already multiple specifications including cjs/amd/iife/umd/esm, so supporting them (or at least supporting mainstream cjs/esm) has also become a must thing. Fortunately, the packaging tool rollup provides corresponding configurations to support output files in different formats.

GitHub sample project

Shaped like

// 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()],
})
Enter fullscreen mode Exit fullscreen mode

Then specify in package.json

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

Many libraries support cjs/esm, such as rollup, but there are also libraries that only support esm, such as unified.js series

Platform-limited code

-Package different export files through different entry files, and specify environment-related code through browser, such as dist/browser.js/dist/node.js: you need to pay attention to the packaging tool when using it (transfer the cost To users)
-Use code to determine the dynamic loading of the operating environment

Comparison Different Exports Code Judgment
Advantages More thorough code isolation Does not depend on packaging tool behavior
The final code only contains the code of the current environment
Disadvantages Depends on the behavior of the user's packaging tool The code for judging the environment may not be accurate
The final code contains all the codes, but is selectively loaded

axios combines the above two methods to achieve browser and nodejs support, but at the same time it leads to the shortcomings of the two methods and a little confusing behavior. Refer to getDefaultAdapter. For example, in the jsdom environment, it will be considered as a browser environment, please refer to detect jest and use http adapter instead of XMLHTTPRequest

Pack different export files through different entry files

GitHub sample project

// 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()],
})
Enter fullscreen mode Exit fullscreen mode
{
  "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"
  }
}
Enter fullscreen mode Exit fullscreen mode

Use code to determine the dynamic loading of the runtime environment

GitHub sample project

Basically, it is judged in the code and then 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)
  }
}
Enter fullscreen mode Exit fullscreen mode
// rollup.config.js
export default defineConfig({
  input: 'src/index.ts',
  output: { dir: 'dist', format: 'cjs', sourcemap: true },
  plugins: [typescript()],
})
Enter fullscreen mode Exit fullscreen mode

Note: vitejs cannot bundle this kind of package, because the nodejs native package does not exist in the browser environment, this is a known error, refer to: Cannot use amplify-js in browser environment (breaking vite/snowpack/esbuild).

Platform-specific dependencies

  • Direct use of import as a dependency: it will explode in different environments (for example, node-fetch will explode in the browser)
  • It is judged in the code that the dependency is dynamically introduced through require at runtime: it will cause it to be packaged and loaded even if it is not used
  • Dynamically introduce dependencies through import() when judged in the code at runtime: it will lead to code segmentation, and dependencies are selectively loaded as separate files
  • Package different export files through different entry files, such as dist/browser.js/dist/node.js: you need to pay attention when using it (transfer the cost to the user)
  • Declare peerDependencies optional dependencies, let users fill in by themselves: pay attention when using (pass the cost to the user)
Contrast require import
Will it be loaded Yes No
Does the developer need to pay attention No No
Will it be loaded multiple times No Yes
Is it synchronized Yes No
rollup support yes yes

In the code to determine the runtime, dynamically introduce dependencies through require

GitHub project example

// 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) ||
        {}
      // The key is the dynamic require here
      Reflect.set(globalVar, 'fetch', require('node-fetch').default)
    }
  }

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

1624018106300

In the code to determine the runtime, dynamically introduce dependencies through import()

GitHub project example

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

export class BrowserAdapter implements BaseAdapter {
  // Note that this has become an asynchronous function
  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()
  }
}
Enter fullscreen mode Exit fullscreen mode

Pack the result

1624018026889

Some sub-problems encountered

  • How to judge whether there are global variables
typeof fetch === 'undefined'
Enter fullscreen mode Exit fullscreen mode
  • How to write ployfill for global variables in different environments
const globalVar: any =
  (typeof globalThis !== 'undefined' && globalThis) ||
  (typeof self !== 'undefined' && self) ||
  (typeof global !== 'undefined' && global) ||
  {}
Enter fullscreen mode Exit fullscreen mode
  • TypeError: Right-hand side of'instanceof' is not callable: mainly axios will judge FormData, and form-data has a default export, so you need to use (await import('form-data' )).default (My generation always feels like digging a hole for myself) 1622828175546

Users may encounter compatibility issues when using rollup packaging. In fact, they need to choose whether to inline the code or package them separately into a file. Refer to: https://rollupjs.org/guide/en/#inlinedynamicimports

Inline => outline

// inline
export default {
  output: {
    file: 'dist/extension.js',
    format: 'cjs',
    sourcemap: true,
  },
}
Enter fullscreen mode Exit fullscreen mode
// Outreach
export default {
  output: {
    dir: 'dist',
    format: 'cjs',
    sourcemap: true,
  },
}
Enter fullscreen mode Exit fullscreen mode

Platform-limited type definition

The following solutions are essentially multiple bundles

  • Mixed type definition. E.g. axios
  • Pack different export documents and type definitions, and require users to specify the required documents by themselves. For example, load different functions through module/node/module/browser (in fact, it is very close to the plug-in system, it is nothing more than whether to separate multiple modules)
  • Use the plug-in system to separate the adaptation codes of different environments into multiple sub-modules. E.g. remark.js community
Comparison Multiple type definition files Mixed type definition Multi-module
Advantages Clearer environmental designation Unified entrance Clearer environmental designation
Disadvantages Need to choose by the user Type definition redundancy Need to choose by the user
Redundant dependencies Relatively troublesome to maintain (especially when the maintainer is not alone)

Pack different export documents and type definitions, and require users to specify the required documents by themselves

GitHub project example

It is mainly to make a layer of abstraction in the core code, and then extract the platform-specific code out and package it separately.

// 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
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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()],
  },
])
Enter fullscreen mode Exit fullscreen mode

User example

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)))
//}
Enter fullscreen mode Exit fullscreen mode

Use the plug-in system to separate the adaptation code of different environments into multiple submodules

Simply put, if you want to spread runtime dependencies into different submodules (such as the node-fetch above), or your plug-in API is very powerful, then you can use some official adaptation code Separate into plug-in sub-modules.

Discussion (0)