随着单页应用(SPA)概念的日趋火热,React框架在设计和实践中同样也围绕着SPA的概念来打造自己的技术栈体系,其中路由模块便是非常重要的一个组成部分。它承载着应用功能分区,复杂模块组织,数据传递,应用状态维护等诸多功能,如何结合好React框架的技术栈特性来进行路由模块设计就显得尤为重要,本文则以探索React动态路由设计最佳实践作为切入点,分享下在实际项目开发中的心得与体会。
为什么需要做动态路由
1.首屏加载效率,随着项目的业务需求持续添加,react中的代码复杂度将面临着持续上升的问题,同时由于react中的jsx和es6语法的文件在实际生产环境中,也会被babel-js重新编译成浏览器所支持的基于ES5的语法模块,各个模块打体积将会变得非常的臃肿不堪,直接影响到页面加载的等待时常。以下图为例,如果不做处理,我们的业务模块通常体积会达到兆级,这对首屏加载速率和用户体验的影响无疑是巨大的。
2.降低模块间的功能影响,react中的jsx无疑是一个很方便的设计,能让开发者像写html一样来书写虚拟dom,但是它同样也贯彻执行着"all in js"的理念,最终构建完成后所有的业务代码都将打包到1-2个bundle文件中,这就等于将所有的功能模块都集中到了一个物理文件中,如果遇到业务处理的复杂性,接口层变更,异常处理出错等诸多代码健壮性问题时,一个子模块出现了错误,就很有可能导致用户界面整体性出错从而无法使用的风险。
3.二八定律,通常在一个应用中,最重要和高频访的功能模块只占其中一小部分,约20%,其余80%尽管是多数,却是次要的。以后台系统为例,普通业务人员通常使用的高频模块只有3-5个,但是业务系统通常会有各式各样的权限设计,不同的权限映射着能访问的路由模块也不尽相同,虽然我们可以在用户的数据访问和路由地址上做拦截限制,但是同样也需要对其能访问的模块资源进行限制,才能做到真正的按需加载,随取随用。
4.工具体系支撑,无论是react-router还是对应搭配的构建工具webpack,其中都有针对动态路由部分的设计与优化,使用好了往往能起到事半功倍的效果。
简化版实现:bundle-loader
bundle-loader是webpack官方出品与维护的一个loader,主要用来处理异步模块的加载,将简单的页面模块转成异步模块,非常方便。
改造前页面
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> ); } }
在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',}] } ] } }
在工程中使用带 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
创建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) } }
对我们需要异步加载的组件函数进行二次封装(注: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)); } }
改造后页面
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> ); } }
完成构建后我们就可以从浏览器中看到,我们定制后的模块已经被能被支持异步加载了
同时在webpack构建中也能清晰地看到多了一个chunk:
高阶版实现: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的处理方式,直接在原生模块上进行动态路由处理,具体设计实现如下:
封装一个高阶组件,用来实现将普通的组件转换成动态组件
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;
对我们需要用到的普通组件进行引入和包装处理
// 组件增强 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") )
完成构建后我们就可以从浏览器中看到,我们定制后的模块也和之前一样,被能被支持异步加载了
同时在webpack构建界面中的能看到多了一个chunk,并且chunkName就是我们自定义的名称,对于定位分析一些模块问题时会非常管用。
从中我们也不难发现,相对于bundle-loader,dynamic-imports + AsyncComponent高阶组件的方式更为简单灵活,同时对于现有的代码改动也较小,故作为在实际开发中的首选方案使用,同时我们也推荐一个非常不错的webpack的chunk分析工具webpack-bundle-analyzer,方便查看每个异步路由中的构建的具体模块内容。
One more thing:路由模块的组织
react-router功能强大,上手简单,作为官方唯一指定的路由框架已经成为了react应用开发中必备的部分,但是由于react天生组件化的原因,意味着react-router的配置文件中在实际使用中,会难免出现如下不佳场景:
2、路由配置会随着业务嵌套越来越深,团队协作开发时极易产生冲突
3、非jsx写法,模块清晰简单,但是会导致路由模块和业务模块耦合,不利于集中管理,同时无法明确表达出母子路由的嵌套关系,参见huge-apps
问题来了:如何既保证路由模块的清晰简单,又能集中管理维护,还能支持嵌套定义和动态加载?
借鉴python flask中的blueprint设计思路,重新实现路由模块的划分
经过前面的分析,我们不难发现react-router的路由配置模块会随着业务的深入变得越来越臃肿,其根本原因在于我们将所有的资源和配置信息都写在了一个文件中,这和软件设计中提倡的清晰一单一,低耦合高内聚等指导原则是背道而驰的,为此我们针对路由模块的划分这块进行了重构,改进方式如下:
routes ├── asyncComponent.js ├── callManage.js ├── index.js ├── opportunity.js ├── osManage.js ├── salesKit.js ├── salesManage.js ├── system.js ├── uploadOppor.js └── workBoard.js
- 在模块的入口文件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> );
- 在子路由模块中完成对应具体业务模块的加载,支持同时混合使用同步和异步组件的管理方式
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> );
这样重构的好处是即使未来随着业务的深入,对应的开发人员也只需要维护自身负责的子路由模块,再在根路由下进行注册即可使用,并且由于子路由模块都从物理文件上进行了隔离,也能最大程度地减少协作冲突,同时,因为维持了jsx的描述型结构,路由的嵌套关系和集中维护等优点依旧能沿用。
总结
本文从react-router的动态路由实践着手,整合了webpack的bundle-loader,dynamic-imports和高阶组件等实践的明细介绍,附带介绍了改进路由模块的组织方式,以此作为react-router深入实践的经验总结,希望能对各位读者在实际项目开发中有所帮助。
参考文献
Webpack3官方文档
React-Router官方文档
基于Webpack 2的React组件懒加载
React-router 4 按需加载的实现方式及原理
React Router最新指南与异步加载实践