From a5f73e06c011a697e0f8683369f5ca469870a2f9 Mon Sep 17 00:00:00 2001 From: Evan Wallace Date: Tue, 30 Jun 2020 01:32:20 -0700 Subject: [PATCH] enable code splitting for the esm format (#16) --- CHANGELOG.md | 22 +++++++++++++ lib/api-common.ts | 1 + lib/api-types.ts | 1 + pkg/api/api.go | 1 + pkg/api/api_impl.go | 9 +++++ pkg/cli/cli_impl.go | 3 ++ scripts/end-to-end-tests.js | 65 +++++++++++++++++++++++++++++++++++++ 7 files changed, 102 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c439028128..3d70e90c55e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## Unreleased + +* Experimental code splitting with `--splitting` ([#16](https://github.com/evanw/esbuild/issues/16)) + + This release includes experimental support for code splitting. Enable it with the `--splitting` flag. This currently only works with the `esm` output format. Support for the `cjs` and `iife` formats will come later. It's being released early so people can try it out and provide feedback. + + When enabled, code splitting does two things: + + * An asynchronous `import('path')` expression will create another chunk that will only be loaded when that expression is evaluated. This is intended to be used for lazily loading additional code. All additional chunks will be written to the directory configured with `outdir`. + + Note that when code splitting is disabled (i.e. the default behavior), an `import('path')` expression behaves similar to `Promise.resolve(require('path'))` and still bundles the imported file into the entry point bundle. No additional chunks are generated in this case. + + * Multiple entry points will cause additional chunks to be created for code that is shared between entry points. Chunks are generated automatically based on simple principles: code should only ever be in one chunk (i.e. no duplication) and no unnecessary code should be loaded (i.e. chunk boundaries are minimal). + + The way this works is by traversing through the module dependency graph and marking which top-level statements are reachable from which entry points. The set of entry points for a given top-level statement determines which chunk that statement is in. + + This is an advanced form of code splitting where even a single file may end up being split into different chunks. This is not something most other bundlers can do at the moment. + + Note that using code splitting with many entry points may generate many chunks for shared code reachable from different combinations of entry points. This should work fine and should still be efficient with HTTP/2. If you want to only let certain entry points share code, you can run esbuild multiple times for different groups of entry points. + + Please try it out and report any issues on [#16](https://github.com/evanw/esbuild/issues/16). + ## 0.5.15 * Remove some unnecessary helper functions ([#206](https://github.com/evanw/esbuild/issues/206)) diff --git a/lib/api-common.ts b/lib/api-common.ts index e809d62f592..4689754d248 100644 --- a/lib/api-common.ts +++ b/lib/api-common.ts @@ -27,6 +27,7 @@ function flagsForBuildOptions(options: types.BuildOptions, isTTY: boolean): stri if (options.sourcemap) flags.push(`--sourcemap${options.sourcemap === true ? '' : `=${options.sourcemap}`}`); if (options.globalName) flags.push(`--global-name=${options.globalName}`); if (options.bundle) flags.push('--bundle'); + if (options.splitting) flags.push('--splitting'); if (options.metafile) flags.push(`--metafile=${options.metafile}`); if (options.outfile) flags.push(`--outfile=${options.outfile}`); if (options.outdir) flags.push(`--outdir=${options.outdir}`); diff --git a/lib/api-types.ts b/lib/api-types.ts index 4b3d9252873..d553a8eb6f3 100644 --- a/lib/api-types.ts +++ b/lib/api-types.ts @@ -27,6 +27,7 @@ export interface CommonOptions { export interface BuildOptions extends CommonOptions { globalName?: string; bundle?: boolean; + splitting?: boolean; outfile?: string; metafile?: string; outdir?: string; diff --git a/pkg/api/api.go b/pkg/api/api.go index 1101cc84b71..04c24336b9d 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -187,6 +187,7 @@ type BuildOptions struct { GlobalName string Bundle bool + Splitting bool Outfile string Metafile string Outdir string diff --git a/pkg/api/api_impl.go b/pkg/api/api_impl.go index 064f8d9a564..1a0d60d3756 100644 --- a/pkg/api/api_impl.go +++ b/pkg/api/api_impl.go @@ -331,6 +331,7 @@ func buildImpl(options BuildOptions) BuildResult { MinifyIdentifiers: options.MinifyIdentifiers, ModuleName: options.GlobalName, IsBundling: options.Bundle, + CodeSplitting: options.Splitting, OutputFormat: validateFormat(options.Format), AbsOutputFile: validatePath(log, realFS, options.Outfile), AbsOutputDir: validatePath(log, realFS, options.Outdir), @@ -350,6 +351,9 @@ func buildImpl(options BuildOptions) BuildResult { if bundleOptions.AbsOutputDir == "" && len(entryPaths) > 1 { log.AddError(nil, ast.Loc{}, "Must use \"outdir\" when there are multiple input files") + } else if bundleOptions.AbsOutputDir == "" && bundleOptions.CodeSplitting { + log.AddError(nil, ast.Loc{}, + "Must use \"outdir\" when code splitting is enabled") } else if bundleOptions.AbsOutputFile != "" && bundleOptions.AbsOutputDir != "" { log.AddError(nil, ast.Loc{}, "Cannot use both \"outfile\" and \"outdir\"") } else if bundleOptions.AbsOutputFile != "" { @@ -389,6 +393,11 @@ func buildImpl(options BuildOptions) BuildResult { } } + // Code splitting is experimental and currently only enabled for ES6 modules + if bundleOptions.CodeSplitting && bundleOptions.OutputFormat != printer.FormatESModule { + log.AddError(nil, ast.Loc{}, "Spltting currently only works with the \"esm\" format") + } + var outputFiles []OutputFile // Stop now if there were errors diff --git a/pkg/cli/cli_impl.go b/pkg/cli/cli_impl.go index 087ff8cc34f..ee5f2664e35 100644 --- a/pkg/cli/cli_impl.go +++ b/pkg/cli/cli_impl.go @@ -34,6 +34,9 @@ func parseOptionsImpl(osArgs []string, buildOpts *api.BuildOptions, transformOpt case arg == "--bundle" && buildOpts != nil: buildOpts.Bundle = true + case arg == "--splitting" && buildOpts != nil: + buildOpts.Splitting = true + case arg == "--minify": if buildOpts != nil { buildOpts.MinifySyntax = true diff --git a/scripts/end-to-end-tests.js b/scripts/end-to-end-tests.js index 33cb9d80fde..37a8b946f17 100644 --- a/scripts/end-to-end-tests.js +++ b/scripts/end-to-end-tests.js @@ -633,6 +633,71 @@ }), ) + // Code splitting tests + tests.push( + // Code splitting via sharing + test(['a.js', 'b.js', '--outdir=out', '--splitting', '--format=esm', '--bundle'], { + 'a.js': ` + import * as ns from './common' + export let a = 'a' + ns.foo + `, + 'b.js': ` + import * as ns from './common' + export let b = 'b' + ns.foo + `, + 'common.js': ` + export let foo = 123 + `, + 'node.js': ` + import {a} from './out/a.js' + import {b} from './out/b.js' + if (a !== 'a123' || b !== 'b123') throw 'fail' + `, + }), + + // Code splitting via ES6 module double-imported with sync and async imports + test(['a.js', '--outdir=out', '--splitting', '--format=esm', '--bundle'], { + 'a.js': ` + import * as ns1 from './b' + export default async function () { + const ns2 = await import('./b') + return [ns1.foo, -ns2.foo] + } + `, + 'b.js': ` + export let foo = 123 + `, + 'node.js': ` + export let async = async () => { + const {default: fn} = await import('./out/a.js') + const [a, b] = await fn() + if (a !== 123 || b !== -123) throw 'fail' + } + `, + }, { async: true }), + + // Code splitting via CommonJS module double-imported with sync and async imports + test(['a.js', '--outdir=out', '--splitting', '--format=esm', '--bundle'], { + 'a.js': ` + import * as ns1 from './b' + export default async function () { + const ns2 = await import('./b') + return [ns1.foo, -ns2.default.foo] + } + `, + 'b.js': ` + exports.foo = 123 + `, + 'node.js': ` + export let async = async () => { + const {default: fn} = await import('./out/a.js') + const [a, b] = await fn() + if (a !== 123 || b !== -123) throw 'fail' + } + `, + }, { async: true }), + ) + // Test writing to stdout tests.push( // These should succeed