React Router v4 之代碼分割:從放棄到入門

背景介紹

React Router v4 推出已有六個月了,網絡上因版本升級帶來的哀嚎彷彿就在半年前。我在使用這個版本的 React Router 時,也遇到了一些問題,好比這裏所說的代碼分割,因此寫了這篇博客做爲總結,但願能對他人有所幫助。html

什麼是代碼分割(code splitting)

在用戶瀏覽咱們的網站時,一種方案是一次性地將全部的 JavaScript 代碼都下載下來,可想而知,代碼體積會很可觀,同時這些代碼中的一部分多是用戶此時並不須要的。另外一種方案是按需加載,將 JavaScript 代碼分紅多個塊(chunk),用戶只需下載當前瀏覽所需的代碼便可,用戶進入到其它頁面或須要渲染其它部分時,才加載更多的代碼。這後一種方案中用到的就是所謂的代碼分割(code splitting)了。react

固然爲了實現代碼分割,仍然須要和 webpack 搭配使用,先來看看 webpack 的文檔中是如何介紹的。webpack

Webpack 文檔的 code splitting 頁面中介紹了三種方法:git

  1. 利用 webpack 中的 entry 配置項來進行手動分割
  2. 利用 CommonsChunkPlugin 插件來提取重複 chunk
  3. 動態引入(Dynamic Imports)

你能夠讀一下此篇文檔,從而對 webpack 是如何進行代碼分割的有個基本的認識。本文後面將提到的方案就是基於上述的第三種方法。github

React Router 中如何進行代碼分割

在 v4 以前的版本中,通常是利用 require.ensure() 來實現代碼分割的,而在 v4 中又是如何處理的呢?web

使用 bundle-loader 的方案

在 React Router v4 官方給出的文檔中,使用了名爲 bundle-loader 的工具來實現這一功能。redux

其主要實現思路爲建立一個名爲 <Bundle> 的組件,當應用匹配到了對應的路徑時,該組件會動態地引入所需模塊並將自身渲染出來。數組

示例代碼以下:網絡

import loadSomething from 'bundle-loader?lazy!./Something'

<Bundle load={loadSomething}>
  {(mod) => (
    // do something w/ the module
  )}
</Bundle>

更多關於 <Bundle> 組件的實現可參見上面給出的文檔地址。react-router

使用 bundle-loader 方法存在的不足之處

這裏提到的兩個缺點咱們在實際開發工做中遇到的,與咱們的項目特定結構相關,因此你可能並不會趕上。

1、 代碼醜陋

因爲咱們的項目是從 React Router v2, v3 升級過來的,在以前的版本中對於異步加載的實現採用了集中配置的方案,即項目中存在一個 Routes.js 文件,整個項目的路徑設置都放在了該文件中,這樣方便集中管理。

可是在 React Router v4 版本中,因爲使用了 bundle-loader 來實現代碼分割,必須使用如下寫法來引入組件:

import loadSomething from 'bundle-loader?lazy!./Something'

而咱們的 reducersaga 文件也須要使用此種方法引入,致使 Routes.js 文件頂端將會出現一長串及其冗長的組件引入代碼,不易維護。以下所示:

clipboard.png

當用這種方法引入的模塊數量過多時,文件將會不忍直視。

2、 存在莫名的組件生命週期Bug

在使用了這種方案後,在某些頁面中會出現這樣的一個Bug:應用中進行頁面跳轉時,上一個頁面的組件會在 unmount 以後從新建立一次。表現爲已經到了下一頁面,可是會調用存在於跳轉前頁面中的組件的 componentDidMount 方法。

固然,這個Bug只與我本身的特定項目有關,錯誤緣由可能與 bundle-loader 並沒有太大關聯。不過由於一直沒法解決這一問題,因此決定換一個方案來代替 bundle-loader

使用 import() 的新方案

Dan Abramov 在這個 create-react-app 的 issue 中給出了 bundle-loader 的替代方案的連接:Code Splitting in Create React App,能夠參考該篇文章來實現咱們項目中的代碼分割功能。

代碼分割和 React Router v4

一個常規的 React Router 項目結構以下:

// 代碼出處:
// http://serverless-stack.com/chapters/code-splitting-in-create-react-app.html

/* Import the components */
import Home from './containers/Home';
import Posts from './containers/Posts';
import NotFound from './containers/NotFound';

/* Use components to define routes */
export default () => (
  <Switch>
    <Route path="/" exact component={Home} />
    <Route path="/posts/:id" exact component={Posts} />
    <Route component={NotFound} />
  </Switch>
);

首先根據咱們的 route 引入相應的組件,而後將其用於定義相應的 <Route>

可是,無論匹配到了哪個 route,咱們這裏都一次性地引入全部的組件。而咱們想要的效果是當匹配了一個 route,則只引入與其對應的組件,這就須要實現代碼分割了。

建立一個異步組件(Async Component)

異步組件,即只有在須要的時候纔會引入。

import React, { Component } from 'react';

export default function asyncComponent(importComponent) {

  class AsyncComponent extends Component {

    constructor(props) {
      super(props);

      this.state = {
        component: null,
      };
    }

    async componentDidMount() {
      const { default: component } = await importComponent();

      this.setState({
        component: component
      });
    }

    render() {
      const C = this.state.component;

      return C
        ? <C {...this.props} />
        : null;
    }

  }

  return AsyncComponent;
}

asyncComponent 接收一個 importComponent 函數做爲參數,importComponent() 在被調用時會動態引入給定的組件。

componentDidMount()中,調用傳入的 importComponent(),並將動態引入的組件保存在 state 中。

使用異步組件(Async Component)

再也不使用以下靜態引入組件的方法:

import Home from './containers/Home';

而是使用 asyncComponent 方法來動態引入組件:

const AsyncHome = asyncComponent(() => import('./containers/Home'));

此處的 import() 來自於新的 ES 提案,其結果是一個 Promise,這是一種動態引入模塊的方法,即上文 webpack 文檔中提到的第三種方法。更多關於 import() 的信息能夠查看這篇文章:ES proposal: import() – dynamically importing ES modules

注意這裏並無進行組件的引入,而是傳給了 asyncComponent 一個函數,它將在 AsyncHome 組件被建立時進行動態引入。同時,這種傳入一個函數做爲參數,而非直接傳入一個字符串的寫法可以讓 webpack 意識到此處須要進行代碼分割。

最後以下使用這個 AsyncHome 組件:

<Route path="/" exact component={AsyncHome} />

對於 reducer 和 saga 文件的異步加載

在上面的這篇文章中,只給出了對於組件的異步引入的解決方案,而在咱們的項目中還存在將 reducersaga 文件異步引入的需求。

processReducer(reducer) {
    if (Array.isArray(reducer)) {
        return Promise.all(reducer.map(r => this.processReducer(r)));
    } else if (typeof reducer === 'object') {
        const key = Object.keys(reducer)[0];
        return reducer[key]().then(x => {
            injectAsyncReducer(key, x.default);
        });
    }
}

將須要異步引入的 reducer 做爲參數傳入,利用 Promise 來對其進行異步處理。在 componentDidMount 方法中等待 reducer 處理完畢後在將組件保存在 state 中,對於 saga 文件同理。

// componentDidMount 中作以下修改
async componentDidMount() {
    const { default: component } = await importComponent();

    Promise.all([this.processReducer(reducers),
            this.processSaga(sagas)]).then(() => {
        this.setState({
            component
        });
    });
}

在上面對 reducer 文件進行處理時,使用了這樣的一行代碼:

injectAsyncReducer(key, x.default);

其做用是利用 Redux 中的 replaceReducer() 方法來修改 reducer,具體代碼見下。

// reducerList 是你當前的 reducer 列表
function createReducer(asyncReducers) {
    asyncReducers
    && !reducersList[Object.keys(asyncReducers)[0]]
    && (reducersList = Object.assign({}, reducersList, asyncReducers));
    return combineReducers(reducersList);
}

function injectAsyncReducer(name, asyncReducer) {
    store.replaceReducer(createReducer({ [name]: asyncReducer }));
}

完整的 asyncComponent 代碼可見此處 gist,注意一點,爲了可以靈活地使用不一樣的 injectAsyncReducer, injectAsyncSaga 函數,代碼中使用了高階組件的寫法,你能夠直接使用內層的 asyncComponent 函數。

asyncComponent 方法與 React Router v4 的結合使用

組件、reducer、saga 的異步引入

考慮到代碼可讀性,可在你的 route 目錄下新建一個 asyncImport.js 文件,將須要異步引入的模塊寫在該文件中:

// 引入前面所寫的異步加載函數
import asyncComponent from 'route/AsyncComponent';

// 只傳入第一個參數,只須要組件
export const AsyncHomePage = asyncComponent(() => import('./homepage/Homepage'));

// 傳入三個參數,分別爲 component, reducer, saga
// 注意這裏的第二個參數 reducer 是一個對象,其鍵值對應於redux store中存放的鍵值
export const AsyncArticle = asyncComponent(
    () => import('./market/Common/js/Container'),
    { market: () => import('./market/Common/js/reducer') },
    () => import('./market/Saga/watcher')
);

// reducer 和 saga 參數能夠傳入數組
// 當只有 saga,而無 reducer 參數時,第二項參數傳入空數組 []
const UserContainer = () => import('./user/Common/js/Container');
const userReducer = { userInfo: () => import('./user/Common/js/userInfoReducer') };
const userSaga = () => import('./user/Saga/watcher');
export const AsyncUserContainer = asyncComponent(
    UserContainer,
    [userReducer, createReducer],
    [userSaga, createSaga]
);

而後在項目的 Router 組件中引用:

// route/index.jsx
<Route path="/user" component={AsyncArticle} />
<Route path="/user/:userId" component={AsyncUserContainer} />

根據 React Router v4 的哲學,React Router 中的一切皆爲組件,因此沒必要在一個單獨的文件中統一配置全部的路由信息。建議在你最外層的容器組件,好比個人 route/index.jsx 文件中只寫入對應一個單獨頁面的容器組件,而頁面中的子組件在該容器組件中異步引入並使用。

在 React Router v5 發佈以前完成了本文,可喜可賀?

參考資料

Code Splitting in Create React App
ES proposal: import() – dynamically importing ES modules


本文在我博客上的原地址:React Router v4 之代碼分割:從放棄到入門

相關文章
相關標籤/搜索