Skip to content

Commit

Permalink
fix #2161: optimized output for the json loader
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw committed Jun 16, 2022
1 parent 4ee0aad commit 3f3d716
Show file tree
Hide file tree
Showing 4 changed files with 164 additions and 55 deletions.
55 changes: 55 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,61 @@
╵ ~~~~~~
```

* Optimize the output of the JSON loader ([#2161](https://github.com/evanw/esbuild/issues/2161))

The `json` loader (which is enabled by default for `.json` files) parses the file as JSON and generates a JavaScript file with the parsed expression as the `default` export. This behavior is standard and works in both node and the browser (well, as long as you use an [import assertion](https://v8.dev/features/import-assertions)). As an extension, esbuild also allows you to import additional top-level properties of the JSON object directly as a named export. This is beneficial for tree shaking. For example:

```js
import { version } from 'esbuild/package.json'
console.log(version)
```

If you bundle the above code with esbuild, you'll get something like the following:

```js
// node_modules/esbuild/package.json
var version = "0.14.44";

// example.js
console.log(version);
```

Most of the `package.json` file is irrelevant and has been omitted from the output due to tree shaking. The way esbuild implements this is to have the JavaScript file that's generated from the JSON look something like this with a separate exported variable for each property on the top-level object:

```js
// node_modules/esbuild/package.json
export var name = "esbuild";
export var version = "0.14.44";
export var repository = "https://github.com/evanw/esbuild";
export var bin = {
esbuild: "bin/esbuild"
};
...
export default {
name,
version,
repository,
bin,
...
};
```

However, this means that if you import the `default` export instead of a named export, you will get non-optimal output. The `default` export references all top-level properties, leading to many unnecessary variables in the output. With this release esbuild will now optimize this case to only generate additional variables for top-level object properties that are actually imported:

```js
// Original code
import all, { bar } from 'data:application/json,{"foo":[1,2,3],"bar":[4,5,6]}'
console.log(all, bar)

// Old output (with --bundle --minify --format=esm)
var a=[1,2,3],l=[4,5,6],r={foo:a,bar:l};console.log(r,l);

// New output (with --bundle --minify --format=esm)
var l=[4,5,6],r={foo:[1,2,3],bar:l};console.log(r,l);
```

Notice how there is no longer an unnecessary generated variable for `foo` since it's never imported. And if you only import the `default` export, esbuild will now reproduce the original JSON object in the output with all top-level properties compactly inline.

## 0.14.44

* Add a `copy` loader ([#2255](https://github.com/evanw/esbuild/issues/2255))
Expand Down
148 changes: 104 additions & 44 deletions internal/bundler/linker.go
Original file line number Diff line number Diff line change
Expand Up @@ -1912,69 +1912,58 @@ func (c *linkerContext) generateCodeForLazyExport(sourceIndex uint32) {
// parts so they can be tree shaken individually.
part.Stmts = nil

type prevExport struct {
ref js_ast.Ref
partIndex uint32
}

generateExport := func(name string, alias string, value js_ast.Expr) prevExport {
// Generate a new symbol
// Generate a new symbol and link the export into the graph for tree shaking
generateExport := func(name string, alias string) (js_ast.Ref, uint32) {
ref := c.graph.GenerateNewSymbol(sourceIndex, js_ast.SymbolOther, name)

// Generate an ES6 export
var stmt js_ast.Stmt
if alias == "default" {
stmt = js_ast.Stmt{Loc: value.Loc, Data: &js_ast.SExportDefault{
DefaultName: js_ast.LocRef{Loc: value.Loc, Ref: ref},
Value: js_ast.Stmt{Loc: value.Loc, Data: &js_ast.SExpr{Value: value}},
}}
} else {
stmt = js_ast.Stmt{Loc: value.Loc, Data: &js_ast.SLocal{
IsExport: true,
Decls: []js_ast.Decl{{
Binding: js_ast.Binding{Loc: value.Loc, Data: &js_ast.BIdentifier{Ref: ref}},
ValueOrNil: value,
}},
}}
}

// Link the export into the graph for tree shaking
partIndex := c.graph.AddPartToFile(sourceIndex, js_ast.Part{
Stmts: []js_ast.Stmt{stmt},
DeclaredSymbols: []js_ast.DeclaredSymbol{{Ref: ref, IsTopLevel: true}},
CanBeRemovedIfUnused: true,
})
c.graph.GenerateSymbolImportAndUse(sourceIndex, partIndex, repr.AST.ModuleRef, 1, sourceIndex)
repr.Meta.TopLevelSymbolToPartsOverlay[ref] = []uint32{partIndex}
repr.Meta.ResolvedExports[alias] = graph.ExportData{Ref: ref, SourceIndex: sourceIndex}
return prevExport{ref: ref, partIndex: partIndex}
return ref, partIndex
}

// Unwrap JSON objects into separate top-level variables
var prevExports []js_ast.Ref
jsonValue := lazy.Value
if object, ok := jsonValue.Data.(*js_ast.EObject); ok {
clone := *object
clone.Properties = append(make([]js_ast.Property, 0, len(clone.Properties)), clone.Properties...)
for i, property := range clone.Properties {
for _, property := range object.Properties {
if str, ok := property.Key.Data.(*js_ast.EString); ok &&
(!file.IsEntryPoint() || js_lexer.IsIdentifierUTF16(str.Value) ||
!c.options.UnsupportedJSFeatures.Has(compat.ArbitraryModuleNamespaceNames)) {
name := helpers.UTF16ToString(str.Value)
exportRef := generateExport(name, name, property.ValueOrNil).ref
prevExports = append(prevExports, exportRef)
clone.Properties[i].ValueOrNil = js_ast.Expr{Loc: property.Key.Loc, Data: &js_ast.EIdentifier{Ref: exportRef}}
if name := helpers.UTF16ToString(str.Value); name != "default" {
ref, partIndex := generateExport(name, name)

// This initializes the generated variable with a copy of the property
// value, which is INCORRECT for values that are objects/arrays because
// they will have separate object identity. This is fixed up later in
// "generateCodeForFileInChunkJS" by changing the object literal to
// reference this generated variable instead.
//
// Changing the object literal is deferred until that point instead of
// doing it now because we only want to do this for top-level variables
// that actually end up being used, and we don't know which ones will
// end up actually being used at this point (since import binding hasn't
// happened yet). So we need to wait until after tree shaking happens.
repr.AST.Parts[partIndex].Stmts = []js_ast.Stmt{{Loc: property.ValueOrNil.Loc, Data: &js_ast.SLocal{
IsExport: true,
Decls: []js_ast.Decl{{
Binding: js_ast.Binding{Loc: property.ValueOrNil.Loc, Data: &js_ast.BIdentifier{Ref: ref}},
ValueOrNil: property.ValueOrNil,
}},
}}}
}
}
}
jsonValue.Data = &clone
}

// Generate the default export
finalExportPartIndex := generateExport(file.InputFile.Source.IdentifierName+"_default", "default", jsonValue).partIndex

// The default export depends on all of the previous exports
for _, exportRef := range prevExports {
c.graph.GenerateSymbolImportAndUse(sourceIndex, finalExportPartIndex, exportRef, 1, sourceIndex)
}
ref, partIndex := generateExport(file.InputFile.Source.IdentifierName+"_default", "default")
repr.AST.Parts[partIndex].Stmts = []js_ast.Stmt{{Loc: jsonValue.Loc, Data: &js_ast.SExportDefault{
DefaultName: js_ast.LocRef{Loc: jsonValue.Loc, Ref: ref},
Value: js_ast.Stmt{Loc: jsonValue.Loc, Data: &js_ast.SExpr{Value: jsonValue}},
}}}
}

func (c *linkerContext) createExportsForFile(sourceIndex uint32) {
Expand Down Expand Up @@ -3927,6 +3916,13 @@ func (c *linkerContext) generateCodeForFileInChunkJS(
stmtList.insideWrapperSuffix = nil
}

var partIndexForLazyDefaultExport ast.Index32
if repr.AST.HasLazyExport {
if defaultExport, ok := repr.Meta.ResolvedExports["default"]; ok {
partIndexForLazyDefaultExport = ast.MakeIndex32(repr.TopLevelSymbolToParts(defaultExport.Ref)[0])
}
}

// Add all other parts in this chunk
for partIndex := partRange.partIndexBegin; partIndex < partRange.partIndexEnd; partIndex++ {
part := repr.AST.Parts[partIndex]
Expand All @@ -3946,7 +3942,71 @@ func (c *linkerContext) generateCodeForFileInChunkJS(
continue
}

c.convertStmtsForChunk(partRange.sourceIndex, &stmtList, part.Stmts)
stmts := part.Stmts

// If this could be a JSON file that exports a top-level object literal, go
// over the non-default top-level properties that ended up being imported
// and substitute references to them into the main top-level object literal.
// So this JSON file:
//
// {
// "foo": [1, 2, 3],
// "bar": [4, 5, 6],
// }
//
// is initially compiled into this:
//
// export var foo = [1, 2, 3];
// export var bar = [4, 5, 6];
// export default {
// foo: [1, 2, 3],
// bar: [4, 5, 6],
// };
//
// But we turn it into this if both "foo" and "default" are imported:
//
// export var foo = [1, 2, 3];
// export default {
// foo,
// bar: [4, 5, 6],
// };
//
if partIndexForLazyDefaultExport.IsValid() && partIndex == partIndexForLazyDefaultExport.GetIndex() {
stmt := stmts[0]
defaultExport := stmt.Data.(*js_ast.SExportDefault)
defaultExpr := defaultExport.Value.Data.(*js_ast.SExpr)

// Be careful: the top-level value in a JSON file is not necessarily an object
if object, ok := defaultExpr.Value.Data.(*js_ast.EObject); ok {
objectClone := *object
objectClone.Properties = append([]js_ast.Property{}, objectClone.Properties...)

// If any top-level properties ended up being imported directly, change
// the property to just reference the corresponding variable instead
for i, property := range object.Properties {
if str, ok := property.Key.Data.(*js_ast.EString); ok {
if name := helpers.UTF16ToString(str.Value); name != "default" {
if export, ok := repr.Meta.ResolvedExports[name]; ok {
if part := repr.AST.Parts[repr.TopLevelSymbolToParts(export.Ref)[0]]; part.IsLive {
ref := part.Stmts[0].Data.(*js_ast.SLocal).Decls[0].Binding.Data.(*js_ast.BIdentifier).Ref
objectClone.Properties[i].ValueOrNil = js_ast.Expr{Loc: property.Key.Loc, Data: &js_ast.EIdentifier{Ref: ref}}
}
}
}
}
}

// Avoid mutating the original AST
defaultExprClone := *defaultExpr
defaultExprClone.Value.Data = &objectClone
defaultExportClone := *defaultExport
defaultExportClone.Value.Data = &defaultExprClone
stmt.Data = &defaultExportClone
stmts = []js_ast.Stmt{stmt}
}
}

c.convertStmtsForChunk(partRange.sourceIndex, &stmtList, stmts)
}

// Hoist all import statements before any normal statements. ES6 imports
Expand Down
6 changes: 2 additions & 4 deletions internal/bundler/snapshots/snapshots_default.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1914,15 +1914,13 @@ TestLoaderDataURLApplicationJSON
var json_31_32_33_default = "123";

// <data:application/json;base64,eyJ3b3JrcyI6dHJ1ZX0=>
var works = true;
var json_base64_eyJ3b3JrcyI6dHJ1ZX0_default = { works };
var json_base64_eyJ3b3JrcyI6dHJ1ZX0_default = { works: true };

// <data:application/json;charset=UTF-8,%31%32%33>
var json_charset_UTF_8_31_32_33_default = 123;

// <data:application/json;charset=UTF-8;base64,eyJ3b3JrcyI6dHJ1ZX0=>
var works2 = true;
var json_charset_UTF_8_base64_eyJ3b3JrcyI6dHJ1ZX0_default = { works: works2 };
var json_charset_UTF_8_base64_eyJ3b3JrcyI6dHJ1ZX0_default = { works: true };

// entry.js
console.log([
Expand Down
10 changes: 3 additions & 7 deletions internal/bundler/snapshots/snapshots_loader.txt
Original file line number Diff line number Diff line change
Expand Up @@ -352,9 +352,7 @@ var require_x = __commonJS({
});

// y.json
var y1 = true;
var y2 = false;
var y_default = { y1, y2 };
var y_default = { y1: true, y2: false };

// z.json
var small = "some small text";
Expand Down Expand Up @@ -430,16 +428,14 @@ TestLoaderJSONNoBundleIIFE
TestLoaderJSONSharedWithMultipleEntriesIssue413
---------- /out/a.js ----------
// data.json
var test = 123;
var data_default = { test };
var data_default = { test: 123 };

// a.js
console.log("a:", data_default);

---------- /out/b.js ----------
// data.json
var test = 123;
var data_default = { test };
var data_default = { test: 123 };

// b.js
console.log("b:", data_default);
Expand Down

0 comments on commit 3f3d716

Please sign in to comment.