今天我们来学习一下 react 全家桶中的路由库:react-router-dom。
本文将从应用出发,到基本的实现原理来分析 react-router-dom,相信在完整阅读本文之后,你将会对 react-router-dom 的应用以及实现原理有更加深入的了解。
应用
那么我们先来看看 react-router-dom 的简单使用吧:
import React from 'react'
import {
BrowserRouter as Router,
Route,
Link,
Switch
} from 'react-router-dom'
import HomePage from './pages/HomePage'
import LoginPage from './pages/LoginPage'
import _404Page from './pages/_404Page'
export default function App(props) {
return (
<div className="app">
<Router>
<Link to="/">首页</Link>
<Link to="/user">用户</Link>
<Link to="/login">登录</Link>
<Switch>
<Route exact path="/" component={HomePage} />
<Route path="/user" children={() => <div>用户</div>} />
<Route path="/login" render={() => <LoginPage />} />
<Route component={_404Page} />
</Switch>
</Router>
</div>
)
}
相信大家对上面的代码已经非常熟悉了,不过这里还是简单介绍一下:
- BrowserRouter:router 的模式,也有 HashRouter
- Link:跳转组件,to 则是目标地址
- Switch:将展示第一个匹配的组件
- Route:路由组件,exact 为精确匹配模式,渲染方式有三种:
- component
- render
- children
- 上述三者中,优先级为:children > component > render,并且如果以 children 的方式渲染,那么无论路由是否匹配,都将完成渲染,下面来看看效果:
export default function App(props) {
return (
<div className="app">
<Router>
<Route
exact
path="/"
children={() => {
return <div>children</div>
}}
/>
</Router>
</div>
)
}
可以看到,这里 login 是不匹配的,但是依然将 chilren 渲染了出来,另外,在 Swtich 模式下的时候,则一定需要路由匹配才能进行渲染,即使使用 chilren 方式也不例外:
export default function App(props) {
return (
<div className="app">
<Router>
<Switch>
<Route
exact
path="/"
children={() => {
return <div>children</div>
}}
/>
</Switch>
</Router>
</div>
)
}
可以看到,只有在匹配的时候,才成功渲染出了 children。
原理
上面简单的介绍了 react-router-dom 的基础用法,下面我们来分析一下,应该如何来实现上面的功能。
Router
首先来实现路由最重要的,也就是组件展示的功能:通过前面的例子,很容易知道,实现这个功能最核心的 api 是 Route,但在此之前,我们需要先实现 Router。
那么 Router 应该具备什么功能呢?
我们知道,当 url 发生改变的时候,Router 会根据其中包裹的 Route 进行视图的渲染,显然它需要管理一个 locaiton,当 url 改变的时候重新渲染。其次,在后代的组件中,我们能够获取 location,history 等,这些也应该由它来提供,那么它的功能主要就分为两块:
- 监听 url,当发生改变的时候重新渲染视图
- 为后代组件提供属性
知道了这些,我们的 Router 就比较清晰了:
import React, { useState } from 'react'
export default function Router({ children }) {
const [location, setLocation] = useState()
// ? 监听
subscript((location) => {
setLocation(location)
})
return (
<Provider location={location} history={history}>
{children}
</Provider>
)
}
新的问题又来了:
- 如何监听?
- 如何提供数据?
这里,以 BrowserRouter 为例,我们知道,最终导出的组件是 BrowserRouter(或者 HashRouter),而实现核心功能的位置在 Router,那么这里我们只需要在 BrowserRouter 层利用 history 库提供路由操作相关的方法即可:
import React from 'react'
import { createBrowserHistory } from 'history'
import Router from './Router'
export default function BrowserRouter({ children }) {
const history = createBrowserHistory()
return <Router history={history}>{children}</Router>
}
至于数据的提供,利用 Context 即可:
import { createContext } from 'react'
export const RouterContext = createContext()
那么我们的 Router 就没什么问题了:
import React, { useEffect, useState } from 'react'
import { RouterContext } from './Context'
export default function Router({ children, history }) {
const [location, setLocation] = useState(history.location)
const unlisten = history.listen((location) => {
setLocation(location)
})
useEffect(() => {
return () => {
unlisten && unlisten()
}
})
return (
<RouterContext.Provider
value={{
location,
history,
}}
>
{children}
</RouterContext.Provider>
)
}
Route
接下来是 Route,它的核心功能其实没什么好说的:判断 location 与 path 是否相等,如果相等则根据对应方式渲染组件即可。这里我们先默认以 component 的方式来编写 Route:
import React, { useContext } from 'react'
import { RouterContext } from './Context'
export default function Route(props) {
const { location } = useContext(RouterContext)
const { path, component } = props
const match = location.pathname === path
return <>{match ? component : null}</>
}
然而这段代码却存在一个很大的问题,我们来看看渲染结果:
可以看到,明明 url 成功匹配,却没有渲染出任何内容,这是为什么呢?
来看看控制台:
原来是因为 component 并不是一个 React 的节点,那么这里我们就需要借助 React 提供的 createElement 了:
import React, { createElement, useContext } from 'react'
import { RouterContext } from './Context'
export default function Route(props) {
const { location } = useContext(RouterContext)
const { path, component } = props
const match = location.pathname === path
return <>{match ? createElement(component, props) : null}</>
}
再来看看渲染结果:
到这里已经能够成功的渲染内容了,接下来我们继续细化 Route 的功能。
首先,我们上面默认了 Route 的渲染方式为 component,而实际上 Route 有三种渲染方式,其逻辑如下:
- 优先级:children > component > render
- childre:
- function:children(props)
- node:children
- component:createElement(component, props)
- render:render(props)
这样可能还是不太便于理解,我们来看看流程图(下图中默认向下为 y):
根据上图,渲染的逻辑就可以比较容易的编写出来了:
import React, { createElement, useContext } from 'react'
import { RouterContext } from './Context'
export default function Route(props) {
const { location } = useContext(RouterContext)
const { path, component, children, render } = props
const match = location.pathname === path
return (
<>
{match
? children
? typeof children === 'function'
? children(props)
: children
: component
? createElement(component, props)
: render
? render(props)
: null
: typeof children === 'function'
? children(props)
: null}
</>
)
}
接下来,我们知道 Route 会增强 props,如下:
所以这里我们需要对 props 进行混合:
const mixinProps = {
...context,
match,
}
然后可以看到的是,match 是一个包含了 path, url, params...... 的对象,所以这里不能再简单的通过判断 url 是否等于 path 来判断,而是需要做一些更复杂的操作。
其中 path 和 url 都比较容易获取,而 params 则需要解析 pathname,这里我们可以看看源码的处理方案:
import pathToRegexp from 'path-to-regexp'
const cache = {}
const cacheLimit = 10000
let cacheCount = 0
function compilePath(path, options) {
const cacheKey = `${options.end}${options.strict}${options.sensitive}`
const pathCache = cache[cacheKey] || (cache[cacheKey] = {})
if (pathCache[path]) return pathCache[path]
const keys = []
const regexp = pathToRegexp(path, keys, options)
const result = { regexp, keys }
if (cacheCount < cacheLimit) {
pathCache[path] = result
cacheCount++
}
return result
}
/**
* Public API for matching a URL pathname to a path.
*/
function matchPath(pathname, options = {}) {
if (typeof options === 'string' || Array.isArray(options)) {
options = { path: options }
}
const { path, exact = false, strict = false, sensitive = false } = options
const paths = [].concat(path)
return paths.reduce((matched, path) => {
if (!path && path !== '') return null
if (matched) return matched
const { regexp, keys } = compilePath(path, {
end: exact,
strict,
sensitive,
})
const match = regexp.exec(pathname)
if (!match) return null
const [url, ...values] = match
const isExact = pathname === url
if (exact && !isExact) return null
return {
path, // the path used to match
url: path === '/' && url === '' ? '/' : url, // the matched portion of the URL
isExact, // whether or not we matched exactly
params: keys.reduce((memo, key, index) => {
memo[key.name] = values[index]
return memo
}, {}),
}
}, null)
}
export default matchPath
可以看到,源码借助 path-to-regexp 进行解析,并且做了缓存和更多的配置操作,最后返回前文所述的 math 格式。
到这里,我们基本完成了根据 path 展示路由的功能,而在基础用法中,还有 Swtich 和 Link 两个关键 api,我们先来看看 Swtich。
Switch
Switch 的功能相对比较简单:遍历 children,找到第一个匹配的 Route(或 Redirect)并返回组件。具体流程如下:
- 设定 match 和第一个被找到的组件 element
- 遍历 children,判断路由是否匹配,如果匹配则不再进行额外操作
- 最终返回成功匹配到的 element 或者没有匹配到返回 null
这里值得一提的 api 是 React.Children,可以让我们不用再判断 children 的类型。
import React, { Children, isValidElement, cloneElement, useContext } from 'react'
import matchPath from './matchPath'
import { RouterContext } from './Context'
export default function Switch({ children }) {
const context = useContext(RouterContext)
let match, element
return (
<>
{Children.forEach(children, (child) => {
if (match == null && isValidElement(child)) {
element = child
const { path } = child.props
match = path ? matchPath(context.location.pathname, child.props) : context.match
}
})}
{match
? React.cloneElement(element, {
computedMatch: match,
})
: null}
</>
)
}
由于 Switch 在更加外层,所以其优先级是高于 Route 的,所以即使使用 children 进行渲染,如果使用 Switch 进行包裹的话,在路由不匹配的情况下依然不会渲染:
<Switch>
<Route path="/login" component={() => <>login</>} />
<Route path="/welcome" component={WelcomePage} />
</Switch>
Link
接下来是 Link,这个组件本质上就是对 a 标签进行一层包裹,并没有太多的细节:
import React, { useContext } from 'react'
import { RouterContext } from './Context'
export default function Link({ to, children, ...otherProps }) {
const { history } = useContext(RouterContext)
const handleClick = (e) => {
e.preventDefault()
history.push(to)
}
return (
<a href={to} {...otherProps} onClick={handleClick}>
{children}
</a>
)
}
扩展
除了上面的几个 api,react-router-dom 还提供了一些非常常用的 api,诸如:Redirect, withRouter, hooksApi......下面我们就深入看看这些 api 的实现原理又是怎么样的。
Redirect
这里我们只需要借助 history,在组件加载的时候跳转到目标组件即可:
import React, { useContext, useEffect } from 'react'
import { RouterContext } from './Context'
export default function Redirect({ to, push = false }) {
const { history } = useContext(RouterContext)
useEffect(() => {
push ? history.push(to) : history.replace(to)
}, [])
return <></>
}
withRouter
withRouter 是一个高阶组件,用于增强被修饰组件的 props,这里我们直接将 context 混合进去即可:
import React, { useContext } from 'react'
import { RouterContext } from './Context'
export default withRouter = (WarppedComponent) => (props) => {
const context = useContext(RouterContext)
return <WarppedComponent {...props} {...context} />
}
HooksApi
在 react-router-dom 中被使用到的 hook 有四个:
- useParams
- useHistory
- useLocation
- useRouteMatch
实现非常简单,直接返回数据即可:
import { useContext } from 'react'
import { RouterContext } from './Context'
export const useHistory = () => {
const { history } = useContext(RouterContext)
return history
}
export const useLocation = () => {
const { location } = useContext(RouterContext)
return location
}
export function useRouteMatch() {
const { match } = useContext(RouterContext)
return match
}
export function useParams() {
const { match } = useContext(RouterContext)
return match ? match.params : {}
}