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深刻实践的经验总结,但愿能对各位读者在实际项目开发中有所帮助。

参考文献

相关文章
相关标签/搜索