Skip to content

Commit 4749052

Browse files
filipesilvamgechev
authored andcommitted
feat(@angular-devkit/build-angular): add experimentalRollupPass (angular#15690)
In applications that make heavy use of lazy routes and ES2015 libraries, this option can improve bundle sizes. It might also break your bundles in ways we don't understand fully, so please test and report any problems you find. NOTE: the following are known problems with experimentalRollupPass - vendorChunk, commonChunk, namedChunks: these won't work, because by the time webpack sees the chunks, the context of where they came from is lost. - webWorkerTsConfig: workers must be imported via a root relative path (e.g.`app/search/search.worker`) instead of a relative path (`/search.worker`) because of the same reason as above. - loadChildren string syntax: doesn't work because rollup cannot follow the imports.
1 parent 33e9039 commit 4749052

File tree

11 files changed

+410
-3
lines changed

11 files changed

+410
-3
lines changed

packages/angular_devkit/build_angular/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"postcss-loader": "3.0.0",
4343
"raw-loader": "3.1.0",
4444
"regenerator-runtime": "0.13.3",
45+
"rollup": "1.21.4",
4546
"rxjs": "6.5.3",
4647
"sass": "1.23.0",
4748
"sass-loader": "8.0.0",

packages/angular_devkit/build_angular/src/angular-cli-files/models/build-options.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ export interface BuildOptions {
8888

8989
/* When specified it will be used instead of the script target in the tsconfig.json. */
9090
scriptTargetOverride?: ScriptTarget;
91+
92+
experimentalRollupPass?: boolean;
9193
}
9294

9395
export interface WebpackTestOptions extends BuildOptions {

packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/common.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@ import {
1212
import { tags } from '@angular-devkit/core';
1313
import * as CopyWebpackPlugin from 'copy-webpack-plugin';
1414
import * as path from 'path';
15+
import { RollupOptions } from 'rollup';
1516
import { ScriptTarget } from 'typescript';
1617
import {
1718
Compiler,
1819
Configuration,
1920
ContextReplacementPlugin,
2021
HashedModuleIdsPlugin,
22+
Rule,
2123
compilation,
2224
debug,
2325
} from 'webpack';
@@ -29,6 +31,7 @@ import { BundleBudgetPlugin } from '../../plugins/bundle-budget';
2931
import { CleanCssWebpackPlugin } from '../../plugins/cleancss-webpack-plugin';
3032
import { NamedLazyChunksPlugin } from '../../plugins/named-chunks-plugin';
3133
import { ScriptsWebpackPlugin } from '../../plugins/scripts-webpack-plugin';
34+
import { WebpackRollupLoader } from '../../plugins/webpack';
3235
import { findAllNodeModules, findUp } from '../../utilities/find-up';
3336
import { WebpackConfigOptions } from '../build-options';
3437
import { getEsVersionForFileName, getOutputHashFormat, normalizeExtraEntryPoints } from './utils';
@@ -57,6 +60,7 @@ export function getCommonConfig(wco: WebpackConfigOptions): Configuration {
5760

5861
// tslint:disable-next-line:no-any
5962
const extraPlugins: any[] = [];
63+
const extraRules: Rule[] = [];
6064
const entryPoints: { [key: string]: string[] } = {};
6165

6266
const targetInFileName = getEsVersionForFileName(
@@ -65,7 +69,51 @@ export function getCommonConfig(wco: WebpackConfigOptions): Configuration {
6569
);
6670

6771
if (buildOptions.main) {
68-
entryPoints['main'] = [path.resolve(root, buildOptions.main)];
72+
const mainPath = path.resolve(root, buildOptions.main);
73+
entryPoints['main'] = [mainPath];
74+
75+
if (buildOptions.experimentalRollupPass) {
76+
// NOTE: the following are known problems with experimentalRollupPass
77+
// - vendorChunk, commonChunk, namedChunks: these won't work, because by the time webpack
78+
// sees the chunks, the context of where they came from is lost.
79+
// - webWorkerTsConfig: workers must be imported via a root relative path (e.g.
80+
// `app/search/search.worker`) instead of a relative path (`/search.worker`) because
81+
// of the same reason as above.
82+
// - loadChildren string syntax: doesn't work because rollup cannot follow the imports.
83+
84+
// Rollup options, except entry module, which is automatically inferred.
85+
const rollupOptions: RollupOptions = {};
86+
87+
// Add rollup plugins/rules.
88+
extraRules.push({
89+
test: mainPath,
90+
// Ensure rollup loader executes after other loaders.
91+
enforce: 'post',
92+
use: [{
93+
loader: WebpackRollupLoader,
94+
options: rollupOptions,
95+
}],
96+
});
97+
98+
// Rollup bundles will include the dynamic System.import that was inside Angular and webpack
99+
// will emit warnings because it can't resolve it. We just ignore it.
100+
// TODO: maybe use https://webpack.js.org/configuration/stats/#statswarningsfilter instead.
101+
102+
// Ignore all "Critical dependency: the request of a dependency is an expression" warnings.
103+
extraPlugins.push(new ContextReplacementPlugin(/./));
104+
// Ignore "System.import() is deprecated" warnings for the main file and js files.
105+
// Might still get them if @angular/core gets split into a lazy module.
106+
extraRules.push({
107+
test: mainPath,
108+
enforce: 'post',
109+
parser: { system: true },
110+
});
111+
extraRules.push({
112+
test: /\.js$/,
113+
enforce: 'post',
114+
parser: { system: true },
115+
});
116+
}
69117
}
70118

71119
let differentialLoadingNeeded = false;
@@ -482,6 +530,7 @@ export function getCommonConfig(wco: WebpackConfigOptions): Configuration {
482530
enforce: 'pre',
483531
...sourceMapUseRule,
484532
},
533+
...extraRules,
485534
],
486535
},
487536
optimization: {
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. 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+
/**
10+
* @license
11+
* @author Erik Desjardins
12+
* Forked as of SHA 10fb020f997a146725963b202d79290c8798a7a0 from https://github.com/erikdesjardins/webpack-rollup-loader.
13+
* Licensed under a MIT license.
14+
* See https://github.com/erikdesjardins/webpack-rollup-loader/blob/10fb020f997a146725963b202d79290c8798a7a0/LICENSE for full license.
15+
*/
16+
17+
import { VirtualFileSystemDecorator } from '@ngtools/webpack/src/virtual_file_system_decorator';
18+
import { dirname, join } from 'path';
19+
import { OutputAsset, OutputChunk, rollup } from 'rollup';
20+
import { RawSourceMap } from 'source-map';
21+
import webpack = require('webpack');
22+
23+
function splitRequest(request: string) {
24+
const inx = request.lastIndexOf('!');
25+
if (inx === -1) {
26+
return {
27+
loaders: '',
28+
resource: request,
29+
};
30+
} else {
31+
return {
32+
loaders: request.slice(0, inx + 1),
33+
resource: request.slice(inx + 1),
34+
};
35+
}
36+
}
37+
38+
// Load resolve paths using Webpack.
39+
function webpackResolutionPlugin(
40+
loaderContext: webpack.loader.LoaderContext,
41+
entryId: string,
42+
entryIdCodeAndMap: { code: string, map: RawSourceMap },
43+
) {
44+
return {
45+
name: 'webpack-resolution-plugin',
46+
resolveId: (id: string, importerId: string) => {
47+
if (id === entryId) {
48+
return entryId;
49+
} else {
50+
return new Promise((resolve, reject) => {
51+
// split apart resource paths because Webpack's this.resolve() can't handle `loader!`
52+
// prefixes
53+
const parts = splitRequest(id);
54+
const importerParts = splitRequest(importerId);
55+
56+
// resolve the full path of the imported file with Webpack's module loader
57+
// this will figure out node_modules imports, Webpack aliases, etc.
58+
loaderContext.resolve(
59+
dirname(importerParts.resource),
60+
parts.resource,
61+
(err, fullPath) => err ? reject(err) : resolve(parts.loaders + fullPath),
62+
);
63+
});
64+
}
65+
},
66+
load: (id: string) => {
67+
if (id === entryId) {
68+
return entryIdCodeAndMap;
69+
}
70+
71+
return new Promise((resolve, reject) => {
72+
// load the module with Webpack
73+
// this will apply all relevant loaders, etc.
74+
loaderContext.loadModule(
75+
id,
76+
(err, source, map) => err ? reject(err) : resolve({ code: source, map: map }),
77+
);
78+
});
79+
},
80+
};
81+
}
82+
83+
export default function webpackRollupLoader(
84+
this: webpack.loader.LoaderContext,
85+
source: string,
86+
sourceMap: RawSourceMap,
87+
) {
88+
// Note: this loader isn't cacheable because it will add the lazy chunks to the
89+
// virtual file system on completion.
90+
const callback = this.async();
91+
if (!callback) {
92+
throw new Error('Async loader support is required.');
93+
}
94+
const options = this.query || {};
95+
const entryId = this.resourcePath;
96+
const sourcemap = this.sourceMap;
97+
98+
// Get the VirtualFileSystemDecorator that AngularCompilerPlugin added so we can write to it.
99+
// Since we use webpackRollupLoader as a post loader, this should be there.
100+
// TODO: we should be able to do this in a more elegant way by again decorating webpacks
101+
// input file system inside a custom WebpackRollupPlugin, modelled after AngularCompilerPlugin.
102+
const vfs = this._compiler.inputFileSystem as VirtualFileSystemDecorator;
103+
const virtualWrite = (path: string, data: string) =>
104+
vfs.getWebpackCompilerHost().writeFile(path, data, false);
105+
106+
// Bundle with Rollup
107+
const rollupOptions = {
108+
...options,
109+
input: entryId,
110+
plugins: [
111+
...(options.plugins || []),
112+
webpackResolutionPlugin(this, entryId, { code: source, map: sourceMap }),
113+
],
114+
};
115+
116+
rollup(rollupOptions)
117+
.then(build => build.generate({ format: 'es', sourcemap }))
118+
.then(
119+
(result) => {
120+
const [mainChunk, ...otherChunksOrAssets] = result.output;
121+
122+
// Write other chunks and assets to the virtual file system so that webpack can load them.
123+
const resultDir = dirname(entryId);
124+
otherChunksOrAssets.forEach(chunkOrAsset => {
125+
const { fileName, type } = chunkOrAsset;
126+
if (type == 'chunk') {
127+
const { code, map } = chunkOrAsset as OutputChunk;
128+
virtualWrite(join(resultDir, fileName), code);
129+
if (map) {
130+
// Also write the map if there's one.
131+
// Probably need scriptsSourceMap set on CLI to load it.
132+
virtualWrite(join(resultDir, `${fileName}.map`), map.toString());
133+
}
134+
} else if (type == 'asset') {
135+
const { source } = chunkOrAsset as OutputAsset;
136+
// Source might be a Buffer. Just assuming it's a string for now.
137+
virtualWrite(join(resultDir, fileName), source as string);
138+
}
139+
});
140+
141+
// Always return the main chunk from webpackRollupLoader.
142+
// Cast to any here is needed because of a typings incompatibility between source-map versions.
143+
// tslint:disable-next-line:no-any
144+
callback(null, mainChunk.code, (mainChunk as any).map);
145+
},
146+
(err) => callback(err),
147+
);
148+
}

packages/angular_devkit/build_angular/src/angular-cli-files/plugins/webpack.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ export {
2020

2121
import { join } from 'path';
2222
export const RawCssLoader = require.resolve(join(__dirname, 'raw-css-loader'));
23+
export const WebpackRollupLoader = require.resolve(join(__dirname, 'webpack-rollup-loader'));

packages/angular_devkit/build_angular/src/browser/schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,11 @@
375375
"anonymous",
376376
"use-credentials"
377377
]
378+
},
379+
"experimentalRollupPass": {
380+
"type": "boolean",
381+
"description": "Concatenate modules with Rollup before bundling them with Webpack.",
382+
"default": false
378383
}
379384
},
380385
"additionalProperties": false,

packages/angular_devkit/build_angular/src/utils/webpack-browser-config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,18 @@ export async function generateWebpackConfig(
4848
throw new Error(`The 'buildOptimizer' option cannot be used without 'aot'.`);
4949
}
5050

51+
// Ensure Rollup Concatenation is only used with compatible options.
52+
if (options.experimentalRollupPass) {
53+
if (!options.aot) {
54+
throw new Error(`The 'experimentalRollupPass' option cannot be used without 'aot'.`);
55+
}
56+
57+
if (options.vendorChunk || options.commonChunk || options.namedChunks) {
58+
throw new Error(`The 'experimentalRollupPass' option cannot be used with the`
59+
+ `'vendorChunk', 'commonChunk', 'namedChunks' options set to true.`);
60+
}
61+
}
62+
5163
const tsConfigPath = path.resolve(workspaceRoot, options.tsConfig);
5264
const tsConfig = readTsconfig(tsConfigPath);
5365

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. 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 { Architect } from '@angular-devkit/architect';
10+
import {
11+
BrowserBuildOutput,
12+
browserBuild,
13+
createArchitect,
14+
host,
15+
lazyModuleFiles,
16+
lazyModuleFnImport,
17+
} from '../utils';
18+
19+
20+
describe('Browser Builder Rollup Concatenation test', () => {
21+
const target = { project: 'app', target: 'build' };
22+
const overrides = {
23+
experimentalRollupPass: true,
24+
// JIT Rollup bundles will include require calls to .css and .html file, that have lost their
25+
// path context. AOT code already inlines resources so that's not a problem.
26+
aot: true,
27+
// Webpack can't separate rolled-up modules into chunks.
28+
vendorChunk: false,
29+
commonChunk: false,
30+
namedChunks: false,
31+
};
32+
const prodOverrides = {
33+
// Usual prod options.
34+
fileReplacements: [{
35+
replace: 'src/environments/environment.ts',
36+
with: 'src/environments/environment.prod.ts',
37+
}],
38+
optimization: true,
39+
sourceMap: false,
40+
extractCss: true,
41+
namedChunks: false,
42+
aot: true,
43+
extractLicenses: true,
44+
vendorChunk: false,
45+
buildOptimizer: true,
46+
// Extra prod options we need for experimentalRollupPass.
47+
commonChunk: false,
48+
// Just for convenience.
49+
outputHashing: 'none',
50+
};
51+
const rollupProdOverrides = {
52+
...prodOverrides,
53+
experimentalRollupPass: true,
54+
};
55+
let architect: Architect;
56+
57+
const getOutputSize = async (output: BrowserBuildOutput) =>
58+
(await Promise.all(
59+
Object.keys(output.files)
60+
.filter(name => name.endsWith('.js') &&
61+
// These aren't concatenated by Rollup so no point comparing.
62+
!['runtime.js', 'polyfills.js'].includes(name))
63+
.map(name => output.files[name]),
64+
))
65+
.map(content => content.length)
66+
.reduce((acc, curr) => acc + curr, 0);
67+
68+
beforeEach(async () => {
69+
await host.initialize().toPromise();
70+
architect = (await createArchitect(host.root())).architect;
71+
});
72+
73+
afterEach(async () => host.restore().toPromise());
74+
75+
it('works', async () => {
76+
await browserBuild(architect, host, target, overrides);
77+
});
78+
79+
it('works with lazy modules', async () => {
80+
host.writeMultipleFiles(lazyModuleFiles);
81+
host.writeMultipleFiles(lazyModuleFnImport);
82+
await browserBuild(architect, host, target, overrides);
83+
});
84+
85+
it('creates smaller or same size bundles for app without lazy bundles', async () => {
86+
const prodOutput = await browserBuild(architect, host, target, prodOverrides);
87+
const prodSize = await getOutputSize(prodOutput);
88+
const rollupProdOutput = await browserBuild(architect, host, target, rollupProdOverrides);
89+
const rollupProd = await getOutputSize(rollupProdOutput);
90+
expect(prodSize).toBeGreaterThan(rollupProd);
91+
});
92+
93+
it('creates smaller bundles for apps with lazy bundles', async () => {
94+
host.writeMultipleFiles(lazyModuleFiles);
95+
host.writeMultipleFiles(lazyModuleFnImport);
96+
const prodOutput = await browserBuild(architect, host, target, prodOverrides);
97+
const prodSize = await getOutputSize(prodOutput);
98+
const rollupProdOutput = await browserBuild(architect, host, target, rollupProdOverrides);
99+
const rollupProd = await getOutputSize(rollupProdOutput);
100+
expect(prodSize).toBeGreaterThan(rollupProd);
101+
});
102+
});

0 commit comments

Comments
 (0)