webpack4异步加载JS优化体验
对于复杂的webpack前端项目,单个entry可能达到几十MB的大小,导致下载过程漫长,页面白屏很久。
基本的压缩方法在我之前的博客也写过,比如:UglifyJSPlugin压缩代码,splitchunks提取entry之间的公共代码,提取CSS到独立文件,开启gzip压缩等。
我的项目就遇到了一个问题:就是用尽了所有方法之后,JS文件仍旧有5MB大小,这意味着浏览器需要先下载5MB的文件,此后浏览器才开始出现渲染画面,用户等待期间会以为我服务器挂了。
因此,下面谈一下如何实现JS的异步加载,从而可以随心所欲的将部分JS代码抽取到独立的JS文件中,然后仅在代码执行到调用位置的时候再异步加载。
先理解chunk
首先要理解现在遇到的问题,即单个entry入口关联的所有JS代码总体积太大,比如我有这些entry:
1 2 3 4 5 6 7 8 |
entry: { 'lib.min': ['react', 'react-dom'], 'gui': './src/playground/index_wrapper.js', 'blocksonly': './src/playground/blocks-only.jsx', 'compatibilitytesting': './src/playground/compatibility-testing.jsx', 'player': './src/playground/h5/h5_wrapper.js', 'my': './src/web/pages/my/my.jsx' }, |
最初学习webpack,可能会误解说每个entry就对应一个页面的入口JS,其实并不应该这样理解。
每一个entry只是一个JS代码的编译入口,所做的就是将相关的JS文件打包到一起而已,称之为一个chunk。
从上述entry字典可以看出,lib.min这个entry其实就不是一个页面,而是把react和react-dom库打包了起来,作为一个chunk。
再理解optimization.splitChunks(webpack4之前叫做CommonsChunkPlugin)
很多人用webpack都配置过CommonsChunkPlugin,这个插件作用其实是将多个chunk中使用的相同代码提取到一个独立的JS文件中作为公用,这样每个chunk只需要保留不共用的代码,因此所有chunk体积都变小了。
在webpack4中这个插件改成了另外一种配置方法,下面是我的例子:
1 2 3 4 5 6 7 8 9 |
optimization: { splitChunks: { chunks: 'all', name: 'lib.min' }, runtimeChunk: { name: 'lib.min' } }, |
splitChunks就是提取公共代码的意思,其中chunks写了一个’all’表示从所有chunk中提取公共代码到1个公共chunk里共享,这个chunk的名字叫做lib.min。
至于runtimeChunk呢,也是有点用的,主要是为了尽量避免代码调整后引起浏览器的缓存失效:
runtimeChunk 设置为 true, webpack 就会把 chunk 文件名全部存到一个单独的 chunk 中, 这样更新一个文件只会影响到它所在的 chunk 和 runtimeChunk,避免了引用这个 chunk 的文件也发生改变。
仅仅提取完公共库还不够,在我们注入页面chunk到HTML页面的时候别忘记引入公共chunk:
1 2 3 4 5 6 |
new HtmlWebpackPlugin({ chunks: ['lib.min', 'player'], template: 'src/playground/index.ejs', filename: 'h5.html', title: '页面标题' }), |
该页面的入口是player这个entry(或者叫chunk),同时因为公共代码被提取了,所以lib.min这个chunk也要注入,我们看一下HTML最终引入了什么:
1 2 |
<script type="text/javascript" src="https://assets.scratch.xxxx.com/webpack/1562315960922/lib.min.9c1e064086a037d5f5bc.js"></script> <script type="text/javascript" src="https://assets.scratch.xxxx.com/webpack/1562315960922/player.db4fcf89ef7476abaae5.js"></script> |
异步加载JS
当我完成了上述优化后,发现lib.min chunk变成了18MB,而各个entry chunk变成了1MB的样子,这是因为项目依赖了大量的第三方组件,光这些代码打包到lib.min就是18MB。
因为HTML需要同时引入lib.min和entry chunk,因此这对优化打开速度没有任何作用。
所以我们需要意识到,common chunk的价值是在打开不同entry页面的时候,可以避免重复下载相同的代码,从而可以加速访问,但是对于首次打开1个entry来说是没有任何优化意义的。
这个时候,我们才需要异步加载。
异步加载在我看来可以解决2个问题:
- 更好的体验:在关键路径中,可以先展出一个骨架屏,告知用户正在loading,当JS异步下载完成后再正式渲染应用。
- 更快的下载:对非关键路径,可以通过分析代码结构,对依赖的模块进行异步加载(会被提取到异步chunk中),这样仅当用户访问到对应的路径时才触发下载,这样就可以减少同步加载主chunk的文件大小。
怎么做呢?
我举一个简单的例子。
我有一个很复杂的react组件,我很难分析它内部哪些模块可以异步加载,因此我能做的就是先画一个骨架屏或者说loading屏,然后对这个react组件做异步加载,等JS下载回来之后再开始渲染应用,这是我通过最小的成本来优化用户体验的一个手段了。
原本我有一个h5.jsx的页面,大概是这样直接渲染的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
const ConnectedH5 = connect( mapStateToProps, mapDispatchToProps )(H5); const WrappedH5 = compose( AppStateHOC, HashParserHOC, TitledHOC )(ConnectedH5); const appTarget = document.createElement('div'); document.body.appendChild(appTarget); ReactDOM.render(<WrappedH5 isPlayerOnly />, appTarget); |
但是这个H5组件非常大且内部包含非常多的子组件,可以说是非常复杂,浏览器白屏好久才能完成bundle JS的下载。
那么,我希望给这个H5组件套一个Loading的壳,先绘制出loading页面,然后发起异步JS加载h5.jsx模块,等到模块下载回来之后,再将H5组件绘制到div里替换掉loading界面。
因此,将H5组件改为export默认导出:
1 2 3 4 5 6 7 8 9 10 11 12 |
const ConnectedH5 = connect( mapStateToProps, mapDispatchToProps )(H5); const WrappedH5 = compose( AppStateHOC, HashParserHOC, TitledHOC )(ConnectedH5); export default WrappedH5; |
然后定义一个h5_wrapper.js组件,负责loading渲染和异步加载的触发:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
const appTarget = document.createElement('div'); document.body.appendChild(appTarget); class H5Wrapper extends React.Component { constructor (props) { super(props); } componentDidMount() { import(/* webpackChunkName: "h5_index" */ './h5.jsx').then( (module) => { ReactDOM.render(<module.default isPlayerOnly></module.default>, appTarget); } ) } render () { return ( <React.Fragment> <div className={styles['async-loading-box']}> <img src={logo123}/> <div className={styles['async-loading-words']}>努力加载中...</div> <div className={styles['async-loading-words']}>美好的事情总是值得等待的!</div> </div> </React.Fragment> ); } } ReactDOM.render(<H5Wrapper/>, appTarget); |
在loading屏render后,调用import()方法加载h5.jsx模块。
import()只需要写加载哪个Js文件即可,webpack会替我们搞定编译和AJAX请求的所有事情。
这里的webpackChunkName注释是告知webpack,将h5.jsx模块的代码提取到叫做h5_index的chunk当中。不仅如此,其实h5.jsx递归依赖的其他模块都将被提取到h5_index chunk中。
import()返回的是一个Promise,我们通过then注册回调等待模块被浏览器AJAX异步加载完成后,我们就可以访问到这个模块了,module就是h5.jsx模块,module.default就是export default导出的东西,也就是之前的H5组件。
我们还要记得把webpack.config.js中的entry改为h5_wrapper.js,替换掉原先的h5.js。
现在我们再去打开页面,就会发现一开始加载了一个很小的entry chunk,并且渲染了loading界面;过了一会,一个做h5_index.373556cd780c69afe641.js的js chunk被AJAX下载回来,然后应用被渲染出来了。
还能怎么做?
因为我上面的场景比较简单,所以在异步加载后直接暴力覆盖了h5_wrapper绘制的loading界面。
对于正规的做法来说,当异步加载到module后,应该通过react的state或者redux来保存module,从而触发再次render,并且在render中根据state/redux props中是否有加载好的module属性,来决定是否绘制应用。
安装import插件
import()异步加载方法需要安装babel插件来翻译:
1 |
"@babel/plugin-syntax-dynamic-import": "^7.0.0", |
在webpack.config.js中,为babel-loader配置plugins:
1 2 |
plugins: [ '@babel/plugin-syntax-dynamic-import', |
其他问题
实践过程中,需要注意splitChunk公共代码提取可能会先于异步加载被执行,因此仍旧会得到一个很大的common chunk,达不到优化效果。
我当前是关闭了splitChunk来避免异步加载的代码被提取到了common chunk中,代价就是跨页面访问的时候没法共享公共代码缓存了。
如果文章帮助您解决了工作难题,您可以帮我点击屏幕上的任意广告,或者赞助少量费用来支持我的持续创作,谢谢~
