How to split your apps by routes with Webpack
July 16 update: as soon as webpack 2 is around the corner, I’ve updated this article to be up-to-date with the coming changes. So, if you’re leaving on the edge and using webpack 2 betas already, please follow the corresponding code snippets (only one snippet really, changes are minimal), they are marked in the same way as this text.
Users are expecting more and more from modern web apps every day. Borders between native and web-based apps are slowly fading. Developer’s job is to adapt to these changing behaviors, environments, and limitations. You need to keep up not only with modern technologies and trends but also with the best practices of delivering your apps to users.
Dumping megabytes of JS/CSS/Assets into your users’ browsers is not only an example of a bad taste nowadays but also a very costly mistake: this poor user will just choose your competitor, whose app doesn’t eat his whole monthly bandwidth quota in only 2 runs. This is a real website obesity crisis.
And now, thanks to open source and browser vendors, we have a lot of great tools and techniques to avoid this. We just need to know how to use them.
In this article, I want to show you how you can start splitting your app in chunks and delivering them on-demand. Ryan Florence inspired me to write this by his post, I just want to expand his ideas with more examples and try to make them more general, not tied to React and/or React Router. There is only one prerequisite — your app should use webpack.
Initial state
To imitate routing system in our example app, I will use Backbone Router — very popular, powerful, and simple to work with solution to the single-page apps routing problem. But this approach will suffice any router, even home-brewed, it just needs to be able to handle missing routes and to refresh the page.
Let’s imagine that we have an app with 4 routes:
- Homepage
- Very important page that users are visiting very often
- About page, with tons of graphics
- Some heavy, complex part of your app. It is very powerful, but users do not use it every time while visiting your site. Or maybe it is available only for paying users
So, in terms of code it will look something like this:
We have routes object which maps URLs to route handlers and then the route handlers themselves. I know, they are very simple, but making them real or complex is not the point of this post, please use your imagination here.
And the main index file which runs the app:
Now let’s try to think how we can split this. I propose to extract About page and the “Heavy” app into separate modules that should be loaded on-demand. So we need to solve two tasks: force webpack to build About page and Heavy app into separate bundles and then somehow load them when their routes are hit.
Apps extraction
Before we touch a webpack config, we need to split our code into separate logical modules with their own smaller routers:
- Extract About’s router from the main one (./src/apps/about/router.js):
2. And Heavy’s router too (./src/apps/heavy/router.js):
3. Create ./src/apps/about/index.js and ./src/apps/heavy/index.js with:
4. Import and call them in the main ./src/index.js:
5. And do not forget to remove apps’ routes from the main router (./src/router.js):
At this moment, our internal mini apps are logically split but they are still bundled together with the main one.
To change this, we will use require.ensure — the way to tell webpack that every require/import inside the passed callback should be extracted into a separate bundle and then loaded on-demand. And the rule of a thumb here is quite easy: you will have as many bundles as an amount of calls to the require.ensure: 1 call — 1 bundle, 2 calls — 2 bundles, etc. Easy.
Webpack 2 users should use System.import, which not operates with a callback but returns a promise. See an example later in the article.
Let’s sum up what we did:
- changed ES2015 imports to CJS requires because you can’t dynamically import using import {x} from ‘foo’ (this won’t be needed in webpack 2.x):
The module syntax addition (import x from ‘foo’) is intentionally designed to be statically analyzable, which means that you cannot do dynamic imports.
- wrapped each call to the on-demand app in require.ensure.
Success! Now you can see 3 requests for bundles in dev tools on page refresh:
Loading apps depending on a route
Even if our app is fully split and bundles are loading asynchronously, we’re still loading everything on the main page, not on-demand. Let’s go ahead and change this.
The idea is simple: if our main router can’t handle this path we need to try to understand which mini app can. Or if it’s not the case then fallback to the classic 404 behavior.
And now comes a chicken-egg problem: routes that our mini apps can handle are inside separate bundles, and our main app can’t access them until these bundles are loaded, but we don’t want to load all apps one-by-one to understand if they are capable of handling this particular path.
The first thing that comes to mind is to copy routes from the mini apps inside the main one and map the current route to the app that can resolve it. Let’s create a new module that manages this (./src/app_finder.js):
AppFinder looks a little bit more complex than it should because we need to convert paths from the Backbone’s routes’ definitions into plain RegExes (but this is only because we’re using Backbone’s Router in this example, so with another router it could be simpler).
Time to use it from our main router:
And don’t forget to remove all traces of mini apps from the main index.js:
Everything is working, but if we look into the Network tab of our dev tools we will see that for both apps we’re loading the same bundle. This is because we squashed 2 require.ensure calls into one, and according to our rule, we’re getting only one bundle now.
But how are all these resolved in webpack internally? Why one bundle and not two, three, n?
We already know that every time webpack sees require.ensure it treats it as a split point and puts all requested in the passed callback modules in a separate chunk. But we’ve introduced a new way to do require here — a dynamic one:
require(‘./apps/’ + mini_app_name + ‘/index.js’).default;
Webpack understands that the parameter that we’ve passed to require is a dynamic one, but it should be resolved at the moment of a building process. So webpack converts this into a call to require.context. Require.context is a very powerful feature that allows you to precisely control what should go into the bundle by specifying directories, search depth and a regex to use to filter the files. In our case webpack used that one:
./src/apps ^\.\/.*\/index\.js$
But placed everything it found into one big (not now, but could be!) chunk. The fix is an easy one: we should wrap every app in a call to require.ensure. We can do that in their index files ( ./src/apps/about/index.js and ./src/apps/heavy/index.js) for example. But this is a little bit tedious and error prone — the next developer after us can forget to add it and code splitting will partially break. But this is a great case for loaders to solve, and there is one that does exactly what we need — bundle-loader.
Just a small change to our require and we again have a separate bundle for every mini app!
But if you’re a happy user of webpack 2, you don’t need a bundle loader, you can just use System.import. Webpack will automatically go through all the files that match the specified pattern and will create a separate bundle for every one of them.
As you can see, System.import returns us a promise which will resolve with a bundle if everything is ok or reject if file loading is failed (yay for that, that was not possible to do with webpack 1). Also a good thing is that we can use template strings now.
Making everything better
Theoretically, we can stop now: we have the main app and 2 mini apps, which are loaded asynchronously if we hit a route which they can handle. But let’s step aside and think what should the next developer do to add a new mini app:
- create a folder inside ./src/apps/ with index.js and a router
- describe routes and app’s logic
- copy app’s routes into AppFinder’s custom_apps_routes constant
Seems pretty easy and logical. But I can only imagine how many hours will be spent trying to understand why a new app is not loading because of the forgotten last step (copying app’s routes). And also, we’re breaking open/closed principle here by changing AppFinder’s code every time we need to add a new mini app. Can we do better?
To solve this, we need to somehow force webpack to include routes from mini apps into main bundle, but only routes. To make this easier let’s extract routes into separate files from routers. For About mini app it will look like this:
This operation would be identical for the Heavy app.
So, we’ve extracted routes. Even if we merge them into main bundle, we still need to map them somehow with the app name. Looks like we require something more: for every app let’s create a file called metadata.js, that will expose app’s name, routes, and anything that we can want to know about the app in the future (maybe some apps can be accessible only by pro-customers and we want to check that before bundle loading):
Now we need to ask webpack to find all metadata.js files in every apps’ directory and combine them with main bundle. This is a job for require.context. We need to change AppFinder and the main router:
And for webpack 2 users the last file will look like this:
Done. We just changed the last error-prone step for the next developer. Let’s update the checklist for adding a new mini app:
- create a folder inside ./src/apps/ with index.js and a router
- describe routes in the routes.js and app’s logic
- Create metadata.js that exposes an object with app’s name and routes
Now we’re talking. To add a new mini app we only need to create files inside the new mini app’s folder rather than changing anything in the main app.
Let’s sum up
Now we have an app that is better and lighter for a user, but almost the same (or even better structured) for a developer.
What could be the next step? I think that you can extract all app loading functionality into a separate module, handle possible loading/access errors, add auth layer, the list can go on and on but the foundation is here and it shouldn’t change a lot.
You can find all the code from this tutorial in the repository + initial webpack config and package.json with all dependencies. Feel free to read the commits, they are in sync with the steps we just took.
All changes to migrate from webpack 1 to webpack 2 are combined in this pull-request.
I hope that after reading this article you will be full of thoughts on how you can start splitting your app with webpack. Let’s make the web leaner together!
If you like what you’ve read please recommend the article and feel free to follow me. I have plans to write more.