实现一个简单版React Router v4理解其原理
实现一个简单版React Router v4理解其原理
对于React-Router的实现原理,参考自Build your own React Router v4这篇英文原文,另外,React-Router底层库history的源码也值得一读。
欢迎访问博客文章
接下来是关于React-Router v4的一个简单版实现,通过这个实现来理解路由的原理,这里可能不会完全按照英文原文翻译,会有些意译或者省略的地方。
在单页应用中,应用代码和路由代码是极其重要的两部分,两者是相辅相成的,你是否对这两者有一些困惑。
关于路由的一些疑问点:
- 路由通常是相对比较复杂的,这让很多库的作者,在如何找到合适的路由抽象变得更加复杂。
- 因为这些复杂的原因,路由库的使用者倾向于盲目的相信库本身的抽象,而不是去理解背后的原理。
本文会通过实现一个简易版的React Router v4版本来点亮前一个问题的灯塔,并且让你了解背后的原理后去判断这样的抽象是否合适。
场景
下面是用于测试React Router实现的业务代码,你也可以访问线上的最终版代码:
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
现在看起来是这样的,
static propTypes = {
exact: PropTypes.bool,
path: PropTypes.string,
component: PropTypes.func,
}
这里面有些微妙之处。首先,path
没有申明必填是因为如果一个Route
没有提供path
,那么它会被自动渲染。其次,component
也没有申明必填是因为有多个不同的方法告诉React Router在路径匹配时你要渲染的UI,一个没有在上述例子中的方法是通过render
属性。例如,
<Route path='/settings' render={({ match }) => {
return <Settings authed={isAuthed} match={match} />
}} />
render
允许我们方便的提供一个内联函数来返回一些UI而不用创建一个单独的组件。因此我们也要添加到propTypes中,
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
会在后续完成。
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,否则不做任何事。让我们看一下代码:
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
函数的声明看起来是这样的,
const match = matchPath(location.pathname, { path, exact });
这里的match
是一个对象或者null,基于是否有匹配结果。根据这个函数签名,我们可以构建matchPath
的第一部分,
const matchPath = (pathname, options) => {
const { exact = false, path } = options;
}
这里我们使用一些ES6的语法。创建一个exact变量并且等于options.exact,如果这个值没有定义,则默认设置为false。另外,创建一个path变量等于options.path。
之前有提到“path
不要求必填的原因是,如果一个Route
没有path,那么它会被自动渲染”。这是间接的决定matchPath
函数是否渲染,让现在我们添加这个功能。
const matchPath = (pathname, options) => {
const { exact = false, path } = options;
if (!path) {
return {
path: null,
url: pathname,
isExact: true,
}
}
}
现在到了匹配的部分。React Router使用pathToRegex,我们这里简化一下,就使用一个简单的正则表达式。
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
是什么了,我们需要做的就是找出是否有匹配结果。
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看起来像这样,
<Link to='/some-path' replace={false} />
这里的to
是一个字符串,表示要链接的位置;replace
是一个布尔值,如果是true,点击链接后会在history栈中替换当前的入口记录,而不会添加一个新的记录。
添加propType到我们的Link组件中,
class Link extends Component {
static propTypes = {
to: PropTypes.string.isRequired,
replace: PropTypes.bool,
}
}
现在我们知道在Link
组件的渲染方法中需要返回一个超链接标签,但是显示我们不能在每次切换路由的时候引起页面的全量刷新,因此我们要添加一个onClick
处理函数来劫持超链接的默认跳转。
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。
const historyPush = (path) => {
history.pushState({}, null, path);
}
const historyReplace = (path) => {
history.replaceState({}, null, paht);
}
在Link
组件内部,我们要根据replace
属性来调用historyPush
或者historyReplace
。
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方法。
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>
组件先,
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
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可以完美的工作。
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>
组件。使用前面写的代码,可以直接创建这样的组件。
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版本演示
- 示例代码中可以忽略类型校验
- 个人参考代码