React Router v4 推出已有六个月了,网络上因版本升级带来的哀嚎仿佛就在半年前。我在使用这个版本的 React Router 时,也遇到了一些问题,好比这里所说的代码分割,因此写了这篇博客做为总结,但愿能对他人有所帮助。html
在用户浏览咱们的网站时,一种方案是一次性地将全部的 JavaScript 代码都下载下来,可想而知,代码体积会很可观,同时这些代码中的一部分多是用户此时并不须要的。另外一种方案是按需加载,将 JavaScript 代码分红多个块(chunk),用户只需下载当前浏览所需的代码便可,用户进入到其它页面或须要渲染其它部分时,才加载更多的代码。这后一种方案中用到的就是所谓的代码分割(code splitting)了。react
固然为了实现代码分割,仍然须要和 webpack 搭配使用,先来看看 webpack 的文档中是如何介绍的。webpack
Webpack 文档的 code splitting 页面中介绍了三种方法:git
entry
配置项来进行手动分割CommonsChunkPlugin
插件来提取重复 chunk你能够读一下此篇文档,从而对 webpack 是如何进行代码分割的有个基本的认识。本文后面将提到的方案就是基于上述的第三种方法。github
在 v4 以前的版本中,通常是利用 require.ensure()
来实现代码分割的,而在 v4 中又是如何处理的呢?web
在 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
这里提到的两个缺点咱们在实际开发工做中遇到的,与咱们的项目特定结构相关,因此你可能并不会赶上。
1、 代码丑陋
因为咱们的项目是从 React Router v2, v3 升级过来的,在以前的版本中对于异步加载的实现采用了集中配置的方案,即项目中存在一个 Routes.js
文件,整个项目的路径设置都放在了该文件中,这样方便集中管理。
可是在 React Router v4 版本中,因为使用了 bundle-loader
来实现代码分割,必须使用如下写法来引入组件:
import loadSomething from 'bundle-loader?lazy!./Something'
而咱们的 reducer
和 saga
文件也须要使用此种方法引入,致使 Routes.js
文件顶端将会出现一长串及其冗长的组件引入代码,不易维护。以下所示:
当用这种方法引入的模块数量过多时,文件将会不忍直视。
2、 存在莫名的组件生命周期Bug
在使用了这种方案后,在某些页面中会出现这样的一个Bug:应用中进行页面跳转时,上一个页面的组件会在 unmount
以后从新建立一次。表现为已经到了下一页面,可是会调用存在于跳转前页面中的组件的 componentDidMount
方法。
固然,这个Bug只与我本身的特定项目有关,错误缘由可能与 bundle-loader
并没有太大关联。不过由于一直没法解决这一问题,因此决定换一个方案来代替 bundle-loader
。
Dan Abramov 在这个 create-react-app 的 issue 中给出了 bundle-loader
的替代方案的连接:Code Splitting in Create React App,能够参考该篇文章来实现咱们项目中的代码分割功能。
一个常规的 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,则只引入与其对应的组件,这就须要实现代码分割了。
异步组件,即只有在须要的时候才会引入。
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 中。
再也不使用以下静态引入组件的方法:
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
文件异步引入的需求。
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
函数。
考虑到代码可读性,可在你的 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 之代码分割:从放弃到入门