DEV Community

Cover image for Build Angular Like An Architect (Part 2)
Stephen Belovarich
Stephen Belovarich

Posted on • Updated on

Build Angular Like An Architect (Part 2)

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 }];
    }),
  );
}
Enter fullscreen mode Exit fullscreen mode

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) ),
Enter fullscreen mode Exit fullscreen mode

Install @angular-devkit/build-optimizer in the build_tools directory.

npm i @angular-devkit/build-optimizer --save-dev
Enter fullscreen mode Exit fullscreen mode

Import buildOptimizer like so.

import { buildOptimizer } from '@angular-devkit/build-optimizer';
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Import glob into src/closure/index.ts.

import { glob } from 'glob';
Enter fullscreen mode Exit fullscreen mode

In the optimizeBuild method, declare a new const and call it files.

const files = glob.sync(normalize('out-tsc/**/*.component.js'));
Enter fullscreen mode Exit fullscreen mode

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);

Enter fullscreen mode Exit fullscreen mode

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']
        },] },
];
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

src/main.ts references this configuration in a newly scaffolded project.

import { environment } from './environments/environment';

if (environment.production) {
  enableProdMode();
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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<{}> {

}
Enter fullscreen mode Exit fullscreen mode

If we have env defined as an option in the schema.json.

"env": {
    "type": "string",
    "description": "Environment to build for (defaults to prod)."
  }
Enter fullscreen mode Exit fullscreen mode

We can use the same argument for running this build with the Architect CLI.

architect build_repo:closure_build --env=prod
Enter fullscreen mode Exit fullscreen mode

In the method we just created we can reference the env argument on the options object.

const env = options.env ? options.env : 'prod';
Enter fullscreen mode Exit fullscreen mode

To copy the correct environment, we can use a tool available in node called exec.

import { exec } from 'child_process';
Enter fullscreen mode Exit fullscreen mode

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')
             ));
}
Enter fullscreen mode Exit fullscreen mode

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) ),
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
spock123 profile image
Lars Rye Jeppesen

Wow, mind blown

Collapse
 
sebastiandg7 profile image
Sebastián Duque G

+1 🤯

Collapse
 
sebastiandg7 profile image
Sebastián Duque G

this is gold