这是Webpack+React系列配置过程记录的第四篇。其他内容请参考:
- 第一篇:使用webpack、babel、react、antdesign配置单页面应用开发环境
- 第二篇:使用react-router实现单页面应用路由
- 第三篇:优化单页面开发环境:webpack与react的运行时打包与热更新
- 第四篇:React配合Webpack实现代码分割与异步加载
自从前几篇文章介绍如何搭建React+Webpack单页面应用开发环境之后,我就基于这个环境对我的书籍分享网站的管理后台进行业务代码的实现。随着业务代码量的增加,我自定义的React组件也越来越多,这导致每次我刷新浏览器地址的时候都要等待挺久的一段时间。
解决这个问题的思路还是比较简单,分块加载每次需要用到什么就加载什么。基于这个思路进一步扩展一下,我想要针对CDN后者浏览器的缓存做一下优化,从而让浏览器每次只加载被我修改的那部分代码。
代码切割
参考Webpack官方文档,代码分割可以从以下几个方面进行。
CSS资源
之前我们的CSS样式通过Webpack编译到JS代码中,然后由JS代码动态插入到head标签里。这种加载CSS样式的方式,一方面会让JS代码非常大,另一方面会导致在异步加载方式渲染页面的时候网页会闪烁。
这里我们换一种加载方式,让CSS代码作为独立资源导出。这样就减少了JS代码规模,利用浏览器的多个连接同时加载JS代码和CSS代码,提高加载速度。这需要用到一个Webpack的插件:ExtractTextPlugin。
安装ExtractTextPlugin:
npm install --save-dev extract-text-webpack-plugin
修改webpack.config.js文件:
// 引入ExtractTextPlugin var ExtractTextPlugin = require('extract-text-webpack-plugin'); // 修改module.rules中关于CSS的节点的内容 //{ // test: /.css$/, // use: ['style-loader', 'css-loader'] //}, { test: /-m.css$/, use: ExtractTextPlugin.extract({ fallback: "style-loader", use: [ { loader: 'css-loader', options: { modules: true, localIdentName: '[path][name]-[local]-[hash:base64:5]' } } ] }) }, { test: /^((?!(-m)).)*.css$/, use: ExtractTextPlugin.extract({ fallback: 'style-loader', use: 'css-loader' }) } // 在webpack的plugins节点增加下面一行: plugins: [ new ExtractTextPlugin('styles.css'), // 增加的行,样式将输出到styles.css new webpack.HotModuleReplacementPlugin(), new webpack.NoEmitOnErrorsPlugin() ]
上面的配置使用ExtractTextPlugin让Webpack把结果生成到styles.css文件中。这个文件对外的访问目录与js一样。我在这里使用了两种处理CSS文件的方式。首先是带-m结尾的文件,我使用css-loader的启用了模块化处理,让我能够在js中以对象的方式应用css样式。然后是非-m结尾的文件,让webpack调用css-loader和style-loader默认处理。
下面验证一下效果。
在src目录下我创建一个css文件,BasicExample-m.css,内容如下:
.red { color: red; }
在BasicExample.js文件中引入css文件,然后在js中应用red样式到一个p标签(这也是我为什么要让css文件名是-m结尾的原因)。改动如下:
... // 引入 import styles from './BasicExample-m.css'; ... // 应用 <p className={styles.red}>Red Text</p> ...
修改一下index.html,让它引入styles.css即可。
<html> <head> <link rel="stylesheet" href="/styles.css"/> </head> <body> <p>Hello world</p> <div id='main'></div> <script src="/out.js"></script> </body> </html>
启动,然后在浏览器查看一下效果。
启用开发者工具查看网络请求,发现确实请求了styles.css和out.js文件;而且请求到的index.html内容中,head标签内也没有发现嵌入了样式代码。
第三方依赖
第三方依赖在开发过程中属于不常变化的部分,导出到一个独立文件。
假设我的项目使用了第三方库jQuery,因此我使用npm install --save jquery
安装了jQuery依赖。
首先我们在src/index.js中添加对jQuery的调用代码,这是为了模拟实际开发中对第三方依赖的调用。如果你的代码没有调用依赖的代码,Webpack找不到入口,也就没有必要为之导出JS文件了。
index.js的内容改动如下:
... ReactDOM.render( <AppContainer> <BasicExample/> </AppContainer>, document.getElementById('main') ); // 添加的代码 import $ from 'jquery'; $('body').append('<p>Hello vendor</p>'); if (module.hot) { module.hot.accept(); }
接下来开始真正配置针对第三方依赖的代码分割,需要用到Webpack内置的优化插件CommonsChunkPlugin。修改webpack.config.js文件中output节点和plugins节点的代码:
... entry: { main:[ 'react-hot-loader/patch' 'webpack-hot-middleware/client', './src/index.js' ] }, output: { filename: '[name].js', path: path.resolve(__dirname, 'public') }, ... plugins: [ new ExtractTextPlugin('styles.css'), new webpack.HotModuleReplacementPlugin(), new webpack.NoEmitOnErrorsPlugin(), new webpack.optimize.CommonsChunkPlugin({ name: 'vendor', minChunks: function (module) { // TODO 对其他第三方依赖也要在这里进行代码分割 return module.context && module.context.indexOf('jquery') !== -1; } }), new webpack.optimize.CommonsChunkPlugin({ name: 'common' }) ] ...
首先修改了输出的filename,使之根据模块名称命名文件。并且配置了入口为main,因此将代码将导出到main.js而不是原来我们配置的out.js了。
你可能会注意到我两次用到了CommonsChunkPlugin插件。这样做是有原因的。我配置了名为vendor的导出项,用于导出第三方依赖的代码到vendor.js。但是由于Webpack在导出代码的时候会往代码里面加入运行时相关的代码。这就造成我们的main.js和vendor.js都包含同样的Webpack运行时相关代码。所以我配置了第二个名为common的导出项,把这部分的代码抽离出来存放在common.js中。
最后在index.html中引用common.js、vendor.js和main.js。需要注意的是这三个文件之间是有依赖关系的。vendor和main依赖了common,main依赖了vendor。都是调用关系,注意即可。
运行可以看到页面显示了jQuery插入的“Hello vendor”了。打开控制台也可以看到网页请求的内容。
应用代码
对应用里面的代码进行分割就不是通过配置Webpack实现的,而是使用Webpack提供的dynamic import方式实现。Webpack针对React或Vue等框架都有不同的解决方法。我尽在这里介绍React配合react-router如何实现异步加载React组件。
首先需要知道的是dynamic import通过返回Promise的方式实现异步加载功能。
import('./component.js') .then((m) => { // 处理异步加载到的模块m }) .catch((err) => { // 错误处理 });
要注意的是import的参数不能使用变量,简单原则是至少要让Webpack知晓应该预先加载哪些内容。这里的参数除了使用常量之外,还可以使用模板字符串`componentDir/${name}.js`
。
其实到这里基本完成代码切割了,接下来做得就是结合react-router实现按模块异步加载。这是跟业务代码相关的,因此每个人的做法都是不一样的。所以以下代码仅供参考。
异步加载
我参考react-router的例子写了个简单的异步加载组件AsyncLoader.js,内容:
import React from 'react'; export default class AsyncLoader extends React.Component { static propTypes = { path: React.PropTypes.string.isRequired, loading: React.PropTypes.element, }; static defaultProps = { path: '', loading: <p>Loading...</p>, error: <p>Error</p> }; constructor(props) { super(props); this.state = { module: null }; } componentWillMount() { this.load(this.props); } componentWillReceiveProps(nextProps) { if (nextProps.path !== this.props.path || nextProps.error !== this.props.error || nextProps.loading !== this.props.loading) { this.load(nextProps); } } load(props) { this.setState({module: props.loading}); // TODO:异步代码的路径希望做成可以配置的方式 import(`./path/${props.path}`) .then((m) => { let Module = m.default ? m.default : m; console.log("module: ", Module); this.setState({module: <Module/>}); }).catch(() => { this.setState({module: props.error}); }); } render() { return this.state.module; } }
使用方法
<Route exact path='/book' render={()=><AsyncLoader path={'./components/Book.js'}/>} />
Webpack打包的时候会根据import的参数生成相应的js文件,默认使用id(webpack生成的,从0开始)命名这个文件。
这个过程中我踩了一个坑,这里提出来供大家参考一下。
问题是这样的,当前路径为http://localhost/books
时发出异步加载请求,浏览器请求的代码为正常的http://localhost/0.js
;但是当前路径为http://localhost/books/detail
时发出异步加载请求,浏览器请求的是http://localhost/books/0.js
,而/books/0.js
这个文件是不存在的。
这个问题折磨了我挺长时间的。后来发现解决办法很简单,只需要在webpack.config.js文件的output节点中添加publicPath属性和值就可以了。虽然没有官方文档可以参考,但是我测试发现,Webpack生成js的时候,如果没有指明publicPath则生成的代码中异步请求是相对于当前地址开始的;否则是相对于publicPath的值。
我把BasicExample.js中的Counter.js修改成异步加载,运行结果如下所示: