实现一个 react-router

1,514 阅读3分钟

本文将用尽可能容易理解的方式,实现最小可用的 react-router v4history,目的为了了解 react-router 实现原理。

本文首发于 www.lvdawei.com/post/build-…

一、开始之前

在开始阅读本文之前,希望你至少使用过一次 react-router,知道 react-router 的基本使用方法。

二、已实现的功能

  • 根据当前页面的 location.pathname,渲染对应 Route 中的 component
  • 点击 Link,页面无刷新,pathname 更新,渲染对应 Route 中的 component
  • 浏览器后退/前进,页面无刷新,渲染对应 Route 中的 component

三、Github 地址与在线预览

四、原理分析

1. Route 的实现

先来看一段代码,我们需要实现的逻辑是:当 location.pathname = '/' 时,页面渲染 Index 组件,当 location.path = '/about/' 时,页面渲染 About 组件。

import React from 'react';

import { BrowserRouter as Router, Route, Link } from "./react-router-dom";

function Index(props) {
  console.log('Index props', props);
  return <h2>Home</h2>;
}

function About() {
  return <h2>About</h2>;
}

function Users() {
  return <h2>Users</h2>;
}

function App() {
  return (
    <Router>
      <div>
        <nav>
          <ul>
            <li>
              <Link to="/">Home</Link>
            </li>
            <li>
              <Link to="/about/">About</Link>
            </li>
            <li>
              <Link to="/users/">Users</Link>
            </li>
          </ul>
        </nav>

        <Route path="/" exact component={Index} />
        <Route path="/about/" component={About} />
        <Route path="/users/" component={Users} />
      </div>
    </Router>
  );
}

export default App;

其实,Route 组件内部的核心逻辑就是判断当前 pathname 是否与自身 props 上的 path 相等,如果相等,则渲染自身 props 上的 component,不等的时候不渲染,返回 null。

好,来看下 Route 的实现:

import React from 'react';
import { RouterContext } from './BrowserRouter';

export default class Route extends React.Component {
    render() {
        const { path, component } = this.props;
        if (this.context.location.pathname !== path) return null;
        return React.createElement(component, { ...this.context })
    }
}

Route.contextType = RouterContext

Route 主要就是一个 render() 函数,内部通过 context 获得当前 pathname。那么这个 context 是哪来的呢?

2. BrowserRouter 的实现

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

const history = createBrowserHistory()

export const RouterContext = React.createContext(history)

export default class BrowserRouter extends React.Component {
    constructor(props) {
        super(props)
        this.state = {
            location: {
                pathname: window.location.pathname
            }
        }
    }

    render() {
        const { location } = this.state;
        return (
            <RouterContext.Provider value={{ history, location }}>
                {this.props.children}
            </RouterContext.Provider>
        )
    }
};

这里仅贴出了首次渲染的逻辑代码。BrowserRouter 在 constructor 中根据 window.location 初始化 location,然后将 location 传入 RouterContext.Provider 组件,子组件 Route 接收到含有 location 的 context,根据 1. Route 的实现 完成首次渲染。

注意到传入 RouterContext.Provider 组件的对象不光有 location,还有 history 对象。这个 history 是做什么用的呢?其实是暴露 history.push 和 history.listen 方法,提供给外部做跳转和监听跳转事件使用的。Link 组件的实现也是用到了 history,我们接着往下看。

3. Link 的实现

import React from 'react';
import { RouterContext } from './BrowserRouter';

export default class Link extends React.Component {
    constructor(props) {
        super(props)
        this.clickHandler = this.clickHandler.bind(this)
    }

    clickHandler(e) {
        console.log('click', this.props.to);
        e.preventDefault()
        this.context.history.push(this.props.to)
    }

    render() {
        const { to, children } = this.props;
        return <a href={to} onClick={this.clickHandler}>{children}</a>
    }
}

Link.contextType = RouterContext

Link 组件其实就是一个 a 标签,与普通 a 标签不同,点击 Link 组件并不会刷新整个页面。组件内部把 a 标签的默认行为 preventDefault 了,Link 组件从 context 上拿到 history,将需要跳转的动作告诉 history,即 history.push(to)

如下面代码所示,BrowserRouter 在 componentDidMount 中,通过 history.listen 监听 location 的变化。当 location 变化的时候,setState 一个新的 location 对象,触发 render,进而触发子组件 Route 的重新渲染,渲染出对应 Route。

// BrowserRouter
componentDidMount() {
    history.listen((pathname) => {
        console.log('history change', pathname);
        this.setState({ location: { pathname } })
    })
}

4. history 的实现

history 的内部实现是怎么样的呢?请看下面的代码:

let globalHistory = window.history;

export default function createBrowserHistory() {
    let listeners = []

    const push = function (pathname) {
        globalHistory.pushState({}, '', pathname)
        notifyListeners(pathname)
    }

    const listen = function (listener) {
        listeners.push(listener)
    }

    const notifyListeners = (...args) => {
        listeners.forEach(listener => listener(...args))
    }

    window.onpopstate = function () {
        notifyListeners(window.location.pathname)
    }

    return {
        listeners,
        listen,
        push
    }
};

history 通过 listen 方法收集外部的监听事件。当外部调用 history.push 方法时,使用 window.history.pushState 修改当前 location,执行 notifyListeners 方法,依次回调所有的监听事件。注:这里为了让代码更加容易理解,简化了 listener this 上下文的处理。

另外,history 内部增加了 window.onpopstate 用来监听浏览器的前进后退事件,执行 notifyListeners 方法。

五、总结

我们使用了 100 多行代码,实现了 react-router 的基本功能,对 react-router 有了更深入的认识。想更加深入的了解 react-router,建议看一下 react-router 的源码,快速走读一遍,再对比下本文的实现细节,相信你会有一个更清晰的理解。

觉得本文帮助到你的话,请给我的 build-your-own-react-router 项目点个⭐️吧!