這是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。說明組件狀態沒有被重置。
完畢。