对于React-Router的实现原理,参考自Build your own React Router v4这篇英文原文,另外,React-Router底层库history的源码也值得一读。
欢迎访问博客文章
接下来是关于React-Router v4的一个简单版实现,通过这个实现来理解路由的原理,这里可能不会完全按照英文原文翻译,会有些意译或者省略的地方。
在单页应用中,应用代码和路由代码是极其重要的两部分,两者是相辅相成的,你是否对这两者有一些困惑。
关于路由的一些疑问点:
- 路由通常是相对比较复杂的,这让很多库的作者,在如何找到合适的路由抽象变得更加复杂。
- 因为这些复杂的原因,路由库的使用者倾向于盲目的相信库本身的抽象,而不是去理解背后的原理。
本文会通过实现一个简易版的React Router v4版本来点亮前一个问题的灯塔,并且让你了解背后的原理后去判断这样的抽象是否合适。
场景
下面是用于测试React Router实现的业务代码,你也可以访问线上的最终版代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
| const Home = () => ( <h2>Home</h2> )
const About = () => ( <h2>About</h2> )
const Topic = ({ topicId }) => ( <h3>{topicId}</h3> )
const Topics = ({ match }) => { const items = [ { name: 'Rendering with React', slug: 'rendering' }, { name: 'Components', slug: 'components' }, { name: 'Props v. State', slug: 'props-v-state' }, ] return ( <div> <h2>Topics</h2> <ul> {items.map(({ name, slug }) => ( <li key={name}> <Link to={`${match.url}/${slug}`}>{name}</Link> </li> ))} </ul> {items.map(({ name, slug }) => ( <Route key={name} path={`${match.path}/${slug}`} render={() => ( <Topic topicId={name} /> )} /> ))} <Route exact path={match.url} render={() => ( <h3>Please select a topic.</h3> )}/> </div> ) }
const App = () => ( <div> <ul> <li><Link to="/">Home</Link></li> <li><Link to="/about">About</Link></li> <li><Link to="/topics">Topics</Link></li> </ul> <hr /> <Route exact path="/" component={Home} /> <Route path="/about" component={About} /> <Route path="/topics" component={Topics} /> </div> )
|
关于这段代码,简单解释一下。Route
组件是在当一个URL匹配Route中的path
属性时渲染对应的组件UI,Link
组件是一个声明式的应用内导航。换句话说,Link
组件可以用来更新URL,Route
组件基于新的URL来改变UI。如果关于React Router v4的使用上有疑问,建议去官方的文档了解后再继续阅读。
在React Router v4中API仅仅只是组件。这表示如果已经熟悉React,那么你对组件及关于组合的想法,同样应用在路由的代码中。而且,由于你熟悉如何创建组件,那么创建React Router也就是你所熟悉的,创建更多的组件而已。
Route组件
我们要开始创建Route
组件。在开始之前,先检查一下API(看看有哪些props)。
在上面的例子中,<Route>
接收了3个props。exact
,path
和component
。这表示Route
组件的propTypes
现在看起来是这样的,
1 2 3 4 5
| static propTypes = { exact: PropTypes.bool, path: PropTypes.string, component: PropTypes.func, }
|
这里面有些微妙之处。首先,path
没有申明必填是因为如果一个Route
没有提供path
,那么它会被自动渲染。其次,component
也没有申明必填是因为有多个不同的方法告诉React Router在路径匹配时你要渲染的UI,一个没有在上述例子中的方法是通过render
属性。例如,
1 2 3
| <Route path='/settings' render={({ match }) => { return <Settings authed={isAuthed} match={match} /> }} />
|
render
允许我们方便的提供一个内联函数来返回一些UI而不用创建一个单独的组件。因此我们也要添加到propTypes中,
1 2 3 4 5 6
| static propTypes = { exact: PropTypes.bool, path: PropTypes.string, component: PropTypes.func, render: PropTypes.func, }
|
现在我们知道Route
接收哪些props,让我们再谈谈它实际上做了什么。Route
根据在path
属性中指定的位置与URL进行匹配并渲染对应的UI。基于此定义,我们知道<Route>
需要一些功能来检查当前URL是否匹配组件的path
属性。如果匹配,则渲染一些UI。如果不匹配,就仅仅返回null不做任何渲染。
让我们看一下代码的实现,关于匹配的函数matchPath
会在后续完成。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| class Route extends Component { static propTypes = { exact: PropTypes.bool, path: PropTypes.string, component: PropTypes.func, render: PropTypres.func, } render() { const { path, exact, component, render, } = this.props; const match = matchPath( location.pathname, { path, exact } ) if (!match) { // 由于当前位置不匹配path属性,不做任何事 return null; } if (component) { // component属性优先级比render函数高 // 如果当前位置匹配path属性,创建一个元素并传递match作为属性 return React.createElement(component, { match }); } if (render) { // 如果匹配路径但是component没有定义,调用render属性并传递match作为参数 return render({ match }); } return null; } }
|
现在Route
看起来比较可靠了。如果当前位置匹配传递进来的path
属性,则渲染一些UI,否则什么都不做。
让我们暂停一下,谈谈通常路由的实现方法。在一个客户端应用中,一般只有两种方法能让用户更新URL。第一个是用户点击一个超链接标签,另一个是点击后退和前进按钮。基本上,我们的路由需要注意当前的URL并基于此进行渲染UI。也就是说,我们的路由需要注意URL的变化,并基于新的URL确定哪个新的UI需要显示。如果我们知道只有通过超链接标签和后退/前进按钮可以更新URL,那么我们就可以规划和响应这些变化。我们会在后续构建我们的<Link>
组件时接触超链接标签,现在先聚焦在后退/前进按钮上。React Router使用History的.listen
方法来监听当前的URL,但是为了避免引入其他的库,我们使用HTML5的popstate
事件。popstate
,会在用户点击后退或前进按钮时被触发,这就是我们想要的。由于Route
需要基于当前的URL进行UI渲染,这让Route
有能力在popstate
事件发生时进行监听并重新渲染。通过重新渲染,每个Route
可以重新检查他们是否匹配新的URL,如果匹配,则渲染UI,否则不做任何事。让我们看一下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| class Route extends Component { static propTypes: { path: PropTypes.string, exact: PropTypes.bool, component: PropTypes.func, render: PropTypes.func, } componentWillMount() { addEventListener("popstate", this.handlePop); } componentWillUnmount() { removeEventListener("popstate", this.handlePop); } handlePop = () => { this.forceUpdate(); } render() { const { path, exact, component, render, } = this.props; const match = matchPath(location.pathname, { path, exact }); if (!match) return null; if (component) return React.createElement(component, { match }); if (render) return render({ match }); return null; } }
|
你会注意到我们做的就是当组件挂载时添加popstate
事件监听,当popstate
事件触发时,我们会调用forceUpdate
并开始重新渲染。
现在,不管有多少<Route>
渲染,每个组件都会基于后退/前进按钮进行监听,重新匹配,和重新渲染。
还有一件事情直到还没有做的是matchPath
函数。这个函数是我们路由的关键,因为这个函数用来决定当前的URL是否和<Route>
组件匹配。对于matchPath
的一个细微点是,我们要考虑<Route>
的exact
属性。如果你不熟悉exact
是什么,这里有个根据文档的直接解释。
当为true
时,只会当路径和location.pathname
完全匹配时匹配。
path |
location.pathname |
exact |
matches? |
/one |
/one/two |
true |
no |
/one |
/one/two |
false |
yes |
现在,让我们开始matchPath
函数的实现。如果回头看Route
组件,你会发现matchPath
函数的声明看起来是这样的,
1
| const match = matchPath(location.pathname, { path, exact });
|
这里的match
是一个对象或者null,基于是否有匹配结果。根据这个函数签名,我们可以构建matchPath
的第一部分,
1 2 3
| const matchPath = (pathname, options) => { const { exact = false, path } = options; }
|
这里我们使用一些ES6的语法。创建一个exact变量并且等于options.exact,如果这个值没有定义,则默认设置为false。另外,创建一个path变量等于options.path。
之前有提到“path
不要求必填的原因是,如果一个Route
没有path,那么它会被自动渲染”。这是间接的决定matchPath
函数是否渲染,让现在我们添加这个功能。
1 2 3 4 5 6 7 8 9 10 11
| const matchPath = (pathname, options) => { const { exact = false, path } = options; if (!path) { return { path: null, url: pathname, isExact: true, } } }
|
现在到了匹配的部分。React Router使用pathToRegex,我们这里简化一下,就使用一个简单的正则表达式。
1 2 3 4 5 6 7 8 9 10 11 12 13
| const matchPath = (pathname, options) => { const { exact = false, path } = options; if (!path) { return { path: null, url: pathname, isExact: true, } } const match = new RegExp(`^${path}`).exec(pathname); }
|
如果你不熟悉.exec
,它的逻辑是这样的,如果找到匹配返回一个包含匹配结果文本的数组,否则返回null。
这里是当我们的实例app路由到/topics/components
时每个match
的结果,
path |
location.pathname |
返回值 |
/ |
/topics/components |
[‘/‘] |
/about |
/topics/components |
null |
/topics |
/topics/components |
[‘/topics’] |
/topics/rendering |
/topics/components |
null |
/topics/components |
/topics/components |
[‘/topics/components’] |
/topics/props-v-state |
/topics/components |
null |
/topics |
/topics/components |
[‘/topics’] |
注意在我们的app中会获取每一个<Route>
的match
。因为,每个<Route>
都会在它的渲染函数中调用matchPath
现在我们知道.exec
返回的match
是什么了,我们需要做的就是找出是否有匹配结果。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| const matchPath = (pathname, options) => { const { exact = false, path } = options; if (!path) { return { path: null, url: pathname, isExact: true, } } const match = new RegExp(`^${path}`).exec(pathname); if (!match) { // 没有匹配结果 return null; } const url = match[0]; const isExact = pathname === url; if (exact && !isExact) { // 如果有一个匹配结果,并且不是在exact属性中指定的结果 return null; } return { path, url, isExact, } }
|
Link组件
之前有谈到用户只有两种方法可以更新URL,一种是通过后退/前进按钮,另一种是点击一个超链接标签。我们在Route
中通过监听popstate
事件关注了点击后退/前进按钮进行重新渲染,现在通过构建Link
组件来关注一下超链接标签。
Link
的API看起来像这样,
1
| <Link to='/some-path' replace={false} />
|
这里的to
是一个字符串,表示要链接的位置;replace
是一个布尔值,如果是true,点击链接后会在history栈中替换当前的入口记录,而不会添加一个新的记录。
添加propType到我们的Link组件中,
1 2 3 4 5 6
| class Link extends Component { static propTypes = { to: PropTypes.string.isRequired, replace: PropTypes.bool, } }
|
现在我们知道在Link
组件的渲染方法中需要返回一个超链接标签,但是显示我们不能在每次切换路由的时候引起页面的全量刷新,因此我们要添加一个onClick
处理函数来劫持超链接的默认跳转。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| class Link extends Component { static propTypes = { to: PropTypes.string.isRequired, replace: PropTypes.bool, } handleClick = (event) => { const { replace, to } = this.props; event.preventDefault(); // 这里是路由 } render() { const { to, children } = this.props; return ( <a href={to} onClick={this.handleClick}> {children} </a> ) } }
|
现在缺少的就是真正的改变当前位置。React Router是使用History的push
和replace
方法,但是我们这里为了避免引入一个依赖,使用HTML5的pushState和replaceState。
我们在文章里忽略了History这个库是为了避免外部的依赖,但是对于真正的React Router是很关键的,用于处理在不同浏览器环境管理会话历史的差异。
pushState
和replaceState
同时都接收三个参数。第一个是一个关联新的历史入口的对象,我们不需要这个功能所以传一个空对象。第二个是标题,我们也不需要传一个null。第三个,我们实际要使用的,是一个相对的URL。
1 2 3 4 5 6 7
| const historyPush = (path) => { history.pushState({}, null, path); }
const historyReplace = (path) => { history.replaceState({}, null, paht); }
|
在Link
组件内部,我们要根据replace
属性来调用historyPush
或者historyReplace
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| class Link extends Component { static propTypes = { to: PropTypes.string.isRequired, replace: PropTypes.bool, } handleClick = (event) => { const { replace, to } = this.props; event.preventDefault(); replace ? historyReplace(to) : historyPush(to); } render() { const { to, children } = this.props; return ( <a href={to} onClick={this.handleClick}> {children} </a> ) } }
|
组合
现在只有一个最重要的内容要添加。如果使用当前的路由代码来配合一开始的场景,会发现一个大的问题。使用导航的时候,URL会更新,但是UI会一直保持不变。这是因为我们即使通过historyRepalce
或historyPush
函数改变位置,我们的<Route>
并不知道改变,并且也不知道要重新匹配和重新渲染。为了解决这个问题,我们要跟踪<Route>
是否已渲染,并且在任何路由改变时调用forceUpdate
。
React Router是通过结合你包裹在一个Router组件代码里相关的setState, context和history.listen来解决这个问题的。
为了保持我们的路由简单,我们通过维护一个他们的实例在一个数组中来跟踪哪些<Route>
被渲染,然后任何时候一个位置的改变发生时,我们就可以遍历数组并调用所有实例上的forceUpdate方法。
1 2 3 4
| let instances = [];
const register = (comp) => instances.push(comp); const unregister = (comp) => instances.splice(instances.indexOf(comp), 1);
|
这里我们创建了两个函数。register
是在任何时候一个<Router>
挂载时调用,当组件卸载时调用unregister
。然后,不管何时我们调用historyPush
或historyReplace
(每次用户点击<Link>时
),我们会遍历所有的实例并调用forceUpdate
。
让我们更新<Route>
组件先,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| class Route extends Component { statc propTypes: { path: PropTypes.string, exact: PropTypes.bool, component: PropTypes.func, render: PropTypes.func, } componentWillMount() { addEventListener("popstate", this.handlePop); register(this); } componentWillUnmount() { unregister(this); removeEventListener("popstate", this.handlePop); } }
|
现在,让我们更新historyPush
和historyReplace
1 2 3 4 5 6 7 8 9
| const historyPush = (path) => { history.pushState({}, null, path); instances.forEach(instance => instance.forceUpdate()); }
const historyReplace = (path) => { history.replaceState({}, null, path); instances.forEach(instance => instance.forceUpdate()); }
|
现在任何时候一个<Link>
被点击时位置会改变,每个<Route>
将会注意到并重新匹配和渲染。
我们完整的路由代码就如下所示,示例的场景app可以完美的工作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113
| import React, { PropTypes, Component } from 'react'
let instances = []
const register = (comp) => instances.push(comp) const unregister = (comp) => instances.splice(instances.indexOf(comp), 1)
const historyPush = (path) => { history.pushState({}, null, path) instances.forEach(instance => instance.forceUpdate()) }
const historyReplace = (path) => { history.replaceState({}, null, path) instances.forEach(instance => instance.forceUpdate()) }
const matchPath = (pathname, options) => { const { exact = false, path } = options
if (!path) { return { path: null, url: pathname, isExact: true } }
const match = new RegExp(`^${path}`).exec(pathname)
if (!match) return null
const url = match[0] const isExact = pathname === url
if (exact && !isExact) return null
return { path, url, isExact, } }
class Route extends Component { static propTypes: { path: PropTypes.string, exact: PropTypes.bool, component: PropTypes.func, render: PropTypes.func, }
componentWillMount() { addEventListener("popstate", this.handlePop) register(this) }
componentWillUnmount() { unregister(this) removeEventListener("popstate", this.handlePop) }
handlePop = () => { this.forceUpdate() }
render() { const { path, exact, component, render, } = this.props
const match = matchPath(location.pathname, { path, exact })
if (!match) return null
if (component) return React.createElement(component, { match })
if (render) return render({ match })
return null } }
class Link extends Component { static propTypes = { to: PropTypes.string.isRequired, replace: PropTypes.bool, } handleClick = (event) => { const { replace, to } = this.props
event.preventDefault() replace ? historyReplace(to) : historyPush(to) }
render() { const { to, children} = this.props
return ( <a href={to} onClick={this.handleClick}> {children} </a> ) } }
|
红利: React Router API中同样提供了<Redirect>
组件。使用前面写的代码,可以直接创建这样的组件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| class Redirect extends Component { static defaultProps = { push: false } static propTypes = { to: PropTypes.string.isRequired, push: PropTypes.bool.isRequired } componentDidMount() { const { to, push } = this.props; push ? historyPush(to) : historyReplace(to); } render() { return null; } }
|
注意这个组件实际上没有渲染任何UI,只是一个纯粹的路由跳转。
总结
希望这篇文章能帮助你创建一个关于在React Router中发生了什么的模型,同时能帮助你欣赏React Router的优雅和“只有组件”的API。我总是说,React会让你成为一个更好的JavaScript开发者,我也同样相信React Router可以让你成为一个更好的React开发者。因为一切都是组件,如果你了解React,那么你也同样了解React Router。
注意
- 示例代码是以react v15.4.2版本演示
- 示例代码中可以忽略类型校验
- 个人参考代码