webpack4异步加载JS优化体验

对于复杂的webpack前端项目,单个entry可能达到几十MB的大小,导致下载过程漫长,页面白屏很久。

基本的压缩方法在我之前的博客也写过,比如:UglifyJSPlugin压缩代码,splitchunks提取entry之间的公共代码,提取CSS到独立文件,开启gzip压缩等。

我的项目就遇到了一个问题:就是用尽了所有方法之后,JS文件仍旧有5MB大小,这意味着浏览器需要先下载5MB的文件,此后浏览器才开始出现渲染画面,用户等待期间会以为我服务器挂了。

因此,下面谈一下如何实现JS的异步加载,从而可以随心所欲的将部分JS代码抽取到独立的JS文件中,然后仅在代码执行到调用位置的时候再异步加载。

先理解chunk

首先要理解现在遇到的问题,即单个entry入口关联的所有JS代码总体积太大,比如我有这些entry:

最初学习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中这个插件改成了另外一种配置方法,下面是我的例子:

splitChunks就是提取公共代码的意思,其中chunks写了一个’all’表示从所有chunk中提取公共代码到1个公共chunk里共享,这个chunk的名字叫做lib.min。

至于runtimeChunk呢,也是有点用的,主要是为了尽量避免代码调整后引起浏览器的缓存失效:

runtimeChunk 设置为 true, webpack 就会把 chunk 文件名全部存到一个单独的 chunk 中, 这样更新一个文件只会影响到它所在的 chunk 和 runtimeChunk,避免了引用这个 chunk 的文件也发生改变。

仅仅提取完公共库还不够,在我们注入页面chunk到HTML页面的时候别忘记引入公共chunk:


该页面的入口是player这个entry(或者叫chunk),同时因为公共代码被提取了,所以lib.min这个chunk也要注入,我们看一下HTML最终引入了什么:

异步加载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的页面,大概是这样直接渲染的:

但是这个H5组件非常大且内部包含非常多的子组件,可以说是非常复杂,浏览器白屏好久才能完成bundle JS的下载。

那么,我希望给这个H5组件套一个Loading的壳,先绘制出loading页面,然后发起异步JS加载h5.jsx模块,等到模块下载回来之后,再将H5组件绘制到div里替换掉loading界面。

因此,将H5组件改为export默认导出:

然后定义一个h5_wrapper.js组件,负责loading渲染和异步加载的触发:

在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插件来翻译:

在webpack.config.js中,为babel-loader配置plugins:

其他问题

实践过程中,需要注意splitChunk公共代码提取可能会先于异步加载被执行,因此仍旧会得到一个很大的common chunk,达不到优化效果。

我当前是关闭了splitChunk来避免异步加载的代码被提取到了common chunk中,代价就是跨页面访问的时候没法共享公共代码缓存了。

如果文章帮助您解决了工作难题,您可以帮我点击屏幕上的任意广告,或者赞助少量费用来支持我的持续创作,谢谢~