Webpack’s prefetch strategy is struggling me for a long time, today I will show u how to fight them.
In short, by using webpack u can do code splitting and lazy load like this:
// In app.js
if (renderPageA) {
import('./pageA.js').then(module => render(module.default))
}if (renderPageB) {
import('./pageB.js').then(module => render(module.default))
}
Then two new chunks will be created along with app.hash.js chunk
0.hash.js
1.hash.js
if u don’t like the number in the chunk name, u can specify ur own one too:
import(/* webpackChunkName: 'pageA' */ './pageA.js')
import(/* webpackChunkName: 'pageB' */ './pageB.js')
the magic comment webpackChunkName set the chunk name, then u got:
pageA.hash.js
pageB.hash.js
Check above link u can see more magic comments can be used, we will focus on /* webpackPrefetch: true*/
// In app.js
if (renderPageA) {
import(/* webpackPrefetch: true */'./pageA.js')...
}
The webpackPrefetch comment told webpack to insert a prefetch link into HTML head when app.hash.js chunk is loaded:
<link rel="prefetch" as="script" href="pageA.hash.js"/>
The webpack prefetch strategy is: if a JS chunk says some async-chunks need to be prefetch, then when that JS chunk loaded, immediately all the async-chunks with /* webpackPrefetch: true */is prefetched.
See the problem here?
U don’t actually have the control on prefetch
Prefetch resources take network times, and worse in mobile users, it takes money too.
If a JS chunk, say a search-wrapper.js chunk, load some async-chunks base on media query API, like desktop-search.js & mobile-search.js, and u add
/* webpackPrefetch: true */
on both async-chunks, then when mobile user load search-wrapper.js, webpack will prefetch desktop-search.js for him too!
Why webpack prefetch work like this?
My understanding is webpack actually don’t have a choice because of the import statement like this:
import('./pageA.js').then(module => ...)
is an ES standard of dynamic module importing, when it executed, the browser has to fetch the pageA.js and parsing, evaluating….then resolve the module to the promise callback.
So u see webpack have no way pass prefetch control to u, the import statement is an ES standard, if webpack add some magic method on it like this:
const a = document.getElementById('linkToPageA')// This just prefetch, doesn't evaluate the JS
a.onmousehover = ()=> import('./pageA.js').prefetch()// This do parse, evaluate the JS
a.onclick = () => import('./pageA.js').load().then(...)
It doesn’t feel right, right? as long as import() is the standard, it just makes people confuse…
How we take over control of this?
Babel is my answer, to be specific it’s a babel plugin.
We already learn that webpack can modify import statement’s behavior by using some magic comments, without break the ES standards, if we can add our own magic comment, we can achieve something interest.
I write a babel plugin to generate the prefetch code for each import statement.
So if ur lazy component declares and uses like this:
import { prefetchable } from "webpack-prefetcher";const lazyPageA = prefetchable(() => import('./pageA.js'))// This just prefetch, doesn't evaluate the JS
a.onmousehover = () => lazyPageA.prefetch()// This do parse, evaluate the JS
a.onclick = () => lazyPageA.load().then(...)
My babel plugin will transform it to:
const lazyPageA = prefetchable(() =>
import('./pageA.js'), 'pageA-chunk', '.js')// This just prefetch, doesn't evaluate the JS
a.onmousehover = () => lazyPageA.prefetch()// This do parse, evaluate the JS
a.onclick = () => lazyPageA.load().then(...)
Notice two extra parameters added to lazyPageA’s declaration?
pageA-chunk is the chunk name, .js is the chunk extension, they are extracted from the import statement:
import(‘./pageA.js’)
Let check how prefetchable() implemented:
export function prefetchable(importFunc, chunkId, chunkExtension){
return {
load: importFunc,
prefetch: as => Prefetcher.prefetch(chunkId, chunkExtension, as)
}
}
Prefetcher is a simple helper class that contains the manifest of webpack output, and can help us on inserting the prefetch link:
export class Prefetcher {
static manifest = {
"pageA.js": "/pageA.hash.js",
"pageA.css": "/pageA.hash.css",
"app.js": "/app.hash.js",
"index.html": "/index.html"
}static function prefetch(chunkId, chunkExtension, as) {
const link = document.createElement('link')
link.rel = "prefetch"
link.as = as
link.href = Prefetcher.manifest[chunkId + chunkExtension]
document.head.appendChild(link)
}
}
U can extract the manifest from webpack by some manifest plugins, I leave this to u cause it’s out scope here.
U can see the limit here, the chunkId we use for prefetch is extracted from webpackChunkName comment, what if u don’t set that name? The answer is u have to set one, otherwise, this plugin will give u one, and it doesn’t handle chunk name confliction, so if u have two js with the same name, things will mess-up:P.
If u don’t like the webpackChunkName, u can use babel-plugin-smart-webpack-import to generate a better chunk name automatically.
How the plugin implemented?
The code is too long to paste here, so I leave it in GitHub:
https://gist.github.com/xiaogdgenuine/921c643bc4eedd029cdf2bfe11c79a00
Overall, the plugin works like this:
- Gather all the prefetchable(() => import(args)) statements in ur code
- Find the chunkId, chunkExtension from the import() statement
- Add chunkId, chunkExtension to the end of prefetchable() call
Pls note that it’s just a few days works for a proof of concept, so
Be careful to use it in prod!!!
Is there a package for all these?
Yes!! Check my first npm package here: https://www.npmjs.com/package/webpack-prefetcher
I feel it’s kinda overkill, can’t webpack implement this directly?
If u want webpack team to have this feature implemented, instead of my hacky way, pls share ur voice here~:
https://github.com/webpack/webpack/issues/8470
Special thanks:
The inspiration for this plugin comes from:
https://github.com/sebastian-software/babel-plugin-smart-webpack-import