Switching from Aurelia CLI to Webpack

Posted on 2020-07-04 in Aurelia

Recently I decided to change the build system I use on my main Aurelia project. I changed in April 2016 from JSPM to Webpack (JSPM was really not awesome at the time, I can't say for now), again in August 2016 from Webpack to the CLI and now I am switching back to webpack. At the time, I moved away from webpack because of a big bundle of 1.3 MB that was always loaded. I didn't manage to split it correctly using Webpack while Aurelia CLI gave me the flexibility I needed to load less JS by default. So why did I decided to switch again?

  • To get smaller bundles: since Webpack is not used just by Aurelia it supports many optimization to reduce the size of your bundles (like tree shaking to remove unused code).
  • To simplify assets management: Webpack can do it directly. I used to rely on gulp-rev to correctly version them. But it required manual work to get it working. Now I can just rely on Webpack.
  • Currently, Server Side Rendering with Aurelia is only supported by Webpack. This could be a big plus for my site where I have public facing pages that would gain from being correctly indexed by Google.
  • Hot Module Reload to speed development (alone this wouldn't have been enough for a switch, but it's nice).

Since my last try, Webpack and Aurelia's support for Webpack both significantly improved so I guessed this was worth a try to help me reduce my bundles (that the main reason I tried to be honest, the other ones are more bonuses).

Here's how I made the switch. I:

  1. Copied required dependencies from a new project created with the CLI (it was of course configured to use webpack, which is now the default option for a bundler). That included of course webpack and webpack-dev-server.

  2. Changed .css into .scss in HTML files so styles are correctly loaded by webpack on my require lines.

  3. Used PLATFORM.moduleName where necessary. This is required to bridge Aurelia with Webpack. See the documentation. This mainly implied to:

    1. Add PLATFORM.moduleName to link moduleId to their module my route configurations.
    2. Add PLATFORM.moduleName in plugin definition.
  4. Updated my storage service so it uses a prefix for my keys. This was required because webpack use localStorage to store things and I looped on all the keys which resulted in errors in my code.

  5. Updated aurelia.json to match one provided by the webpack skeleton.

  6. Added a webpack configuration. It's based on the one from the skeleton. I had to change (to match what I do in my project):

    1. Where I get sources from src to app.
    2. The name of the static folder from public to assets.
    3. Where I send the static files when the app is built.
  7. Import all my images and assets directly in JS files so Webpack would correctly copy and version them. This way, in JS I can use the imported name to point to the assets and let Webpack handle the versioning. I had a bunch of gulp tasks to do this before and it was a bit cumbersome. See this page and this commit for more details on how to manage assets with webpack. To help me generate the import lines and the mapping objects I often needed, I relied on this small Python script:

    files_list = """COPY HERE THE RESULT OF ``ls -d PATH``""".strip().split()
    
    import_name_to_file_name = {}
    for elt in files_list:
        file = elt.split("/")[-1]
        file = file[:-4]
        components = file.split('-')
        components = [c.title() if index > 0 else c for index, c in enumerate(components)]
        import_name = ''.join(components)
        import_name_to_file_name[file] = import_name
        print(f'import {import_name} from "../../{elt}"')
    print('{')
    for key, val in import_name_to_file_name.items():
        print(f'"{key}": {val},')
    print('}')
    
  8. Added index.ejs as a base index file for webpack and adapt it to suit my needs.

  9. Updated my jest configuration so I could run my tests again. I even went a bit further and stopped using Aurelia CLI to use Jest CLI directly. This allowed me to have access to all the options provided by Jest more easily. To do this, aside from updating the configuration to support Webpack, I had to make sure the environment variable BABEL_TARGET was correctly set to node. Without it, Babel doesn't target the proper runtime and it doesn't work. Since I already used nps to launch some scripts, this wasn't an issue. I defined my tasks to run tests like this:

    test: {
        default: crossEnv("BABEL_TARGET=node jest"),
        coverage: crossEnv("BABEL_TARGET=node jest --coverage"),
        watch: crossEnv("BABEL_TARGET=node jest --watch"),
    },
    

    See this commit for more details.

  10. Used a webpack plugin to help me generate my ServiceWorker to preload all bundles and assets: serviceworker-webpack-plugin. The details are here.

I had one big issue during the switch: compose. I use this to dynamically select a view model to display in an element like this: <compose view-model="./${type}" model.one-way="popupModel"></compose> (where type is passed to my component and corresponds to JS and HTML files to load on the view (for instance if type is transition Aurelia will load and inject the content of transition.js and transition.html). Sadly, Webpack doesn't know what to do with this out of the box. Luckily, I found a thread on the forum that gave the solution: declare all you need to use with PLATFORM.moduleName in the component definition. See this commit for more details (or the thread on the forum).

I'd also like to point out that the configuration generated by the CLI can optimize for two major use cases:

  • Usage over HTTP 1.1: you get fewer but bigger bundles.
  • Usage over HTTP 2: you get a lot of small bundles (around 50 in my case).

Since my site is only compatible with modern browser and supports HTTP 2, I decided to optimize for that. One important point to know is that by default, all bundles will be loaded by the index.html even those you won't need yet. To prevent this (and put some pages in dedicated bundles), you can specify a chunk name to PLATFORM.moduleName like this: PLATFORM.moduleName('myRouteModule', 'chunkName'). This way, all that's required for myRouteModule to run will only be loaded when the route becomes first active. That's a good thing to know to avoid loading to much JS from the start if your app is big. See this page to learn more about this.

Results:

  • Bundle sizes before webpack:
    • Total first party JS loaded on the whole site: 1.54 MB
    • Total first party JS on the home page: 1.02 MB
  • Bundle sizes with webpack:
    • Total first party JS loaded on the whole site: 1.26 MB
    • Total first party JS on the home page: 618 kB

So that's a gain of about a 400 kB. Not bad.

To conclude, I am satisfied with the switch: I reached my goal and can take advantages of webpack which is nice! I'd also like to point out that the transition went way smother that I expected: I got it working in a couple of hours and then had to spend some time with various issues (like assets management or the issue with compose) and to tailor the build to my needs which are not standard. All in all, I think I made the switch in less than 30 hours. Not bad. If your needs are less complex than mine, it should be way less than this. I guess both Webpack and Aurelia matured a lot since the last time I made a build system switch. That's a very good thing!