React-Router動態路由設計最佳實踐

寫在前面

隨着單頁應用(SPA)概念的日趨火熱,React框架在設計和實踐中一樣也圍繞着SPA的概念來打造本身的技術棧體系,其中路由模塊即是很是重要的一個組成部分。它承載着應用功能分區,複雜模塊組織,數據傳遞,應用狀態維護等諸多功能,如何結合好React框架的技術棧特性來進行路由模塊設計就顯得尤其重要,本文則以探索React動態路由設計最佳實踐做爲切入點,分享下在實際項目開發中的心得與體會。javascript

爲何須要作動態路由

動態路由:對於大型應用來講,一個首當其衝的問題就是所需加載的 JavaScript 的大小。程序應當只加載當前渲染頁所需的 JavaScript。有些開發者將這種方式稱之爲 "代碼分拆(code-splitting)" — 將全部的代碼分拆成多個小包,在用戶瀏覽過程當中按需加載。html

1. 首屏加載效率

隨着項目的業務需求持續添加,react中的代碼複雜度將面臨着持續上升的問題,同時因爲react中的jsx和es6語法的文件在實際生產環境中,也會被babel-js從新編譯成瀏覽器所支持的基於ES5的語法模塊,各個模塊打體積將會變得很是的臃腫不堪,直接影響到頁面加載的等待時常。如下圖爲例,若是不作處理,咱們的業務模塊一般體積會達到兆級,這對首屏加載速率和用戶體驗的影響無疑是巨大的。java

all_chunk

2. 下降模塊間的功能影響

react中的jsx無疑是一個很方便的設計,能讓開發者像寫html同樣來書寫虛擬dom,可是它一樣也貫徹執行着"all in js"的理念,最終構建完成後全部的業務代碼都將打包到1-2個bundle文件中,這就等於將全部的功能模塊都集中到了一個物理文件中,若是遇到業務處理的複雜性,接口層變動,異常處理出錯等諸多代碼健壯性問題時,一個子模塊出現了錯誤,就頗有可能致使用戶界面總體性出錯從而沒法使用的風險。此外,若是業務模塊須要分功能上線的時候,下降彼此之間的影響也是必需要考慮的。node

3. 符合二八定律

一般在一個應用中,最重要和高頻訪的功能模塊只佔其中一小部分,約20%,其他80%儘管是多數,倒是次要的。之後臺系統爲例,普通業務人員一般使用的高頻模塊只有3-5個,可是業務系統一般會有各式各樣的權限設計,不一樣的權限映射着能訪問的路由模塊也不盡相同,雖然咱們能夠在用戶的數據訪問和路由地址上作攔截限制,可是一樣也須要對其能訪問的模塊資源進行限制,才能作到真正的按需加載,隨取隨用。python

4. 工具體系支撐

不管是react-router仍是對應搭配的構建工具webpack,其中都有針對動態路由部分的設計與優化,使用好了每每能起到事半功倍的效果。react

chunk_split2

簡化版實現:bundle-loader

bundle-loader是webpack官方出品與維護的一個loader,主要用來處理異步模塊的加載,將簡單的頁面模塊轉成異步模塊,很是方便。webpack

1. 改造前頁面

import React from 'react'
import {Route, Router} from 'react-router-dom'
import createHistory from 'history/createHashHistory'
import './app.less'

import ReactChildrenMap from './containers/Commons/ReactChildrenMap'
import Home from './containers/Home/Home'
import Search from './containers/Search/Search'
import BookList from './containers/BookList/BookList'
import BookDetail from './containers/BookDetail/bookDetail.bundle.js'

const history = createHistory()

export default class App extends React.Component {
  render() {
    return (
      <Router history={history}>
        <Route render={({location}) => {
          return (
            <ReactChildrenMap key={location.pathname}>
              <Route location={location} exact path="/" component={Home}/>
              <Route location={location} path="/search" component={Search}/>
              <Route location={location} path="/detail" component={BookDetail}/>
              <Route location={location} path="/bookList/:bookId" component={BookList}/>
            </ReactChildrenMap>
          )
        }}/>
      </Router>
    );
  }
}

2. 在webpack.config.js中增長rules

// npm install bundle-loader -D
// 若是不想經過配置調用,也能夠寫成: import file from "bundle-loader?lazy&name=my-chunk!./file.js"的內嵌寫法

module.exports = {
  module: {
    rules: [
      {
        test: /\.bundle\.js$/, // 經過文件名後綴自動處理須要轉成bundle的文件
        include: /src/,
        exclude: /node_modules/,
        use: [{
          loader: 'bundle-loader',
          options: {
            name: 'app-[name]',
            lazy: true
          }
        }, {
          loader: 'babel-loader',
        }]
      }
    ]
  }
}

3. 在工程中使用帶 xxx.bunlde.js結尾的類型文件時,就會被bundle-loader識別並作編譯處理

// bundle-loader處理前
import BookDetail from './containers/BookDetail/bookDetail.bundle.js'

// bundle-loader處理後
module.exports = function(cb) {
  // 自動會被bundle-loader處理成異步加載的寫法
  require.ensure([], function(require) {
    cb(require("!!../../../node_modules/babel-loader/lib/index.js!./bookDetail.bundle.js"));
  }, "app-bookDetail.bundle");
}
// WEBPACK FOOTER //
// ./containers/BookDetail/bookDetail.bundle.js

4. 建立LazyBundle.js文件,這個文件會用來調用被bundle-loader處理後的組件

// LazyBundle.js
import React, { Component } from 'react'

export default class LazyBundle extends React.Component {

  state = {
    // short for "module" but that's a keyword in js, so "mod"
    mod: null
  }

  componentWillMount() {
    this.load(this.props)
  }

  componentWillReceiveProps(nextProps) {
    if (nextProps.load !== this.props.load) {
      this.load(nextProps)
    }
  }

  load(props) {
    this.setState({
      mod: null
    })
    
    props.load((mod) => {
      this.setState({
        // handle both es imports and cjs
        mod: mod.default ? mod.default : mod
      })
    })
  }

  render() {
    if (!this.state.mod) {
      return false
    }
    return this.props.children(this.state.mod)
  }
}

5. 對咱們須要異步加載的組件函數進行二次封裝

注:react-router3和4因爲是不兼容升級,因此處理動態路由的方法也略有不一樣,在此列出了兩種版本下的處理方式可供參考git

import LazyBundle from './LazyBundle'
import BookDetail from './containers/BookDetail/bookDetail.bundle.js'

/* use for react-router4
 * component={lazyLoadComponent(BookDetail)}
 */
const lazyLoadComponent = (comp) => (props) => (
  <LazyBundle load={comp}>
    {(Container) => <Container {...props}/>}
  </LazyBundle>
)

/* use for react-router3
 * getComponent={lazyLoadComponentOld(BookDetail)}
 */
function lazyLoadComponentOld(comp) {
  return (location, cb) => {
    comp(module => cb(null, module.default));
  }
}

6. 改造後頁面

import React from 'react'
import {Route, Router} from 'react-router-dom'
import createHistory from 'history/createHashHistory'

const history = createHistory()

import './app.less'

import Home from 'containers/Home/Home'
import ReactChildrenMap from './containers/Commons/ReactChildrenMap'
import Search from './containers/Search/Search'
import BookList from './containers/BookList/BookList'
import LazyBundle from './LazyBundle'
import BookDetail from './containers/BookDetail/bookDetail.bundle.js'

/* use for react-router4
 * component={lazyLoadComponent(BookDetail)}
 */
const lazyLoadComponent = (comp) => (props) => (
  <LazyBundle load={comp}>
    {(Container) => <Container {...props}/>}
  </LazyBundle>
)

export default class App extends React.Component {
  render() {
    return (
      <Router history={history}>
        <Route render={({location}) => {
          return (
            <ReactChildrenMap key={location.pathname}>
              <Route location={location} exact path="/" component={Home}/>
              <Route location={location} path="/search" component={Search}/>
              <Route location={location} path="/detail" component={lazyLoadComponent(BookDetail)} />
              <Route location={location} path="/bookList/:bookId" component={BookList}/>
            </ReactChildrenMap>
          )
        }}/>
      </Router>
    );
  }
}

完成構建後咱們就能夠從瀏覽器中看到,咱們定製後的模塊已經被能被支持異步加載了
bundle_chunkes6

同時在webpack構建中也能清晰地看到多了一個chunk:github

bundle_name

高階版實現:dynamic-imports

dynamic-imports是webpack在升級到2版本之後,對js的模塊處理進行了加強的,其中就有對require.ensure的改進,基於原生的Promise對象進行了從新實現,採用了import()做爲資源加載方法,將其看作一個分割點並將其請求的module打包爲一個獨立的chunk。import()以模塊名稱做爲參數而且返回一個Promise對象,具體介紹能夠參考筆者以前寫過的翻譯文章Webpack2 升級指南和特性摘要,具體使用比對以下:

// require.ensure
module.exports = function (cb) {
  require.ensure([], function(require) {
    var app = require('./file.js');
    cb(app);
  }, "custom-chunk-name");
};

// import()
import("./module").then(module => {
    return module.default;
}).catch(err => {
    console.log("Chunk loading failed");
});
// This creates a separate chunk for each possible route
​````

結合import的高級特性,咱們就能夠省去bundle-loader的處理方式,直接在原生模塊上進行動態路由處理,具體設計實現以下:

1.封裝一個高階組件,用來實現將普通的組件轉換成動態組件

import React from 'react'

const AsyncComponent = loadComponent => (
  class AsyncComponent extends React.Component {
    state = {
      Component: null,
    }

    componentWillMount() {
      if (this.hasLoadedComponent()) {
        return;
      }

      loadComponent()
        .then(module => module.default)
        .then((Component) => {
          this.setState({Component});
        })
        .catch((err) => {
          console.error(`Cannot load component in <AsyncComponent />`);
          throw err;
        });
    }

    hasLoadedComponent() {
      return this.state.Component !== null;
    }

    render() {
      const {Component} = this.state;
      return (Component) ? <Component {...this.props} /> : null;
    }
  }
);

export default AsyncComponent;

2.對咱們須要用到的普通組件進行引入和包裝處理

// 組件加強
const Search = AsyncComponent(() => import("./containers/Search/Search"))

// 路由調用
<Route location={location} path="/list" component={BookList} />

利用weback3中的Magic Comments對生成的chunk指定chunkName

const BookList = AsyncComponent(() => 
  import(/* webpackChunkName: "bookList" */ "./containers/BookList/BookList")
)

完成構建後咱們就能夠從瀏覽器中看到,咱們定製後的模塊也和以前同樣,被能被支持異步加載了
async_component

同時在webpack構建界面中的能看到多了一個chunk,而且chunkName就是咱們自定義的名稱,對於定位分析一些模塊問題時會很是管用。
bundle_name_comment

從中咱們也不難發現,相對於bundle-loader,dynamic-imports + AsyncComponent高階組件的方式更爲簡單靈活,同時對於現有的代碼改動也較小,故做爲在實際開發中的首選方案使用,同時咱們也推薦一個很是不錯的webpack的chunk分析工具webpack-bundle-analyzer,方便查看每一個異步路由中的構建的具體模塊內容。

One more thing:路由模塊的組織

react-router功能強大,上手簡單,做爲官方惟一指定的路由框架已經成爲了react應用開發中必備的部分,可是因爲react天生組件化的緣由,意味着react-router的配置文件中在實際使用中,會不免出現以下不佳場景:

一、路由配置入口文件持續臃腫,文件越引越多

components

二、路由配置會隨着業務嵌套愈來愈深,團隊協做開發時極易產生衝突

route-config

三、非jsx寫法,模塊清晰簡單,可是會致使路由模塊和業務模塊耦合,不利於集中管理,同時沒法明確表達出母子路由的嵌套關係,參見huge-apps

js-route

問題來了:如何既保證路由模塊的清晰簡單,又能集中管理維護,還能支持嵌套定義和動態加載?

借鑑python flask中的blueprint設計思路,從新實現路由模塊的劃分

通過前面的分析,咱們不難發現react-router的路由配置模塊會隨着業務的深刻變得愈來愈臃腫,其根本緣由在於咱們將全部的資源和配置信息都寫在了一個文件中,這和軟件設計中提倡的清晰單一,低耦合高內聚等指導原則是背道而馳的,爲此咱們針對路由模塊的劃分這塊進行了重構,改進方式以下:

1.拆分routes.js入口文件

將路由模塊的總體由一個routes.js文件拆成若干個彼此間互相獨立的子路由模塊文件模塊的拆分原則能夠和業務功能劃分一一對應,逐步減小主配置中的內容耦合。

routes
├── asyncComponent.js
├── callManage.js
├── index.js
├── opportunity.js
├── osManage.js
├── salesKit.js
├── salesManage.js
├── system.js
├── uploadOppor.js
└── workBoard.js

2.在模塊的入口文件index.js中完成對各個子模塊的引入,以下所示:

import React from 'react';
import { Route, IndexRedirect } from 'react-router';
import NotFound from '../components/NotFound';
import Layout from '../containers/Main';
import Opportunity from './opportunity';
import OsManage from './osManage';
import SalesKit from './salesKit';
import System from './system';
import CallManage from './callManage';
import SalesManage from './salesManage';
import WorkBoard from './workBoard';
import UploadOppor from './uploadOppor';

const routeList = [
  Opportunity,
  UploadOppor,
  OsManage,
  SalesKit,
  System,
  CallManage,
  SalesManage,
  WorkBoard
];

export default (
  <Route path='/' component={Layout} >
    {routeList}
    <Route path='*' component={NotFound} />
  </Route>
);

3.在子路由模塊中完成對應具體業務模塊的加載,支持同時混合使用同步和異步組件的管理方式

import React from 'react';
import { Route } from 'react-router';
import UploadOpportunities from '../containers/opportunity/UploadOpportunities'
import UploadVisitOpportunity from '../containers/UploadVisitOpportunity'
import asyncComponent from './asyncComponent'

// upload_frozen_phone
const UploadFrozenPhone = asyncComponent(
  () => import(/* webpackChunkName: "upload_frozen_phone" */'../components/uploadFrozenPhone/UploadFrozenPhone')
);

// upload_phone_state
const UploadPhoneState = asyncComponent(
  () => import(/* webpackChunkName: "upload_phone_state" */'../components/uploadPhoneState/UploadPhoneState')
);

export default (
  <Route key='uploadOpportunities'>
    <Route path='upload_opportunity/:type' component={UploadOpportunities} />
    <Route path='upload_visit_opportunity' component={UploadVisitOpportunity} />
    <Route path='frozen_phone' component={UploadFrozenPhone} />
    <Route path='phone_state' component={UploadPhoneState} />
  </Route>
);

4. 優點小結:

這樣重構的好處是即便將來隨着業務的深刻,對應的開發人員也只須要維護自身負責的子路由模塊,再在根路由下進行註冊便可使用,而且因爲子路由模塊都從物理文件上進行了隔離,也能最大程度地減小協做衝突,同時,由於維持了jsx的描述型結構,路由的嵌套關係和集中維護等優勢依舊能沿用。

總結

本文從react-router的動態路由實踐着手,整合了webpack的bundle-loader,dynamic-imports和高階組件等實踐的明細介紹,附帶介紹了改進路由模塊的組織方式,以此做爲react-router深刻實踐的經驗總結,但願能對各位讀者在實際項目開發中有所幫助。

參考文獻

相關文章
相關標籤/搜索