這是Webpack+React系列配置過程記錄的第四篇。其他內容請參考: 第一篇:使用webpack、babel、react、antdesign配置單頁面應用開發環境 第二篇:使用react-router實現單頁面應用路由 第三篇:優化單頁面開發環境:webpack與react的運行時打包與熱更新 ...
這是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修改成非同步載入,運行結果如下所示: