【第1646期】 Webpack 应用瘦身
前言
项目还没开始,webpack先搞起来。今日早读文章由《Webpack实战》作者@居玉皓分享。
正文从这开始~~
对大多数 Web 应用来说,页面性能直接影响着流量。这是一个经常为我们所忽视的事实。用户长时间的等待流失的不仅仅是跳出率、转化率,还有对产品的耐心和信赖。很多时候我们没有意识到性能问题,那是因为平常开发使用的都是高效的设备和网络。而到了真实世界中却会发现,实际用户的网络环境会更加复杂,而如果使用的是移动设备的话,有限的计算能力也会拖慢代码的解析执行,这些都会影响页面的渲染效率。
Web 应用的加载速度很大程度上取决于资源的大小,下面是 Youtube 桌面端页面通过 PageSpeed Insights 检测得到的数据,整个页面渲染加载了 2861 KB 的资源,其中 JavaScript 占了大头。
下面我总结了一些借助 Webapck 进行构建的工程可以采用的一些优化输出资源体积的方法,使打包出的 JavaScript、CSS 文件更小,页面加载更快。
代码分片
首先让我们从代码分片(code splitting)说起。代码分片就是通过把原本的代码进行“提取”和“分离”使客户端尽可能地只加载当前需要的资源。
曾经遇到过一个这样一个工程,它有十几个页面,每个页面都引用了其所使用的框架以及 UI 库,导致产出的资源体积非常大,有时在打包过程中直接就内存溢出崩掉了。后来通过代码分片把公共模块提取到了单独的文件中,再让各个页面分别引用它,整个打包结果的体积只有原先的几分之一,并且也不再有内存溢出的问题了。
上面说的“提取”指的是找到代码中重复的部分或者是不经常变动的部分,并将其作为一个独立的资源打包出来。在 Webpack4 之前通常使用 CommonsChunkPlugin,但它在设计上存在一些问题,并且在某些场景下难以使用,在 Webpack4 时就被官方替换为了 SplitChunksPlugin。
对 CommonsChunkPlugin 熟悉的人应该清楚,使用这个插件时要通过各种配置项对指定入口的指定模块进行提取,让人感觉像是命令式的;相比之下 SplitChunksPlugin 则更像是声明式的——由使用者来定义提取规则,比如新的 Chunk 必须可以被共享以及体积要大于 30KB 等等,当模块满足了这些规则就会被提取出来。这样灵活性更强,对使用者也更加友好。
下面是一个使用 SplitChunksPlugin 的例子:
module.exports = {
entry: {
pageA: './pageA.js',
pageB: './pageB.js',
},
output: {
filename: [name].js,
},
mode: 'development',
optimization: {
splitChunks: {
chunks: 'all',
},
},
};
在该配置下,如果 pageA 和 pageB 包含了一些体积比较大的公共模块,那么它们就会自动地被提取出来,结果如下图。
说完了“提取”再说一下“分离”。它是指将部分代码延迟加载或者说动态加载,在 Webpack 中通过import() 语法来实现。请看下面这个例子:
// util.js
export function add(a, b) {
return a + b;
}
// index.js
import('./util').then(({ add }) => {
console.log(add(2, 3));
});
使用import()加载的模块及其依赖模块会构建出一个 async chunk,并在页面上延迟加载。比如上面的例子中的 util.js 将不会被打包到 index.js 的 bundle 中,而是在浏览器加载完 index.js 后再去请求 util.js,等 util.js 加载完成后后再去执行回调函数里面的逻辑。
这种方法适合于处理第三方库以及用户不会立即使用的功能,或者配合 SPA 路由,将页面级别的代码全部使用动态加载。
比如在 Vue 中,我们可以这样实现:
const Home = () => import('./Home.vue');
const router = new VueRouter({
routes: [
{ path: '/', component: Home }
]
});
类似的 React 的例子,结合 React.lazy 与 Suspense 也可以有相同的效果:
const Home = lazy(() => import('./routes/Home'));
const App = () => (
<Router>
<Suspense fallback={<div>Loading...</div>}>
<Switch>
<Route exact path="/" component={Home}/>
</Switch>
</Suspense>
</Router>
);
排除非必要资源
有些时候在加载了一个库、框架或者工具之后,也会连带地加载一些不必要的资源,使打包结果体积无故增大了许多。
这个问题最常见的就是对 Moment.js 的使用。Moment.js 是一个用于处理时间和日期的库,它支持非常多的语言。这是一个非常方便的特性,比如我们可以用中文显示一个日期离现在有多久,会得到“一小时前”或者“两天前”等等。但同时这个特性也有一个问题,即默认情况下它会加载进所有语言包。比如下面这个例子。
import moment from 'moment';
console.log(moment());
当加载了 moment 模块之后,我们在打包结果中会看到非常多类似./node_modules/moment/locale/zh-cn.js的语言包,所有这些由引入 Moment.js 带来的模块最后产生的 bundle.js 有 600KB。
为了解决这个问题,我们可以借助 IgnorePlugin 将语言包模块进行忽略:
new Webpack.IgnorePlugin(/^\.\/locale$/, /moment$/)
重新打包后产出的 bundle.js 仅有 233KB。
如果需要保留一些特定的语言,只要直接在代码中加载特定的语言包模块就可以了。请看这个例子。
// index.js
import moment from 'moment';
import 'moment/locale/zh-cn';
上面由于直接采用了模块路径的形式来加载,它并不会被我们配置的 IgnorePlugin 匹配到,因此依旧会打包到最后的 bundle 中。
减小 CSS 体积
相比于 JavaScript 和图片来说,CSS 的体积通常没有那么大,但对整个页面的渲染性能来说 CSS 仍然是十分重要的一环。因为页面的初始渲染一定是要等 CSS 加载完成后再进行页面内容排布的,CSS 的体积将直接影响到用户从开始请求页面到看到有意义内容的时间,这个时间是评估页面性能的一项关键指标。
减小 CSS 体积要做的第一件事是压缩代码,下面是一个提取 CSS 代码到文件并进行压缩的示例。
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
module.exports = {
optimization: {
minimizer: [new OptimizeCSSAssetsPlugin({})],
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].css',
chunkFilename: '[id].css',
})
],
module: {
rules: [
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader'],
}
],
},
};
导致 CSS 文件体积较大的情况通常是由于代码中包含了过多没用的样式。通过 Chrome dev tools 可以获取到当前页面中所使用到的 CSS 的占比,帮助检查出冗余的样式代码。
另一个容易使 CSS 文件体积过大的是 url-loader。如果在 Webpack 配置中使用了 url-loader 的话要注意 CSS 的内容中是不是包含了过多图片的 base64 URI。url-loader 的 limit 如果设置的比较大,同时页面又有很多小的图片,并且由于 base64 URI 的 gzip 效果很差,很容易就会使 CSS 的体积变得很大。
下面的示例将 url-loader 的 limit 设为 2 KB,具体的数值设置要根据项目实际情况。
rules: [
{
test: /\.(png|jpg|gif)$/i,
use: [
{
loader: 'url-loader',
options: {
limit: 2048,
},
}
],
}
]
使用 Brotli 进行资源压缩
Brotli 是由 Google 开发的无损压缩算法,可以在几乎相同的速度下比 gzip 得到更好的压缩效果,并且它已经被绝大多数现代浏览器所支持:
有人通过大量网络上的资源对 Brotli 和 gzip 进行了一个对比:
对于 JavaScript 文件,Brotli 产出的压缩结果比 Gzip 小了 14%;
HTML 文件缩小了 21%;
CSS 文件缩小了 17%。
有很多工具可以让我们在构建流程中使用 Brotli 进行资源压缩,对于 Webpack 工程的话可以直接使用brotli-webpack-plugin。请看下面的例子。
var BrotliPlugin = require('brotli-webpack-plugin');
module.exports = {
plugins: [
new BrotliPlugin({
asset: '[path].br',
test: /\.(js|css|svg)$/
})
],
};
通过上面的配置,Webpack 在打包后会在原有资源的基础上生成一个.br文件,也就是经过 Brotli 压缩后的版本。我们可以将它与原有的资源文件一同上传到 CDN,这样如果浏览器不支持 Brotli,也可以使其回退来使用 gzip。
Brotli 生效的话,返回头中 content-encoding 的值应该为 br。
资源打包分析和监控
最后也是最重要的一点,是对项目资源进行持续的监控和分析。下面介绍几个比较常用的工具。
webpack-bundle-analyzer
webpack-bundle-analyzer 借助可视化的方式直观地展示输出资源的构成,比如下面的例子。
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin(),
],
};
以上面提到过的 moment.js 为例,会得到下面这样一个分析图:
从图中可以发现 moment.js 中 locale 文件过多的问题。实际工程的情况会比这个更复杂,但仍然可以帮助我们排查冗余模块的存在。
size-plugin
size-plugin 是一个 Webpack 插件,可以在每次执行打包命令后打印出本次构建的资源体积并和上次构建结果进行对比。
const SizePlugin = require('size-plugin');
module.exports = {
plugins: [new SizePlugin()],
};
Import Cost
Import Cost 是一个 VSCode 的一个扩展,可以在模块加载语句旁边展示出所加载模块的大小。
总结
上面介绍了几种给 Webpack 应用“瘦身”的方法,更多的关于 Webpack 优化方面的介绍可以参考本人写的《Webpack实战》。这本书从头梳理了关于 Webpack 的概念,覆盖了常见的使用场景、问题和解决方法,也有关于最新打包工具前沿趋势的思考。这本书的初衷是尽量区别于官方文档,更多地加入我个人的经验与思考。把我曾经遇到过的问题写出来,让后面的人少走一些弯路。
参与活动方式
参与方式一如既往的简单,只要参与话题:你觉得webpack的难点在哪里?在评论区分享你的观点,点赞前五名者可获得《Webpack实战》一本。(如果点赞数相同,则按顺序)。截止时间:2019-06-21 22点
为你推荐