react-router 实现原理

react路由通过不同的路径渲染出不同的组件,这篇文章模拟react-router-dom的api,从零开始实现一个react的路由库

两种实现方式

路由的实现方式有两种:hash路由Browser路由

HashRouter

HashRouter即通过hash实现页面路由的变化,hash的应用很广泛,我最开始写代码时接触到hash,一般用来做页面导航和轮播图定位的,hash值的变化我们可以通过hashchange来监听:

window.addEventListener('hashchange',()=>{
  console.log(window.location.hash);
});

BrowserRouter

浏览器路由的变化通过h5的pushState实现,pushState是全局对象history的方法, pushState会往History写入一个对象,存储在History包括了length长度和state值,其中state可以加入我们自定义的数据信息传递給新页面,pushState方法我们可以通过浏览器提供的onpopstate方法监听,不过浏览器没有提供onpushstate方法,还需要我们动手去实现它,当然如果只是想替换页面不添加到history的历史记录中,也可以使用replaceState方法,更多history可以查看MDN,这里我们给浏览器加上onpushstate事件:

((history)=>{
  let pushState = history.pushState; // 先把旧的pushState方法存储起来

  // 重写pushState方法
  history.pushState=function(state,title,pathname){
    if (typeof window.onpushstate === "function"){
      window.onpushstate(state,pathname);
    }
    return pushState.apply(history,arguments);
  }
})(window.history);

准备入口文件

首先新建react-router-dom的入口文件index.js,这篇文章会实现里面主要的api,所以我把主要的文件和导出内容也先写好:

import HashRouter from  "./HashRouter";
import BrowserRouter from  "./BrowserRouter";

import Route from  "./Route";
import Link from  "./Link";
import MenuLink from  "./MenuLink";

import Switch from  "./Switch";
import Redirect from  "./Redirect";

import Prompt from  "./Prompt";
import WithRouter from  "./WithRouter";

export {
  HashRouter,
  BrowserRouter,

  Route,
  Link,
  MenuLink,

  Switch,
  Redirect,

  Prompt,
  WithRouter
}

Context

包含在路由里面的组件,可以通过props拿到路由的api的,所以react-router-dom应该有一个属于自己的Context,所以我们新建一个context存放里面的数据:

// context.js
import React from "react";
export default React.createContext();

HashRouter

接下来编写HashRouter,作为路由最外层的父组件,Router应该包含了提供给子组件所需的api:

//HashRouter.js

import React, { Component } from 'react'
import RouterContext from "./context";

export default class HashRouter extends Component {
  render() {
    const value={
      history:{},
      location:{}
    }
    return (
      <RouterContext.Provider value={value}>
        {this.props.children}
      </RouterContext.Provider>
    )
  }
}

react路由最主要的是通过监听路由的变化,渲染出不同的组件,这里我们可以先在hashRouter监听路由变化,再传递给子组件路由的变化信息,所有我么需要一个state来存储变化的location信息,并且能够监听到它的变化:

//HashRouter.js
...
export default class HashRouter extends Component {
  state = {
    location: {
      pathname:location.hash.slice(1)
    }
  }
  componentDidMount(){
    window.addEventListener("hashchange",(event)=>{
        this.setState({
          location:{
            ...this.state.location,
            pathname:location.hash.slice(1)
          }
        })
    })
  }
  render() {
    const value={
      history:{},
      location: this.state.location
    }
    ...
  }
}

同时给history添加上push方法,而且我们知道history是可以携带自定义state信息的,所有我们也在组件里面定义locationState属性,存储路由的state信息:

//HashRouter.js

//render
const $comp = this;
const value = {
  history: {
    push(to){
     // to 可能是一个对象:{pathname,state}
      if (typeof to === "object") {
        location.hash = to.pathname;
        $comp.locationState = to.state;
      } else {
        location.hash = to;
        $comp.locationState = null;
      }
    }
  },
  location: {
      state: $comp.locationState, //locationState存储路由state信息
      pathname: this.state.location.pathname
  }
}

BrowserRouter

BrowserRouterhashRouter很像,只是监听的对象不一样了,监听的事件变成了onpopstateonpushstate,并且页面跳转用history.pushState,其它跟hashRouter一样,我们新建BrowserRouter.js,:

//BrowserRouter.js

import React, { Component } from 'react'
import RouterContext from "./context";

// 重写 pushState
((history) => {
  let pushState = history.pushState;
  history.pushState = function (state, title, pathname) {
    if (typeof window.onpushstate === "function") {
      // 添加 onpushstate 事件的监听
      window.onpushstate(state, pathname);
    }
    return pushState.apply(history, arguments);
  }
})(window.history);

export default class HashRouter extends Component {

  state = { location: { pathname: location.hash.slice(1) } };
  
  componentDidMount() {
  	// 监听浏览器后退事件
    window.onpopstate = (event) => {
      this.setState({
        location: {
          ...this.state.location,
          state: event.state,
          pathname: event.pathname,
        }
      })
    }
    // 监听浏览器前进事件,自定义
    window.onpushstate = (state, pathname) => {
      this.setState({
        location: {
          ...this.state.location,
          state, pathname
        }
      })
    }
  }
  render() {
    const value = {
      history: {
        push(to) {
          if (typeof to === "object") {
            history.pushState(to.state, '', to.pathname);
          } else {
            history.pushState('', '', to);
          }
        },
      },
      location: this.state.location
    }
    return (
      <RouterContext.Provider value={value}>
        {this.props.children}
      </RouterContext.Provider>
    )
  }
}

Route

Route组件靠pathcomponent两个参数渲染页面组件,逻辑是拿当前url路径跟组件的path参数进行正则匹配,如果匹配成功,就返回组件对应的Component

path-to-regexp

路径的正则转换用的是path-to-regexp,路径还根据组件参数exact判断是否全匹配,转换后的正则可以通过regulex进行测试,首先安装path-to-regexp:

npm install path-to-regexp --save

path-to-regexp使用:

const keys = [];
const regexp = pathToRegexp("/foo/:bar", keys);
// regexp = /^\/foo\/([^\/]+?)\/?$/i
// keys = [{ name: 'bar', prefix: '/', suffix: '', pattern: '[^\\/#\\?]+?', modifier: '' }]

Route.js

新建Route.js

//Route.js
import React, { Component } from 'react'
import RouterContext from "./context"
import { pathToRegexp } from "path-to-regexp"

export default class Route extends Component {
  static contextType = RouterContext;
  render() {
    // 获取url路径 pathname 和 组件参数 path 进行正则匹配
    const {pathname} = this.context.location;
    const {path="/",component:Component,exact=false} = this.props;

    const keys = []; // 正则匹配的 keys 数组集合
    const regexp = pathToRegexp(path,keys,{end:exact});
    const result = pathname.match(regexp); // 获得匹配结果

    // 将context的值传递给子组件使用
    let props = {
      history:this.context.history,
      location:this.context.location,
    }
    
    if (result){
      // 如果匹配成功,获取路径参数
      const match = {};
      // 将result解构出来,第一个是url路径
      const [url,...values] = result; 
      // 将路径参数提取出来
      const params = keys.map(k=>k.name).reduce((total,key,i)=>(total[key]=values[i]),{});

      match = {url,path,params,isExact:url===pathname};
      props.match = match;

      return <Component {...props} />
    }
    
    return null;   

  }
}

render和children

Route组件还提供了两个参数renderchildren,这两个参数能够让组件通过函数进行渲染,并将props作为参数提供,这在编写高阶函数中非常有用:

<Route path="/" component={Comp} render={(props) => <Comp {...props}/>}></Route>
<Route path="/" component={Comp} children={(props) => <Comp {...props}/>}></Route>

所以我们在Route组件中也解构出renderchildren,如果有这两个参数的情况下,直接执行返回后的结果并把props传进去:

//Route.js
...
const { render, children } = this.props;
...
if (result) {
  if (render) return render(props);
  if (children) return children(props);

  return <Component {...props} />
}

if (render) return render(props);
if (children) return children(props);

return null;

LinkMenuLink两个组件都提供了路由跳转的功能,其实是包装了一层的a标签,方便用户在hashbrowser路由下都能保持同样的跳转和传参操作,而MenuLink还给当前路由匹配的组件添加active类名,方便样式的控制。

// Link.js
import React, { Component } from 'react'
import RouterContext from "./context";

export default class Link extends Component {
  static contextType = RouterContext;
  render() {
    let to = this.props.to;
    return (
      <a {...this.props} onClick={()=>this.context.history.push(to)}>{this.props.children}</a>
    )
  }
}

MenuLink直接用函数组件,这里用到了Routechildren方法去渲染子组件

//MenuLink
import React from 'react'
import {Link,Route} from "../react-router-dom"

export default function MenuLink({to,exact,children,...rest}) {
  let pathname = (typeof to === "object") ? to.pathname : to;
  return <Route path={pathname} 
                exact={exact} 
                children={(props)=>(
                  <Link {...rest} className={props.match ? 'active' : ''} to={to}>{children}</Link>
                )} />
}

Switch 和 Redirect

Redirect组件重定向到指定的组件,不过需要配合Switch组件使用

Redirect

Redirect的逻辑很简单,就是拿到参数to直接执行跳转方法

//Redirect.js
import React, { Component } from 'react'
import RouterContext from "./context"

export default class Redirect extends Component {
  static contextType = RouterContext;
  componentDidMount(){
    this.context.history.push(this.props.to);
  }
  render() {
    return null;
  }
}

Switch

可以看到Redirect组件就是直接重定向到指定路径,如果在组件中直接引入到了这里就直接跳转了,所以我们要写个Switch配合它:

//Switch.js
import React, { Component } from 'react'
import RouterContext from "./context";
import {pathToRegexp} from "path-to-regexp";

export default class Switch extends Component {
  static contextType = RouterContext;
  render() {
	// 取出Switch里面的子组件,遍历查找出跟路由路径相同的组件返回,如果没有,就会到Redirect组件中去
    let {children} = this.props; 
    let {pathname} = this.context.location;
    for (let i = 0, len = children.length;i<len;i++){
      let {path="/",exact=false} = children[i].props;
      let regexp = pathToRegexp(path,[],{end:exact});
      let result = pathname.match(regexp);

      if (result) return children[i];
    }
    return null
  }
}

WithRouter 和 Prompt

WithRouter

WithRouter是一个高阶函数,经过它的包装可以让组件享有路由的方法:

//WithRouter.js
import React, { Component } from 'react'
import { Route } from '../react-router-dom';

function WithRouter(WrapperComp) {
  return () => (
    <Route render={(routerProps) => <WrapperComp {...routerProps} />} />
  )
}

export default WithRouter

Prompt

Prompt用的比较少,组件中如果需要用到它,需要提供whenmessage两个参数,给用户提示信息:

//Prompt.js

import React, { Component } from 'react'
import RouterContext from "./context"

export default class Prompt extends Component {
  static contextType = RouterContext;
  componentWillUnmount() {
    this.context.history.unBlock();
  }
  render() {
    let { message, when } = this.props;
    let { history, location } = this.context;
    if (when) {
      history.block(message(location))
    } else {
      history.unBlock();
    }
    return null;
  }
}

可以看到,history中新增了blockunBlock方法,用来显示提示的信息,所以要到history中添加这两个方法,并在路由跳转的时候截取,如果有信息需要提示,就给予提示:

//HashRouter.js && BrowserRouter.js
...
history: {
  push(){
    if ($comp.message) {
      let confirmResult = confirm($comp.message);
      if (!confirmResult) return;
      $comp.message = null;
    }
    ...
  },
  block(message){
    $comp.message = message
  },
  unBlock(){
    $comp.message = null;
  }
}
...

ok! 到了这里,一个react的路由插件就大功告成了!