對于大型的 Web 應用來說,如果將所有的代碼都放在一個文件中,然后一次性加載,這對于頁面的性能來說可能存在問題,特別是當很多代碼需要滿足一定的條件才需要加載的情況下。Webpack 可以允許將代碼分割成為不同的 chunk,然后按需加載這些 chunk,這種特性就是我們常說的 code splitting。在本章節中會主要論述 Webpack 與 React-Router 一起實現按需加載的內容。其中包括如何針對開發環境和生產環境配置不同的 webpack.config.js 內容以及 Webpack 按需加載的表現,通過本章節的學習應該能夠學會如何實現按需加載,以及如何使用該特性提升首頁加載性能。
開發環境搭建
配置 webpack.config.js
配置如下的 webpack.config.js:
//webpack.config.js
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const path = require('path');
module.exports = env => {
const ifProd = plugin => env.prod ? plugin : undefined;
const removeEmpty = array => array.filter(p => !!p);
return {
entry: {
app: path.join(__dirname, '../src/'),
vendor: ['react', 'react-dom', 'react-router'],
},
output: {
filename: '[name].[hash].js',
path: path.join(__dirname, '../build/'),
},
module: {
loaders: [
{
test: /\.(js)$/,
exclude: /node_modules/,
loader: 'babel-loader',
query: {
cacheDirectory: true,
},
},
],
},
plugins: removeEmpty([
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
minChunks: Infinity,
filename: '[name].[hash].js',
}),
new HtmlWebpackPlugin({
template: path.join(__dirname, '../src/index.html'),
filename: 'index.html',
inject: 'body',
hash:true
}),
ifProd(new webpack.optimize.DedupePlugin()),
ifProd(new webpack.optimize.UglifyJsPlugin({
compress: {
'screw_ie8': true,
'warnings': false,
'unused': true,
'dead_code': true,
},
output: {
comments: false,
},
sourceMap: false,
})),
]),
};
};
首先應該關注如下的方法:
const ifProd = plugin => env.prod ? plugin : undefined;
這個方法表示,只有在生產模式下才會添加特定的插件,如果不是在生產模式下,那么給插件就不需要添加。比如上面的 UglifyJsPlugin 插件壓縮代碼,在開發模式下是不需要的,只有在項目上線以后才需要將 js/css 代碼進行壓縮。假如現在處于開發階段,那么一般是需要啟動 webpack-dev-server 的,來看看如何對 webpack-dev-server 進行配置:
const webpack = require('webpack');
const WebpackDevServer = require('webpack-dev-server');
const webpackConfig = require('./webpack.config');
const path = require('path');
const env = {dev: process.env.NODE_ENV };
const devServerConfig = {
contentBase: path.join(__dirname, '../build/'),
historyApiFallback: { disableDotRule: true },
stats: { colors: true }
};
const server = new WebpackDevServer(webpack(webpackConfig(env)), devServerConfig);
server.listen(3000, 'localhost');
注意:上面直接調用 webpack 方法會得到一個 Compiler 對象,webpack-dev-server 的很多功能,比如 HMR 都是基于這個對象來完成的,包括從內存中拿到資源來處理請求(參考 webpack-dev-server 的 lazyload 部分)。此時我們直接調用 listen 方法來完成 webpack-dev-server 的啟動。
配置 package.json 的 script
"scripts": {
"start": "NODE_ENV=development node webpack/webpack-dev-server --env.dev",
"build": "rm -rf build/* | NODE_ENV=production webpack --config webpack/webpack.config.js --progress --env.prod"
},
此時可以通過下面簡單的命令來替換復雜的 webpack 命令(參數很長):
npm start
//或者 npm run start
npm run build
//相當于 rm -rf build/* | NODE_ENV=production webpack --config webpack/webpack.config.js --progress --env.prod
此時應該注意到了,在特定的命令后面,比如 start 命令后面會有 env.dev,而 build 后會存在 env.prod,所以,我們可以在 webpack.config.js 中通過 env 對象判斷當前所處的環境,從而在生產模式下添加特定的 webpack 插件。比如上面說的 UglifyJsPlugin 或者 DedupePlugin 等等。此時運行 npm start 就可以啟動服務器,在瀏覽器中打開 http://localhost:3000 就可以看到當前的頁面了。
入口文件分析
在 Webpack 中一個重要的概念就是入口文件,通過入口文件可以構建前面章節所說的模塊依賴圖譜。來看看上面的入口文件的內容:
//src/index.js
import React from 'react';
import { render } from 'react-dom';
import Root from './root';
render(<Root />, document.getElementById('App'));
而 root.js 中的內容如下:
import React from 'react';
import Router from 'react-router/lib/Router';
import browserHistory from 'react-router/lib/browserHistory';
import routes from './routes';
const Root = () => <Router history={browserHistory} routes={routes} />;
export default Root;
此時,在 Router 組件中的 routes 配置就是指的前端路由對象,而這也是 Webpack 結合 React-Router 實現按需加載的核心代碼,來看看真實的代碼結構:
import Core from './components/Core';
function errorLoading(error) {
throw new Error(`Dynamic page loading failed: ${error}`);
}
function loadRoute(cb) {
return module => cb(null, module.default);
}
export default {
path: '/',
component: Core,
indexRoute: {
getComponent(location, cb) {
System.import('./components/Home')
.then(loadRoute(cb))
.catch(errorLoading);
},
},
childRoutes: [
{
path: 'about',
getComponent(location, cb) {
System.import('./components/About')
.then(loadRoute(cb))
.catch(errorLoading);
},
},
{
path: 'users',
getComponent(location, cb) {
System.import('./components/Users')
.then(loadRoute(cb))
.catch(errorLoading);
},
},
{
path: '*',
getComponent(location, cb) {
System.import('./components/Home')
.then(loadRoute(cb))
.catch(errorLoading);
},
},
],
};
注意:上面的例子使用的是 react-router 的對象配置方式,它的作用和下面的配置是一樣的
<Route path="/" component={Core}>
<IndexRoute component={Home}/>
<Route path="about" component={About}/>
<Route path="users" component={Users}>
<Route path="*" component={Home}/>
</Route>
其中,最重要的代碼就是上面看到的 System.import,它和 require.ensure 方法是一樣的,這部分內容在 webpack1 中就已經引入了。比如上面的配置:
{
path: 'users',
getComponent(location, cb) {
System.import('./components/Users')
.then(loadRoute(cb))
.catch(errorLoading);
},
}
表示如果路由滿足 /users 的時候就會動態加載 components 下的 Users 組件,而且 Users 組件的內容是不會和入口文件打包在一起的,而是會單獨打包到一個獨立的 chunk 中的,只有這樣才能實現按需加載的功能。而且針對上面的 loadRoute 方法也做一個說明:
function loadRoute(cb) {
return module => cb(null, module.default);
}
其中加載 module.default 是因為 ES6 的模塊機制導致的,可以查看導出的模塊內容:
//Users.js
import React from 'react';
const Users = () => <div>Users</div>;
export default Users;
其實是通過 export default 來完成的,如果引入了 babel-plugin-add-module-export 就不需要這樣處理了,可以參考 __esModule是什么?。如果要將上面的代碼 users 路由修改為 require.ensure 加載,可以使用如下方式:
<Route path="users" getComponent={(location, cb) => {
require.ensure([], require => {
cb(null, require('./components/Users').default)
},'users');
}} />
</Route>
注意:require.ensure的簽名如下:
require.ensure(dependencies, callback, chunkName)
所以,我們通過第三個參數可以指定該 chunk 的名稱,如果不指定該 chunk 的名稱,將獲得下面的 0.xx、1.xx 這種 Webpack 自動分配的 chunk 名稱。
依賴圖譜分析
當使用了 code splitting 特性,可以使用很多工具來查看每一個 chunk 中都包含了什么特定的模塊。比如我常用的這個
webpack 官方分析工具。下面是我使用了這個工具查看本章節例子中的 stats.json 得到的依賴圖譜。
通過這個圖譜,可以看到很多內容。比如其中的 entry 因為含有 webpack 的特定加載環境,所以需要在所有的 chunk 加載之前加載,這部分內容在前面章節也已經講過;而 initial 部分表示在 webpack.config.js 中配置的入口文件。其他的 id 為 0/1/2 的 chunk 就是動態產生的 chunk,比如通過 System.import 或者 require.ensure 產生的動態的模塊。
還有一點就是其中的 names 列,因為我們調用 require.ensure 的時候并沒有指定當前的 chunk 的名稱,即第三個參數,所以 names 就是為空數組。而且很多如 assets、modules、warnings、errors 等信息都可以在這個頁面進行查看,此處不再贅述。當然,也可以使用第三方的工具來查看我們的 stats.json 的內容。
按需加載的表現
當頁面初始加載的時候會看到下面的內容:
其中 vendor.js 應該很好理解,就是包含上面配置的框架代碼:
vendor: ['react', 'react-dom', 'react-router'],
這部分如果不理解,可以回到前面章節進行復習。而 app.js 就是入口文件內容,即不包含動態加載的模塊的內容。而另外一個0.xxxx的 chunk 就是我們上面配置的:
indexRoute: {
getComponent(location, cb) {
System.import('./components/Home')
.then(loadRoute(cb))
.catch(errorLoading);
},
}
因為 indexRoute 表示默認初始化的子組件。而當你訪問localhost:3000/about的時候將會看到下面的內容:
其中2.xx的內容就是上面配置的about組件的內容:
{
path: 'about',
getComponent(location, cb) {
System.import('./components/About')
.then(loadRoute(cb))
.catch(errorLoading);
},
}
而當訪問localhost:3000/users的時候將會看到下面的內容:
其中1.xx就是上面配置的如下內容:
{
path: 'users',
getComponent(location, cb) {
System.import('./components/Users')
.then(loadRoute(cb))
.catch(errorLoading);
},
},
所以,按需加載的表現就是:當訪問特定的路由的時候才會加載特定的模塊,而不會將所有的代碼一股腦的一次性全部加載進來。這對于優化首頁加載的速度是很好的方案。同時,如果在上面運行的是 npm run start,那么在相應的目錄下會看不到輸出的文件,這是因為此時啟動的是 webpack-dev-server,而 webpack-dev-server 會將輸出的內容直接寫出到內存中,而不是寫到具體的文件系統中。但是如果運行的是
npm run build
,會發現文件會寫到文件系統中。來看看在 Webpack 中如何指定自己的輸出結果到底是內存還是具體的文件系統:
const MemoryFS = require("memory-fs");
const webpack = require("webpack");
const fs = new MemoryFS();
const compiler = webpack({ /* options*/ });
compiler.outputFileSystem = fs;
compiler.run((err, stats) => {
// Read the output later:
const content = fs.readFileSync("...");
});
通過 Webpack 的官方實例會發現,其實只要我們指定了 compiler.outFileSystem 為 MemoryFS 實例,那么在 run/watch 等方法中就可以通過相應的方法從內存中讀取文件而不是文件系統了。這種方式在開發模式下還是很有用的,但是在生產模式下建議不要使用。
本章總結
通過本章節的學習,應該對于 webpack+react-router 實現按需加載方案有了一個總體的認識。其實現的核心是通過 require.ensure 或者 System.import 來完成的。其中,本實例的完整代碼可以點擊這里查看。