Skip to content

Commit da32daa

Browse files
clydinalan-agius4
authored andcommitted
perf(@angular-devkit/build-angular): use combination of esbuild and terser as a JavaScript optimizer
The javascript optimization pipeline is now a two-phase process. `esbuild` is used in the first phase to remove the majority of the unused code and shorten identifiers in each output bundle script. `esbuild` can accomplish this in a fraction of the time that `terser` previously required. However, `esbuild` does not yet implement all of the optimizations that `terser` performs. As a result, `terser` is used as a second phase to further optimize and reduce the size of the output bundle scripts. Since `terser` is operating on a smaller input size, the time required for `terser` to complete is significantly reduced. To further improve performance when source maps are enabled, the source map merging is now performed within the optimization workers. A maximum of four (4) optimization workers are currently used and this value can be adjusted via the `NG_BUILD_MAX_WORKERS` environment variable.
1 parent 203e1a4 commit da32daa

File tree

9 files changed

+428
-72
lines changed

9 files changed

+428
-72
lines changed

package.json

+3
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
"ajv-formats/ajv": "8.6.0"
6767
},
6868
"devDependencies": {
69+
"@ampproject/remapping": "1.0.1",
6970
"@angular/animations": "12.1.0-next.6",
7071
"@angular/cdk": "12.1.0-rc.0",
7172
"@angular/common": "12.1.0-next.6",
@@ -146,6 +147,7 @@
146147
"css-minimizer-webpack-plugin": "3.0.1",
147148
"debug": "^4.1.1",
148149
"enhanced-resolve": "5.8.2",
150+
"esbuild": "0.12.8",
149151
"eslint": "7.29.0",
150152
"eslint-config-prettier": "8.3.0",
151153
"eslint-plugin-header": "3.1.1",
@@ -191,6 +193,7 @@
191193
"parse5-html-rewriting-stream": "6.0.1",
192194
"pidtree": "^0.5.0",
193195
"pidusage": "^2.0.17",
196+
"piscina": "3.1.0",
194197
"popper.js": "^1.14.1",
195198
"postcss": "8.3.5",
196199
"postcss-import": "14.0.2",

packages/angular_devkit/build_angular/BUILD.bazel

+3
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ ts_library(
104104
"//packages/angular_devkit/core",
105105
"//packages/angular_devkit/core/node",
106106
"//packages/ngtools/webpack",
107+
"@npm//@ampproject/remapping",
107108
"@npm//@angular/compiler-cli",
108109
"@npm//@angular/core",
109110
"@npm//@angular/localize",
@@ -148,6 +149,7 @@ ts_library(
148149
"@npm//critters",
149150
"@npm//css-loader",
150151
"@npm//css-minimizer-webpack-plugin",
152+
"@npm//esbuild",
151153
"@npm//find-cache-dir",
152154
"@npm//glob",
153155
"@npm//https-proxy-agent",
@@ -165,6 +167,7 @@ ts_library(
165167
"@npm//open",
166168
"@npm//ora",
167169
"@npm//parse5-html-rewriting-stream",
170+
"@npm//piscina",
168171
"@npm//postcss",
169172
"@npm//postcss-import",
170173
"@npm//postcss-loader",

packages/angular_devkit/build_angular/package.json

+3
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"typings": "src/index.d.ts",
77
"builders": "builders.json",
88
"dependencies": {
9+
"@ampproject/remapping": "1.0.1",
910
"@angular-devkit/architect": "0.0.0",
1011
"@angular-devkit/build-optimizer": "0.0.0",
1112
"@angular-devkit/build-webpack": "0.0.0",
@@ -32,6 +33,7 @@
3233
"critters": "0.0.10",
3334
"css-loader": "5.2.6",
3435
"css-minimizer-webpack-plugin": "3.0.1",
36+
"esbuild": "0.12.8",
3537
"find-cache-dir": "3.3.1",
3638
"glob": "7.1.7",
3739
"https-proxy-agent": "5.0.0",
@@ -47,6 +49,7 @@
4749
"open": "8.2.1",
4850
"ora": "5.4.1",
4951
"parse5-html-rewriting-stream": "6.0.1",
52+
"piscina": "3.1.0",
5053
"postcss": "8.3.5",
5154
"postcss-import": "14.0.2",
5255
"postcss-loader": "6.1.0",

packages/angular_devkit/build_angular/src/browser/specs/optimization-level_spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ describe('Browser Builder optimization level', () => {
3131

3232
const overrides = { optimization: true };
3333
const { files } = await browserBuild(architect, host, target, overrides);
34-
expect(await files['vendor.js']).toMatch(/class \w{constructor\(\){/);
34+
expect(await files['vendor.js']).toMatch(/class \w{1,3}{constructor\(\){/);
3535
});
3636

3737
it('supports styles only optimizations', async () => {

packages/angular_devkit/build_angular/src/webpack/configs/common.ts

+29-66
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { findAllNodeModules } from '../../utils/find-up';
4646
import { Spinner } from '../../utils/spinner';
4747
import { addError } from '../../utils/webpack-diagnostics';
4848
import { DedupeModuleResolvePlugin, ScriptsWebpackPlugin } from '../plugins';
49+
import { JavaScriptOptimizerPlugin } from '../plugins/javascript-optimizer-plugin';
4950
import {
5051
getEsVersionForFileName,
5152
getOutputHashFormat,
@@ -316,78 +317,40 @@ export function getCommonConfig(wco: WebpackConfigOptions): Configuration {
316317
const extraMinimizers = [];
317318

318319
if (scriptsOptimization) {
319-
const TerserPlugin = require('terser-webpack-plugin');
320-
const angularGlobalDefinitions = buildOptions.aot
321-
? GLOBAL_DEFS_FOR_TERSER_WITH_AOT
322-
: GLOBAL_DEFS_FOR_TERSER;
323-
324-
// TODO: Investigate why this fails for some packages: wco.supportES2015 ? 6 : 5;
325-
const terserEcma = 5;
326-
327-
const terserOptions = {
328-
warnings: !!buildOptions.verbose,
329-
safari10: true,
330-
output: {
331-
ecma: terserEcma,
332-
// For differential loading, this is handled in the bundle processing.
333-
ascii_only: !differentialLoadingMode,
334-
// Default behavior (undefined value) is to keep only important comments (licenses, etc.)
335-
comments: !buildOptions.extractLicenses && undefined,
336-
webkit: true,
337-
beautify: shouldBeautify,
338-
wrap_func_args: false,
339-
},
340-
// On server, we don't want to compress anything. We still set the ngDevMode = false for it
341-
// to remove dev code, and ngI18nClosureMode to remove Closure compiler i18n code
342-
compress:
343-
allowMinify &&
344-
(platform === 'server'
345-
? {
346-
ecma: terserEcma,
347-
global_defs: angularGlobalDefinitions,
348-
keep_fnames: true,
349-
}
350-
: {
351-
ecma: terserEcma,
352-
pure_getters: buildOptions.buildOptimizer,
353-
// PURE comments work best with 3 passes.
354-
// See https://github.com/webpack/webpack/issues/2899#issuecomment-317425926.
355-
passes: buildOptions.buildOptimizer ? 3 : 1,
356-
global_defs: angularGlobalDefinitions,
357-
pure_funcs: ['forwardRef'],
358-
}),
359-
// We also want to avoid mangling on server.
360-
// Name mangling is handled within the browser builder
361-
mangle: allowMangle && platform !== 'server' && !differentialLoadingMode,
362-
};
363-
364320
const globalScriptsNames = globalScriptsByBundleName.map((s) => s.bundleName);
365321

366-
extraMinimizers.push(
367-
new TerserPlugin({
368-
parallel: maxWorkers,
369-
extractComments: false,
370-
exclude: globalScriptsNames,
371-
terserOptions,
372-
}),
322+
if (globalScriptsNames.length > 0) {
373323
// Script bundles are fully optimized here in one step since they are never downleveled.
374324
// They are shared between ES2015 & ES5 outputs so must support ES5.
375-
new TerserPlugin({
376-
parallel: maxWorkers,
377-
extractComments: false,
378-
include: globalScriptsNames,
379-
terserOptions: {
380-
...terserOptions,
381-
compress: allowMinify && {
382-
...terserOptions.compress,
383-
ecma: 5,
384-
},
385-
output: {
386-
...terserOptions.output,
325+
// The `terser-webpack-plugin` will add the minified flag to the asset which will prevent
326+
// additional optimizations by the next plugin.
327+
const TerserPlugin = require('terser-webpack-plugin');
328+
extraMinimizers.push(
329+
new TerserPlugin({
330+
parallel: maxWorkers,
331+
extractComments: false,
332+
include: globalScriptsNames,
333+
terserOptions: {
387334
ecma: 5,
335+
compress: allowMinify,
336+
output: {
337+
ascii_only: true,
338+
wrap_func_args: false,
339+
},
340+
mangle: allowMangle && platform !== 'server',
388341
},
389-
mangle: allowMangle && platform !== 'server',
390-
},
342+
}),
343+
);
344+
}
345+
346+
extraMinimizers.push(
347+
new JavaScriptOptimizerPlugin({
348+
define: buildOptions.aot ? GLOBAL_DEFS_FOR_TERSER_WITH_AOT : GLOBAL_DEFS_FOR_TERSER,
349+
sourcemap: scriptsSourceMap,
350+
target: wco.scriptTarget,
351+
keepNames: !allowMangle || platform === 'server',
352+
removeLicenses: buildOptions.extractLicenses,
353+
advanced: buildOptions.buildOptimizer,
391354
}),
392355
);
393356
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
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.io/license
7+
*/
8+
9+
import Piscina from 'piscina';
10+
import { ScriptTarget } from 'typescript';
11+
import { maxWorkers } from '../../utils/environment-options';
12+
13+
/**
14+
* The maximum number of Workers that will be created to execute optimize tasks.
15+
*/
16+
const MAX_OPTIMIZE_WORKERS = maxWorkers;
17+
18+
/**
19+
* The name of the plugin provided to Webpack when tapping Webpack compiler hooks.
20+
*/
21+
const PLUGIN_NAME = 'angular-javascript-optimizer';
22+
23+
/**
24+
* The options used to configure the {@link JavaScriptOptimizerPlugin}.
25+
*/
26+
export interface JavaScriptOptimizerOptions {
27+
/**
28+
* Enables advanced optimizations in the underlying JavaScript optimizers.
29+
* This currently increases the `terser` passes to 3 and enables the `pure_getters`
30+
* option for `terser`.
31+
*/
32+
advanced: boolean;
33+
34+
/**
35+
* An object record of string keys that will be replaced with their respective values when found
36+
* within the code during optimization.
37+
*/
38+
define: Record<string, string | number | boolean>;
39+
40+
/**
41+
* Enables the generation of a sourcemap during optimization.
42+
* The output sourcemap will be a full sourcemap containing the merge of the input sourcemap and
43+
* all intermediate sourcemaps.
44+
*/
45+
sourcemap: boolean;
46+
47+
/**
48+
* The ECMAScript version that should be used when generating output code.
49+
* The optimizer will not adjust the output code with features present in newer
50+
* ECMAScript versions.
51+
*/
52+
target: ScriptTarget;
53+
54+
/**
55+
* Enables the retention of identifier names and ensures that function and class names are
56+
* present in the output code.
57+
*/
58+
keepNames: boolean;
59+
60+
/**
61+
* Enables the removal of all license comments from the output code.
62+
*/
63+
removeLicenses: boolean;
64+
}
65+
66+
/**
67+
* A Webpack plugin that provides JavaScript optimization capabilities.
68+
*
69+
* The plugin uses both `esbuild` and `terser` to provide both fast and highly-optimized
70+
* code output. `esbuild` is used as an initial pass to remove the majority of unused code
71+
* as well as shorten identifiers. `terser` is then used as a secondary pass to apply
72+
* optimizations not yet implemented by `esbuild`.
73+
*/
74+
export class JavaScriptOptimizerPlugin {
75+
constructor(public options: Partial<JavaScriptOptimizerOptions> = {}) {}
76+
77+
apply(compiler: import('webpack').Compiler) {
78+
const { OriginalSource, SourceMapSource } = compiler.webpack.sources;
79+
80+
compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => {
81+
compilation.hooks.processAssets.tapPromise(
82+
{
83+
name: PLUGIN_NAME,
84+
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE,
85+
},
86+
async (compilationAssets) => {
87+
const scriptsToOptimize = [];
88+
89+
// Analyze the compilation assets for scripts that require optimization
90+
for (const assetName of Object.keys(compilationAssets)) {
91+
if (assetName.endsWith('.js')) {
92+
const scriptAsset = compilation.getAsset(assetName);
93+
if (scriptAsset && !scriptAsset.info.minimized) {
94+
const { source, map } = scriptAsset.source.sourceAndMap();
95+
scriptsToOptimize.push({
96+
name: scriptAsset.name,
97+
code: typeof source === 'string' ? source : source.toString(),
98+
map,
99+
});
100+
}
101+
}
102+
}
103+
104+
if (scriptsToOptimize.length === 0) {
105+
return;
106+
}
107+
108+
// Ensure all replacement values are strings which is the expected type for esbuild
109+
let define: Record<string, string> | undefined;
110+
if (this.options.define) {
111+
define = {};
112+
for (const [key, value] of Object.entries(this.options.define)) {
113+
define[key] = String(value);
114+
}
115+
}
116+
117+
let target = 2017;
118+
if (this.options.target) {
119+
if (this.options.target <= ScriptTarget.ES5) {
120+
target = 5;
121+
} else if (this.options.target < ScriptTarget.ESNext) {
122+
target = Number(ScriptTarget[this.options.target].slice(2));
123+
} else {
124+
target = 2020;
125+
}
126+
}
127+
128+
// Setup the options used by all worker tasks
129+
const optimizeOptions = {
130+
sourcemap: this.options.sourcemap,
131+
define,
132+
keepNames: this.options.keepNames,
133+
target,
134+
removeLicenses: this.options.removeLicenses,
135+
advanced: this.options.advanced,
136+
};
137+
138+
// Sort scripts so larger scripts start first - worker pool uses a FIFO queue
139+
scriptsToOptimize.sort((a, b) => a.code.length - b.code.length);
140+
141+
// Initialize the task worker pool
142+
const workerPath = require.resolve('./javascript-optimizer-worker');
143+
const workerPool = new Piscina({
144+
filename: workerPath,
145+
maxThreads: MAX_OPTIMIZE_WORKERS,
146+
});
147+
148+
// Enqueue script optimization tasks and update compilation assets as the tasks complete
149+
try {
150+
const tasks = [];
151+
for (const { name, code, map } of scriptsToOptimize) {
152+
tasks.push(
153+
workerPool
154+
.run({
155+
asset: {
156+
name,
157+
code,
158+
map,
159+
},
160+
options: optimizeOptions,
161+
})
162+
.then(
163+
({ code, name, map }) => {
164+
let optimizedAsset;
165+
if (map) {
166+
optimizedAsset = new SourceMapSource(code, name, map);
167+
} else {
168+
optimizedAsset = new OriginalSource(code, name);
169+
}
170+
compilation.updateAsset(name, optimizedAsset, { minimized: true });
171+
},
172+
(error) => {
173+
const optimizationError = new compiler.webpack.WebpackError(
174+
`Optimization error [${name}]: ${error.stack || error.message}`,
175+
);
176+
compilation.errors.push(optimizationError);
177+
},
178+
),
179+
);
180+
}
181+
182+
await Promise.all(tasks);
183+
} finally {
184+
void workerPool.destroy();
185+
}
186+
},
187+
);
188+
});
189+
}
190+
}

0 commit comments

Comments
 (0)