In this section of the blog series Build Angular Like an Architect we look at optimizing a production build with angular-devkit, and round out our custom build by figuring out how to implement environments.
Recap
In Build Angular Like an Architect (Part 1) we looked at getting started with the latest Architect API. By coding the Builder with the Architect API and RxJS we were able to extend Angular CLI with a new production build that optimizes Angular with Closure Compiler.
We ended up with a function that executes a RxJS Observable like so:
export function executeClosure(
options: ClosureBuilderSchema,
context: BuilderContext
): Observable<BuilderOutput> {
return of(context).pipe(
concatMap( results => ngc(options, context) ),
concatMap( results => compileMain(options, context)),
concatMap( results => closure(options, context) ),
mapTo({ success: true }),
catchError(error => {
context.reportStatus('Error: ' + error);
return [{ success: false }];
}),
);
}
In the beginning of this section let's add more optimizations to the production bundle using a tool in @angular-devkit called buildOptimizer
.
Create a new method called optimizeBuild that returns an RxJS Observable and add the method to the pipe
in executeClosure
.
return of(context).pipe(
concatMap( results => ngc(options, context) ),
concatMap( results => compileMain(options, context)),
concatMap( results => optimizeBuild(options, context)),
concatMap( results => closure(options, context) ),
Install @angular-devkit/build-optimizer
in the build_tools directory.
npm i @angular-devkit/build-optimizer --save-dev
Import buildOptimizer
like so.
import { buildOptimizer } from '@angular-devkit/build-optimizer';
Essentially after the Angular Compiler runs, every component.js file in the out-tsc needs to be postprocessed with buildOptimizer. This tool removes unnecessary decorators that can bloat the bundle.
The algorithm for the script is as follows:
- list all files with extension .component.js in the out-tsc directory
- read each file in array of filenames
- call buildOptimizer, passing in content of each file
- write files to disk with the output of buildOptimizer
Let's use a handy npm package called glob to list all the files with a given extension.
Install glob in the build_tools directory.
npm i glob --save-dev
Import glob into src/closure/index.ts.
import { glob } from 'glob';
In the optimizeBuild
method, declare a new const
and call it files
.
const files = glob.sync(normalize('out-tsc/**/*.component.js'));
glob.sync
will synchronously format all files matching the glob into an array of strings. In the above example, files
equals an array of strings that include paths to all files with the extension .component.js
.
Now we have an array of filenames that require postprocessing with buildOptimizer
. Our function optimizeBuild
needs to return an Observable but we have an array of filenames.
Essentially optimizeBuild
should not emit until all the files are processed, so we need to map files to an array of Observables and use a RxJS method called forkJoin
to wait until all the Observables are done. A proceeding step in the build is to bundle the application with Closure Compiler. That task has to wait for optimizeBuild
to complete.
const optimizedFiles = files.map((file) => {
return new Observable((observer) => {
readFile(file, 'utf-8', (err, data) => {
if (err) {
observer.error(err);
}
writeFile(file, buildOptimizer({ content: data }).content, (error) => {
if (error) {
observer.error(error);
}
observer.next(file);
observer.complete();
});
});
});
});
return forkJoin(optimizedFiles);
Each file is read from disk with readFile
, the contents of the file are postprocessed with buildOptimizer
and the resulting content is written to disk with writeFile
. The observer calls next
and complete
to notify forkJoin
the asynchronous action has been performed.
If you look at the files in the out-tsc directory prior to running this optimization the files would include decorators like this one:
AppComponent.decorators = [
{ type: Component, args: [{
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
},] },
];
Now the decorators are removed with buildOptimizer
with you run architect build_repo:closure_build
.
Let's move onto incorporating environments so we can replicate this feature from the default Angular CLI build.
Handling Environments
Handling the environment configuration is much simpler than the previous exercises. First let's look at the problem.
In src/environments there are two files by default.
- environment.ts
- enviroment.prod.ts
environment.prod.ts looks like this by default.
export const environment = {
production: true
};
src/main.ts references this configuration in a newly scaffolded project.
import { environment } from './environments/environment';
if (environment.production) {
enableProdMode();
}
Notice the environment object is always imported from ./environments/environment but we have different files per environment?
The solution is quite simple.
After the AOT compiler runs and outputs JavaScript to the out-tsc directory but before the application is bundled we have to swap the files.
cp out-tsc/src/environment/environment.prod.js out-tsc/src/environment/environment.js
The above snippet uses the cp Unix command to copy the production environment file to the default environment.js.
After the environment.js file is replaced with the current environment, the application is bundled and all references to environment
in the app correspond to the correct environment.
Create a new function called handleEnvironment
and pass in the options as an argument. The function is like the others so far, it returns an Observable.
export function handleEnvironment(
options:ClosureBuilderSchema,
context: BuilderContext
): Observable<{}> {
}
If we have env
defined as an option in the schema.json.
"env": {
"type": "string",
"description": "Environment to build for (defaults to prod)."
}
We can use the same argument for running this build with the Architect CLI.
architect build_repo:closure_build --env=prod
In the method we just created we can reference the env
argument on the options
object.
const env = options.env ? options.env : 'prod';
To copy the correct environment, we can use a tool available in node called exec
.
import { exec } from 'child_process';
exec
allows you to run bash commands like you normally would in a terminal.
Functions like exec
that come packaged with node are promised based. Luckily RxJS Observables are interoperable with Promises. We can use the of
method packaged in RxJS
to convert exec
into an Observable. The finished code is below.
export function handleEnvironment(
options:ClosureBuilderSchema,
context: BuilderContext
): Observable<{}> {
const env = options.env ? options.env : 'prod';
return of(exec('cp '+
normalize('out-tsc/src/environments/environment.' + env + '.js') + ' ' +
normalize('out-tsc/src/environments/environment.js')
));
}
Add the new method to executeClosure
with another call to concatMap
. It should feel like a needle and thread at this point.
return of(context).pipe(
concatMap( results => ngc(options, context) ),
concatMap( results => compileMain(options, context)),
concatMap( results => optimizeBuild(options, context)),
concatMap( results => handleEnvironment(options, context)),
concatMap( results => closure(options, context) ),
Take a moment to reflect on the build guru you've become. All the steps are now in place for a production build!
Top comments (3)
Wow, mind blown
+1 🤯