-
Notifications
You must be signed in to change notification settings - Fork 1
Lazy Loading
- break up initial bundle
- site loads faster
- less JS to process, initial render is faster
Lazy loading means splitting our webpack bundle into smaller chunks, and only requesting/loading each chunk when it is needed.
A logical way to divide these chunks is by route. The initial bundle has just enough code to start the app running, when we navigate to a new route, the app loads the code for that route, before rendering it.
Webpack provides a few ways of splitting bundles. We are going to use require.ensure
.
src/components/app/app.js
let RobotProfileViewContainer
require.ensure([], (require) => {
RobotProfileViewContainer = require('../../containers/robot-profile-view.container').default
})
We also have to update .eslintrc
to allow this syntax.
"rules": {
...
"global-require": [0],
},
If we run yarn build
, then look in our build dir, we will see there are two more files than there were before (0.[hash].js and 0.[hash].map.js). Webpack has taken all the code that starts at the ../../containers/robot-profile-view.container
entry point and created a new bundle.
If we run yarn dev
and navigate to localhost:8080
, in the network tab, we can see that both bundles are loading immediately. We need to defer loading the second bundle, by making sure we don't call require.ensure, until we navigate to the correct route.
<Route
path="/profile/:id"
render={() => {
require.ensure([], (require) => {
RobotProfileViewContainer = require('../../containers/robot-profile-view.container').default
})
if (RobotProfileViewContainer) {
return <RobotProfileViewContainer />
}
return <h2>Component still loading...</h2>
}}
/>
Now if we navigate to localhost:8080
, we will see that the second bundle does not load until we navigate to a profile page. But it never renders the RobotProfileView component. The problem is the handler we pass to render
runs synchronously, there is no way to force a re-render. We need a wrapper component, that we can force to re-render by calling setState
.
touch src/components/lazy.js
src/components/lazy.js
import React, { Component } from 'react'
class Lazy extends Component {
componentWillMount() {
this.setState({
component: null,
})
this.props.load((comp) => {
this.setState({
component: comp,
})
})
}
render() {
return this.state.component
? <this.state.component {...this.props} />
: <h2>Loading component... </h2>
}
}
Lazy.propTypes = {
load: React.PropTypes.func.isRequired,
}
export default Lazy
Our wrapper component accepts one prop load
with a type of React.PropTypes.func
. On componentWillMount
, it sets this.state.component
to null
, before calling load
. It passes a callback to load
, it is up to the load
function to send the loaded component to the callback once it has loaded. The load
callback will then call setState
updating this.state.component
and forcing a re-render.
The render
function checks the value of this.state.component
and, if available, renders it, passing all of it's props
down with the object spread operator {...this.props}
. This way it doesn't need to know anything about the component being passed in.
Now we need to wrap require.ensure
in a function that accepts a callback.
src/components/app/app.js
import Lazy from '../lazy'
const robotProfileLoader = (cb) =>
require.ensure([], (require) => {
cb(require('../../containers/robot-profile-view.container').default)
})
...
<Route
path="/profile/:id"
render={props => <Lazy load={robotProfileLoader} {...props} />}
/>
Lazy loading is now working client side, but if we run navigate directly to a profile page, our node server will throw TypeError: require.ensure is not a function
.
There is no node polyfill for require.ensure
(AFAIK), but, even if there was, we want this to run synchronously when on the server, so we can serve the correct html.
In node we can check variables on process.env
, but this does not exist in the browser, webpack allows us to define variables at compile time and include/exclude chunks of code based on those variables.
webpack.config.js
new webpack.DefinePlugin({
"process.env": {
NODE_SERVER: false
}
})
package.json
"start": "export NODE_SERVER=true && node server"
Then we can wrap our robotProfileLoader definition with a ternary operator
const robotProfileLoader = process.env.NODE_SERVER
? cb => cb(require('../../containers/robot-profile-view.container').default)
: cb => require.ensure([], (require) => {
cb(require('../../containers/robot-profile-view.container').default)
})
If we are quite confident that the user is going to navigate to one of our routes, and, therefore, need a particular bundle. We can tell the client to preload a bundle with a link tag.
example:
<Link rel="preload" href="bundle.js" />
The browser will load bundle.js
in the background (it will not affect load time, or first-paint), but will not run any of the code. It makes the resource immediately available when the client requests it (doesn't have to make the round-trip to the server).
We can inject a Link
tag for each bundle we want to preload into our html template, but the name of our bundle is terrible, even in the manifest.json
. To clean this up, we can provide a third argument to require.ensure
(this is why we used require.ensure
over import()
).
const robotProfileLoader = process.env.NODE_SERVER
? cb => cb(require('../../containers/robot-profile-view.container').default)
: cb => require.ensure([], (require) => {
cb(require('../../containers/robot-profile-view.container').default)
}, 'profile')
Now we can pass the path to our html template
const htmlResponse = htmlTemplate({
jsPath: manifest['main.js'],
cssPath: manifest['main.css'],
preloadChunks: [manifest['profile.js']],
appHTML,
state,
})
module.exports.htmlTemplate = ({ cssPath, jsPath, preloadChunks, appHTML, state }) => `
...
${preloadChunks.map(src => `<link rel="preload" as="script" href="${src}" />`)}
Lazy loading is working, and the server is sending the correct HTML to the client. But, because we are ignoring styles on the server, the client does not get any of the CSS for the component until it loads and runs the bundle itself. The user will get a flash of un-styled content.
One solution is to run the styles on the server. Luckily, there is a way to do this in React, inline-styles.
Start by renaming src/components/profile/profile.css
to src/components/profile/profile.css.js
. Then convert all css blocks to javascript objects and export them.
.profile {
display: flex;
flex-direction: row;
justify-content: left;
align-items: center;
height: 100%;
}
becomes...
export const profile = {
display: 'flex',
flexDirection: 'row',
justifyContent: 'left',
alignItems: 'center',
height: '100%',
}
Import individual javascript style objects in profile-view.js
and profile.js
, and replace className
with style
.
src/components/profile/profile-view.js
import {
profilePage,
button,
} from './profile.css'
...
<div style={profilePage}>
...
<Link style={button} to="/">Back</Link>
src/components/profile/profile.js
import {
profile,
column,
headshot,
headshotH2,
headshotImg,
address,
addressP,
button,
} from './profile.css'
const Profile = ({ robot }) => (
<div style={profile}>
<div style={[column, headshot]}>
<div>
<img style={headshotImg} alt={robot.name} src={`//robohash.org/${robot.id}?size=200x200`} />
</div>
<h2 style={headshotH2}>{robot.name}</h2>
</div>
<div style={[column, address]}>
<h3>Address</h3>
<p style={addressP}>
{robot.address.street},
{robot.address.suite}
</p>
<p style={addressP}>{robot.address.city}</p>
<p style={addressP}>{robot.address.zipcode}</p>
<a style={button} href={`mailto:${robot.email}`}>Email</a>
</div>
</div>)
Run and navigate to localhost:8080
. It almost looks right. We can't define an array of styles. We need to a helper library.
yarn add radium
Import Radium and wrap the component export in the Radium decorator.
src/components/profile/profile.js
import Radium from 'radium'
...
export default Radium(Profile)
Radium adds some nice plugins (auto-prefixer), merges arrays of styles, allows us to do psuedo-classes (like :hover and :active) and media-queries in our inline styles.
src/components/profile/profile.css.js
export const button = {
...
transition: 'all .2s ease-out',
':hover': {
backgroundColor: lightGreen,
},
}
If we build and run (disable javascript for testing), the styles still don't look perfect. On the server, Radium's auto-prefixer doesn't know what browser to prefix for, so it adds all of them. This breaks some of the styling in chrome. We need to give Radium some information about the client on the server. We can do this by passing the user-agent to radiumConfig
, but first we have to wrap our App
component with the Radium
decorator.
src/components/app/app.js
import Radium from 'radium'
...
export default Radium(App)
Now we can grab the user-agent from the request headers, and pass it to radiumConfig
in the server.
server.js
function bootstrapApp(location, store, agent) {
const context = {}
const appEntry = createElement(
Provider, { store }, createElement(
StaticRouter, { location, context }, createElement(
App, { radiumConfig: { userAgent: agent } })))
const appHTML = renderToString(appEntry)
return { appHTML, context }
}
...
const { appHTML } = bootstrapApp(request.url, store, request.headers['user-agent'])
...
const { context } = bootstrapApp(request.url, store, request.headers['user-agent'])
Run the server again. It works!