By Lakshya Ranganath from Treebo, and Addy Osmani from Chrome
Treebo is one of India’s best-known budget hotel chains, with a $20 billion market in tourism. They recently developed a new progressive application (PWA) as the default mobile experience, initially using React but eventually switching to Preact in production.
Compared to previous mobile devices, the new version has a 70% improvement in first screen rendering time and a 31% reduction in initial interaction time. Most users can browse the complete content in less than 4s using their mobile devices in 3G environment. Using WebPageTest to simulate India’s super-slow 3G network also takes less than 5s.
Migrating from React to Preact also reduced initial interaction times by 15%. You can go to Treebo.com to get the full experience, but today we want to delve into some of the technical implementations in the process of analyzing this PWA.
This is Treebo’s new PWA
Performance optimization journey
Old mobile
The old Treebo mobile was built on top of the Django framework. The user must wait for a server request before jumping to a page. This version has a first screen rendering time of 1.5 seconds, a full first screen rendering time of 5.9 seconds, and an initial interaction time of 6.5 seconds.
Basic React single page application
Their first iteration of the Treebo was to build a single-page application with React and a simple Webpack.
You can look at the code that you wrote earlier. This results in the generation of simple (and huge) Javascript and CSS bundles.
/* webpack.js */
entry: {
main: './client/index.js',
},
output: {
path: path.resolve('./build/client'),
filename: 'js/[name].[chunkhash:8].js',
},
module: {
rules: [
{ test: /\.js$/, exclude: /node_modules/, use: ['babel-loader'] },
{ test: /\.css$/, loader: ExtractTextPlugin.extract({ fallback: ['style-loader'], use: ['css-loader'] }) },
],
}
new ExtractTextPlugin('css/[name].[contenthash:8].css'),
Copy the code
The first screen rendering time was 4.8 seconds, the initial interaction time was about 5.6 seconds, and the full first screen image load time was 7.2 seconds.
Server Side Rendering (SSR)
Next, they set out to optimize the first screen rendering time, so they tried server-side rendering. It is worth noting that server-side rendering is not without side effects. It optimizes and consumes other performance.
With server-side rendering, what you get back to the browser is the HTML you’re about to redraw the page, so the browser doesn’t have to wait for all the Javascript to load and execute before rendering the page.
Treebo uses React’s renderToString() to render the component as an HTML string and inject state during application initialization.
// reactMiddleware.js const serverRenderedHtml = async (req, res, renderProps) => { const store = configureStore(); //call, wait, and set api responses into redux store's state (ghub.io/redux-connect) await loadOnServer({ ... renderProps, store }); //render the html template const template = html( renderToString( <Provider store={store} key="provider"> <ReduxAsyncConnect {... renderProps} /> </Provider>, ), store.getState(), ); res.send(template); }; const html = (app, initialState) => ` <! doctype html> <html lang="en"> <head> <link rel="stylesheet" href="${assets.main.css}"> </head> <body> <div id="root">${app}</div> `<script>window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}</script>` `<script src="${assets.main.js}">`</script> </body> </html> `;Copy the code
In Treebo’s case, using server-side rendering, the first screen rendering time was reduced to 1.1s and the full first screen rendering time to 2.4s — this improved the user’s perception of page loading speed, they could get content earlier, and the display in SEO was also slightly improved in testing. The downside, however, is that it has a bad impact on initial interaction time.
Although the user can see the content of the site, the main thread is blocked when the javascript is initialized to load and is stuck there.
With SSR, the browser needs to handle a larger LOAD of HTMl than the previous request, and then request, parse/compile, and execute Javascript. Although it gets more done efficiently.
But that means the first interaction takes 6.6 seconds, which is less than before.
SSR can also shorten TTI by locking the main thread of the downstream device. Transmission Time Interval
Route-based code splitting and loading on demand
The next thing Treebo does is load on demand, which reduces the initial interaction time.
The purpose of load on Demand is to provide the minimum code needed for the interaction of a routing page, with code-splitting splitting routes into “blocks” that are loaded on demand. This allows the loaded resources to be closer to the granularity of the module written by the developer.
What they do here is they divide their third-party dependency library, Webpack Runtime Manifests, and their routing into separate blocks. (To understand the WebPack Runtime and manifest, click here.)
// reactMiddleware.js
//add the webpackManifest and vendor script files to your html
<body>
<div id="root">${app}</div>
`<script>window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}</script>`
`<script src="${assets.webpackManifest.js}">`</script>
`<script src="${assets.vendor.js}">`</script>
`<script src="${assets.main.js}">`</script>
</body>
Copy the code
// vendor.js
import 'redux-pack';
import 'redux-segment';
import 'redux-thunk';
import 'redux';
// import other external dependencies
Copy the code
// webpack.js
entry: {
main: './client/index.js',
vendor: './client/vendor.js',
},
new webpack.optimize.CommonsChunkPlugin({
names: ['vendor', 'webpackManifest'],
minChunks: Infinity,
}),
Copy the code
// routes.js
<Route
name="landing"
path="/"
getComponent={
(_, cb) => import('./views/LandingPage/LandingPage' /* webpackChunkName: 'landing' */)
.then((module) => cb(null, module.default))
.catch((error) => cb(error, null))
}
>
</Route>
Copy the code
// webpack.js
//extract css from all the split chunks into main.hash.css
new ExtractTextPlugin({
filename: 'css/[name].[contenthash:8].css',
allChunks: true,
}),
Copy the code
This directly reduces the initial interaction time to 4.8 seconds. Awesome!
The only thing that is not ideal is that the Javascript for the current page will not start downloading until the initialization of bundles is complete.
But at least it improves the experience. They’ve made some more subtle improvements to on-demand loading, code splitting, and the experience. They load the modules asynchronously using the Webpack import method by calling the getComponent supported by the React Router declaration. For getComponent, click here.
PRPL performance mode
Loading on demand is a great first step towards more granular running and caching of code. Treebo wanted to optimize and found inspiration in PRPL mode.
PRPL is a pattern for structuring and delivering Progressive Web App (PWA) that emphasizes application delivery and startup performance.
It stands for:
- Push – Push key resources for initial URL routing.
- Render – Render the initial route.
- Precache – Precache remaining routes.
- Lazy load – Lazy load and create remaining routes as needed.
Jimmy Moon did a schematic of the PRPL
The “push” section recommends designing a discrete structure for the server/browser combination to optimize caching while supporting HTTP/2 to deliver the resources needed to speed up the browser’s first screen rendering. The passing of these resources can be done efficiently with or HTTP/2 Push.
Treebo selects to load the current routing module. When the initial module is finished executing, the Webpack callback gets the current route, which is already in the cache, thus reducing the initial interaction time. So the initial interaction time now begins at 4.6 seconds.
The only downside to using Preload is that it does not support cross-browser support. Safari already supports link Rel Preload. I hope it will continue this year. Firefox is also being implemented.
HTML flow
One of the drawbacks of using renderToString() is that it is asynchronous, which can be a performance bottleneck for server-side rendering in React projects. The server does not send requests until all HTML has been created. When the Web server outputs web site content, the browser renders the page to the user before all requests are completed. Projects like React-DOM-Stream can help.
To improve their app-aware performance and introduce a feeling of progressive rendering, Treebo uses HTML streams. They prioritise outputting header tags with Link rel preload to preload CSS and Javascript. It then performs server-side rendering and sends the remaining resources to the browser.
The benefit of this is that resources start to download earlier than before, reducing the first screen rendering time to 0.9 seconds and the initial interaction time to 4.4 seconds. The app always stays at a node of 4.9/5 seconds before starting interaction.
The downside is that it keeps the connection between the client and the server for a while, which can be problematic if you encounter slightly longer latency times. For HTML streams, Treebo defines the transferred content as preloaded modules, main content modules, and modules to be loaded. All of this is inserted into the page. Something like this:
// html.js earlyChunk(route) { return ` <! doctype html> <html lang="en"> <head> <link rel="stylesheet" href="${assets.main.css}"> <link rel="preload" as="script" href="${assets.webpackManifest.js}"> <link rel="preload" as="script" href="${assets.vendor.js}"> <link rel="preload" as="script" href="${assets.main.js}"> ${! assets[route.name] ? '' : `<link rel="preload" as="script" href="${assets[route.name].js}">`} </head>`; }, lateChunk(app, head, initialState) { return ` <body> <div id="root">${app}</div> `<script>window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}</script>` `<script src="${assets.webpackManifest.js}">`</script> `<script src="${assets.vendor.js}">`</script> `<script src="${assets.main.js}">`</script> </body> </html> `; },Copy the code
// reactMiddleware.js const serverRenderedChunks = async (req, res, renderProps) => { const route = renderProps.routes[renderProps.routes.length - 1]; const store = configureStore(); //set the content type since you're streaming the response res.set('Content-Type', 'text/html'); //flush the head with css & js resource tags first so the download starts immediately const earlyChunk = html.earlyChunk(route); res.write(earlyChunk); res.flush(); //call & wait for api's response, set them into state await loadOnServer({ ... renderProps, store }); //flush the rest of the body once app the server side rendered const lateChunk = html.lateChunk( renderToString( <Provider store={store} key="provider"> <ReduxAsyncConnect {... renderProps} /> </Provider>, ), Helmet.renderStatic(), store.getState(), route, ); res.write(lateChunk); res.flush(); //let client know the response has ended res.end(); };Copy the code
For all the different script tags, the preload module has gotten their REL =preload declarations. The module to be loaded either gets the HTML and other content containing state returned by the server or is using Javascript that has already been loaded.
The path is inlined with the CSS
The CSS stylesheet blocks the rendering of the page. The page will remain blank until the browser requests, receives, downloads, and parses your stylesheet. By reducing the amount of CSS that the browser has to load and inlining the path styles to the page, you eliminate one HTTP request and make the page render faster.
Treebo supports inline paths for the current route and asynchronously loads the rest of the CSS using loadCSS when DOMContentLoaded.
This eliminates the blocking of the tag from rendering the corresponding path page and adds a small amount of core CSS, reducing the first screen rendering time to 0.4 seconds.
// fragments.js import assetsManifest from '.. /.. /build/client/assetsManifest.json'; //read the styles into an assets object during server startup export const assets = Object.keys(assetsManifest) .reduce((o, entry) => ({ ... o, [entry]: { ... assetsManifest[entry], styles: assetsManifest[entry].css ? fs.readFileSync(`build/client/css/${assetsManifest[entry].css.split('/').pop()}`, 'utf8') : undefined, }, }), {}); export const scripts = { //loadCSS by filamentgroup loadCSS: 'var loadCSS=function(e,n,t){func... ', loadRemainingCSS(route) { return Object.keys(assetsManifest) .filter((entry) => assetsManifest[entry].css && entry ! == route.name && entry ! == 'main') .reduce((s, entry) => `${s}loadCSS("${assetsManifest[entry].css}"); `, this.loadCSS); }};Copy the code
// html.js
//use the assets object to inline styles into your lateChunk template generation logic during runtime
lateChunk(route) {
return `
<style>${assets.main.styles}</style>
<style>${assets[route.name].styles}</style>
</head>
<body>
<div id="root">${app}</div>
`<script>window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}</script>`
`<script src="${assets.webpackManifest.js}">`</script>
`<script src="${assets.vendor.js}">`</script>
`<script src="${assets.main.js}">`</script>
`<script>${scripts.loadRemainingCSS(route)}</script>`
</body>
</html>
`;
},
Copy the code
// webpack.client.js
//replace ExtractTextPlugin with ExtractCssChunks from 'extract-css-chunks-webpack-plugin'
module: {
rules: isProd ? [
{ test: /\.js$/, exclude: /node_modules/, use: ['babel-loader'] },
{ test: /\.css$/, loader: ExtractCssChunks.extract({ use: [{ loader: 'css-loader', options: { importLoaders: 1 } }, 'postcss-loader'] }) },
//...
plugins: [
new ExtractCssChunks('css/[name].[contenthash:8].css'),
//this generates a css chunk alongside the js chunk for each dynamic import() call (route-split path in our case) for eg,
//main.hash.js, main.hash.css
//landing.hash.js, landing.hash.css
//cities.hash.js, cities.hash.css
//the landing.hash.css and cities.hash.css will contain the css rules for their respective chunks
//but will also contain shared rules between them like button, grid, typography css and so on
//to extract these shared rules to the main.hash.css use the CommonsChunkPlugin
//bonus: this also extracts the common js code shared between landing.hash.js and cities.hash.js into main.hash.js
new webpack.optimize.CommonsChunkPlugin({
children: true,
minChunks: 2,
}),
//use the assets-webpack-plugin to get a manifest of all the generated files
new AssetsPlugin({
filename: 'assetsManifest.json',
path: path.resolve('./build/client'),
prettyPrint: true,
}),
//...
Copy the code
// html.js
//use the assets object to inline styles into your lateChunk template generation logic during runtime
lateChunk(route) {
return `
<style>${assets.main.styles}</style>
<style>${assets[route.name].styles}</style>
</head>
<body>
<div id="root">${app}</div>
`<script>window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}</script>`
`<script src="${assets.webpackManifest.js}">`</script>
`<script src="${assets.vendor.js}">`</script>
`<script src="${assets.main.js}">`</script>
`<script>${scripts.loadRemainingCSS(route)}</script>`
</body>
</html>
`;
},
Copy the code
// webpack.client.js
//replace ExtractTextPlugin with ExtractCssChunks from 'extract-css-chunks-webpack-plugin'
module: {
rules: isProd ? [
{ test: /\.js$/, exclude: /node_modules/, use: ['babel-loader'] },
{ test: /\.css$/, loader: ExtractCssChunks.extract({ use: [{ loader: 'css-loader', options: { importLoaders: 1 } }, 'postcss-loader'] }) },
//...
plugins: [
new ExtractCssChunks('css/[name].[contenthash:8].css'),
//this generates a css chunk alongside the js chunk for each dynamic import() call (route-split path in our case) for eg,
//main.hash.js, main.hash.css
//landing.hash.js, landing.hash.css
//cities.hash.js, cities.hash.css
//the landing.hash.css and cities.hash.css will contain the css rules for their respective chunks
//but will also contain shared rules between them like button, grid, typography css and so on
//to extract these shared rules to the main.hash.css use the CommonsChunkPlugin
//bonus: this also extracts the common js code shared between landing.hash.js and cities.hash.js into main.hash.js
new webpack.optimize.CommonsChunkPlugin({
children: true,
minChunks: 2,
}),
//use the assets-webpack-plugin to get a manifest of all the generated files
new AssetsPlugin({
filename: 'assetsManifest.json',
path: path.resolve('./build/client'),
prettyPrint: true,
}),
//...
Copy the code
The downside is that the first screen rendering time increases slightly to 4.6s because the inline style makes loading resources larger and parsing takes time before Javascript executes.
Offline static resource cache
The Service Worker is a programmable network agent that allows you to control how network requests sent by your page are handled.
Treebo adds Service workers to support static resources and custom caching of offline pages. Below I can see the registration of Service workers and how they use sw-Precach-webpack-plugin to cache resources.
// fragments.js // register the service worker after the onload event to prevent // bandwidth resource contention during the main and vendor js downloads export const scripts = { serviceWorker: `"serviceWorker" in window.navigator && window.addEventListener("load", function() { window.navigator.serviceWorker.register("/serviceWorker.js") .then(function(r) { console.log("ServiceWorker registration successful with scope: ", r.scope) }).catch(function(e) { console.error("ServiceWorker registration failed: ", e) }) }); `};Copy the code
// html.js `<script src="${assets.webpackManifest.js}">`</script> `<script src="${assets.vendor.js}">`</script> `<script src="${assets.main.js}">`</script> `<script>${scripts.loadRemainingCSS(route)}</script>` //add the serviceWorker script to your html template `<script>${scripts.serviceWorker}</script>`Copy the code
// server.js
//serve it at the root level scope
app.use('/serviceWorker.js', express.static('build/client/serviceWorker.js'));
Copy the code
// webpack.js
new SWPrecacheWebpackPlugin({
cacheId: 'app-name',
filename: 'serviceWorker.js',
staticFileGlobsIgnorePatterns: [/\.map$/, /manifest/i],
dontCacheBustUrlsMatching: /./,
minify: true,
}),
Copy the code
Caching static resources (such as CSS and Javascript packages) means that pages can be loaded immediately from the hard disk cache upon repeated visits, rather than having to request the server each time. For hard disk cache hit ratio, hard disk defined cache headers can have the same effect, but the Service Worker gives us offline support.
When caching Javascript, the Service Worker uses the caching API(as mentioned in our introduction to Javascript Performance article), which gives Treebo a high priority in V8’s code caching as well, saving Treebo a bit of time to start up on repeated visits.
Next, Treebo tried to reduce the size of their third-party plugin package and JS execution time, so they replaced React with Preact in production.
Preact replace the React
Preact is an alternative to React that uses the ES2015 API and is reduced to 3KB. It is designed to provide high-performance rendering and is used in conjunction with the rest of the React ecosystem, such as Redux (preact-Compat).
The streamlined part of Preact is the removal of Synthetic Events and PropType validation. It also contains:
-
Comparison between the Virtual DOM and the real DOM
-
Props for class and for
-
Passed in the Render method (props, state)
-
Use standard browser events
-
Full support for asynchronous rendering
-
SubTree is invalid by default
In many PWA applications, replacing Preact with Preact allows applications to reduce the size of JS packages and reduce Javascript initialization time. Recently launched PWA such as Lyft, Uber and Housing.com all use Preact in their production environments.
Note: If your React project is developed and you want to switch to Preact? Ideally, you should use Preact and preact-Compat for development, production, and testing. This allows you to catch any interoperability errors early on. If you only want to use aliases preact and preact-compat build builds in Webpack (for example, if you start with Enzyme), make sure to thoroughly test that everything works before deploying to the server.
In Treebo’s case, switching to Preact reduced their third-party package size directly from 140KB to 100KB. Of course, it’s all after Gzip. This allowed Treebo to successfully reduce the initial interaction time on the target mobile device from 4.6 seconds to 3.9 seconds.
You can configure alias in your Webpack. React corresponds to preact-compat, and react-dom corresponds to preact-compat.
// webpack.js
resolve: {
alias: {
react: 'preact-compat',
'react-dom': 'preact-compat',
},
},
Copy the code
The downside of this approach is that it needs to be compatible with other companion solutions so Preact can work equally in all parts of the React ecosystem they want to use
If you’re using React, Preact is the best choice for 95% of cases; For the other 5%, you may need to submit bugs for marginal cases that haven’t been considered yet.
Note: Since WebPageTest does not currently support testing real Moto G4s in India, the performance test was run under the “Mumbai – EC2 – Chrome – Emulating MOTOROLA G (4th generation) – 3GSlow – Mobile” setting. If you want to see the records, you can find them here.
Load a placeholder map
“Loading a placeholder map is essentially a blank page with content gradually loading.”
~Luke Wroblewski
Treebo wants to use preview components (similar to adding loading placeholders to each component) to load placeholders. The essence of this method is to add a preview component to all the underlying components (text, images, etc.) so that the preview component is displayed when the data source for the component is not loaded.
For example, the hotel names, city names, prices and so on that you’re seeing in this list above, they use typography components like this, adding two additional prop, Preview and previewStyle.
// Text.js <Text preview={! hotel.name} previewStyle={{width: 80%}} > {hotel.name} </Text>Copy the code
Basically, if hotel.name does not exist, the component changes the background to gray and sets the width and other styles based on the passed previewStyle (default to 100% if no previewStyle is passed).
// text.css. text {font-size: 1.2rem; color: var(--color-secondary); & - preview {opacity: 0.1; height: 13px; width: 100%; background: var(--color-secondary); } @media (--medium-screen) {font-size: 1.4rem; &--preview { height: 16px; }}}Copy the code
// Text.js import React, { PropTypes } from 'react'; import cn from 'classnames'; const Text = ({ className, tag, preview, previewStyle, children, ... props }) => React.createElement(tag, { style: preview ? previewStyle : {}, className: cn('text', { 'text--preview': preview, }, className), ... props, }, children); Text.propTypes = { className: PropTypes.string, tag: PropTypes.string.isRequired, preview: PropTypes.bool.isRequired, previewStyle: PropTypes.object, children: PropTypes.node, }; Text.defaultProps = { tag: 'p', preview: false, }; export default Text;Copy the code
Treebo likes this approach because the logic of switching to preview mode is independent of the actual data being displayed, which makes it seem more flexible. When you browse through the “include all taxes on XX” section, it’s just static text that might appear fine at first, but when the API is called, the price is still loading, which can be confusing to the user.
So in order to display the static text “including all taxes of XX” in preview mode throughout the rest of the UI, they use the price itself as a logical judgment.
// TextPreview.js <Text preview={! price.sellingPrice}> Incl. of all taxes </Text>Copy the code
This will give you a preview screen while the price is still loading, and you can see the data displayed once the API returns successfully.
Webpack-bundle-analyzer
At this point, Treebo wants to do package analysis so that it can find some low-frequency packages to optimize.
Note: If you’re using a library like React on mobile, it’s important to always optimize the third-party libraries you introduce. Failure to do so may cause performance problems. Consider how best to package your third-party libraries so that routing only loads the libraries that your page needs
Treebo uses Webpack-bundle-Analyzer to track changes in the size of their packages and monitor the modules contained within each routing block. They also use it to find areas where they can optimize to reduce package sizes, such as removing locales from moment.js and reusing deep dependencies.
Optimize moment.js with Webpack
Treebo relies heavily on moment.js for their date operations. When you import moment.js and pack it with webpack, your package will contain all of moment.js, and its default language package, Gizp, will be around 61.95 KB later. This significantly increases the size of packages that end up packaged by third-party libraries.
To optimize the size of moment.js, there are two webpack plugins available: IgnorePlugin and ContextReplacementPlugin
When Treebo no longer needs any language packs, they select IgnorePlugin to remove all language files.
new webpack.IgnorePlugin(/^.\/locale$/, /moment$/)
With the language pack removed, moment.js packs are reduced to about 16.48 KB after GIzp.
One of the biggest improvements to the marginal impact of removing the moment.js language pack is that the third party pack size has been directly reduced from 179kb to 119kb. For a critical package to load on the first screen, 60KB is a significant drop. All of this means a significant drop in first interaction time. You can read more about optimizing moment.js here.
Reuse depth dependence
Treebo initially uses the “QS” module for query string operations. In the results of webPack-bundle-Analyzer analysis, they found that the “history” module contained in the “React-Router” contained the “Query-String” module.
Because these two different modules do the same thing, replacing “QS” with the current version of “Query-String” (which is currently installed) in their source code, and reducing the size of their package GIzp by 2.72 KB (which is the size of the “QS” module).
Treebo is a good open source participant. They use a lot of open source software. In return, they have made most of their Webpack configuration open source, including many of their production configurations, which can be used as a template. You can find it here: github.com/lakshyarang…
They also promised to keep it as up-to-date as possible. As you improve, you can use them as a reference for another PWA implementation.
End and future
Treebo knows that no app is perfect, and they actively explore ways to continuously improve the experience they offer their users. Some of them:
Lazy loading of images
As some of you may know from the previous web waterfall diagram, web image downloads compete with JS downloads for bandwidth.
Since browsers trigger image downloads immediately after parsing the IMG tag, they share bandwidth during JS downloads. A simple solution is to lazily load images when they enter the user view, which also reduces our interaction time.
Lighthouse highlights these issues with out-of-view image reviews:
Double quotes
Treebo also realized that while they were loading the remaining CSS of the application asynchronously (after loading the inline corresponding path CSS), as their application evolved, this approach was not feasible for users in the long run. More iterations and pages mean more CSS and downloads, which leads to bandwidth usage and waste.
Borrowing the loadCSS and babel-plugin-dual-import implementations, Treebo executes the import(‘ chunkpath ‘) method in parallel and asynchronously in their respective JS modules. The CSS module is then returned via the custom implementation importCss(‘ chunkName ‘) method to change the loading method of the CSS.
// html.js import assetsManifest from '.. /.. /build/client/assetsManifest.json'; lateChunk(app, head, initialState, route) { return ` <style>${assets.main.styles}</style> // inline the current route's css and assign an id to it ${! assets[route.name] ? '' : `<style id="${route.name}.css">${assets[route.name].styles}</style>`} </head> <body> <div id="root">${app}</div> `<script>window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}</script>` `<script>window.__ASSETS_MANIFEST__ = ${JSON.stringify(assetsManifest)}</script>` `<script src="${assets.webpackManifest.js}">`</script> `<script src="${assets.vendor.js}">`</script> `<script src="${assets.main.js}">`</script> </body> </html>`; },Copy the code
// importCSS.js export default (chunkName) => { if (! __BROWSER__) { return Promise.resolve(); } else if (! (chunkName in window.__ASSETS_MANIFEST__)) { return Promise.reject(`chunk not found: ${chunkName}`); } else if (! window.__ASSETS_MANIFEST__[chunkName].css) { return Promise.resolve(`chunk css does not exist: ${chunkName}`); } else if (document.getElementById(`${chunkName}.css`)) { return Promise.resolve(`css chunk already loaded: ${chunkName}`); } const head = document.getElementsByTagName('head')[0]; const link = document.createElement('link'); link.href = window.__ASSETS_MANIFEST__[chunkName].css; link.id = `${chunkName}.css`; link.rel = 'stylesheet'; return new Promise((resolve, reject) => { let timeout; link.onload = () => { link.onload = null; link.onerror = null; clearTimeout(timeout); resolve(`css chunk loaded: ${chunkName}`); }; link.onerror = () => { link.onload = null; link.onerror = null; clearTimeout(timeout); reject(new Error(`could not load css chunk: ${chunkName}`)); }; timeout = setTimeout(link.onerror, 30000); head.appendChild(link); }); };Copy the code
// routes.js <IndexRoute name="landing" getComponent={(_, cb) => { Promise.all([ import('./views/LandingPage/LandingPage' /* webpackChunkName: 'landing' */), importCss('landing'), ]).then(([module]) => cb(null, module.default)); }} /> <Route name="search" path="/search/" getComponent={(_, cb) => { Promise.all([ import('./views/SearchResultsPage/SearchResultsPage' /* webpackChunkName: 'search' */), importCss('search'), ]).then(([module]) => cb(null, module.default)); }} / >Copy the code
With this new approach, the redirect makes two parallel asynchronous requests, one to JS and one to CSS, rather than all CSS being loaded when DOMContentLoaded. This is more feasible for users to download only the CSS they need to access the page currently.
A/B testing
Treebo is currently implementing an AB testing approach that includes server-side rendering and code splitting to pull down the versions that users need during server-side and client-side rendering. (Treebo will post a blog post on how they solved the problem).
preload
Ideally, in order to avoid traffic contention for critical resource downloads, Treebo does not want to load all application-split modules at the beginning of the page, and for mobile users, it would be a waste of valuable traffic if they did not use the service-worker to cache the next time they visited. If we look at how Treebo is doing with continuous interaction, there is still a lot of room for improvement:
This is an area they are trying to improve. An example is preloading the next routing module during a button’s ripple animation. When clicked, Treebo uses the WebPack dynamic Import () callback to load the next routing module and uses setTimeout to delay the route jump. They also want to ensure that the next routing module is small enough to load in a given 400ms on a slow 3G network.
That’s all
It was a pleasure collaborating on this article. There’s still a lot of work to do, but we hope you enjoy reading this performance tour of Treebo 🙂 find us both on twitter @addyosmani and @__lakshya(yes, two short underscores) we’d love to hear what you think.
Thanks to _@zouhir, _@developit and @samcccone for proofreading and suggestions.
If you’re new to React, React for Beginners by Wes Bos is a comprehensive article for getting started with React.
Jason Miller and Lakshya Ranganath.