Skip to content

Commit 8e7bb83

Browse files
committed
refactor(@angular/build): add experimental chunk optimizer for production application builds
An experimental chunk optimizer is now available for initial usage. To enable the optimization, script optimization must be enabled as well as an environment variable `NG_BUILD_OPTIMIZE_CHUNKS=1`. This build step uses `rollup` internally to process the build files directly in memory. The main bundling performs all resolution, bundling, and tree-shaking of the application. The chunk optimizer step then only needs to access the in-memory built files and does not need to perform any disk access or module resolution. This allows the step to be performed fairly quickly but it does add time to the overall production build. The `NG_BUILD_DEBUG_PERF=1` environment variable can be used to view how long the step takes within a build via the `OPTIMIZE_CHUNKS` entry. In the future, this optimization step may be automatically enabled based on initial file entry count and size. There are several current known issues: 1) Bundle budgets for named lazy chunks may not work as expected. 2) The console output may not show names (files will be present) for lazy chunk files. 3) The stats file (`--stats-json` option) will not exactly reflect the final written application files. This is similar to the current behavior of the `browser` builder with Webpack's stat file.
1 parent 75abe2c commit 8e7bb83

File tree

8 files changed

+269
-4
lines changed

8 files changed

+269
-4
lines changed

packages/angular/build/BUILD.bazel

+1
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ ts_library(
8989
"@npm//picomatch",
9090
"@npm//piscina",
9191
"@npm//postcss",
92+
"@npm//rollup",
9293
"@npm//sass",
9394
"@npm//semver",
9495
"@npm//tslib",

packages/angular/build/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"parse5-html-rewriting-stream": "7.0.0",
3939
"picomatch": "4.0.2",
4040
"piscina": "4.6.1",
41+
"rollup": "4.18.0",
4142
"sass": "1.77.6",
4243
"semver": "7.6.2",
4344
"undici": "6.19.2",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import assert from 'node:assert';
10+
import { rollup } from 'rollup';
11+
import {
12+
BuildOutputFile,
13+
BuildOutputFileType,
14+
BundleContextResult,
15+
InitialFileRecord,
16+
} from '../../tools/esbuild/bundler-context';
17+
import { createOutputFile } from '../../tools/esbuild/utils';
18+
import { assertIsError } from '../../utils/error';
19+
20+
export async function optimizeChunks(
21+
original: BundleContextResult,
22+
sourcemap: boolean | 'hidden',
23+
): Promise<BundleContextResult> {
24+
// Failed builds cannot be optimized
25+
if (original.errors) {
26+
return original;
27+
}
28+
29+
// Find the main browser entrypoint
30+
let mainFile;
31+
for (const [file, record] of original.initialFiles) {
32+
if (
33+
record.name === 'main' &&
34+
record.entrypoint &&
35+
!record.serverFile &&
36+
record.type === 'script'
37+
) {
38+
mainFile = file;
39+
break;
40+
}
41+
}
42+
43+
// No action required if no browser main entrypoint
44+
if (!mainFile) {
45+
return original;
46+
}
47+
48+
const chunks: Record<string, BuildOutputFile> = {};
49+
const maps: Record<string, BuildOutputFile> = {};
50+
for (const originalFile of original.outputFiles) {
51+
if (originalFile.type !== BuildOutputFileType.Browser) {
52+
continue;
53+
}
54+
55+
if (originalFile.path.endsWith('.js')) {
56+
chunks[originalFile.path] = originalFile;
57+
} else if (originalFile.path.endsWith('.js.map')) {
58+
// Create mapping of JS file to sourcemap content
59+
maps[originalFile.path.slice(0, -4)] = originalFile;
60+
}
61+
}
62+
63+
const usedChunks = new Set<string>();
64+
65+
let bundle;
66+
let optimizedOutput;
67+
try {
68+
bundle = await rollup({
69+
input: mainFile,
70+
plugins: [
71+
{
72+
name: 'angular-bundle',
73+
resolveId(source) {
74+
// Remove leading `./` if present
75+
const file = source[0] === '.' && source[1] === '/' ? source.slice(2) : source;
76+
77+
if (chunks[file]) {
78+
return file;
79+
}
80+
81+
// All other identifiers are considered external to maintain behavior
82+
return { id: source, external: true };
83+
},
84+
load(id) {
85+
assert(
86+
chunks[id],
87+
`Angular chunk content should always be present in chunk optimizer [${id}].`,
88+
);
89+
90+
usedChunks.add(id);
91+
92+
const result = {
93+
code: chunks[id].text,
94+
map: maps[id]?.text,
95+
};
96+
97+
return result;
98+
},
99+
},
100+
],
101+
});
102+
103+
const result = await bundle.generate({
104+
compact: true,
105+
sourcemap,
106+
chunkFileNames(chunkInfo) {
107+
// Do not add hash to file name if already present
108+
return /-[a-zA-Z0-9]{8}$/.test(chunkInfo.name) ? '[name].js' : '[name]-[hash].js';
109+
},
110+
});
111+
optimizedOutput = result.output;
112+
} catch (e) {
113+
assertIsError(e);
114+
115+
return {
116+
errors: [
117+
// Most of these fields are not actually needed for printing the error
118+
{
119+
id: '',
120+
text: 'Chunk optimization failed',
121+
detail: undefined,
122+
pluginName: '',
123+
location: null,
124+
notes: [
125+
{
126+
text: e.message,
127+
location: null,
128+
},
129+
],
130+
},
131+
],
132+
warnings: original.warnings,
133+
};
134+
} finally {
135+
await bundle?.close();
136+
}
137+
138+
// Remove used chunks and associated sourcemaps from the original result
139+
original.outputFiles = original.outputFiles.filter(
140+
(file) =>
141+
!usedChunks.has(file.path) &&
142+
!(file.path.endsWith('.map') && usedChunks.has(file.path.slice(0, -4))),
143+
);
144+
145+
// Add new optimized chunks
146+
const importsPerFile: Record<string, string[]> = {};
147+
for (const optimizedFile of optimizedOutput) {
148+
if (optimizedFile.type !== 'chunk') {
149+
continue;
150+
}
151+
152+
importsPerFile[optimizedFile.fileName] = optimizedFile.imports;
153+
154+
original.outputFiles.push(
155+
createOutputFile(optimizedFile.fileName, optimizedFile.code, BuildOutputFileType.Browser),
156+
);
157+
if (optimizedFile.map && optimizedFile.sourcemapFileName) {
158+
original.outputFiles.push(
159+
createOutputFile(
160+
optimizedFile.sourcemapFileName,
161+
optimizedFile.map.toString(),
162+
BuildOutputFileType.Browser,
163+
),
164+
);
165+
}
166+
}
167+
168+
// Update initial files to reflect optimized chunks
169+
const entriesToAnalyze: [string, InitialFileRecord][] = [];
170+
for (const usedFile of usedChunks) {
171+
// Leave the main file since its information did not change
172+
if (usedFile === mainFile) {
173+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
174+
entriesToAnalyze.push([mainFile, original.initialFiles.get(mainFile)!]);
175+
continue;
176+
}
177+
178+
// Remove all other used chunks
179+
original.initialFiles.delete(usedFile);
180+
}
181+
182+
// Analyze for transitive initial files
183+
let currentEntry;
184+
while ((currentEntry = entriesToAnalyze.pop())) {
185+
const [entryPath, entryRecord] = currentEntry;
186+
187+
for (const importPath of importsPerFile[entryPath]) {
188+
const existingRecord = original.initialFiles.get(importPath);
189+
if (existingRecord) {
190+
// Store the smallest value depth
191+
if (existingRecord.depth > entryRecord.depth + 1) {
192+
existingRecord.depth = entryRecord.depth + 1;
193+
}
194+
195+
continue;
196+
}
197+
198+
const record: InitialFileRecord = {
199+
type: 'script',
200+
entrypoint: false,
201+
external: false,
202+
serverFile: false,
203+
depth: entryRecord.depth + 1,
204+
};
205+
206+
entriesToAnalyze.push([importPath, record]);
207+
}
208+
}
209+
210+
return original;
211+
}

packages/angular/build/src/builders/application/execute-build.ts

+13-1
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,13 @@ import { BuildOutputFileType, BundlerContext } from '../../tools/esbuild/bundler
1313
import { ExecutionResult, RebuildState } from '../../tools/esbuild/bundler-execution-result';
1414
import { checkCommonJSModules } from '../../tools/esbuild/commonjs-checker';
1515
import { extractLicenses } from '../../tools/esbuild/license-extractor';
16+
import { profileAsync } from '../../tools/esbuild/profiling';
1617
import { calculateEstimatedTransferSizes, logBuildStats } from '../../tools/esbuild/utils';
1718
import { BudgetCalculatorResult, checkBudgets } from '../../utils/bundle-calculator';
19+
import { shouldOptimizeChunks } from '../../utils/environment-options';
1820
import { resolveAssets } from '../../utils/resolve-assets';
1921
import { getSupportedBrowsers } from '../../utils/supported-browsers';
22+
import { optimizeChunks } from './chunk-optimizer';
2023
import { executePostBundleSteps } from './execute-post-bundle';
2124
import { inlineI18n, loadActiveTranslations } from './i18n';
2225
import { NormalizedApplicationBuildOptions } from './options';
@@ -59,11 +62,20 @@ export async function executeBuild(
5962
bundlerContexts = setupBundlerContexts(options, browsers, codeBundleCache);
6063
}
6164

62-
const bundlingResult = await BundlerContext.bundleAll(
65+
let bundlingResult = await BundlerContext.bundleAll(
6366
bundlerContexts,
6467
rebuildState?.fileChanges.all,
6568
);
6669

70+
if (options.optimizationOptions.scripts && shouldOptimizeChunks) {
71+
bundlingResult = await profileAsync('OPTIMIZE_CHUNKS', () =>
72+
optimizeChunks(
73+
bundlingResult,
74+
options.sourcemapOptions.scripts ? !options.sourcemapOptions.hidden || 'hidden' : false,
75+
),
76+
);
77+
}
78+
6779
const executionResult = new ExecutionResult(bundlerContexts, codeBundleCache);
6880
executionResult.addWarnings(bundlingResult.warnings);
6981

packages/angular/build/src/utils/environment-options.ts

+4
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,7 @@ export const useTypeChecking =
9696
const buildLogsJsonVariable = process.env['NG_BUILD_LOGS_JSON'];
9797
export const useJSONBuildLogs =
9898
isPresent(buildLogsJsonVariable) && isEnabled(buildLogsJsonVariable);
99+
100+
const optimizeChunksVariable = process.env['NG_BUILD_OPTIMIZE_CHUNKS'];
101+
export const shouldOptimizeChunks =
102+
isPresent(optimizeChunksVariable) && isEnabled(optimizeChunksVariable);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import assert from 'node:assert/strict';
2+
import { readFile } from 'node:fs/promises';
3+
import { execWithEnv } from '../../utils/process';
4+
5+
/**
6+
* AOT builds with chunk optimizer should contain generated component factories
7+
*/
8+
export default async function () {
9+
await execWithEnv('ng', ['build', '--aot=true', '--configuration=development'], {
10+
...process.env,
11+
NG_BUILD_OPTIMIZE_CHUNKS: '1',
12+
});
13+
14+
const content = await readFile('dist/test-project/browser/main.js', 'utf-8');
15+
assert.match(content, /AppComponent_Factory/);
16+
}

tests/legacy-cli/e2e/tests/build/material.ts

+21-2
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
import { appendFile } from 'node:fs/promises';
1+
import assert from 'node:assert/strict';
2+
import { appendFile, readdir } from 'node:fs/promises';
23
import { getGlobalVariable } from '../../utils/env';
34
import { readFile, replaceInFile } from '../../utils/fs';
45
import {
56
getActivePackageManager,
67
installPackage,
78
installWorkspacePackages,
89
} from '../../utils/packages';
9-
import { ng } from '../../utils/process';
10+
import { execWithEnv, ng } from '../../utils/process';
1011
import { isPrereleaseCli, updateJsonFile } from '../../utils/project';
1112

1213
const snapshots = require('../../ng-snapshot/package.json');
@@ -89,4 +90,22 @@ export default async function () {
8990
);
9091

9192
await ng('e2e', '--configuration=production');
93+
94+
const usingApplicationBuilder = getGlobalVariable('argv')['esbuild'];
95+
if (usingApplicationBuilder) {
96+
// Test with chunk optimizations to reduce async animations chunk file count
97+
await execWithEnv('ng', ['build'], {
98+
...process.env,
99+
NG_BUILD_OPTIMIZE_CHUNKS: '1',
100+
});
101+
const distFiles = await readdir('dist/test-project/browser');
102+
const jsCount = distFiles.filter((file) => file.endsWith('.js')).length;
103+
// 3 = polyfills, main, and one lazy chunk
104+
assert.equal(jsCount, 3);
105+
106+
await execWithEnv('ng', ['e2e', '--configuration=production'], {
107+
...process.env,
108+
NG_BUILD_OPTIMIZE_CHUNKS: '1',
109+
});
110+
}
92111
}

yarn.lock

+2-1
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,7 @@ __metadata:
414414
parse5-html-rewriting-stream: "npm:7.0.0"
415415
picomatch: "npm:4.0.2"
416416
piscina: "npm:4.6.1"
417+
rollup: "npm:4.18.0"
417418
sass: "npm:1.77.6"
418419
semver: "npm:7.6.2"
419420
undici: "npm:6.19.2"
@@ -15623,7 +15624,7 @@ __metadata:
1562315624
languageName: node
1562415625
linkType: hard
1562515626

15626-
"rollup@npm:^4.13.0, rollup@npm:^4.18.0, rollup@npm:^4.4.0, rollup@npm:~4.18.0":
15627+
"rollup@npm:4.18.0, rollup@npm:^4.13.0, rollup@npm:^4.18.0, rollup@npm:^4.4.0, rollup@npm:~4.18.0":
1562715628
version: 4.18.0
1562815629
resolution: "rollup@npm:4.18.0"
1562915630
dependencies:

0 commit comments

Comments
 (0)