|
| 1 | +# Fundementals of ESM/CJS modules and their Heresies |
| 2 | + |
| 3 | +<!-- TOC start (generated with https://github.com/derlin/bitdowntoc) --> |
| 4 | + |
1 | 5 | - [`std.module.format` ](#stdmoduleformat) |
2 | | - + [ES6 exports / imports cheat sheet](#es6-exports--imports-cheat-sheet) |
| 6 | + + [Different Types of Imports and Exports](#different-types-of-imports-and-exports) |
| 7 | + * [The Special Case of `export default`](#the-special-case-of-export-default) |
| 8 | + + [Why the Difference?](#why-the-difference) |
| 9 | + * [The Third Way: `export { thing as default }`](#the-third-way-export-thing-as-default-) |
| 10 | + + [Special Case: `export default function`](#special-case-export-default-function) |
| 11 | + * [Circular Dependencies and Hoisting](#circular-dependencies-and-hoisting) |
| 12 | + + [Function Hoisting Behavior](#function-hoisting-behavior) |
| 13 | + + [Circular Dependencies Example](#circular-dependencies-example) |
| 14 | + * [Summary](#summary) |
| 15 | + * [Why you may need to publish CJS to have ESM work](#why-you-may-need-to-publish-cjs-to-have-esm-work) |
| 16 | + * [Usage](#usage) |
| 17 | + + [Multiple Entries](#multiple-entries) |
| 18 | + + [Custom Files](#custom-files) |
| 19 | + + [Best Practices](#best-practices) |
| 20 | + - [Named exports (blue boxes) create live references when imported through any import syntax](#named-exports-blue-boxes-create-live-references-when-imported-through-any-import-syntax) |
| 21 | + - [Default exports (orange boxes) behave differently:](#default-exports-orange-boxes-behave-differently) |
| 22 | + - [Circular dependencies behavior:](#circular-dependencies-behavior) |
| 23 | + + [ES6 exports / imports cheat sheet](#es6-exports-imports-cheat-sheet) |
3 | 24 | * [`package.json` TLDR](#packagejson-tldr) |
4 | 25 | * [Problematic Settings](#problematic-settings) |
5 | | - + [~~Dont use `browser` field~~ Use `browser` specifically for CDN's](#dont-use-browser-field-use-browser-specifically-for-cdns) |
| 26 | + + [~~Dont use `browser` field~~ Use `browser` specifically for CDN's, YMMV per module usage](#dont-use-browser-field-use-browser-specifically-for-cdns-ymmv-per-module-usage) |
6 | 27 | + [Restrictions on Import Assertions Under ](#restrictions-on-import-assertions-under) |
7 | 28 | + [dont use `preserveModules` ](#dont-use-preservemodules) |
8 | 29 | + [`moduleResolution` `NodeNext` only allows defined import paths ](#moduleresolution-nodenext-only-allows-defined-import-paths) |
|
21 | 42 | + [Cheatsheet](#cheatsheet) |
22 | 43 | * [Avoid Default Exports and Prefer Named Exports](#avoid-default-exports-and-prefer-named-exports) |
23 | 44 | + [Context](#context) |
24 | | - + [Summary](#summary) |
| 45 | + + [Summary](#summary-1) |
25 | 46 | * [Decision](#decision) |
26 | 47 | + [ECMAScript Module Support in Node.js](#ecmascript-module-support-in-nodejs) |
27 | | - + [`.mjs`, `.cjs`, == `.mts`, `.cts` && `.d.mts` and `.d.cts`.](#mjs-cjs--mts-cts--dmts-and-dcts) |
| 48 | + + [`.mjs`, `.cjs`, == `.mts`, `.cts` && `.d.mts` and `.d.cts`.](#mjs-cjs-mts-cts-dmts-and-dcts) |
28 | 49 | * [Avoid Export Default](#avoid-export-default) |
29 | 50 | + [Poor Discoverability](#poor-discoverability) |
30 | 51 | + [Autocomplete](#autocomplete) |
|
35 | 56 | + [Dynamic Imports](#dynamic-imports) |
36 | 57 | + [ES Module Interop](#es-module-interop) |
37 | 58 | * [Note](#note) |
38 | | - + [Needs two lines for non-class / non-function](#needs-two-lines-for-non-class--non-function) |
39 | | - + [React.js - Named Exports](#reactjs---named-exports) |
| 59 | + + [Needs two lines for non-class / non-function](#needs-two-lines-for-non-class-non-function) |
| 60 | + + [React.js - Named Exports](#reactjs-named-exports) |
40 | 61 | * [{} type](#-type) |
41 | 62 | + [If you want a type that means "empty object"](#if-you-want-a-type-that-means-empty-object) |
42 | | - + [If you are using React, and you want to define `type Props = {}`.](#if-you-are-using-react-and-you-want-to-define-type-props--) |
| 63 | + + [If you are using React, and you want to define `type Props = {}`.](#if-you-are-using-react-and-you-want-to-define-type-props-) |
43 | 64 | + [`GenericObject`](#genericobject) |
44 | 65 | + [Module-related host hooks](#module-related-host-hooks) |
45 | 66 | * [Customizing module resolution](#customizing-module-resolution) |
|
50 | 71 | + [Use a compatibility layer](#use-a-compatibility-layer) |
51 | 72 | * [License](#license) |
52 | 73 |
|
| 74 | +<!-- TOC end --> |
| 75 | + |
53 | 76 | <a name="stdmoduleformat"></a> |
54 | 77 | # `std.module.format` |
55 | 78 |
|
@@ -219,6 +242,206 @@ export default thing; |
219 | 242 | export default 'hello!'; |
220 | 243 | ``` |
221 | 244 |
|
| 245 | +## Why you may need to publish CJS to have ESM work |
| 246 | + |
| 247 | +> `cjyes` [](https://www.npmjs.org/package/cjyes) |
| 248 | +
|
| 249 | +> source: <https://gist.github.com/developit/96de429483bb98927c7cd27c773b0fff> |
| 250 | +
|
| 251 | +If you're publishing ES Modules, you need to also publish CommonJS versions of those modules. |
| 252 | + |
| 253 | +This isn't to support old browsers or Node versions: even in Node 14, using `require()` to load a module won't work if it's only available as ESM. |
| 254 | + |
| 255 | +`cjyes` is the bare minimum fix for this problem. You write ES Modules and fill out a valid `package.json`, and it'll generate the corresponding CommonJS files pretty much instantly. `cjyes` takes up 500kb of disk space including its two dependencies. |
| 256 | + |
| 257 | +## Usage |
| 258 | + |
| 259 | +The easiest way to use `cjyes` is to define [package exports](https://nodejs.org/api/esm.html#esm_conditional_exports) the way Node 13+ requires: |
| 260 | + |
| 261 | +```json |
| 262 | +{ |
| 263 | + "main": "index.mjs", |
| 264 | + "exports": { |
| 265 | + "import": "./index.mjs", |
| 266 | + "require": "./dist/index.cjs" |
| 267 | + }, |
| 268 | + "scripts": { |
| 269 | + "prepare": "cjyes" |
| 270 | + }, |
| 271 | + "devDependencies": { |
| 272 | + "cjyes": "^0.3.0" |
| 273 | + } |
| 274 | +} |
| 275 | +``` |
| 276 | + |
| 277 | +`cjyes` will create CommonJS versions of all modules listed in the `"exports"` field and place them at the specified locations. |
| 278 | + |
| 279 | +> You can also use `.js` file extensions and the `{"type":"module"}` field - cjyes will detect this and generate the required `.cjs` output files. |
| 280 | +
|
| 281 | +### Multiple Entries |
| 282 | + |
| 283 | +Multiple entry points are supported automatically. Simply define them in your export map: |
| 284 | + |
| 285 | +```json |
| 286 | +{ |
| 287 | + "main": "index.mjs", |
| 288 | + "exports": { |
| 289 | + ".": { |
| 290 | + "import": "./index.mjs", |
| 291 | + "require": "./index.cjs" |
| 292 | + }, |
| 293 | + "./jsx": { |
| 294 | + "import": "./jsx.mjs", |
| 295 | + "require": "./jsx.cjs" |
| 296 | + }, |
| 297 | + "./hooks": { |
| 298 | + "import": "./hooks/index.mjs", |
| 299 | + "require": "./hooks/index.cjs" |
| 300 | + } |
| 301 | + }, |
| 302 | + "scripts": { "prepare": "cjyes" }, |
| 303 | + "devDependencies": { "cjyes": "^0.3.0" } |
| 304 | +} |
| 305 | +``` |
| 306 | + |
| 307 | +### Custom Files |
| 308 | + |
| 309 | +It is also possible to pass a list of input modules to `cjyes` directly: |
| 310 | + |
| 311 | +```sh |
| 312 | +cjyes src/index.js src/other.mjs |
| 313 | +# generates the following: |
| 314 | +# dist/ |
| 315 | +# index.cjs |
| 316 | +# other.cjs |
| 317 | +``` |
| 318 | + |
| 319 | +<details> |
| 320 | +<summary> cjyes.js code |
| 321 | + |
| 322 | +</summary> |
| 323 | + |
| 324 | +~~~javascript |
| 325 | +#! /usr/bin/env node |
| 326 | + |
| 327 | +const path = require('path'); |
| 328 | +const fs = require('fs').promises; |
| 329 | +const MagicString = require('magic-string').default; |
| 330 | +const { parse } = require('es-module-lexer'); |
| 331 | + |
| 332 | +const ALIASES = { |
| 333 | + '-v': 'verbose', |
| 334 | + '-s': 'silent', |
| 335 | + '-d': 'dry' |
| 336 | +}; |
| 337 | +const FLAGS = { |
| 338 | + default: 'Force `exports.default=` instead of `module.exports=`', |
| 339 | + flat: 'Force merging named exports into default (module.exports=A;exports.B=B)', |
| 340 | + dry: `Don't write anything to disk [-d]`, |
| 341 | + verbose: 'Verbose output logging [-v]', |
| 342 | + silent: 'No output logging [-s]' |
| 343 | +}; |
| 344 | +run(process.argv.slice(2)) |
| 345 | + .then(() => process.exit(0)) |
| 346 | + .catch(err => (console.error(err), process.exit(1))); |
| 347 | + |
| 348 | +async function run(argv) { |
| 349 | + const flags = {}; |
| 350 | + const files = argv.filter(file => { |
| 351 | + return !((file in ALIASES || file.startsWith('--')) && (flags[ALIASES[file] || file.substring(2)] = true)); |
| 352 | + }); |
| 353 | + if (flags.help) return console.log(`cjyes [...files]\nOptions:\n ${Object.keys(FLAGS).map(k=>`--${k.padEnd(7)} ${FLAGS[k]}`).join('\n ')}`); |
| 354 | + let pkg; |
| 355 | + try { |
| 356 | + pkg = JSON.parse(await fs.readFile('package.json','utf-8')); |
| 357 | + } catch (e) {} |
| 358 | + if (files.length === 0 && pkg && pkg.exports) { |
| 359 | + crawl(pkg.exports, files); |
| 360 | + if (flags.verbose) { |
| 361 | + console.log(`[cjyes] Using files listing from Export Map:\n ${files.join('\n ')}`); |
| 362 | + } |
| 363 | + } |
| 364 | + const ctx = {}; |
| 365 | + return Promise.all(files.map(f => cjs(f, { flags, pkg, ctx }))) |
| 366 | +} |
| 367 | + |
| 368 | +async function cjs(file, { flags, pkg, ctx }) { |
| 369 | + const code = await fs.readFile(file, 'utf-8'); |
| 370 | + const out = new MagicString(code); |
| 371 | + const [imports, exports] = await parse(code, file); |
| 372 | + for (const imp of imports) { |
| 373 | + const spec = JSON.stringify(code.substring(imp.s, imp.e)); |
| 374 | + const s = code.substring(imp.ss + 6, imp.s - 1).replace(/\s*from\s*/g, ''); |
| 375 | + const r = `const ${s.replace(/\sas\s/g, ':')} = require(${spec})`; |
| 376 | + out.overwrite(imp.ss, imp.se, r); |
| 377 | + } |
| 378 | + const nonDefaultExports = exports.filter(p => p!=='default'); |
| 379 | + const defaultExport = !flags.flat && (flags.default || nonDefaultExports.length) ? 'exports.default=' : 'module.exports='; |
| 380 | + const t = /(^|[;\s(])export(\s*default)?(?:\s*{[^}]+}|\s+(function|const|let|var))/g; |
| 381 | + let token; |
| 382 | + while ((token = t.exec(code))) { |
| 383 | + const r = `${token[2] ? defaultExport : ''}${token[3] || ''}`; |
| 384 | + out.overwrite(token.index + token[1].length, t.lastIndex, r); |
| 385 | + } |
| 386 | + for (const exp of nonDefaultExports) out.append(`\nexports.${exp}=${exp};`); |
| 387 | + |
| 388 | + let outFile; |
| 389 | + // use the export map if one exists: |
| 390 | + const entry = './' + file.replace(/\.m?js$/, '').split(path.sep).join('/'); |
| 391 | + const def = pkg && pkg.exports && resolve(pkg.exports, entry); |
| 392 | + if (def) { |
| 393 | + if (flags.verbose) { |
| 394 | + console.log(`[cjyes] using Export Map entry for ${entry}`); |
| 395 | + } |
| 396 | + outFile = def.replace(/^\.\//,'').split('/').join(path.sep); |
| 397 | + } |
| 398 | + else { |
| 399 | + // fall back to a dist directory |
| 400 | + const ext = pkg && pkg.type === 'module' ? '.cjs' : '.js'; |
| 401 | + const parts = file.replace(/\.m?js$/, ext).split(path.sep); |
| 402 | + const index = parts.lastIndexOf('src'); |
| 403 | + if (index === -1) parts.unshift('dist'); |
| 404 | + else parts[index] = 'dist'; |
| 405 | + outFile = parts.join(path.sep); |
| 406 | + if (!flags.silent && !ctx.warned) { |
| 407 | + ctx.warned = true; |
| 408 | + console.log(`[cjyes] no Export Map found, generating filenames:`); |
| 409 | + if (ext=='.cjs') console.log(` - Using .cjs due to {"type":"module"}`); |
| 410 | + if (index===-1) console.log(` - Replacing src/ with dist/`); |
| 411 | + else console.log(` - Prepending dist/ directory`); |
| 412 | + } |
| 413 | + } |
| 414 | + if (!flags.dry) { |
| 415 | + try { |
| 416 | + await fs.mkdir(path.dirname(outFile), { recursive: true }); |
| 417 | + } catch (e) {} |
| 418 | + await fs.writeFile(outFile, out.toString()); |
| 419 | + } |
| 420 | + if (!flags.silent) { |
| 421 | + console.log(`${file} --> ${outFile}`); |
| 422 | + } |
| 423 | +} |
| 424 | + |
| 425 | +function crawl(exp, files) { |
| 426 | + if (typeof exp==='string') files.push(exp.replace(/^\.\//,'')); |
| 427 | + else if (exp.import || exp.default) crawl(exp.import || exp.default, files); |
| 428 | + else for (let i in exp) { |
| 429 | + if (i[0]=='.' && !i.endsWith('/')) crawl(exp[i], files); |
| 430 | + } |
| 431 | +} |
| 432 | + |
| 433 | +function resolve(exp, entry) { |
| 434 | + if (!exp || typeof exp=='string') return exp; |
| 435 | + return exp.require || exp.default || resolve(select(exp, entry) || exp['.'], entry); |
| 436 | +} |
| 437 | + |
| 438 | +function select(exp, entry) { |
| 439 | + for (let i in exp) if (i==entry) return exp[i]; |
| 440 | +} |
| 441 | +~~~ |
| 442 | + |
| 443 | +</details> |
| 444 | + |
222 | 445 | _Note: The original article was written by Jake Archibald, with contributions from the V8 team members Toon Verwaest, Marja Hölttä, and Mathias Bynens, as well as Dave Herman and Daniel Ehrenberg._ |
223 | 446 |
|
224 | 447 | ### Best Practices |
|
0 commit comments