我还记得我最初开始学习前端路由时候的感觉。那时我还年轻不懂事,刚刚开始摸索SPA
。从一开始我就把程序代码和路由代码分开对待,我感觉这是两个不同的东西,它们就像同父异母的亲兄弟,彼此不喜欢但是不得不在一起生活。
在过去的几年里,我有幸能够将路由的思想传授给其他开发人员。不幸的是,事实证明,我们大多数人的大脑似乎与我的大脑有着相似的思考方式。我认为这有几个原因。首先,路由通常非常复杂。对于这些库的作者来说,这使得在路由中找到正确的抽象变得更加复杂。其次,由于这种复杂性,路由库的使用者往往盲目地信任抽象,而不真正了解底层的情况,在本教程中,我们将深入解决这两个问题。首先,通过重新创建我们自己的React Router v4
的简化版本,我们会对前者有所了解,也就是说,RRv4
是否是一个合理的抽象。
这里是我们的应用程序代码,当我们实现了我们的路由,我们可以用这些代码来做测试。完整的demo
可以参考这里
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>
)
复制代码
如果你对React Router V4
不熟悉,这里做一个基本的介绍,当URL与您在Routes的path中指定的位置匹配时,Routes渲染相应的UI。Links提供了一种声明性的、可访问的方式来导航应用程序。换句话说,Link组件允许您更新URL, Route组件基于这个新URL更改UI。本教程的重点实际上并不是教授RRV4的基础知识,因此如果上面的代码还不是很熟悉,请看官方文档。
首先要注意的是,我们已经将路由器提供给我们的两个组件(Link和Route)引入到我们的应用程序中。我最喜欢React Router v4的一点是,API只是组件。这意味着,如果您已经熟悉React,那么您对组件以及如何组合组件的直觉将继续适用于您的路由代码。对于我们这里的用例来说,更方便的是,因为我们已经熟悉了如何创建组件,创建我们自己的React Router只需要做我们已经做过的事情。
我们将从创建Route组件开始。在深入研究代码之前,让我们先来检查一下这个API(它所需要的工具非常方便)。
在上面的示例中,您会注意到可以包含三个props。exact,path和component。这意味着Route组件的propTypes目前是这样的,
static propTypes = {
exact: PropTypes.bool,
path: PropTypes.string,
component: PropTypes.func,
}
复制代码
这里有一些微妙之处。首先,不需要path的原因是,如果没有给Route指定路径,它将自动渲染。其次,组件没有标记为required的原因也在于,如果路径匹配,实际上有几种不同的方法告诉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了,让我们来再次讨论它实际的功能。当URL与您在Route 的path属性中指定的位置匹配时,Route渲染相应的UI。根据这个定义,我们知道将需要一些功能来检查当前URL是否与组件的 path属性相匹配。如果是,我们将渲染相应的UI。如果没有,我们将返回null。
让我们看看这在代码中是什么样子的,我们会在后面来实现matchPath函数。
class Route extends Component {
static propTypes = {
exact: PropTypes.bool,
path: PropTypes.string,
component: PropTypes.func,
render: PropTypes.func,
}
render () {
const {
path,
exact,
component,
render,
} = this.props
const match = matchPath(
location.pathname, // global DOM variable
{ path, exact }
)
if (!match) {
// Do nothing because the current
// location doesn't match the path prop.
return null
}
if (component) {
// The component prop takes precedent over the
// render method. If the current location matches
// the path prop, create a new element passing in
// match as the prop.
return React.createElement(component, { match })
}
if (render) {
// If there's a match but component
// was undefined, invoke the render
// prop passing in match as an argument.
return render({ match })
}
return null
}
}
复制代码
现在,Route 看起来很稳定了。如果匹配了传进来的path,我们就渲染组件否则返回null。
让我们退一步来讨论一下路由。在客户端应用程序中,用户只有两种方式更新URL。第一种方法是单击锚标签,第二种方法是单击后退/前进按钮。我们的路由器需要知道当前URL并基于它呈现UI。这也意味着我们的路由需要知道什么时候URL发生了变化,这样它就可以根据这个新的URL来决定显示哪个新的UI。如果我们知道更新URL的唯一方法是通过锚标记或前进/后退按钮,那么我们可以开始计划并对这些更改作出响应。稍后,当我们构建组件时,我们将讨论锚标记,但是现在,我想重点关注后退/前进按钮。React Router使用History .listen方法来监听当前URL的变化,但为了避免引入其他库,我们将使用HTML5的popstate事件。popstate正是我们所需要的,它将在用户单击前进或后退按钮时触发。因为基于当前URL呈现UI的是路由,所以在popstate事件发生时,让路由能够侦听并重新呈现也是有意义的。通过重新渲染,每个路由将重新检查它们是否与新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,它将启动重新渲染。
现在,无论我们渲染多少个,它们都会基于forward/back按钮侦听、重新匹配和重新渲染。
在这之前,我们一直使用matchPath函数。这个函数对于我们的路由非常关键,因为它将决定当前URL是否与我们上面讨论的组件的路径匹配。matchPath的一个细微差别是,我们需要确保我们考虑到的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不是必须的原因是,如果没有给定路径,它将自动渲染”。因为它间接地就是我们的matchPath函数,它决定是否渲染UI(通过是否存在匹配),现在让我们添加这个功能。
const matchPath = (pathname, options) => {
const { exact = false, path } = options
if (!path) {
return {
path: null,
url: pathname,
isExact: true,
}
}
}
复制代码
接下来是匹配部分。React Router 使用pathToRegexp来匹配路径,为了简单我们这里就用简单正则表达式。
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。 我们来看一个例子,当我们路由到/topics/components
时匹配到的路径。
如果你不熟悉.exec
,如果它找到匹配它会返回一个包含匹配文本的数组,否则它返回null。
下面是我们的示例应用程序路由到/topics/components
时的每一次匹配
path | location.pathname | return value |
---|---|---|
/ | /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'] |
注意,我们为应用中的每个<Route>
都得到了匹配。这是因为,每个<Route>
在它的渲染方法中调用matchPath
。
现在我们知道了.exec
返回的匹配项是什么,我们现在需要做的就是确定是否存在匹配项。
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) {
// There wasn't a match.
return null
}
const url = match[0]
const isExact = pathname === url
if (exact && !isExact) {
// There was a match, but it wasn't
// an exact match as specified by
// the exact prop.
return null
}
return {
path,
url,
isExact,
}
}
复制代码
前面我提到,如果您是用户,那么只有两种方法可以更新URL
,通过后退/前进按钮,或者单击锚标签。我们已经处理了通过路由中的popstate
事件侦听器对后退/前进单击进行重新渲染,现在让我们通过构建<Link>
组件来处理锚标签。
Link
的API
是这样的,
<Link to='/some-path' replace={false} />
复制代码
to
是一个字符串,是要链接到的位置,而replace
是一个布尔值,当该值为true
时,单击该链接将替换历史堆栈中的当前条目,而不是添加一个新条目。
将这些propTypes
添加到链接组件中,我们得到,
class Link extends Component {
static propTypes = {
to: PropTypes.string.isRequired,
replace: PropTypes.bool,
}
}
复制代码
现在我们知道Link
组件中的render
方法需要返回一个锚标签,但是我们显然不希望每次切换路由时都导致整个页面刷新,因此我们将通过向锚标签添加onClick
处理程序来劫持锚标签
class Link extends Component {
static propTypes = {
to: PropTypes.string.isRequired,
replace: PropTypes.bool,
}
handleClick = (event) => {
const { replace, to } = this.props
event.preventDefault()
// route here.
}
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
都接受三个参数。第一个是与新的历史记录条目相关联的对象——我们不需要这个功能,所以我们只传递一个空对象。第二个是title
,我们也不需要它,所以我们传入null
。第三个,也是我们将要用到的,是一个相对URL
。
const historyPush = (path) => {
history.pushState({}, null, path)
}
const historyReplace = (path) => {
history.replaceState({}, null, path)
}
复制代码
现在在我们的Link
组件中,我们将调用historyPush
或historyReplace
取决于replace
属性,
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
将保持完全相同。这是因为即使我们使用historyReplace
或historyPush
函数更改位置,我们的<Route>
并不知道该更改,也不知道它们应该重新渲染和匹配。为了解决这个问题,我们需要跟踪哪些<Route>
已经呈现,并在路由发生变化时调用forceUpdate
。
React Router
通过使用setState
、context
和history
的组合来解决这个问题。监听包装代码的路由器组件内部。
为了保持路由器的简单性,我们将通过将<Route>
的实例保存到一个数组中,来跟踪哪些<Route>
已经呈现,然后每当发生位置更改时,我们可以遍历该数组并对所有实例调用forceUpdate
。
let instances = []
const register = (comp) => instances.push(comp)
const unregister = (comp) => instances.splice(instances.indexOf(comp), 1)
复制代码
注意,我们创建了两个函数。每当挂载<Route>
时,我们将调用register
;每当卸载<Route>
时,我们将调用unregister
。然后,无论何时调用historyPush
或historyReplace
(每当用户单击<Link>时
,我们都会调用它),我们都可以遍历这些实例并forceUpdate
。
让我们首先更新我们的<Route>
组件,
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)
}
...
}
复制代码
现在,让我们更新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>
都将意识到这一点并重新匹配和渲染。
现在,我们的完整路由器代码如下所示,上面的示例应用程序可以完美地使用它。
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
还带了一个额外的<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
的优雅和“Just Components”API
。我总是说React
会让你成为一个更好的JavaScript
开发者。我现在也相信React Router
会让你成为一个更好的React
开发者。因为一切都是组件,如果你知道React
,你就知道React Router
。
原文地址: Build your own React Router v4
(完)