Implicit Code Splitting and Chunk Loading with React Router and Webpack
In this post, we'll be refactoring React Router's "huge apps" example, using Webpack's bundle-loader to eliminate nearly all of the example's code-splitting boilerplate, and addressing how to use this technique in a project with universal rendering. This post assumes you have experience with React, React Router, Babel/ES6, and Webpack.
You can find the complete original and refactored code on GitHub.
The Original
The original code uses Webpack's require.ensure
API directly. By design, require.ensure
cannot work with dynamic module paths, which leads to a lot of repetitive code (the React Router team split this code into tree of route files). Because of this limitation, routing logic is now scattered throughout the routes folder at various depths, and a commented representation of the routes is necessary to understand what's going on at a glance. The src/
directory looks like this:
├── components
├── routes
│ ├── Calendar
│ │ ├── components
│ │ │ └── Calendar.js
│ │ └── index.js
│ ├── Course
│ │ ├── components
│ │ │ ├── Course.js
│ │ │ ├── Dashboard.js
│ │ │ └── Nav.js
│ │ └── routes
│ │ ├── Announcements
│ │ │ ├── components
│ │ │ │ ├── Announcements.js
│ │ │ │ ├── Sidebar.js
│ │ │ ├── routes
│ │ │ │ └── Announcement
│ │ │ │ ├── components
│ │ │ │ │ └── Announcement
│ │ │ │ └── index.js
│ │ │ └── index.js
│ │ ├── Assignments
│ │ │ ├── components
│ │ │ │ ├── Assignments.js
│ │ │ │ ├── Sidebar.js
│ │ │ ├── routes
│ │ │ │ └── Assignment
│ │ │ │ ├── components
│ │ │ │ │ └── Assignment
│ │ │ │ └── index.js
│ │ │ └── index.js
│ │ └── Grades
│ │ ├── components
│ │ │ └── Grades.js
│ │ └── index.js
│ ├── Grades
│ │ ├── components
│ │ │ └── Grades.js
│ │ └── index.js
│ ├── Messages
│ │ ├── components
│ │ │ └── Messages.js
│ │ └── index.js
│ └── Profile
│ ├── components
│ │ └── Profile.js
│ └── index.js
├── stubs
└── app.js
We can do better than that.
The Refactor
We can use Webpack's own bundle-loader to get around the limitations of raw require.ensure
and greatly simplify our code. Bundle loader abstracts away the require.ensure
logic that bloated and obscured our code. When a module is run through bundle-loader, a chunk will be created for it and its dependencies, and a function wrapper is returned instead of the original module. When the function wrapper is invoked, it will ajax in the module and pass it to a callback function.
We can eliminate all of the index.js
files througout the routes/
directory and flatten the structure where it makes sense. All of our routing logic will be moved to app.js
. This results in a massively simplified src/
directory:
├── components
├── routes
│ ├── Calendar.js
│ ├── Course
│ │ ├── components
│ │ │ ├── Dashboard.js
│ │ │ └── Nav.js
│ │ ├── routes
│ │ │ ├── Announcements
│ │ │ │ ├── routes
│ │ │ │ │ └── Announcement.js
│ │ │ │ ├── Announcements.js
│ │ │ │ └── Sidebar.js
│ │ │ ├── Assignments
│ │ │ │ ├── routes
│ │ │ │ │ └── Assignment.js
│ │ │ │ ├── Assignments.js
│ │ │ │ └── Sidebar.js
│ │ │ └── Grades.js
│ │ └── Course.js
│ ├── Grades.js
│ ├── Messages.js
│ └── Profile.js
├── stubs
└── app.js
Once we've updated the file structure, we can direct webpack to run route components through bundle-loader. Since we don't want to split every single file in routes/
(specifically the files in components/
directories), we will need to write a regex to match the right files. In this particular app, we know route components are any files matching routes/*.js
or routes/SOMETHING/*.js
. Any files matching routes/SOMETHING/components/*.js
should NOT be included:
// NOTE: this assumes you're on a Unix system. You will
// need to update this regex and possibly some other config
// to get this working on Windows (but it can still work!)
var routeComponentRegex = /routes\/([^\/]+\/?[^\/]+).js$/
module.exports = {
// ...rest of config...
modules: {
loaders: [
// make sure to exclude route components here
{
test: /\.js$/,
include: path.resolve(__dirname, 'src'),
exclude: routeComponentRegex,
loader: 'babel'
},
// run route components through bundle-loader
{
test: routeComponentRegex,
include: path.resolve(__dirname, 'src'),
loaders: ['bundle?lazy', 'babel']
}
]
}
// ...rest of config...
}
Now, in app.js
, we can simply import our route components like normal:
// Webpack is configured to create ajax wrappers around each of these modules.
// Webpack will create a separate chunk for each of these imports (including
// any dependencies)
import Course from './routes/Course/Course'
import AnnouncementsSidebar from './routes/Course/routes/Announcements/Sidebar'
import Announcements from './routes/Course/routes/Announcements/Announcements'
import Announcement from './routes/Course/routes/Announcements/routes/Announcement'
import AssignmentsSidebar from './routes/Course/routes/Assignments/Sidebar'
import Assignments from './routes/Course/routes/Assignments/Assignments'
import Assignment from './routes/Course/routes/Assignments/routes/Assignment'
import CourseGrades from './routes/Course/routes/Grades'
import Calendar from './routes/Calendar'
import Grades from './routes/Grades'
import Messages from './routes/Messages'
As mentioned earlier, these are not the route components themselves, but function wrappers that will load the correct chunk when called.
We will need to use React Router's route.getComponent
to load these chunks asynchronously. First, we must set up a function which recieves the ajax wrapper, and returns a function which will load the module when the route is requested:
function lazyLoadComponent(lazyModule) {
return (location, cb) => {
lazyModule(module => cb(null, module))
}
}
Since this project uses route.getComponents
as well, we will need another function:
function lazyLoadComponents(lazyModules) {
return (location, cb) => {
const moduleKeys = Object.keys(lazyModules);
const promises = moduleKeys.map(key =>
new Promise(resolve => lazyModules[key](resolve))
)
Promise.all(promises).then(modules => {
cb(null, modules.reduce((obj, module, i) => {
obj[moduleKeys[i]] = module;
return obj;
}, {}))
})
}
}
Now, your routes can be defined all at once as JSX:
render(
<Router history={ browserHistory }>
<Route path="/" component={ App }>
<Route path="calendar" getComponent={ lazyLoadComponent(Calendar) } />
<Route path="course/:courseId" getComponent={ lazyLoadComponent(Course) }>
<Route path="announcements" getComponents={ lazyLoadComponents({
sidebar: AnnouncementsSidebar,
main: Announcements
}) }>
<Route path=":announcementId" getComponent={ lazyLoadComponent(Announcement) } />
</Route>
<Route path="assignments" getComponents={ lazyLoadComponents({
sidebar: AssignmentsSidebar,
main: Assignments
}) }>
<Route path=":assignmentId" getComponent={ lazyLoadComponent(Assignment) } />
</Route>
<Route path="grades" getComponent={ lazyLoadComponent(CourseGrades) } />
</Route>
<Route path="grades" getComponent={ lazyLoadComponent(Grades) } />
<Route path="messages" getComponent={ lazyLoadComponent(Messages) } />
<Route path="profile" getComponent={ lazyLoadComponent(Calendar) } />
</Route>
</Router>,
document.getElementById('example')
)
That's it! We've eliminated a ton of files and boilerplate code with one webpack loader.
Server Rendering
Since Webpack only creates these Ajax wrappers for the client bundle, you will need to make sure the server requires the files synchronously. This can be handled by adding a little function and using it in place of lazyLoadComponent()
:
function loadComponent(module) {
return __CLIENT__
? lazyLoadComponent(module)
: (location, cb) => cb(null, module);
}
All universal apps need some sort of global which allows you to check whether the code is running on the client or the server. This function will immediately resolve the module normally on the server and lazy load the element on the client. It's as simple as that!