在實現 React
單頁面應用過程中,必須掌握的一個生態就是 React-Router
路由庫,本文將會從零開始實現React-Router
的關鍵部分。
React-Router v4 是一次顛覆性的更新,完全不兼容以前版本的寫法,相比於之前更容易讓人接受的配置式路由寫法,v4 由一個個路由組件(
Link
、Route
、Redirect
...)實現,我覺得這才是真正貼合React
本身的組件化思想,如果你已經會React
了,那麼React-Router
只是多學習幾個組件而已,所以我們實現的關鍵部分主要也是針對 v4 。
起步#
首先我們通過 create-react-app
來快速啟動一個項目,並在 index.js
中敲入官方的 demo,我們先看最終會渲染的 App
組件:
const App = () => (
<div>
<ul>
<li><Link to="/">首頁</Link></li>
<li><Link to="/about">關於</Link></li>
<li><Link to="/topics">主題</Link></li>
</ul>
<hr/>
<Route exact path="/" component={Home} />
<Route path="/about" component={About} />
<Route path="/topics" component={Topics} />
</div>
)
其中就包含了 React-Router
最核心的兩個組件: Route
、Link
。
接下來我們就將重點放在這兩個組件的實現上~
Route#
在上述的例子中我們注意到 Route
可以傳入四個 props : exact
、path
、 component
和 render
,我們先來實現基本的 Route
組件:
class Route extends Component {
static propTypes = {
path: PropTypes.string, // 匹配路徑
exact: PropTypes.bool, // 是否精確匹配
component: PropTypes.func, // 匹配會渲染的組件
render: PropTypes.func // 自定義渲染內容
}
render () {
const { path, exact, component, render } = this.props
// 看路由是否匹配
const match = matchPath(window.location.pathname, { path, exact })
// 如果不匹配就返回 null
if (!match) return null
// (優先)如果傳了匹配組件
if (component) return React.createElement(component, { match })
// 如果自定義了渲染內容
if (render) return render({ match })
return null
}
}
Route
組件的核心就是匹配成功就渲染,不成功就不渲染(返回 null
)。
然後我們來看一下 matchPath
匹配函數的實現:
const match = (pathname, options) => {
const { path, exact = false } = options
// 如果沒有傳 path
if (!path) return { path: null, url: pathname, isExact: true }
// 正則匹配 url
const match = new RegExp(`^${path}`).exec(pathname)
// 不匹配則返回 null
if (!match) return null
// 判斷是否完全匹配
const url = match[0]
const isExact = pathname === url
if (exact && !isExact) return null
return {
path,
url,
isExact
}
}
React-Router
為了保證兼容性是引入了 pathToRegex 庫來做正則匹配,這裡我們就簡單用js
自帶的RegExp
正則對象來實現 。
到現在我們實現了 Route
組件的匹配渲染邏輯,那麼在實際的路由切換中,如何做到 Route
的重新渲染?
class Route extends Component {
...
componentWillMount () {
// 監聽瀏覽器 前進/後退 按鈕的點擊
window.addEventListener('popstate', this.handlePop)
}
componentWillUnmount () {
window.addEventListener('popstate', this.handlePop)
}
handlePop = () => {
this.forceUpdate()
}
...
}
路由會切換有兩種場景,其中一種就是 瀏覽器 前進 / 後退 按鈕的點擊,我們在兩個生命週期函數中監聽這個點擊事件,一旦點擊了就調用自帶的 forceUpdate
方法強制更新 UI 。
React-Router 中使用的是 history.listen 監聽,同樣我們避免引入依賴選擇 HTML5 的
popstate
事件。
另一種路由切換的場景就是 a
標籤的點擊,也就是我們接下來 Link
組件的實現~
Link#
Link
組件的核心在於 聲明式 更新 URL,很容易想到它內部最終還是一個 a
標籤 :
class Link extends Component {
static propTypes = {
to: PropTypes.string.isRequired,
replace: PropTypes.bool
}
// 阻止默認跳轉,調用自定義更新路由方法
handleClick = e => {
const { replace, to } = this.props
e.preventDefault()
replace ? historyReplace(to) : historyPush(to)
}
render () {
const { to, children } = this.props
return (
<a href={to} onClick={this.handleClick}>
{children}
</a>
)
}
}
而自定義的 historyReplace
和 historyPush
方法區別在於是插入 history 棧還是替換 history 棧 :
const historyPush = path => window.history.pushState({}, null, path)
const historyReplace = path => window.history.replaceState({}, null, path)
Link
的實現就是這樣,那我們就很容易引出一個問題,點擊 Link
後如何匹配到對應的 Route
? 在 Route
組件的掛載和銷毀前我們有監聽對應的事件,但是對於 Link
的點擊它並不會生效。
因此在 Route
組件加載時我們有必要將它作為一個實例保存下來(不管它有沒有被匹配到)。
componentWillMount () {
window.addEventListener('popstate', this.handlePop)
register(this)
}
componentWillUnmount () {
unregister(this)
window.addEventListener('popstate', this.handlePop)
}
register
和 unregister
實現如下:
let instances = []
const register = comp => instances.push(comp)
const unregister = comp => instances.splice(instances.indexOf(comp), 1)
所以在 historyPush
和 historyReplace
方法中我們需要遍歷調用各個 Route
實例進而讓它們逐一匹配 :
const historyPush = path => {
window.history.pushState({}, null, path)
instances.forEach(instance => instance.forceUpdate())
}
const historyReplace = path => {
window.history.replaceState({}, null, path)
instances.forEach(instance => instance.forceUpdate())
}
現在我們將 Route
和 Link
組件引入 index.js
中啟動可以看到運行正常。
React-Router
在路由組件內使用setState
、context
和history.listen
的結合來解決這個問題。
最後#
React Router v4
的原理是很值得學習的,React
可以讓你成為一個更好的 JavaScript
開發者,而 React-Router
可以讓你成為一個更好的 React
開發者。
參考 :