Aug 2018
To illustrate these techniques, I created a simple Meteor app which consists of two screens: thehome
screen and thetest
screen .
Thehome
screen (App.js) is a lightweight component; the only bit of heavy-lifting it does is declare routes, usingreact-router
. Thetest
screen (Test.js) imports three extra libraries:material-ui
,moment
, andlodash
.
import React, { Component } from 'react'; | |
import { BrowserRouter as Router, Route, Switch} from 'react-router-dom'; | |
import Test from '../../screens/Test/Test.js'; | |
const HomeScreen = props => ( | |
<div> | |
<header> | |
<h1>The App</h1> | |
</header> | |
<p> | |
Some text... | |
</p></div> | |
); | |
export default class App extends Component { | |
render() { | |
return ( | |
<Router> | |
<div className="App"> | |
<Switch> | |
<Route path="/" exact render={routeProps => (<HomeScreen {...routeProps} />)} /> | |
<Route path="/test" exact render={routeProps => (<Test {...routeProps} name="Meteor developer" />)} /> | |
</Switch> | |
</div> | |
</Router> | |
); | |
} | |
} |
import React from 'react'; | |
import PropTypes from 'prop-types'; | |
import { withStyles, Typography } from '@material-ui/core'; | |
import moment from 'moment'; | |
import _ from 'lodash'; | |
const Test = (props) => { | |
const { classes, name } = props; | |
const lodash = _.get({}, 'x', true); | |
console.log('lodash: ', lodash); | |
const x = _.cloneDeep({ hello: 'world!' }); | |
const time = new moment(); | |
return ( | |
<div className={classes.root}> | |
<Typography variant="display1" >Hello, {name}!</Typography> | |
<Typography>This component depends upon the material-ui, moment, and lodash libraries.</Typography> | |
<Typography>These libraries are loaded dynamically, which means that they are kept out of the initial client bundle️, which reduces the bundle size significantly.</Typography> | |
<Typography>Current date (calculated by moment.js): {time.toString()}</Typography> | |
</div> | |
); | |
}; | |
const style = theme => ({ | |
root: { | |
padding: theme.spacing.unit * 10, | |
height:150, | |
backgroundColor: 'yellow', | |
}, | |
}); | |
export default withStyles(style)(Test); |
Running the bundle visualiser, we see that the bundle size of this app totals 966kB. Particular modules to takes note of are:
The Google lighthouse audit rates the performance of our simple app 89/100. This is a decent rating but, Lighthouse does have a gripe with the 651ms script evaluation time of the meteor.js bundle. We'll improve this and also reduce the bundle size in the following steps.
This step almost halves the bundle size (to 536kB) and it takes minimal effort to implement ✌️
While we can't tree shake our entire app yet, we can eliminate unused modules from bothmaterial-ui
andlodash
. This is accomplished with the babel-plugin-lodash and babel-plugin-direct-import packages. Install using the following command:
meteor npm i --save-dev babel-plugin-lodash babel-plugin-direct-import@^0.6.0-beta.1
Then, in the root of your app, create the following.babelrc file:
{ | |
"plugins": [["direct-import", { "modules": ["@material-ui/core", "@material-ui/icons"] }], "lodash"] | |
} |
Now, when our app compiles, the unused modules from material-ui
and lodash
disappear.
Looking at the bundle viz we can see that the app is now only 536kB. We can see why when we look at the size of the following imports:
Meteor now only bundles thematerial-ui
and lodash
code that we actually use.
The Google Lighthouse rating is now 95/100 and it no longer complains about script evaluation time. #winning
The app's home page is very simple. It doesn't requirematerial-ui
,lodash
ormoment
. Yet, the client still has to download those modules when they visit the homepage. What if we could defer those downloads until the client actually needed them?
Dynamic Imports allow us to do just that.
While a statically imported module would be bundled into the initial JavaScript bundle, a dynamically imported module is fetched from the server at runtime.
Once a module is fetched dynamically from the server, it is cached permanently on the client and additional requests for the same version of the module will not incur the round-trip request to the server. If the module is changed then a fresh copy will always be retrieved from the server.
There are a few ways that we can approach this:
In a previous blog post I showed you how to dynamically import groups of routes (method 1).
Method 2 is a bit rudimentary. It's far easier to dynamically import a single component, than dynamically import a bunch of modules within a component.
Method 3 is my preferred solution and I've created a higher order component to do just this. Use it at thereact-router
level (i.e. pass this HOC to a route, so that a screen is dynamically loaded) , or on its own when you want to defer the loading of a child component. Situations where this HOC is useful:
d3.js
DefferedComponent
extends React.Component. When the component mounts, it goes and does the dynamic import. Once the import has finished it will re-render, using the newly imported component.
The code is shown below, or you can install the@ninjapixel/meteor-defered-component
module by typing the following into your terminal:
meteor npm i --save @ninjapixel/meteor-deferred-component
import React from 'react'; | |
class DeferredComponent extends React.Component { | |
constructor(props) { | |
super(props); | |
this.state = {Component: null, loading: true}; | |
} | |
componentDidMount() { | |
this.loadComponent(); | |
} | |
loadComponent() { | |
this.props.importFunction().then((Component) => { | |
this.setState({loading: false, Component: Component.default}); | |
}); | |
} | |
render() { | |
const props = this.props; | |
const {loading, Component} = this.state; | |
if (loading) { | |
return this.props.loadingComponent || null | |
} | |
return (<Component {...props} />); | |
} | |
} | |
export default DeferredComponent; |
In the following examples, I dynamically load a component and a screen (usingreact-router
v4). We need to specify the path of the react component to be loaded; unfortunately though, we can't just pass this path toDeferredComponent
becasue dynamic imports need be be declared upfront, with static strings, so that the compiler can do it's thang. We work around this by passing a function (importFunction
) that returns the dynamic import.
import React, { Component } from 'react'; | |
import { BrowserRouter as Router, Route, Switch} from 'react-router-dom'; | |
import DeferredComponent from '@ninjapixel/meteor-deferred-component'; | |
const LoadingComponent = () => ( | |
<div className="loading"> | |
<h3>Fetching component...</h3> | |
</div> | |
); | |
const HomeScreen = props => ( | |
<div> | |
<header> | |
<h1>The App</h1> | |
</header> | |
<p> | |
Some text... | |
</p></div> | |
); | |
export default class App extends Component { | |
render() { | |
return ( | |
<Router> | |
<div className="App"> | |
<Switch> | |
<Route path="/" exact render={routeProps => (<HomeScreen {...routeProps} />)} /> | |
<Route path="/test" exact render={routeProps => (<DeferredComponent loadingComponent={<LoadingComponent />} importFunction={() => import('/imports/ui/screens/Test/Test.js')} {...routeProps} name="Meteor developer" />)} /> | |
</Switch> | |
</div> | |
</Router> | |
); | |
} | |
} |
Using this technique to import the
Many UI packages in the Meteor ecosystem depend on Blaze (38.7kB). In turn, Blaze depends on jQuery (96.5kB). So, if you're not using Blaze for your front-end, make sure you're not using packages that depend on Blaze (e.g the accounts-ui package).
There are massive performance gains to be had from only importing the code you need into your initial client bundle. It's pretty easy to implement, too. I'd love to know what you use this for, so hit me up in the comments with what you're up to!