ReactRouter-V4 構建之道與源碼分析 翻譯自build-your-own-react-router-v4,從屬於筆者的 Web 開發基礎與工程實踐 系列。react
多年以後當我回想起初學客戶端路由的那個下午,滿腦子裏充斥着的只是對於單頁應用的驚歎與漿糊。彼時我仍是將應用代碼與路由代碼當作兩個獨立的部分進行處理,就好像同父異母的兄弟儘管不喜歡對方可是不得不在一塊兒。幸而這些年裏我可以和其餘優秀的開發者進行交流,瞭解他們對於客戶端路由的見解。儘管他們中的大部分與我「英雄所見略同」,可是我仍是找到了合適的平衡路由的抽象程度與複雜程度的方法。本文便是我在構建 React Router V4 過程當中的考慮以及所謂路由即組件思想的落地實踐。首先咱們來看下咱們在構建路由過程當中的測試代碼,你能夠用它來測試你的自定義路由:git
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
會在當前 URL 與 path
屬性值相符的時候渲染相關組件,而 Link
提供了聲明式的,易使用的方式來在應用內進行跳轉。換言之,Link
組件容許你更新當前 URL,而 Route
組件則是根據 URL 渲染組件。本文並不專一於講解 RRV4 的基礎概念,你能夠前往官方文檔瞭解更多知識;本文是但願介紹我在構建 React Router V4 過程當中的思惟考慮過程,值得一提的是,我很欣賞 React Router V4 中的 Just Components
概念,這一點就不一樣於 React Router 以前的版本中將路由與組件隔離來看,而容許了路由組件像普通組件同樣自由組合。相信對於 React 組件至關熟悉的開發者毫不會陌生於如何將路由組件嵌入到正常的應用中。github
咱們首先來考量下如何構建Route
組件,包括其暴露的 API,即 Props。在咱們上面的示例中,咱們會發現Route
組件包含三個 Props:exact
、path
以及 component
。這也就意味着咱們的propTypes
聲明以下:正則表達式
static propTypes = { exact: PropTypes.bool, path: PropTypes.string, component: PropTypes.func, }
這裏有一些微妙的細節須要考慮,首先對於path
並無設置爲必須參數,這是由於咱們認爲對於沒有指定關聯路徑的Route
組件應該自動默認渲染。而component
參數也沒有被設置爲必須是由於咱們提供了其餘的方式進行渲染,譬如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
須要接收的組件參數以後,咱們須要來考量其實際功能;Route
核心的功能在於可以當 URL 與 path
屬性相一致時執行渲染操做。基於這個論斷,咱們首先須要實現判斷是否匹配的功能,若是判斷爲匹配則執行渲染不然返回空值。咱們在這裏將該函數命名爲 matchPatch
,那麼此時整個 Route
組件的 render
函數定義以下:react-router
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
看起來已經相對明確了,當路徑相匹配的時候纔會執行界面渲染,不然返回爲空。如今咱們再回過頭來考慮客戶端路由中常見的跳轉策略,通常來講用戶只有兩種方式會更新當前 URL。一種是用戶點擊了某個錨標籤或者直接操做 history
對象的 replace/push
方法;另外一種是用戶點擊前進/後退按鈕。不管哪種方式都要求咱們的路由系統可以實時監聽 URL 的變化,而且在 URL 發生變化時及時地作出響應,渲染出正確的頁面。咱們首先來考慮下如何處理用戶點擊前進/後退按鈕。React Router 使用 History 的 .listen
方法來監聽當前 URL 的變化,其本質上仍是直接監聽 HTML5 的 popstate
事件。popstate
事件會在用戶點擊某個前進/後退按鈕的時候觸發;而在每次重渲染的時候,每一個 Route
組件都會重現檢測當前 URL 是否匹配其預設的路徑參數。函數
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
監聽器;當監聽到 popstate
事件被觸發時,咱們會調用 forceUpdate
函數來強制進行重渲染。總結而言,不管咱們在系統中設置了多少的路由組件,它們都會獨立地監聽 popstate
事件而且相應地執行重渲染操做。接下來咱們繼續討論 matchPath
這個 Route
組件中相當重要的函數,它負責決定當前路由組件的 path
參數是否與當前 URL 相一致。這裏還必須提下咱們設置的另外一個 Route
的參數 exact
,其用於指明路徑匹配策略;當 exact
值被設置爲 true
時,僅當路徑徹底匹配於 location.pathname
纔會被認爲匹配成功:源碼分析
path | location.pathname | exact | matches? |
---|---|---|---|
/one |
/one/two |
true |
no |
/one |
/one/two |
false |
yes |
讓咱們深度瞭解下 matchPath
函數的工做原理,該函數的簽名以下:測試
const match = matchPath(location.pathname, { path, exact })
其中函數的返回值 match
應該根據路徑是否匹配的狀況返回爲空或者一個對象。基於這些推導咱們能夠得出 matchPatch
的原型:
const matchPath = (pathname, options) => { const { exact = false, path } = options }
這裏咱們使用 ES6 的解構賦值,當某個屬性未定義時咱們使用預約義地默認值,即 false
。我在上文說起的 path
非必要參數的具體支撐實現就在這裏,咱們首先進行空檢測,當發現 path
爲未定義或者爲空時則直接返回匹配成功:
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
函數,會在包含指定的文本時返回一個數組,不然返回空值;下表便是當咱們的路由設置爲 /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>
實例建立一個 match
對象。在獲取到 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) { // 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, } }
上文咱們已經說起經過監聽 popstate
狀態來響應用戶點擊前進/後退事件,如今咱們來考慮經過構建 Link
組件來處理用戶經過點擊錨標籤進行跳轉的事件。Link
組件的 API 應該以下所示:
<Link to='/some-path' replace={false} />
其中的 to
是一個指向跳轉目標地址的字符串,而 replace
則是布爾變量來指定當用戶點擊跳轉時是替換 history 棧中的記錄仍是插入新的記錄。基於上述的 API 設計,咱們能夠獲得以下的組件聲明:
class Link extends Component { static propTypes = { to: PropTypes.string.isRequired, replace: PropTypes.bool, } }
如今咱們已經知道 Link
組件的渲染函數中須要返回一個錨標籤,不過咱們的前提是要避免每次用戶切換路由的時候都進行整頁的刷新,所以咱們須要爲每一個錨標籤添加一個點擊事件的處理器:
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> ) } }
這裏實際的跳轉操做咱們仍是執行 History 中的抽象的 push
與 replace
函數,在使用 browserHistory
的狀況下咱們本質上仍是使用 HTML5 中的 pushState
與 replaceState
函數。pushState
與 replaceState
函數都要求輸入三個參數,首先是一個與最新的歷史記錄相關的對象,在 React Router 中咱們並不須要該對象,所以直接傳入一個空對象;第二個參數是標題參數,咱們一樣不須要改變該值,所以直接傳入空便可;最後第三個參數則是咱們須要的,用於指明新的相對地址的字符串:
const historyPush = (path) => { history.pushState({}, null, path) } const historyReplace = (path) => { history.replaceState({}, null, path) }
然後在 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> ) } }
如今咱們須要考慮如何保證用戶點擊了 Link
組件以後觸發所有路由組件的檢測與重渲染。在咱們上面實現的 Link
組件中,用戶執行跳轉以後瀏覽器的顯示地址會發生變化,可是頁面尚不能從新渲染;咱們聲明的 Route
組件並不能收到相應的通知。爲了解決這個問題,咱們須要追蹤那些顯如今界面上實際被渲染的 Route
組件而且當路由變化時調用它們的 forceUpdate
方法。React Router 主要經過有機組合 setState
、context
以及 history.listen
方法來實現該功能。每一個 Route
組件被掛載時咱們會將其加入到某個數組中,而後當位置變化時,咱們能夠遍歷該數組而後對每一個實例調用 forceUpdate
方法:
let instances = [] const register = (comp) => instances.push(comp) const unregister = (comp) => instances.splice(instances.indexOf(comp), 1)
這裏咱們建立了兩個函數,當 Route
掛載時調用 register
函數,而卸載時調用 unregister
函數。而後不管什麼時候調用 historyPush
或者 historyReplace
函數時都會遍歷實例數組中的對象的渲染方法,此時咱們的 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 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 } }
注意這個組件並無真實地進行界面渲染,而是僅僅進行了簡單的跳轉操做。到這裏本文也就告一段落了,但願可以幫助你去了解 React Router V4 的設計思想以及 Just Component 的接口理念。我一直說 React 會讓你成爲更加優秀地開發者,而 React Router 則會是你不小的助力。