前言
最近对一个比较老的公司项目做了一次优化,处理的主要是webpack打包文件体积过大的问题。
这里就写一下对于webpack打包优化的一些经验。
主要分为以下几个方面:
- 去掉开发环境下的配置
- ExtractTextPlugin:提取样式到css文件
- webpack-bundle-analyzer:webpack打包文件体积和依赖关系的可视化
- CommonsChunkPlugin:提取通用模块文件
- 提取manifest:让提取的公共js的hash值不要改变
- 压缩js,css,图片
- react-router 4 之前的按需加载
- react-router 4 的按需加载
- react v16.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的配置文件即可:
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]和[chunkhash]两种hash值的配置。
其中hash根据每次编译的内容计算得到,所以每编译一次所有文件都会生成一个新的hash,也就完全无法利用缓存。
所以我们这里用了[chunkhash],chunkhash是根据内容来生成的,所以如果内容不改变,那么生成的hash值就不会改变。
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()
]
对于names而言,如果chunk已经在entry中定义了,那么就会根据entry中的入口提取chunk文件。如果没有定义,比如mainifest,那么就会生成一个空的chunk文件,来提取其他所有chunk的公共代码。
而我们这段代码的意思就是将webpack注入到包中的那段公共代码提取出来。
打包后的文件:
webpack打包分析图:
看到图中绿色的那个块了吗?
那个东西就是打包后的manifest文件。
这样处理后,当我们再修改主文件中的代码时,生成的公共js的chunkhash是不会改变的,改变的是那个单独提取出来的manifest.bundle.[chunkhash].js的chunkhash。
压缩js,css,图片
这个其实不准备记录进来,因为这些一般项目应该都具备了,不过这里还是顺带提一句吧。
压缩js和css一步即可:
webpack -p
图片的压缩:
image-webpack-loader
具体的使用请查看 Webpack的常用解决方案 的第16点。
react-router 4 之前的按需加载
如果使用过Ant Design 一般都知道有一个配置按需加载的功能,就是在最后打包的时候只把用到的组件代码打包。
而对于一般的react组件其实也有一个使用react-router实现按需加载的玩法。
对于每一个路由而言,其他路由的代码实际上并不是必须的,所以当切换到某一个路由后,如果只加载这个路由的代码,那么首屏加载的速度将大大提升。
首先在webpack的output中配置
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 = () => (
<Provider store={store}>
<Router>
<div>
<Route exact path="/" component={PageMain} />
<Route path="/search" component={PageSearch} />
<Route path="/reader/:bookid/:link" component={PageReader} />
</div>
</Router>
</Provider>
);
应该改为:
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 = () => (
<Provider store={store}>
<Router>
<div>
<Route exact path="/" getComponent={PageMain} />
<Route path="/search" getComponent={PageSearch} />
<Route path="/reader/:bookid/:link" getComponent={PageReader} />
</div>
</Router>
</Provider>
);
react-router 4 的按需加载
上面那种方法应用到react-router 4上是行不通的,因为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 <LoadableComponent/>;
}
}
使用起来并不难,Loadable函数会传入一个参数对象,返回一个渲染到界面上的组件。
这个参数对象的loader属性就是需要动态加载的组件,而loading这个属性传入的是一个展示加载状态的组件,当还没有加载出动态组件时,展示在界面上的就是这个loading组件。
使用这种方法相对于原来的方式优势很明显,我们不只是在路由上可以进行动态加载了,我们动态加载的组件粒度可以更细,比如一个时钟组件,而不是像之前那样往往是一个页面。
通过灵活去使用动态加载可以完美控制加载的js的大小,从而使首屏加载时间和其他页面加载时间控制到一个相对平衡的度。
这里有个点需要注意,就是通常我们在使用loading组件时经常会出现的问题:闪烁现象。
这种现象的原因是,在加载真正的组件前,会出现loading页面,但是组件加载很快,就会导致loading页面出现的时间很短,从而造成闪烁。
解决的方法就是加个属性delay
const LoadableComponent = Loadable({
loader: () => import('./my-component'),
loading: Loading,
delay: 200
});
只有当加载时间大于200ms时loading组件才会出现。
还有更多的关于react-loadable的玩法:https://www.npmjs.com/package/react-loadable
那么现在看下我们的打包文件:
webpack打包分析图:
注意看看上面的打包文件名字,发现通过这种方法进行按需加载的几个文件都是按照数字命名,而没有按照我们期望的组件名命名。
我在这个项目的github上面找了一下,发现它提供的按组件命名的方法需要用到服务端渲染,然后就没有继续下去了。
反正这个东西也不是很重要,所以就没有进一步深究,如果有园友对这个问题有好的办法,也希望能在评论里说明。
react v16.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={<Loading />}>
<LoadableComponent/>;
</Suspense>
)
}
}
临时更新,写得简单点,见谅!
总结
总的来讲,通过以上步骤应该是可以解决绝大多数打包文件体积过大的问题。
当然,因为文中webpack版本和插件版本的差异,在配置和玩法上会有一些不同,但是上面描述的这些方向都是没有问题的,并且相信在各个版本下都可以找到相应的解决方案。
文中如有疑误,请不吝赐教。