实现一个简单版React Router v4理解其原理

对于React-Router的实现原理,参考自Build your own React Router v4这篇英文原文,另外,React-Router底层库history的源码也值得一读。

欢迎访问博客文章

接下来是关于React-Router v4的一个简单版实现,通过这个实现来理解路由的原理,这里可能不会完全按照英文原文翻译,会有些意译或者省略的地方。

在单页应用中,应用代码和路由代码是极其重要的两部分,两者是相辅相成的,你是否对这两者有一些困惑。

关于路由的一些疑问点:

  1. 路由通常是相对比较复杂的,这让很多库的作者,在如何找到合适的路由抽象变得更加复杂。
  2. 因为这些复杂的原因,路由库的使用者倾向于盲目的相信库本身的抽象,而不是去理解背后的原理。

本文会通过实现一个简易版的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。exactpathcomponent。这表示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是使用Historypushreplace方法,但是我们这里为了避免引入一个依赖,使用HTML5的pushStatereplaceState

我们在文章里忽略了History这个库是为了避免外部的依赖,但是对于真正的React Router是很关键的,用于处理在不同浏览器环境管理会话历史的差异。

pushStatereplaceState同时都接收三个参数。第一个是一个关联新的历史入口的对象,我们不需要这个功能所以传一个空对象。第二个是标题,我们也不需要传一个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会一直保持不变。这是因为我们即使通过historyRepalcehistoryPush函数改变位置,我们的<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。然后,不管何时我们调用historyPushhistoryReplace(每次用户点击<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);
}
}

现在,让我们更新historyPushhistoryReplace

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版本演示
  • 示例代码中可以忽略类型校验
  • 个人参考代码
作者

潘绳杰

发布于

2019-08-17

更新于

2025-01-19

许可协议

评论