前端造轮子【7】- 简易 react-router-dom

143 阅读7分钟

今天我们来学习一下 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 : {}
}