作者:Tyler
編譯:胡子大哈
翻譯原文:http://huziketang.com/blog/posts/detail?postId=58d36df87413fc2e82408555
英文原文:Build your own React Router v4)
** 轉載請注明出處,保留原文鏈接以及作者信息**

我還記得我第一次學習開發客戶端應用路由時的感覺,那時候我還是一個涉足在“單頁面應用”的未出世的小伙子,那會兒,要是說它沒把我的腦子弄的跟屎似的,那我是在撒謊。一開始的時候,我的感覺是我的應用程序代碼和路由代碼是兩個獨立且不同的體系,就像是兩個同父異母的兄弟,互相不喜歡但是又不得不在一起。
經過了一些年的努力,我終于有幸能夠教其他開發者關于路由的一些問題了。我發現,好像很多人對于這個問題的思考方式都和我當時很類似。我覺得有幾個原因。首先,路由問題確實很復雜,對于那些路由庫的開發者而言,找到一個合適的路由抽象概念來解釋這個問題就更加復雜。第二,正是由于路由的復雜性,這些路由庫的使用者傾向于只使用庫就好了,而不去弄懂到底背后是什么原理。
本文中,我們會深入地來闡述這兩個問題。我們會通過創建一個簡單版本的 React Router v4 來解決第二個問題,而通過這個過程來闡釋第一個問題。也就是說通過我們自己構建 RRv4 來解釋 RRv4 是否是一個合適的路由抽象。
下面是將要用來測試我們所構建的 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>
)
如果你還不熟悉 React Router v4,就先了解幾個基本問題。Route
用來渲染 UI,當一個 URL 匹配上了你所指定的路由路徑,就進行渲染。Link
提供了一個可以瀏覽訪問你 app 的方法。換句話講,Link
組件允許你更新你的 URL,而 Route
組件根據你所提供的新 URL 來改變 UI。
本文并不會手把手的教你 RRV4 的基礎,所以如果上面的代碼你看起來很費勁的話,可以先來這里看一下官方文檔。把玩一下里面的例子,當你覺得順手了的時候,歡迎回來繼續閱讀。
如上段所說,路由給我們提供了兩個組件可以用于你的 app:Link
和 Route
。我喜歡 React Router v4 的原因是它的 API “只是組件”而已,可以理解成沒有引入其他概念。這就是說如果你對 React 很熟悉的話,那么你對組件以及怎么組合組件一定有自己的理解,而這對于你寫路由代碼依然適用。這就很方便了,因為已經熟悉了如何創造組件,那么創建你自己的 React Router 就只是做你已經熟悉的事情——創建組件。
現在就來一起創建我們的 Route
組件。在上面的例子中,可以注意到 <Route>
使用了三個屬性:exact
、path
和 component
。他們的屬性類型(propTypes)對于 Route
組件來是這樣的:
static propTypes = {
exact: PropTypes.bool,
path: PropTypes.string,
component: PropTypes.func,
}
這里有些小細節。首先,path
并不需要,因為如果路由中沒有給 path
那么將會自動渲染。第二,component
也不需要,是因為如果路徑匹配上了,有很多不同的方法來告訴 React Router 要渲染什么 UI。其中一個上面沒有提到的方法就是使用 render
來通知 React Router,具體代碼像這樣:
<Route path='/settings' render={({ match }) => {
return <Settings authed={isAuthed} match={match} />
}} />
render
允許你創建一個直接返回 UI 的內聯函數而不用創建額外的組件,所以我們也可以把它添加到 proTypes 中:
static propTypes = {
exact: PropTypes.bool,
path: PropTypes.string,
component: PropTypes.func,
render: PropTypes.func,
}
現在我們知道了 Route
接收的屬性,我們來了解一下它們的具體功能。還記得上面說的:“當 URL 匹配上了你所指定的路由 path
以后,Route 渲染其對應的 UI”。基于這樣的定義,可以知道,<Route>
需要一些功能性函數,來判斷當前的 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, // 全局 DOM 變量
{ path, exact }
)
if (!match) {
// 什么都不做,因為沒有匹配上 path 屬性
return null
}
if (component) {
// 如果當前地址匹配上了 path 屬性
// 以 component 創建新元素并且通過 match 傳遞
return React.createElement(component, { match })
}
if (render) {
// 如果匹配上了且 component 沒有定義
// 則調用 render 并以 match 作為參數
return render({ match })
}
return null
}
}
上面的代碼即實現了:如果匹配上了 path
屬性,就返回 UI,否則什么也不做。
我們再來談一下路由的問題。在客戶端應用這邊,一般來講只有兩種方式更新 URL。一種是用戶點擊 a 標簽,一種是點擊后退/前進按鈕。基本上我們的路由只要關心 URL 的變化并且返回相應的 UI 即可。假設我們知道更新 URL 的方式只有上面兩種,那么就可以針對這兩種情況做特殊處理了。稍后在構建 <Link>
組件的時候再詳細介紹 a 標簽的情況,這里先討論后退/前進按鈕。 React Router 使用了 History工程里的 .listen
方法來監聽當前 URL 的變化,為了避免再引入其他的庫,我們使用 HTML5 的 popstate
事件來實現這一功能。當用戶點擊了后退/前進按鈕,popstate
就被觸發,我們需要的就是這個功能。因為 Route
渲染 UI 是根據當前 URL來做的,因此給 Route
配上監聽能力也是合理的,在 popstate
觸發的地方重新渲染 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
來強制做重新渲染的判斷。
這樣就實現了所有的 <Route>
都會監聽,根據后退/前進按鈕來“重匹配”、“重判斷”和“重渲染”。
到現在,我們一直還沒有實現的是 matchPath
函數。這個函數在我們的 router 中是特別關鍵的,因為它是判斷當前 URL 是否匹配上了 <Route>
組件的關鍵點。matchPath
值得注意的一點是一定要把 <Route>
的 exact
考慮清楚。如果你對 exact
還不了解,看下下面這句話,給出了規范文檔中的解釋:
只有當所給路徑精確匹配上
location.pathname
時才返回 true。

接下來就來具體實現 matchPath
函數。如果你回頭看一下上面 Route
組件的代碼,你可以看到 matchPath
函數是這樣的:
const match = matchPath(location.pathname, { path, exact })
這里的 match 要么是對象,要么是 null,這得取決于是否匹配上 path。根據這個聲明,我們來寫 matchPath
代碼:
const matchPatch = (pathname, options) => {
const { exact = false, path } = options
}
這里使用 ES6 語法。上面的意思是,創建一個叫做 exact 的變量,使其等于 options.exact,并且如果非 null 的話則設置其為 false。同樣創建一個叫做 path 的變量,使其等于 options.path。
接下來就添加判斷是否匹配。React Router 使用 pathToRegex 來實現,只需要寫簡單的正則匹配就可以了。
const matchPatch = (pathname, options) => {
const { exact = false, path } = options
if (!path) {
return {
path: null,
url: pathname,
isExact: true,
}
}
const match = new RegExp(`^${path}`).exec(pathname)
}
如果匹配上了,那么返回一個包含有所有匹配串的數組,否則返回 null。
下面是我們示例 app 的路由 '/topics/components' 的一些匹配項。

注意:每個
<Route>
都在自己的渲染方法里調用matchPath
,所以要為每個<Route>
配一個match
。
現在我們要做的是添加判斷是否有匹配的代碼:
const matchPatch = (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,
}
}
提示一下之前有講過的,對于用戶來講,有兩種方式更新 URL:通過后退/前進按鈕和通過點擊 a 標簽。對于后退/前進點擊來說,使用 popstate
事件給 Route
添加監聽就可以,現在來看一下如何通過 Link
解決 a 標簽問題。
Link
的 API 如下:
<Link to='/some-path' replace={false} />
這里 to
是一個 string 類型,指的是要鏈接到的地址。replace
是一個布爾值,如果是 true,那么點擊鏈接將替換當前的實體到歷史堆棧,而不是添加一個新的進去。
添加這些 propTypes 到 Link
組件就得到:
class Link extends Component {
static propTypes = {
to: PropTypes.string.isRequired,
replace: PropTypes.bool,
}
}
我們知道在 Link
組件中的渲染函數需要返回一個 a 標簽,但是我們不想每次變路由都進行一次全頁面刷新,所以通過增加一個 onClick
處理程序來劫持 a 標簽。
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>
)
}
}
ok,代碼寫到現在,就差更改當前 URL 了。在 React Router 是使用 History 工程里面的 push
和 replace
方法。為了避免增加新依賴,這里我使用 HTML5 的 pushState
和 replaceState
。
本文中我們為了防止引入額外的依賴,一直也沒采用 History 庫。但是它對真實的 React Router 卻是至關重要的,因為它對不同的 session 管理和不同的瀏覽器環境進行了規范化處理。
pushState
和 replaceState
都接收三個參數。第一個參數是一個與歷史實體相關聯的對象,我們不需要,所以設置成一個空對象。第二個參數是標題,我們也不需要,所以也設置成空。第三個是我們需要使用的,指的是:相關 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 通過設置狀態、上下文和歷史信息的組合來解決這個問題。監聽路由組件的內部代碼。
為了使路由簡單,我們通過把所有路由對象放到一個數組里的方式來實現 <Route>
跟蹤。每當發生地址改變的時候,就遍歷一遍數組,調用相應對象的 forceUpdate 函數。
let instances = []
const register = (comp) => instances.push(comp)
const unregister = (comp) => instances.splice(instances.indexOf(comp), 1)
注意這里創建了兩個函數。當 <Route>
“裝配”上,就調用 register
;當“解裝配”,就調用 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>
都會接收到消息,并且進行重匹配和重渲染。
這就完成了所有的路由代碼了,并且實例 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 會使你成為一個好的 JavaScript 程序員,而 React Router 會使你成為一個好的 React 程序員。因為一切皆為組件,你懂 React,你就懂 React Router。
我最近正在寫一本《React.js 小書》,對 React.js 感興趣的童鞋,歡迎指點。