Movatterモバイル変換


[0]ホーム

URL:


Anthony Fu @ antfu.me

Ship ESM & CJS in one Package

Nov 29, 2021· 15min

ESM & CJS

In the past decade, due to the lack of a standard module system of JavaScript, CommonJS (a.k.a therequire('xxx') andmodule.exports syntax) has been the way how Node.js and NPM packages work. Until 2015, when ECMAScript modules finally show up as the standard solution, the community start migrating to native ESM gradually.

// CJSconst circle = require('./circle.js')console.log(`The area of a circle of radius 4 is${circle.area(4)}`)
// ESMimport { area } from './circle.mjs'console.log(`The area of a circle of radius 4 is${area(4)}`)

ESM enables named exports, better static analysis, tree-shaking, browsers native support, and most importantly, as a standard, it’s basically the future of JavaScript.

Experimental support of native ESM is introduced in Node.js v12, and stabilized in v12.22.0 and v14.17.0. As the end of 2021, many packages now ship in pure-ESM format, or CJS and ESM dual formats; meta-frameworks likeNuxt 3 andSvelteKit are now recommending users to use ESM-first environment.

The overall migration of the ecosystem is still in progress, for most library authors, shipping dual formats is a safer and smoother way to have the goods from both worlds. In the rest of this blog post, I will show you why and how.

Compatibility

If ESM is the better and the future, why don’t we all move to ESM then? Even though Node.js is smart enough to allow CJS and ESM packages to work together, the main blocker is thatyou can’t use ESM packages in CJS.

If you do:

// in CJSconstpkg = require('esm-only-package')

you will receive the following error

Error [ERR_REQUIRE_ESM]:require() of ES Module esm-only-package not supported.

The root cause is that ESM is asynchronous by nature, meaning you can’t import an async module in synchronous context thatrequire is for. This commonly meansif you want to use ESM packages, you have to use ESM as well. Only one exception is that you can use ESM package in CJS usingdynamicimport():

// in CJSconst{ default: pkg } = await import('esm-only-package')

Since dynamic import will return a Promise, meaning all the sub-sequential callee need to be async as well (so callRed Functions, or I prefer call it Async Infection). In some case it might work, but generally I won’t think this to be an easy approachable solution for users.

On the other hand, if you are able to go with ESM directly, it would be much easier asimport supports both ESM and CJS.

import cjs from 'cjs-package'// in ESMimport { named } from 'esm-package'

Some packages now shippure-ESM packages advocating the ecosystem to move from CJS to ESM. While this might be the "right thing to do", however, giving the fact that that majority of the ecosystem are still on CJS and the migration is not that easy, I found myself more lean to ship both CJS and ESM formats to make the transition smoother.

package.json

Luckily, Node allows you to have those two formats in a single package at the same time. With the newexports field inpackage.json, you can now specify multiple entries to provide those formats conditionally. Node will resolve to the version based on user’s or downstream packages environment.

{  "name": "my-cool-package",  "exports": {    ".": {      "require": "./index.cjs", // CJS      "import": "./index.mjs" // ESM    }  }}

Bundling

So now we have two copies of code with slightly different module syntax to maintain, duplicating them is of course not an ideal solution. At this point you might need to consider introducing some build tools or bundling process to build your code into multiple formats. This might remind you the nightmare of configuring complex Webpack or Rollup, well don’t worry, my mission today is to introduce you two awesome tools that make your life so much easier.

tsup

tsup by@egoist is one of my most used tools. The features zero-config building for TypeScript project. The usage is like:

$ tsup src/index.ts

And then you will havedist/index.js file ready for you to publish.

To support dual formats, it’s just a flag away:

$ tsup src/index.ts --format cjs,esm

Two filesdist/index.js anddist/index.mjs will be generated with it and you are good to go. Powered byesbuild,tsup is not only super easy to use but also incredible fast. I highly recommend to give it a try.

Here is my go-to template ofpackage.json usingtsup:

{  "name": "my-cool-package",  "main": "./dist/index.js",  "module": "./dist/index.mjs",  "types": "./dist/index.d.ts",  "exports": {    ".": {      "require": "./dist/index.js",      "import": "./dist/index.mjs",      "types": "./dist/index.d.ts"    }  },  "scripts": {    "build": "tsup src/index.ts --format cjs,esm --dts --clean",    "watch": "npm run build -- --watch src",    "prepublishOnly": "npm run build"  }}

unbuild

If we saytsup is a minimal bundler for TypeScript,unbuild by the@unjs org is a more generalized, customizable and yet powerful.unbuild is being used to bundle Nuxt 3 and it’s sub packages.

To use it, we createbuild.config.ts file in the root

// build.config.tsimport { defineBuildConfig } from 'unbuild'export default defineBuildConfig({  entries: [    './src/index'  ],  declaration:true,// generate .d.ts files})

and run theunbuild command:

$ unbuild

unbuild will generate both ESM and CJS for you by default!

Stubbing

This is one of the most incredible things that I have found when I first looked intoNuxt 3’s codebase.unbuild introduced a new idea called Stubbing. Instead of firing up a watcher to re-trigger the bundling every time you made changes to the source code, the stubbing inunbuild (so call Passive watcher) does not require you are have another process for that at all. By calling the following commandonly once:

$ unbuild --stub

You are able to play and test out with your library with the up-to-date code!

Want to know the magic? After running the stubbing command, you can check out the generated distribution files:

// dist/index.mjsimport jiti from 'jiti'export default jiti(null, {interopDefault:true })('/Users/antfu/unbuild-test/src/index')
// dist/index.cjsmodule.exports = require('jiti')(null, {interopDefault:true })('/Users/antfu/unbuild-test/src/index')

Instead of the distribution of your code bundle, the dist files are now redirecting to your source code with a wrap ofjiti - another treasure hidden in the@unjs org.jiti provides the runtime support of TypeScript, ESM for Node by transpiling the modules on the fly. Since it directly goes to your source files, there won’t be a misalignment between your source code and bundle dist - thus there is no watcher process needed! This is a huge DX bump for library authors, if you still not getting it, you shall definitely grab it down and play with it yourself.

Bundleless Build

Powered bymkdist - another@unjs package -unbuild also handles static assets and file-to-file transpiling. Bundleless build allows you to keep the structure of your source code, made easy for importing submodules on-demand to optimizing performance and more.

Config inunbuild will look like:

// build.config.tsimport { defineBuildConfig } from 'unbuild'export default defineBuildConfig({  entries: [    // bundling    'src/index',    // bundleless, or just copy assets    {input:'src/components/',outDir:'dist/components' },  ],  declaration:true,})

One of the coolest features on this is that it handles.vue file out-of-box. For example, if I have a componentMyComponent.vue undersrc/components with following content:

<!-- src/components/MyComponent.vue --><template>  <div>{{ count }}</div></template><script lang="ts">  const count:number |string = 0  export default {    data: () => ({ count }),  }</script>

Notice that we are using TypeScript in the Vue file, when we do the build, the component will be copied over but with the TypeScript annotation removed along with aMyComponent.vue.d.ts generated.

<!-- dist/components/MyComponent.vue --><template>  <div>{{ count }}</div></template><script>  const count = 0  export default {    data: () => ({ count }),  }</script>
// dist/components/MyComponent.vue.d.tsdeclare const_default: {  data: () => {    count:number |string  }}export default _default

This way this allows you to use TypeScript in development while not requiring consumers to also have TypeScript in their setup.

P.S.unbuild is working on providing better out-of-box experience by auto infering the entries inpackage.json,learn more.

Context Misalignment

With either of the tools mentioned above, now we are able to write TypeScript as the single source of truth and made the overall codebase easier to maintain. However, there are still some caveats that you will need to keep an eye on it.

In ESM, there is NO__dirname,__filename,require,require.resolve. Instead, you will need to useimport.meta.url and also do some convertion to get the file path string.

So since our code will be compiled to both CJS and ESM, it’s better to avoiding using those environment specific context whenever possible. If you do need them, you can refer to my note aboutIsomorphic__dirname:

import { dirname } from 'node:path'import { fileURLToPath } from 'node:url'const_dirname = typeof__dirname !=='undefined'  ? __dirname  : dirname(fileURLToPath(import.meta.url))

Forrequire andrequire.resolve, you can use

import { createRequire } from 'node:module'constrequire = createRequire(import.meta.url)

Some good news, if you are usingunbuild, you can turn on thecjsBridge flag andunbuild will shims those CJS context in ESM automatically for you!.

import { defineBuildConfig } from 'unbuild'export default defineBuildConfig({  cjsBridge:true,// <--})

On the other hand, if you are usingtsup, it will shims ESM’simport.meta.url for you in CJS instead.

Verify your Packages

Once your published your package, you can verify if it follows the best practices usingpublint.dev made by@bluwy. It will also give you suggestions of how to improve them further.

Final words

This blog post showcased you only a few features of both tools. Do check their docs for more details. And hope you find these setups useful for building your own libraries. If you have any comments or suggestions, ping me on Twitter@antfu7. Happy hacking!

>comment onbluesky /mastodon /twitter
>
CC BY-NC-SA 4.0 2021-PRESENT © Anthony Fu

[8]ページ先頭

©2009-2025 Movatter.jp