62

写在前面

随着单页应用(SPA)概念的日趋火热,React框架在设计和实践中同样也围绕着SPA的概念来打造自己的技术栈体系,其中路由模块便是非常重要的一个组成部分。它承载着应用功能分区,复杂模块组织,数据传递,应用状态维护等诸多功能,如何结合好React框架的技术栈特性来进行路由模块设计就显得尤为重要,本文则以探索React动态路由设计最佳实践作为切入点,分享下在实际项目开发中的心得与体会。

为什么需要做动态路由

动态路由:对于大型应用来说,一个首当其冲的问题就是所需加载的 JavaScript 的大小。程序应当只加载当前渲染页所需的 JavaScript。有些开发者将这种方式称之为 "代码分拆(code-splitting)" — 将所有的代码分拆成多个小包,在用户浏览过程中按需加载。

1. 首屏加载效率

随着项目的业务需求持续添加,react中的代码复杂度将面临着持续上升的问题,同时由于react中的jsx和es6语法的文件在实际生产环境中,也会被babel-js重新编译成浏览器所支持的基于ES5的语法模块,各个模块打体积将会变得非常的臃肿不堪,直接影响到页面加载的等待时常。以下图为例,如果不做处理,我们的业务模块通常体积会达到兆级,这对首屏加载速率和用户体验的影响无疑是巨大的。

all_chunk

2. 降低模块间的功能影响

react中的jsx无疑是一个很方便的设计,能让开发者像写html一样来书写虚拟dom,但是它同样也贯彻执行着"all in js"的理念,最终构建完成后所有的业务代码都将打包到1-2个bundle文件中,这就等于将所有的功能模块都集中到了一个物理文件中,如果遇到业务处理的复杂性,接口层变更,异常处理出错等诸多代码健壮性问题时,一个子模块出现了错误,就很有可能导致用户界面整体性出错从而无法使用的风险。此外,如果业务模块需要分功能上线的时候,降低彼此之间的影响也是必须要考虑的。

3. 符合二八定律

通常在一个应用中,最重要和高频访的功能模块只占其中一小部分,约20%,其余80%尽管是多数,却是次要的。以后台系统为例,普通业务人员通常使用的高频模块只有3-5个,但是业务系统通常会有各式各样的权限设计,不同的权限映射着能访问的路由模块也不尽相同,虽然我们可以在用户的数据访问和路由地址上做拦截限制,但是同样也需要对其能访问的模块资源进行限制,才能做到真正的按需加载,随取随用。

4. 工具体系支撑

无论是react-router还是对应搭配的构建工具webpack,其中都有针对动态路由部分的设计与优化,使用好了往往能起到事半功倍的效果。

chunk_split2

简化版实现:bundle-loader

bundle-loader是webpack官方出品与维护的一个loader,主要用来处理异步模块的加载,将简单的页面模块转成异步模块,非常方便。

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由于是不兼容升级,所以处理动态路由的方法也略有不同,在此列出了两种版本下的处理方式可供参考

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_chunk

同时在webpack构建中也能清晰地看到多了一个chunk:

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的配置文件中在实际使用中,会难免出现如下不佳场景:

1、路由配置入口文件持续臃肿,文件越引越多

components

2、路由配置会随着业务嵌套越来越深,团队协作开发时极易产生冲突

route-config

3、非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深入实践的经验总结,希望能对各位读者在实际项目开发中有所帮助。

参考文献


Abcat
2.1k 声望1.4k 粉丝

浮世滔,人情渺,千古纷争何时了?江湖远,碧空长,几度飘零试锋芒!