ReactRouter-V4 構建之道與源碼分析
多年之后當我回想起初學客戶端路由的那個下午,滿腦子里充斥著的只是對于單頁應用的驚嘆與漿糊。彼時我還是將應用代碼與路由代碼當做兩個獨立的部分進行處理,就好像同父異母的兄弟盡管不喜歡對方但是不得不在一起。幸而這些年里我能夠和其他優秀的開發者進行交流,了解他們對于客戶端路由的看法。盡管他們中的大部分與我“英雄所見略同”,但是我還是找到了合適的平衡路由的抽象程度與復雜程度的方法。本文即是我在構建 React Router V4 過程中的考慮以及所謂路由即組件思想的落地實踐。首先我們來看下我們在構建路由過程中的測試代碼,你可以用它來測試你的自定義路由:
- 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 組件相當熟悉的開發者絕不會陌生于如何將路由組件嵌入到正常的應用中。
Route
我們首先來考量下如何構建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 函數定義如下:
- 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 才會被認為匹配成功:
pathlocation.pathnameexactmatches?/one/one/twotrueno/one/one/twofalseyes
讓我們深度了解下 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,
- }
- }
Link
上文我們已經提及通過監聽 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 則會是你不小的助力。
【本文是51CTO專欄作者“張梓雄 ”的原創文章,如需轉載請通過51CTO與作者聯系】