Skip to content

Commit

Permalink
perf(@angular-devkit/build-angular): use combination of esbuild and…
Browse files Browse the repository at this point in the history
… `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.
  • Loading branch information
clydin authored and alan-agius4 committed Jun 25, 2021
1 parent 203e1a4 commit da32daa
Show file tree
Hide file tree
Showing 9 changed files with 428 additions and 72 deletions.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"ajv-formats/ajv": "8.6.0"
},
"devDependencies": {
"@ampproject/remapping": "1.0.1",
"@angular/animations": "12.1.0-next.6",
"@angular/cdk": "12.1.0-rc.0",
"@angular/common": "12.1.0-next.6",
Expand Down Expand Up @@ -146,6 +147,7 @@
"css-minimizer-webpack-plugin": "3.0.1",
"debug": "^4.1.1",
"enhanced-resolve": "5.8.2",
"esbuild": "0.12.8",
"eslint": "7.29.0",
"eslint-config-prettier": "8.3.0",
"eslint-plugin-header": "3.1.1",
Expand Down Expand Up @@ -191,6 +193,7 @@
"parse5-html-rewriting-stream": "6.0.1",
"pidtree": "^0.5.0",
"pidusage": "^2.0.17",
"piscina": "3.1.0",
"popper.js": "^1.14.1",
"postcss": "8.3.5",
"postcss-import": "14.0.2",
Expand Down
3 changes: 3 additions & 0 deletions packages/angular_devkit/build_angular/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ ts_library(
"//packages/angular_devkit/core",
"//packages/angular_devkit/core/node",
"//packages/ngtools/webpack",
"@npm//@ampproject/remapping",
"@npm//@angular/compiler-cli",
"@npm//@angular/core",
"@npm//@angular/localize",
Expand Down Expand Up @@ -148,6 +149,7 @@ ts_library(
"@npm//critters",
"@npm//css-loader",
"@npm//css-minimizer-webpack-plugin",
"@npm//esbuild",
"@npm//find-cache-dir",
"@npm//glob",
"@npm//https-proxy-agent",
Expand All @@ -165,6 +167,7 @@ ts_library(
"@npm//open",
"@npm//ora",
"@npm//parse5-html-rewriting-stream",
"@npm//piscina",
"@npm//postcss",
"@npm//postcss-import",
"@npm//postcss-loader",
Expand Down
3 changes: 3 additions & 0 deletions packages/angular_devkit/build_angular/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"typings": "src/index.d.ts",
"builders": "builders.json",
"dependencies": {
"@ampproject/remapping": "1.0.1",
"@angular-devkit/architect": "0.0.0",
"@angular-devkit/build-optimizer": "0.0.0",
"@angular-devkit/build-webpack": "0.0.0",
Expand All @@ -32,6 +33,7 @@
"critters": "0.0.10",
"css-loader": "5.2.6",
"css-minimizer-webpack-plugin": "3.0.1",
"esbuild": "0.12.8",
"find-cache-dir": "3.3.1",
"glob": "7.1.7",
"https-proxy-agent": "5.0.0",
Expand All @@ -47,6 +49,7 @@
"open": "8.2.1",
"ora": "5.4.1",
"parse5-html-rewriting-stream": "6.0.1",
"piscina": "3.1.0",
"postcss": "8.3.5",
"postcss-import": "14.0.2",
"postcss-loader": "6.1.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe('Browser Builder optimization level', () => {

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

it('supports styles only optimizations', async () => {
Expand Down
95 changes: 29 additions & 66 deletions packages/angular_devkit/build_angular/src/webpack/configs/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { findAllNodeModules } from '../../utils/find-up';
import { Spinner } from '../../utils/spinner';
import { addError } from '../../utils/webpack-diagnostics';
import { DedupeModuleResolvePlugin, ScriptsWebpackPlugin } from '../plugins';
import { JavaScriptOptimizerPlugin } from '../plugins/javascript-optimizer-plugin';
import {
getEsVersionForFileName,
getOutputHashFormat,
Expand Down Expand Up @@ -316,78 +317,40 @@ export function getCommonConfig(wco: WebpackConfigOptions): Configuration {
const extraMinimizers = [];

if (scriptsOptimization) {
const TerserPlugin = require('terser-webpack-plugin');
const angularGlobalDefinitions = buildOptions.aot
? GLOBAL_DEFS_FOR_TERSER_WITH_AOT
: GLOBAL_DEFS_FOR_TERSER;

// TODO: Investigate why this fails for some packages: wco.supportES2015 ? 6 : 5;
const terserEcma = 5;

const terserOptions = {
warnings: !!buildOptions.verbose,
safari10: true,
output: {
ecma: terserEcma,
// For differential loading, this is handled in the bundle processing.
ascii_only: !differentialLoadingMode,
// Default behavior (undefined value) is to keep only important comments (licenses, etc.)
comments: !buildOptions.extractLicenses && undefined,
webkit: true,
beautify: shouldBeautify,
wrap_func_args: false,
},
// On server, we don't want to compress anything. We still set the ngDevMode = false for it
// to remove dev code, and ngI18nClosureMode to remove Closure compiler i18n code
compress:
allowMinify &&
(platform === 'server'
? {
ecma: terserEcma,
global_defs: angularGlobalDefinitions,
keep_fnames: true,
}
: {
ecma: terserEcma,
pure_getters: buildOptions.buildOptimizer,
// PURE comments work best with 3 passes.
// See https://github.com/webpack/webpack/issues/2899#issuecomment-317425926.
passes: buildOptions.buildOptimizer ? 3 : 1,
global_defs: angularGlobalDefinitions,
pure_funcs: ['forwardRef'],
}),
// We also want to avoid mangling on server.
// Name mangling is handled within the browser builder
mangle: allowMangle && platform !== 'server' && !differentialLoadingMode,
};

const globalScriptsNames = globalScriptsByBundleName.map((s) => s.bundleName);

extraMinimizers.push(
new TerserPlugin({
parallel: maxWorkers,
extractComments: false,
exclude: globalScriptsNames,
terserOptions,
}),
if (globalScriptsNames.length > 0) {
// Script bundles are fully optimized here in one step since they are never downleveled.
// They are shared between ES2015 & ES5 outputs so must support ES5.
new TerserPlugin({
parallel: maxWorkers,
extractComments: false,
include: globalScriptsNames,
terserOptions: {
...terserOptions,
compress: allowMinify && {
...terserOptions.compress,
ecma: 5,
},
output: {
...terserOptions.output,
// The `terser-webpack-plugin` will add the minified flag to the asset which will prevent
// additional optimizations by the next plugin.
const TerserPlugin = require('terser-webpack-plugin');
extraMinimizers.push(
new TerserPlugin({
parallel: maxWorkers,
extractComments: false,
include: globalScriptsNames,
terserOptions: {
ecma: 5,
compress: allowMinify,
output: {
ascii_only: true,
wrap_func_args: false,
},
mangle: allowMangle && platform !== 'server',
},
mangle: allowMangle && platform !== 'server',
},
}),
);
}

extraMinimizers.push(
new JavaScriptOptimizerPlugin({
define: buildOptions.aot ? GLOBAL_DEFS_FOR_TERSER_WITH_AOT : GLOBAL_DEFS_FOR_TERSER,
sourcemap: scriptsSourceMap,
target: wco.scriptTarget,
keepNames: !allowMangle || platform === 'server',
removeLicenses: buildOptions.extractLicenses,
advanced: buildOptions.buildOptimizer,
}),
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import Piscina from 'piscina';
import { ScriptTarget } from 'typescript';
import { maxWorkers } from '../../utils/environment-options';

/**
* The maximum number of Workers that will be created to execute optimize tasks.
*/
const MAX_OPTIMIZE_WORKERS = maxWorkers;

/**
* The name of the plugin provided to Webpack when tapping Webpack compiler hooks.
*/
const PLUGIN_NAME = 'angular-javascript-optimizer';

/**
* The options used to configure the {@link JavaScriptOptimizerPlugin}.
*/
export interface JavaScriptOptimizerOptions {
/**
* Enables advanced optimizations in the underlying JavaScript optimizers.
* This currently increases the `terser` passes to 3 and enables the `pure_getters`
* option for `terser`.
*/
advanced: boolean;

/**
* An object record of string keys that will be replaced with their respective values when found
* within the code during optimization.
*/
define: Record<string, string | number | boolean>;

/**
* Enables the generation of a sourcemap during optimization.
* The output sourcemap will be a full sourcemap containing the merge of the input sourcemap and
* all intermediate sourcemaps.
*/
sourcemap: boolean;

/**
* The ECMAScript version that should be used when generating output code.
* The optimizer will not adjust the output code with features present in newer
* ECMAScript versions.
*/
target: ScriptTarget;

/**
* Enables the retention of identifier names and ensures that function and class names are
* present in the output code.
*/
keepNames: boolean;

/**
* Enables the removal of all license comments from the output code.
*/
removeLicenses: boolean;
}

/**
* A Webpack plugin that provides JavaScript optimization capabilities.
*
* The plugin uses both `esbuild` and `terser` to provide both fast and highly-optimized
* code output. `esbuild` is used as an initial pass to remove the majority of unused code
* as well as shorten identifiers. `terser` is then used as a secondary pass to apply
* optimizations not yet implemented by `esbuild`.
*/
export class JavaScriptOptimizerPlugin {
constructor(public options: Partial<JavaScriptOptimizerOptions> = {}) {}

apply(compiler: import('webpack').Compiler) {
const { OriginalSource, SourceMapSource } = compiler.webpack.sources;

compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => {
compilation.hooks.processAssets.tapPromise(
{
name: PLUGIN_NAME,
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE,
},
async (compilationAssets) => {
const scriptsToOptimize = [];

// Analyze the compilation assets for scripts that require optimization
for (const assetName of Object.keys(compilationAssets)) {
if (assetName.endsWith('.js')) {
const scriptAsset = compilation.getAsset(assetName);
if (scriptAsset && !scriptAsset.info.minimized) {
const { source, map } = scriptAsset.source.sourceAndMap();
scriptsToOptimize.push({
name: scriptAsset.name,
code: typeof source === 'string' ? source : source.toString(),
map,
});
}
}
}

if (scriptsToOptimize.length === 0) {
return;
}

// Ensure all replacement values are strings which is the expected type for esbuild
let define: Record<string, string> | undefined;
if (this.options.define) {
define = {};
for (const [key, value] of Object.entries(this.options.define)) {
define[key] = String(value);
}
}

let target = 2017;
if (this.options.target) {
if (this.options.target <= ScriptTarget.ES5) {
target = 5;
} else if (this.options.target < ScriptTarget.ESNext) {
target = Number(ScriptTarget[this.options.target].slice(2));
} else {
target = 2020;
}
}

// Setup the options used by all worker tasks
const optimizeOptions = {
sourcemap: this.options.sourcemap,
define,
keepNames: this.options.keepNames,
target,
removeLicenses: this.options.removeLicenses,
advanced: this.options.advanced,
};

// Sort scripts so larger scripts start first - worker pool uses a FIFO queue
scriptsToOptimize.sort((a, b) => a.code.length - b.code.length);

// Initialize the task worker pool
const workerPath = require.resolve('./javascript-optimizer-worker');
const workerPool = new Piscina({
filename: workerPath,
maxThreads: MAX_OPTIMIZE_WORKERS,
});

// Enqueue script optimization tasks and update compilation assets as the tasks complete
try {
const tasks = [];
for (const { name, code, map } of scriptsToOptimize) {
tasks.push(
workerPool
.run({
asset: {
name,
code,
map,
},
options: optimizeOptions,
})
.then(
({ code, name, map }) => {
let optimizedAsset;
if (map) {
optimizedAsset = new SourceMapSource(code, name, map);
} else {
optimizedAsset = new OriginalSource(code, name);
}
compilation.updateAsset(name, optimizedAsset, { minimized: true });
},
(error) => {
const optimizationError = new compiler.webpack.WebpackError(
`Optimization error [${name}]: ${error.stack || error.message}`,
);
compilation.errors.push(optimizationError);
},
),
);
}

await Promise.all(tasks);
} finally {
void workerPool.destroy();
}
},
);
});
}
}
Loading

0 comments on commit da32daa

Please sign in to comment.