对于React-Router的实现原理,参考自Build your own React Router v4这篇英文原文,另外,React-Router底层库history的源码也值得一读。
欢迎访问博客文章
接下来是关于React-Router v4的一个简单版实现,通过这个实现来理解路由的原理,这里可能不会完全按照英文原文翻译,会有些意译或者省略的地方。
在单页应用中,应用代码和路由代码是极其重要的两部分,两者是相辅相成的,你是否对这两者有一些困惑。
关于路由的一些疑问点:
- 路由通常是相对比较复杂的,这让很多库的作者,在如何找到合适的路由抽象变得更加复杂。
- 因为这些复杂的原因,路由库的使用者倾向于盲目的相信库本身的抽象,而不是去理解背后的原理。
本文会通过实现一个简易版的React Router v4版本来点亮前一个问题的灯塔,并且让你了解背后的原理后去判断这样的抽象是否合适。
场景
下面是用于测试React Router实现的业务代码,你也可以访问线上的最终版代码:
1 | const Home = () => ( |
关于这段代码,简单解释一下。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 | static propTypes = { |
这里面有些微妙之处。首先,path
没有申明必填是因为如果一个Route
没有提供path
,那么它会被自动渲染。其次,component
也没有申明必填是因为有多个不同的方法告诉React Router在路径匹配时你要渲染的UI,一个没有在上述例子中的方法是通过render
属性。例如,
1 | <Route path='/settings' render={({ match }) => { |
render
允许我们方便的提供一个内联函数来返回一些UI而不用创建一个单独的组件。因此我们也要添加到propTypes中,
1 | static propTypes = { |
现在我们知道Route
接收哪些props,让我们再谈谈它实际上做了什么。Route
根据在path
属性中指定的位置与URL进行匹配并渲染对应的UI。基于此定义,我们知道<Route>
需要一些功能来检查当前URL是否匹配组件的path
属性。如果匹配,则渲染一些UI。如果不匹配,就仅仅返回null不做任何渲染。
让我们看一下代码的实现,关于匹配的函数matchPath
会在后续完成。
1 | class Route extends Component { |
现在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 | class Route extends Component { |
你会注意到我们做的就是当组件挂载时添加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 | const matchPath = (pathname, options) => { |
这里我们使用一些ES6的语法。创建一个exact变量并且等于options.exact,如果这个值没有定义,则默认设置为false。另外,创建一个path变量等于options.path。
之前有提到“path
不要求必填的原因是,如果一个Route
没有path,那么它会被自动渲染”。这是间接的决定matchPath
函数是否渲染,让现在我们添加这个功能。
1 | const matchPath = (pathname, options) => { |
现在到了匹配的部分。React Router使用pathToRegex,我们这里简化一下,就使用一个简单的正则表达式。
1 | const matchPath = (pathname, options) => { |
如果你不熟悉.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 | const matchPath = (pathname, options) => { |
Link组件
之前有谈到用户只有两种方法可以更新URL,一种是通过后退/前进按钮,另一种是点击一个超链接标签。我们在Route
中通过监听popstate
事件关注了点击后退/前进按钮进行重新渲染,现在通过构建Link
组件来关注一下超链接标签。
Link
的API看起来像这样,
1 | <Link to='/some-path' replace={false} /> |
这里的to
是一个字符串,表示要链接的位置;replace
是一个布尔值,如果是true,点击链接后会在history栈中替换当前的入口记录,而不会添加一个新的记录。
添加propType到我们的Link组件中,
1 | class Link extends Component { |
现在我们知道在Link
组件的渲染方法中需要返回一个超链接标签,但是显示我们不能在每次切换路由的时候引起页面的全量刷新,因此我们要添加一个onClick
处理函数来劫持超链接的默认跳转。
1 | class Link extends Component { |
现在缺少的就是真正的改变当前位置。React Router是使用History的push
和replace
方法,但是我们这里为了避免引入一个依赖,使用HTML5的pushState_method)和replaceState_method)。
我们在文章里忽略了History这个库是为了避免外部的依赖,但是对于真正的React Router是很关键的,用于处理在不同浏览器环境管理会话历史的差异。
pushState
和replaceState
同时都接收三个参数。第一个是一个关联新的历史入口的对象,我们不需要这个功能所以传一个空对象。第二个是标题,我们也不需要传一个null。第三个,我们实际要使用的,是一个相对的URL。
1 | const historyPush = (path) => { |
在Link
组件内部,我们要根据replace
属性来调用historyPush
或者historyReplace
。
1 | class Link extends Component { |
组合
现在只有一个最重要的内容要添加。如果使用当前的路由代码来配合一开始的场景,会发现一个大的问题。使用导航的时候,URL会更新,但是UI会一直保持不变。这是因为我们即使通过historyRepalce
或historyPush
函数改变位置,我们的<Route>
并不知道改变,并且也不知道要重新匹配和重新渲染。为了解决这个问题,我们要跟踪<Route>
是否已渲染,并且在任何路由改变时调用forceUpdate
。
React Router是通过结合你包裹在一个Router组件代码里相关的setState, context和history.listen来解决这个问题的。
为了保持我们的路由简单,我们通过维护一个他们的实例在一个数组中来跟踪哪些<Route>
被渲染,然后任何时候一个位置的改变发生时,我们就可以遍历数组并调用所有实例上的forceUpdate方法。
1 | let instances = []; |
这里我们创建了两个函数。register
是在任何时候一个<Router>
挂载时调用,当组件卸载时调用unregister
。然后,不管何时我们调用historyPush
或historyReplace
(每次用户点击<Link>时
),我们会遍历所有的实例并调用forceUpdate
。
让我们更新<Route>
组件先,
1 | class Route extends Component { |
现在,让我们更新historyPush
和historyReplace
1 | const historyPush = (path) => { |
现在任何时候一个<Link>
被点击时位置会改变,每个<Route>
将会注意到并重新匹配和渲染。
我们完整的路由代码就如下所示,示例的场景app可以完美的工作。
1 | import React, { PropTypes, Component } from 'react' |
红利: React Router API中同样提供了<Redirect>
组件。使用前面写的代码,可以直接创建这样的组件。
1 | class Redirect extends Component { |
注意这个组件实际上没有渲染任何UI,只是一个纯粹的路由跳转。
总结
希望这篇文章能帮助你创建一个关于在React Router中发生了什么的模型,同时能帮助你欣赏React Router的优雅和“只有组件”的API。我总是说,React会让你成为一个更好的JavaScript开发者,我也同样相信React Router可以让你成为一个更好的React开发者。因为一切都是组件,如果你了解React,那么你也同样了解React Router。
注意
- 示例代码是以react v15.4.2版本演示
- 示例代码中可以忽略类型校验
- 个人参考代码