目录
前言
最近对公司的一个老项目做了优化,主要解决webpack包文件大小过大的问题。
这里我写下一些关于webpack打包优化的经验。
主要分为以下几个方面:
去掉开发环境中的配置 ExtractTextPlugin:提取样式到 css 文件 webpack-bundle-analyzer:可视化 webpack 捆绑文件的体积和依赖 CommonsChunkPlugin:提取公共模块文件 提取清单:让提取的公共 js 的 hash 值不改变压缩后的js、css、图片在react-router4之前按需加载 在react-router4上按需加载 在reactv16.6之后按需加载(2019.07.04更新)
如何配置本博客使用的webpack插件可以参见我写的这篇博客:
【Webpack使用手册02】Webpack常用解决方案
这里我不会详细介绍这个配置。
删除开发环境中的配置
例如,webpack中的devtool改为false,则不需要热加载,仅在开发环境中使用。
这不是优化,而是错误。
仅在开发环境中有用的东西在打包到生产环境中时会被删除。
ExtractTextPlugin:将样式提取到 css 文件
将样式提取到单独的 css 文件中,而不是将它们嵌入到打包的 js 文件中。
这样做的好处是可以并行下载分离的css和js,从而可以更快地加载样式和脚本。
解决方案:
安装ExtractTextPlugin
npm i --save-dev extract-text-webpack-plugin
然后将 webpack.config.js 更改为:
const ExtractTextPlugin = require('extract-text-webpack-plugin');
module.exports = {
// ...
plugins: [
// ...
new ExtractTextPlugin({ filename: '[name].[contenthash].css', allChunks: false }),
],
module: {
rules: [
{
test: /.css$/,
exclude: /node_modules/,
use: ExtractTextPlugin.extract({
fallback: 'style-loader',
use: ['css-loader?modules', 'postcss-loader'],
}),
}, {
test: /.css$/,
include: /node_modules/,
use: ExtractTextPlugin.extract({
fallback: 'style-loader',
use: ['css-loader', 'postcss-loader'],
}),
},
{
test: /.less$/,
use: ExtractTextPlugin.extract({
fallback: 'style-loader',
use: ['css-loader?modules', 'less-loader', 'postcss-loader'],
}),
},
],
},
}
打包后生成的文件如下:
webpack-bundle-analyzer:webpack 捆绑文件量和依赖关系的可视化
这个东西并不是优化,而是可以让我们清楚的看到输出文件的大小以及各个包的交互关系。
安装:
npm install --save-dev webpack-bundle-analyzer
然后更改 webpack.config.js:
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
module.exports = merge(common, {
// ...
plugins: [
new BundleAnalyzerPlugin({ analyzerPort: 8919 })
],
});
打包后,手动会出现一个端口为8919的站点。 网站内容如下:
可以看到我们打包好的main.js中一部分代码来自于node_modules文件夹下的模块,还有一部分来自于我们自己写的代码,也就是src文件夹下的代码。
为了以后描述方便,我们直接将这张图翻译成webpack打包分析图。
CommonsChunkPlugin:提取公共模块文件
所谓通用模块,就是react、react-dom、redux、axios等几乎每个页面都应用的js模块。
将该js模块提取出来放在一个文件中,不仅可以减小主文件的大小,而且可以在首次下载时实现并行下载,提高加载效率。 更重要的是,这些文件的代码几乎不会改变。 这样每次打包释放时缓存仍然会被继承webpack 按需打包,从而提高加载效率。
对于具有多个文件条目的应用程序来说更有效,因为加载不同页面时,这部分代码是公共的webpack 按需打包,可以直接从缓存中使用。
这个工具不需要安装,直接更改webpack配置文件即可:
const webpack = require('webpack');
module.exports = {
entry: {
main: ['babel-polyfill', './src/app.js'],
vendor: [
'react',
'react-dom',
'redux',
'react-router-dom',
'react-redux',
'redux-actions',
'axios'
]
},
plugins: [
new webpack.optimize.CommonsChunkPlugin({
names: ['vendor'],
minChunks: Infinity,
filename: 'common.bundle.[chunkhash].js',
})
]
}
打包后的webpack包解析图如下:
很明显,react等模块都被打包到了common.js中。
提取manifest:保持提取的公共js的hash值不变。
我们在理解webpack中的hash值时,通常会听到两个hash值的配置:[hash]和[chunkhash]。
哈希值是根据每次编译的内容来估计的,因此每次编译所有文件时,都会生成一个新的哈希值,这意味着缓存完全不可用。
所以我们这里使用[chunkhash]。 Chunkhash是根据内容生成的,所以如果内容不改变,生成的哈希值也不会改变。
Chunkhash适合一般情况,不适用于我们上面的情况。
我去改了主文件代码,但是生成的两个公共js代码的chunkhash值都被改了,并且没有使用主文件。
于是我用文本对比工具对比了他们的代码,发现只有一行代码不同:
这是因为当webpack执行时,会有一个带有模块标识符的运行时代码。
当我们不解压vendor包时,这段代码会被打包到main.js文件中。
当我们将vendor提取到common.js时,这个脚本就会被注入到common.js中,而这个脚本就不再在main.js中了。
当我们将库文件解压到两个包中,分别是common1.js和common2.js时,我们发现这个脚本只出现在一个common1.js中,并且
该标记代码变为:
u.src=t.p+""+e+"."+{0:"9237ad6420af10443d7f",1:"be5ff93ec752c5169d4c"}
后来发现其他包的头部也会有这样的代码:
webpackJsonp([1],{2:functio
该运行时脚本的代码与其他包开头的代码中的编号相对应。
我们可以将这部分代码提取到一个单独的js中,这样打包好的公共js就不会受到影响。
我们可以如下配置:
plugins: [
new webpack.optimize.CommonsChunkPlugin({
names: ['vendor'],
minChunks: Infinity,
filename: 'common.bundle.[chunkhash].js',
}),
new webpack.optimize.CommonsChunkPlugin({
names: ['manifest'],
filename: 'manifest.bundle.[chunkhash].js',
}),
new webpack.HashedModuleIdsPlugin()
]
对于名称,如果entry中已经定义了chunk,则根据entry中的entry提取chunk文件。 如果没有定义,例如mainifest,则会生成一个空的chunk文件,以提取所有其他chunk的公共代码。
我们代码的意义就是提取webpack注入到包中的公共代码。
打包后的文件:
webpack打包分析图:
看到图中的绿色块了吗?
那东西就是打包后的清单文件。
经过这样的处理,当我们改变主文件中的代码时,生成的公共js的chunkhash不会改变。 将会改变的是单独提取的manifest.bundle.[chunkhash].js的chunkhash。
压缩js、css、图片
这个好像就不记录了,因为这种常用的东西应该都有,不过这里顺便提一下。
一步压缩js和css:
webpack -p
图像压缩:
image-webpack-loader
具体使用请参见Webpack常用解决方案第16点。
在react-router4之前按需加载
如果你用过AntDesign,通常都知道有一个按需加载配置的功能,也就是说最后打包的时候只打包用到的组件代码。
对于常见的react组件,还有一种方式是使用react-router来实现按需加载。
对于每条路由来说,其他路由的代码其实并不是必须的,所以切换到某个路由后,如果只加载该路由的代码,那么首屏的加载速度将会得到很大的提升。
首先在webpack的输出中配置它
output: {
// ...
chunkFilename: '[name].[chunkhash:5].chunk.js',
},
之后需要将react-router的加载改为按需加载,如以下代码:
import React from 'react';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import ReactDOM from 'react-dom';
import { HashRouter as Router, Route } from 'react-router-dom';
import PageMain from './components/pageMain';
import PageSearch from './components/pageSearch';
import PageReader from './components/pageReader';
import reducer from './reducers';
const store = createStore(reducer);
const App = () => (
);
应改为:
import React from 'react';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import ReactDOM from 'react-dom';
import { HashRouter as Router, Route } from 'react-router-dom';
import reducer from './reducers';
const store = createStore(reducer);
const PageMain = (location, callback) => {
require.ensure([], require => {
callback(null, require('./components/pageMain').default);
}, 'PageMain');
};
const PageSearch = (location, callback) => {
require.ensure([], require => {
callback(null, require('./components/pageSearch').default);
}, 'PageSearch');
};
const PageReader = (location, callback) => {
require.ensure([], require => {
callback(null, require('./components/pageReader').default);
}, 'PageReader');
};
const App = () => (
);
按需加载react-router4
之前的方法应用于react-router4时不起作用,因为getComponent方法被移动了。
然后我参考了官方的教程方法
这里我们需要使用webpack、babel-plugin-syntax-dynamic-import和react-loadable。
Webpack内置了动态加载,由于我们使用了babel,所以需要使用babel-plugin-syntax-dynamic-import来避免做一些额外的转换。
所以首先我们需要
npm i babel-plugin-syntax-dynamic-import --save-dev
然后在.babelrc中添加配置:
"plugins": [
"syntax-dynamic-import"
]
接下来我们需要使用react-loadable,它是一个高阶组件,用于动态加载组件。
这是官网的反例
import Loadable from 'react-loadable';
import Loading from './my-loading-component';
const LoadableComponent = Loadable({
loader: () => import('./my-component'),
loading: Loading,
});
export default class App extends React.Component {
render() {
return ;
}
}
使用起来并不困难。 Loadable函数会传入一个参数对象并返回一个渲染在界面上的组件。
这个参数对象的loader属性就是需要动态加载的组件,loading属性传入的是一个显示加载状态的组件。 当动态组件尚未加载时,界面上显示的是正在加载的组件。
与原始形式相比,使用这些技术的优点是显着的。 我们不仅可以在路由上动态加载,而且我们动态加载的组件可以更详细,比如时钟组件,而不是像以前那样一个页面。
通过灵活使用动态加载,可以完美控制加载的js大小,从而将首屏加载时间和其他页面加载时间控制在一个相对平衡的程度。
这里有一点需要注意,这是我们使用加载组件时经常出现的问题:闪烁。
产生这些现象的原因是加载页面会在真正的组件加载之前出现,而组件加载速度非常快,这会导致加载页面出现的时间很短,从而造成闪烁。
解决办法是添加delay属性
const LoadableComponent = Loadable({
loader: () => import('./my-component'),
loading: Loading,
delay: 200
});
加载组件只有在加载时间小于200ms时才会出现。
还有更多方法可以使用react-loadable:
现在看一下我们的打包文件:
webpack打包分析图:
注意里面的包文件名,发现通过这些方法按需加载的几个文件都是按照数字来命名的,而不是我们期望的组件名。
我在这个项目的github上搜索了一下,发现组件提供的命名方式需要使用服务端渲染,之后就没有继续了。
总之,这件事不是很重要,所以我不会再考虑了。 如果有园友对这个问题有好的解决办法,希望能在评论里解释一下。
Reactv16.6后按需加载(2019.07.04更新)
此版本的 React 添加了两个新功能:lazy 和 Suspense。
对于之前的按需加载,可以将代码改为:
import React, { Suspense } from 'react';
import Loading from './my-loading-component';
const LoadableComponent = React.lazy(() => import('./my-component'));
export default class App extends React.Component {
render() {
return (
<Suspense fallback={}>
;
)
}
}