1

一、回顾一下官方react-router-dom的使用

要想自己实现一个react-router-dom,就必须先了解一下react-router-dom的基本使用,react-router-dom与vue-router有点不同,react-router-dom采用的是去中心化路由,所以其不需要写一个单独的router.js来集中配置和管理路由,如:

引入react-router-dom模块,react-router-dom中包含了两种路由,即HashRouterBrowserRouter,其中HashRouter采用的是hash值的变化来切换路由,BrowserRouter采用的是history api来切换路由

// 引入HashRouter
import {HashRouter as Router} from "react-router-dom";
// 或者引入BrowserRouter,二者引入其中的一个即可
import {BrowserRouter as Router} from "react-router-dom";

用<Router>组件包裹<App />

ReactDOM.render(
    <Router>
      <App />
    </Router>,
  document.getElementById('root')
);

在需要使用路由的地方,引入<Route>组件即可

import {Route} from "react-router-dom"
class App extends Component {
    render() {
        <Route path="/" component={Home} exact></Route>
        <Route path="/about" component={About}></Route>
        <Route path="/user/:name/:age" component={User}></Route>
    }
}

光引入<Route>组件只能根据浏览器地址栏渲染相应的组件,我们还需要引入<Link>组件才能显示相应的路由切换链接(a标签),如:

import { Link } from "react-router-dom";
<!--使用Link组件会渲染成a标签,可以进行路由的切换-->
<ul className="nav">
    <li><Link to="/">Home</Link></li>
    <li><Link to= {{ pathname: "/about", query: {name: "lihb"}}}>About</Link></li>
    <li><Link to="/user/lihb/28">User</Link></li>
</ul>

⑤ react路由中路径匹配的时候,如果有多个<Route>匹配到,那么匹配到的<Route>都会被渲染出来,我们可以用<Switch>组件将一组<Route>包裹起来,这样就只会渲染第一个匹配到的<Route>组件,如:

import { Switch, Route } from "react-router-dom";
<!--使用Switch组件避免重复渲染-->
<Switch>
    <Route path="/" component={Home} exact></Route>
    <Route path="/about" component={About}></Route>
    <Route path="/user/:name/:age" component={User}></Route>
</Switch>

为了避免所有<Route>都无法匹配的时候,显示空白页面,我们可以引入一个<Redirect>组件将其重定向到某个页面,如:

import { Redirect, Switch, Route } from "react-router-dom";
<Switch>
    <Route path="/" component={Home} exact></Route>
    <Route path="/about" component={About}></Route>
    <Route path="/user/:name/:age" component={User}></Route>
    <Redirect to="/"></Redirect><!--重定向到首页-->
</Switch>

二、react-router-dom原理

react-router-dom利用了Context API,通过上下文对象将当前路由信息对象注入到<Router>组件中,所以<Router>组件中render()渲染的内容就是Context API提供的Provider组件,然后接收<Router>组件中的当前路由信息对象
这样<Router>组件下的所有组件都能通过上下文拿到当前路由信息对象,即其中的<Switch>、<Route>、<Link>、<Redirect>等组件都可以拿到当前路由信息对象,然后通过改变当前路由信息来实现动态切换<Route>组件的渲染

三、开始实现自己的react-router-dom

创建上下文
在src目录下新建一个 src/react-router-dom/context.js文件, 其作用就是引入React并创建上下文对象,然后导出即可,如:

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

实现HashRouter,将当前路由注入到上下文中
这里先以HashRouter为例,在react-router-dom目录下新建一个HashRouter.js文件,这里我们引入上下文对象,然后通过其提供的Provider组件将<Router>组件中的当前路由对象注入到上下文中,并将<Router>内的子组件包含进来:

import React, {Component} from "react";
import Context from "./context"; // 引入上下文对象

export default class HashRouter extends Component {

    render() {
        const currentRoute = { // 当前路由对象

        }
        return (
             // 使用上下文对象提供的Provider组件将当前路由信息对象注入上下文中,以便其Route等子组件能够获取到当前路由信息
            <Context.Provider value={currentRoute}>
                {this.props.children}
            </Context.Provider>
        );
    }
}

填充当前路由信息
在react中,当前路由信息对象包含了locationmatchhistory三个对象,为了方便操作,我们将location和match对象定义到state中,因为这两个对象中的数据是需要动态修改的,如:

// 在HashRouter中新增构造函数,并且将location和match定义到state对象中
constructor(props) {
    super(props);
    this.state = { // 为了方便更改当前路由信息,将location和match放到state中
        location: {
            pathname: window.location.hash.slice(1) || "/", // 获取浏览器地址栏中的hash值,如果不存在则默认为"/"
            query: undefined
        },
        match: {
            params: undefined
        }
    }
 }
// 填充当前路由信息,新增了一个history对象
const currentRoute = { // 当前路由对象
    location: this.state.location, // location直接从state中获取
    match: this.state.match, // match直接从state中获取
    history: { // 新增一个history对象用于实现当前路由的切换
        push: (to) => {
            if (typeof to === "object") { // 如果to是一个对象
                let {pathname, query} = to; // 取出pathname和query
                window.location.hash = pathname; // 更新浏览器hash值,触发浏览器hashchange事件
                this.state.location.query = query; // 更新query
            } else { // 修改浏览器地址栏的hash值
                window.location.hash = to; // 更新浏览器hash值
            }
        }
    }
}

这里给当前路由信息中添加了一个history对象,主要用于实现路由的切换,以便其子组件(如: Link)可以通过上下文拿到当前路由信息进行路由的切换,如:

// 直接传递路径进行路由的切换
this.context.history.push("/about");
// 传入一个对象,可以在切换路由的时候传递参数,比如query
this.context.history.push({ pathname: "/about", query: {name: "lihb"}})

如果传递的是路径字符,那么直接更新浏览器地址的hash值;如果传递是一个对象,那么从对象中取出要跳转的路径传递的参数,同样更新浏览器地址栏的hash值,并将传递的参数保存到location的query中,react中还有search、state等参数,这里只以query为例。

监听浏览器地址栏hash值的变化
上面调用push()方法后,仅仅改变了浏览器地址栏的hash值,也就是说仅仅路径发生了变化,而页面其实并未跟着发生变化,react中要想页面发生变化,必须调用setState()方法,我们可以监听浏览器地址栏hash值的变化,当hash值变化的时候,更新当前路由信息,并调用setState()函数触发<Router>组件渲染,其子组件也会跟着重新渲染,从而渲染出当前路由对应的组件,如:

componentDidMount() {
    window.addEventListener("hashchange", () => { // 监听浏览器地址栏hash值变化
        this.setState({ // 当hash值变化后更新当前路由信息, HashRouter组件内的子组件Route将会重新渲染
            location: {
                ...this.state.location,
                pathname: window.location.hash.slice(1) // 更新pathname
            }
        });
    });
}

因为currentRoute当前路由对象定义在了render函数内,所以调用setState()后,render()函数重新执行currentRoute更新<Route>根据当前路由信息渲染出当前路由对应的组件

创建Route路由子组件
在react-router-dom目录下新建一个Route.js,Route组件要做的事就是从上下文对象中取出当前路由信息,即当前路由的路径,然后取出<Route>组件中配置的path,然后比较这两个值是否相同如果相同,则渲染出<Route>组件上配置的component,如:

import React, {Component} from "react";
import context from "./context";
/**
 * 从上下文对象中获取到当前路由信息,将其中配置的path与当前路由的pathname相同的Route渲染出来
 */
export default class Route extends Component {
    static contextType = context;
    render() {
        const currentRoutePath = this.context.location.pathname; // 从上下文中获取到当前路由的路径
        const {path, component:Component} = this.props; // 获取给Route组件传递的props属性
        const props = {
            ...this.context
        }
        if (currentRoutePath === path) {
            return (
                <Component {...props}></Component>
            );
        }
        return null; // 必须有返回值
    }
}

让Route可以处理带冒号参数的路径
此时已经可以匹配一些写死的路径了,但是如果是带冒号参数的路径则无法匹配,比如 /user/:name/:age 则无法被/user匹配到,路由中处理路径的匹配,需要用到path-to-regexp模块,path-to-regexp模块提供了pathToRegexp, match两个方法,match方法可以拿到匹配的paramspathToRegexp可以检测路径是否匹配成功,如:

const matchFun = match("/user/:name/:age");
console.log(matchFun("/user/lihb/28"));
// 输出结果为
{path:"/user/lihb/28", index:0, params:{name:"lihb",age:"28"}}
const regexp = pathToRegexp("/user/:name/:age", []);
console.log(regexp.test("/user/lihb/28")); // true
console.log(regexp.test("/user1/lihb/28")); // false
console.log(regexp.test("/user/lihb/28/abc")); // false

支持exact
exact就是精确匹配,react中不加exact是非精确匹配,而加上exact则是精确匹配,只有路径完全一致才能匹配成功,可以通过pathToRegexp方法的第三个参数进行控制,如:

const regexp = pathToRegexp("/user/", [], {end: false}); // 非精确匹配,即能匹配比/user更长的路径
console.log(regexp.test("/user")); // true
console.log(regexp.test("/user/lihb/28")); // true
const regexp = pathToRegexp("/user", [], {end: true}); // 精确匹配
console.log(regexp.test("/user")); // true
console.log(regexp.test("/user/lihb/28")); // false

完整的Route.js为

import React, {Component} from "react";
import context from "./context";
import {pathToRegexp, match} from "path-to-regexp";
/**
 * 从上下文对象中获取到当前路由信息,将其中配置的path与当前路由的pathname相同的Route渲染出来
 */
export default class Route extends Component {
    static contextType = context;
    render() {
        const currentRoutePath = this.context.location.pathname; // 从上下文中获取到当前路由的路径
        const {path, component:Component, exact=false} = this.props; // 获取给Route组件传递的props属性
        const paramsRegexp = match(path, {end: exact}); // 生成获取params的正则表达式
        const matchResult = paramsRegexp(currentRoutePath);
        console.log(`matchResult is ${JSON.stringify(matchResult)}`);
        this.context.match.params = matchResult.params; // 将匹配的参数保存到上下文的match对象中
        const props = {
            ...this.context
        }
        const pathRegexp = pathToRegexp(path, [], {end: exact}); // 生成匹配路径的正则表达式
        if (pathRegexp.test(currentRoutePath)) {
            return (
                <Component {...props}></Component>// 将当前路由对象信息当做props传递给组件
            );
        }
        return null; // 必须有返回值
    }
}

实现Link组件
在react-router-dom目录下新建一个Link.js,Link组件非常简单,其实就是在Link组件上配置了一个to属性,表示要去的目标路由,然后渲染成一个标签当点击<Link>标签的时候跳转到目标路由,所以需要给标签添加一个onClick事件通过上下文拿到当前路由信息对象,通过其push()方法跳转到目标路由即可,如:

import React, {Component} from "react";
import context from "./context";
export default class Link extends Component {
    static contextType = context;
    render() {
        let {to} = this.props; // 从Link组件上获取到配置的to路径
        // 点击Link的时候调用当前路由的history的push方法进行跳转即可
        return (
        <a onClick={() => {this.context.history.push(to)}}>{this.props.children}</a>
        );
    }
}

实现Redirect组件
在react-router-dom目录下新建一个Redirect.js,<Redirect>组件要做的事情就是,在所有的<Route>组件都未能匹配的情况下直接重定向到<Redirect>组件配置的路径,所有<Redirect>组件非常简单,即无限制,直接通过当前路由对象的push方法重定向即可

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

export default class Redirect extends Component {
    static contextType = context;
    render() {
        // 无限制,直接重定向跳转到指定的路径
        this.context.history.push(this.props.to);
        return null;
    }
}

实现Switch组件
在react-router-dom目录下新建一个Switch.js,<Switch>组件的作用就是,<Switch>的子组件中如果有多个<Route>组件匹配成功,那么只渲染第一个匹配成功的<Route>,所以,我们需要遍历<Switch>的子组件如果匹配成功则立即返回作为整个<Switch>组件的渲染结果

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

export default class Switch extends Component {
    static contextType = context;
    render() {
        const children = Array.isArray(this.props.children) ? this.props.children : [this.props.children];
        for (let i = 0; i < children.length; i++) { // 遍历Switch组件下的所有子组件
            const child = children[i];
            const {path="/", exact=false} = child.props;
            const {pathname} = this.context.location;
            const regexp = pathToRegexp(path, [], {end: exact});
            if (regexp.test(pathname)) { // 如果匹配成功则立即返回作为<Switch>组件的渲染结果
                return child;
            }
        }
        return null;
    }
}

同样的原理实现BrowserRouter
在react-router-dom目录下新建一个BrowserRouter.js,BrowserRouter和HashRouter原理一样,只不过BrowserRouter监听的是popstate事件

import React, {Component} from "react";
import Context from "./context"; // 引入上下文对象
export default class BrowserRouter extends Component {
    constructor(props) {
        super(props);
        this.state = {
            location: {
                pathname: window.location.pathname || "/",
                query: undefined,
            },
            match: {
                params: undefined
            }
        }
    }
    componentDidMount() {
        window.addEventListener("popstate", () => { // 监听浏览器前进后退按钮
            this.setState({ // 触发页面更新
                location: {
                    pathname: window.location.pathname // 路径变化后更新当前路由对应的pathname
                }
            });
        });
    }
    render() {
        const currentRoute = { // 构造一个当前路由对象,保存当前路由信息,包括location、match、history
            location: this.state.location, // location直接从state中获取
            match: this.state.match, // match直接从state中获取
            history: { // 新增一个history对象用于实现当前路由的切换
                push: (to) => {
                    if (typeof to === "object") { // 如果to是一个对象
                        let {pathname, query} = to; // 取出pathname和query
                        this.state.location.query = query; // 更新query
                        this.state.location.pathname = pathname;// 更新当前路由对应的pathname
                        window.history.pushState({}, {}, pathname); // 添加一条history
                    } else { // 如果to是一个路径字符串
                        this.state.location.pathname = to; // 更新当前路由对应的pathname
                        window.history.pushState({}, {}, to); // 添加一条history
                    }
                    this.setState({}); // 触发页面更新
                }
            }
        }
        return (
            // 使用上下文提供的Provider组件将当前路由信息对象注入上下文中,以便其Route子组件能够获取到当前路由信息
            <Context.Provider value={currentRoute}>
                {this.props.children}
            </Context.Provider>
        );
    }
}

最后再将这些组件导出即可
在react-router-dom目录下新建一个index.js,将这些组件一一导出,如:

import HashRouter from "./HashRouter";
import Route from "./Route";
import Link from "./Link";
import Redirect from "./Redirect";
import Switch from "./Switch"
import BrowserRouter from "./BrowserRouter"

export {
    HashRouter,
    Route,
    Link,
    Redirect,
    Switch,
    BrowserRouter
}

JS_Even_JS
2.6k 声望3.7k 粉丝

前端工程师