实现基本的react-router-dom

565 阅读3分钟

实现基本的react-router-dom

react router提供native 和 h5的两套router,这里只涉及h5方面的,所以实现的是react-router-dom。

要求

需要了解react-router-dom的基本使用

常用api

  • BrowserRouter
  • Router
  • Route
  • Link
  • Switch
    ...

实现思路:

基本想法: 通过url的变化, 触发相应页面渲染

实现路径:

  1. 创建一个全局唯一的Router组件,做为顶级组件(这里使用BrowserRouter)。 提供一个context(上下文)包含{ location, match, history }字段。
  2. 点击路由(Link)跳转时, Router会根据url 去匹配相应的路由(Route), 匹配正确的路由渲染。
  3. 路由(Route)提供3种props方式渲染组件: children、component和render。
  4. Switch 保证每次只有一个路由渲染。

案例: image.png

关键点

  • 在一个项目中只有一个Router,提供顶层的router context (location, match, history)。 在h5中, 常用的Router的形式有BrowserRouter、HashRouter、MemoryRouter。在这里只实现BrowserRouter形式。

  • Switch组件保证只渲染匹配到的第一个Route组件。

  • Route组件提供三种props(children,component,render)来指定渲染的组件, 优先级是 children > component > render

<RouterContext.Provider value={props}>
  {props.match
    ? children
      ? typeof children === "function"
        ? children(props)
        : children
      : component
      ? React.createElement(component, props)
      : render
      ? render(props)
      : null
    : null}
</RouterContext.Provider>
  • 在非Switch组件中, 未提供path的Route一直都渲染。

实现步骤

实现Router的上下文对象(Router、BrowserRouter)

Router

基本目的就是提供全局的router context(location, match, history)。

该组件接受history作为props, 根据history来生成context。

import React from "react"
import RouterContext from "./RouterContext"

class Router extends React.Component {
  static computeRootMatch(pathname) {
    return { path: "/", url: "/", params: {}, isExact: pathname === "/" }
  }
  constructor(props) {
    super(props)
    this.state = { location: props.history.location }
    this.unlisten = props.history.listen((location) => {
      this.setState({ location })
    })
  }

  componentWillUnmount() {
    if (this.unlisten) {
      this.unlisten()
    }
  }

  render() {
    return (
      <RouterContext.Provider
        value={{
          location: this.state.location,
          history: this.props.history,
          match: Router.computeRootMatch(this.state.location.pathname),
        }}
      >
        {this.props.children}
      </RouterContext.Provider>
    )
  }
}

export default Router

RouterContext
import React from 'react';

const RouterContext = React.createContext()

export default RouterContext
BrowserRouter

这里引入history包,管理浏览器history对象的一致性。因为是BrowserRouter组件,所以引入的是createBrowserHistory方法来生成history对象,传入到Router组件中。

import React from 'react'
import Router from './Router'
import { createBrowserHistory } from 'history'


class BrowserRouter extends React.Component {
    history = createBrowserHistory()
    render() {
        return <Router history={this.history} >
            {this.props.children}
        </Router>
    }
}

export default BrowserRouter

子组件消费router context来生成对应的组件(Link、 Route、 Switch)

Route

match(路由匹配成功)的条件是,是否存在computedMatch props(Switch组件中存在),有直接使用, 没有使用matchPath函数来匹配path。

匹配成功渲染的条件: 如果匹配则判断是否有children props, 有就根据children 来渲染,(同时判断children是否为函数形式,为函数形式就调用函数, 不是直接渲染), 没有就判断是否存在component props, 有就根据component来渲染, 没有就判断是否存在render props, 有就渲染, 没有就为空。

import React from "react"
import matchPath from "./matchPath"
import RouterContext from "./RouterContext"

function isEmptyChildren(children) {
  return React.Children.count(children) === 0
}

class Route extends React.Component {
  render() {
    return (
      <RouterContext.Consumer>
        {(context) => {
          const location = this.props.location || context.location
          const match = this.props.computedMatch
            ? this.props.computedMatch // <Switch> already computed the match for us
            : this.props.path
            ? matchPath(location.pathname, this.props)
            : context.match
          const props = { ...context, location, match }
          let { children, component, render } = this.props
          if (Array.isArray(children) && isEmptyChildren(children)) {
            children = null
          }

          return (
            <RouterContext.Provider value={props}>
              {props.match
                ? children
                  ? typeof children === "function"
                    ? children(props)
                    : children
                  : component
                  ? React.createElement(component, props)
                  : render
                  ? render(props)
                  : null
                : null}
            </RouterContext.Provider>
          )
        }}
      </RouterContext.Consumer>
    )
  }
}

export default Route

matchPath

(源码拷贝), 正则匹配path路径

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;

Switch

作用: 保证只匹配第一个

import React from 'react'
import RouterContext from './RouterContext'
import matchPath from './matchPath'

class Switch extends React.Component {
    render() {
        return (
            <RouterContext.Consumer>
                {context => {
                    const location = this.props.location || context.location
                    let element, match
                    React.Children.forEach(this.props.children, child => {
                        if (match == null && React.isValidElement(child)) {
                            element = child
                            const path = child.props.path || child.props.from
                            match = path ? matchPath(location.pathname, {...child.props, path }) : context.match
                        }
                    })

                    return match ? React.cloneElement(element, {location, computedMatch: match}) : null
                }}
            </RouterContext.Consumer>
        )
    }
}

export default Switch
Link

link 基本的实现就是一个a标签, 先阻止默认的事件, 然后根据replace这个props来判断, 是使用history的repalce还是push方法。 触发事件后,顶层的Router组件会监听repalce和push事件,改变相应的loaction, 从而触发Route重新匹配渲染。

import React from 'react'
import RouterContext from './RouterContext'


export const resolveToLocation = (to, currentLocation) =>
  typeof to === "function" ? to(currentLocation) : to;

class Link extends React.Component {
    static contextType = RouterContext
    handleClick = (e) => {
        e.preventDefault()
        const { replace = false } = this.props
        const location = resolveToLocation(this.props.to, this.context.location)
        let method = this.context.history.push
        if (replace) {
            method = this.context.history.replace
        }
        method(location)
    }
    render() {
        const { children, to, ...restProps } = this.props
        return (
            <a href={to} onClick={this.handleClick} {...restProps}>
                {children}
            </a>
        )
    }
}

export default Link

完整示例

完整代码