优化单页面开发环境:webpack与react的运行时打包与热更新
这是Webpack+React系列配置过程记录的第三篇。其他内容请参考:
- 第一篇:使用webpack、babel、react、antdesign配置单页面应用开发环境
- 第二篇:使用react-router实现单页面应用路由
- 第三篇:优化单页面开发环境:webpack与react的运行时打包与热更新
- 第四篇:React配合Webpack实现代码分割与异步加载
前面两篇文章介绍初步搭建单页面应用的开发环境,这篇文章将基于前面两篇文章进一步优化开发环境,实现单页面开发时的运行时打包与热更新。
调整文件布局
在第二篇文章中发现了框架代码文件的命名有些冲突,这里我们需要做一下调整,以便接下来的讲述不易出现问题。调整时需要小小地改动配置文件几个路径。文件布局调整前后对比如下:
图片基本已经说明了情况。我们将在src目录下开发代码,而编译后的代码将存放在public目录中。开发过程中,我们使用server.js配置的服务器进行测试。
接下来开始本文的正题。
配置运行时打包
前面两篇文章中,我们每次改动代码都需要使用下面两条命令
npm run build
npm start
编译和运行代码。这让每次build都需要输入这么多字;而且每次都需要扫描所有文件,效率十分低。
所以这次我们要配置运行时打包,只要测试服务器启动后,就可以让每次改动的内容都被webpack监测到并且自动打包。webpack-dev-middleware这个express的中间件可以实现该需求。
安装
安装webpack-dev-middleware:
npm install --save-dev webpack-dev-middleware
配置与启用webpack-dev-middleware
这是express的中间件,因此需要配置测试服务器端的代码server.js:
var express = require('express'); var app = express(); app.use('/', require('connect-history-api-fallback')()); app.use('/', express.static('public')); if (process.env.NODE_ENV !== 'production') { var webpack = require('webpack'); var webpackConfig = require('./webpack.config.js'); var webpackCompiled = webpack(webpackConfig); // 配置运行时打包 var webpackDevMiddleware = require('webpack-dev-middleware'); app.use(webpackDevMiddleware(webpackCompiled, { publicPath: "/", stats: {colors: true}, lazy: false, watchOptions: { aggregateTimeout: 300, poll: true }, })); } var server = app.listen(2000, function() { var port = server.address().port; console.log('Open http://localhost:%s', port); });
server.js把webpack和express连接到了一起实现了运行时打包。我这里简单使用了webpack-dev-middleware的几个配置项:
- publicPath:这个插件的唯一必填项。由于index.html请求的out.js存放的位置映射到服务器的URI路径是根,即“/”,所以我赋予了publicPath为:“/”。
- stats:我设置了console统计日志带颜色输出。
- lazy:指示是否懒人加载模式。true表示不监控源码修改状态,收到请求才执行webpack的build。false表示监控源码状态,配套使用的watchOptions可以设置与之相关的参数。
还有其他配置项,可以通过官网查阅按需配置。
接下来,我们需要删除之前使用npm run build
命令生成的out.js。否则在验证效果时,由于server.js中静态服务器的static中间件优先捕获到关于out.js的请求,将直接返回结果给客户端,导致看不到运行时打包的效果。
那么index.html引用的out.js文件是哪里来的呢?就是webpack-dev-middleware这个中间件利用缓存方式生成的。
验证
使用npm start
命令启动服务器,在浏览器访问index.html,可以看到页面正常显示。
修改src/index.js文件中的内容并保存。这时服务器后台执行自动打包,可以看到控制台输出了打包的日志,并不需要你再花时间敲那两行代码了。手动刷新浏览器页面就可以看到刚刚改动的内容。这告诉我们服务器已经可以实现运行时加载。
配置热更新
我们会注意到每次改动后还是需要我们刷新浏览器页面才能看到结果,还是未能让人满意。这时候可以配置热更新,让浏览器自动刷新页面。
热更新利用到的是名叫webpack-hot-middleware的依赖。它提供了用于express的中间件用于建立连接和传输更新;也提供了webpack的插件用于生成更新内容;同时还提供了用户端接口用于嵌入到js脚本中用于与express建立连接和应用更新。更详细的原理描述可以参考这里。
我们需要根据这几个方面嵌入webpack-hot-middleware到我们的开发框架中。
安装
使用下面命令安装:
npm install --save-dev webpack-hot-middleware
配置服务器端
改动server.js文件,在express中增加一个中间件即可,改动后如下:
var express = require('express'); var app = express(); app.use('/', require('connect-history-api-fallback')()); app.use('/', express.static('public')); if (process.env.NODE_ENV !== 'production') { var webpack = require('webpack'); var webpackConfig = require('./webpack.config.js'); var webpackCompiled = webpack(webpackConfig); // 配置运行时打包 var webpackDevMiddleware = require('webpack-dev-middleware'); app.use(webpackDevMiddleware(webpackCompiled, { publicPath: "/", stats: {colors: true}, lazy: false, watchOptions: { aggregateTimeout: 300, poll: true }, })); // 配置热更新 var webpackHotMiddleware = require('webpack-hot-middleware'); app.use(webpackHotMiddleware(webpackCompiled)); } var server = app.listen(2000, function() { var port = server.address().port; console.log('Open http://localhost:%s', port); });
在webpack中应用插件
修改webpack.config.js文件:
var path = require('path'); var webpack = require('webpack'); module.exports = { entry: ['webpack-hot-middleware/client', './src/index.js'], output: { filename: 'out.js', path: path.resolve(__dirname, 'public') }, module: { rules: [ { test: /\.js$/, exclude: /node_modules/, use: { loader: 'babel-loader', options: { presets: ['env', 'stage-0', 'react'], plugins: [['import', {"libraryName": "antd", "style": "css"}]] } } }, { test: /\.css$/, use: ['style-loader', 'css-loader'] } ], }, plugins: [ new webpack.HotModuleReplacementPlugin(), new webpack.NoEmitOnErrorsPlugin() ] };
注意改动中首先引入了webpack对象,然后修改了entry节点,最后添加了两个插件。这里两个插件中,webpack.HotModleReplacementPlugin
是关于热更新的,webpack.NoEmitOnErrorsPlugin
可以保证出错时页面不阻塞,且会在编译结束后报错。
前端脚本中配置热更新处理逻辑
热更新的处理逻辑webpack已经封装好了,只要在应用的入口文件中添加以下代码
... if (module.hot) { module.hot.accept(); }
即可。我配置的是src/index.js。
验证
npm start
启动服务器,浏览器访问index.html。页面显示正常,打开开发者工具可以看到发送了一个叫_webpackhmr的请求(请求路径可以配置,我们使用了默认值)。
修改src/index.js中的某个内容并保存,将会看到控制台输出了打包日志,然后浏览器页面自动更新页面内容。效果如下:
到这里热更新配置完毕。
让热更新后保留React的组件状态
React组件的状态对热更新有什么影响?我们先来看下面的一个例子。
在src目录下添加Counter.js文件,内容如下:
import React from 'react'; const COUNT_STEP = 1; export default class Counter extends React.Component { constructor(props) { super(props); this.state = {value: 1}; } componentDidMount() { this.timeout = setTimeout(this.handleTimeoutEvent.bind(this), 1000); } componentWillUnmount() { this.timeout && clearTimeout(this.timeout); } handleTimeoutEvent() { this.setState({value: this.state.value + COUNT_STEP}, () => { this.timeout = setTimeout(this.handleTimeoutEvent.bind(this), 1000); }); } render() { return ( <div> <p> This is a counter: {this.state.value} </p> </div> ); } }
Counter.js定义了一个React组件,这个组件拥有一个状态值叫value,初始值为1。实际上,React组件的状态指的是存储在组件的成员变量state中的内容,value不过是我们测试的一个实例。
在组件挂在的时候建立了一个计时器,每秒钟增加以下value的值,增加量为COUNT_STEP。
然后我们修改一下index.js文件,修改内容如下:
... import Counter from './Counter'; const BasicExample = () => ( <Router> <div> <ul> <li><Link to="/">Home111</Link></li> <li><Link to="/about">About</Link></li> <li><Link to="/topics">Topics</Link></li> <li><Link to="/counter">Counter</Link></li> </ul> <hr/> <Route exact path="/" component={Home}/> <Route path="/about" component={About}/> <Route path="/topics" component={Topics}/> <Route path="/counter" component={Counter}/> </div> </Router> ) ...
重新启动服务器,使用浏览器访问index.html。点击链接Counter页面显示了我们定义的Counter组件,发现内容逐步在递增1。
修改Counter.js文件中的COUNT_STEP为10,浏览器因为热更新而更新了页面,但是我们会发现Counter组件的状态值会被重置为1,然后重新开始递增10。
这是个小问题。但是放大这个问题到其他场景下,我们可以猜测,如果热更新后页面刷新了,那更新前的状态会被重置,更新前被打断的业务逻辑也无法继续,这明显是个bug。
解决这个问题可以使用react-hot-loader。
安装react-hot-loader
使用下面命令安装,官方文档强调要增加@next指定版本。我不太理解为什么。安装后看到添加的版本是3.0.0-beta.6
npm install --save-dev react-hot-loader@next
配置webpack使用react-hot-loader
需要修改webpack.config.js文件。
注意基于webpack2和react-hot-loader3的配置方式跟旧版本有所不同。我在旧的配置方式上被坑了很久,看这里才解决问题。
修改后的内容:
var path = require('path'); var webpack = require('webpack'); module.exports = { entry: [ 'react-hot-loader/patch', 'webpack-hot-middleware/client', './src/index.js' ], output: { filename: 'out.js', path: path.resolve(__dirname, 'public') }, module: { rules: [ { test: /\.js$/, exclude: /node_modules/, use: { loader: 'babel-loader', options: { presets: ['env', 'stage-0', 'react'], plugins: [ ['react-hot-loader/babel'], ['import', {"libraryName": "antd", "style": "css"}] ] } } }, { test: /\.css$/, use: ['style-loader', 'css-loader'] } ], }, plugins: [ new webpack.HotModuleReplacementPlugin(), new webpack.NoEmitOnErrorsPlugin() ] };
配置前端使用react-hot-loader
这里有个坑,且看我直接修改index.js文件:
... import { AppContainer } from 'react-hot-loader'; import Counter from './Counter'; ... //ReactDOM.render(<BasicExample/>, document.getElementById('main')); ReactDOM.render( <AppContainer> <BasicExample/> </AppContainer>, document.getElementById('main') ); ...
启动服务器,访问index.html,发现控制台出现下面错误:
提示告诉我们:不能在index.js中直接定义组件,然后又用AppContainer封装组件。方法很简单,把BasicExample抽离出来定义就可以了。
src目录下创建BasicExample.js文件,做一下简单的修改,内容如下:
import React from 'react'; import { BrowserRouter as Router, Route, Link } from 'react-router-dom'; import Counter from './Counter'; export default class BasicExample extends React.Component { render() { return ( <Router> <div> <ul> <li><Link to="/">Home122</Link></li> <li><Link to="/topics">Topics</Link></li> <li><Link to="/counter">Counter</Link></li> </ul> <hr/> <Route exact path="/" component={Home}/> <Route path="/topics" component={Topics}/> <Route path="/counter" component={Counter}/> </div> </Router> ); } } const Home = () => ( <div> <h2>Home</h2> </div> ) const Topics = ({ match }) => ( <div> <h2>Topics</h2> <ul> <li> <Link to={`${match.url}/props-v-state`}> Props v. State </Link> </li> </ul> <Route path={`${match.url}/:topicId`} component={Topic}/> <Route exact path={match.url} render={() => ( <h3>Please select a topic.</h3> )}/> </div> ) const Topic = ({ match }) => ( <div> <h3>{match.params.topicId}</h3> </div> )
index.js文件修改为:
import React from 'react'; // 必须引入 import ReactDOM from 'react-dom'; import { AppContainer } from 'react-hot-loader'; import BasicExample from './BasicExample'; ReactDOM.render( <AppContainer> <BasicExample/> </AppContainer>, document.getElementById('main') ); if (module.hot) { module.hot.accept(); }
注意尽管index.js中没有使用直接到React,我们仍必须引入React,不然会报错。猜测是后面引入的内容间接使用到了它。
验证
设置Counter.js中的COUNT_STEP为1。重新启动服务器,浏览器访问index.html,点击切换到counter页面,可以看到页面数值在递增1。
修改COUNT_STEP为10,看到页面数值没有重置为1,而是直接在原来的数值上递增10。说明组件状态没有被重置。
完毕。