React-Router原理及丐版实现

775 阅读7分钟

前端路由

在 Web 前端单页面应用 SPA(Single Page Application)中,路由是描述 URL 和 UI 之间的映射关系,这种映射是单向的,即 URL 的改变会引起 UI 更新,无需刷新页面

如何实现前端路由

实现前端路由,需要解决两个核心问题

  1. 如何改变 URL 却不引起页面刷新?
  2. 如何监测 URL 变化?

在前端路由的实现模式有两种模式,hash 和 history 模式,分别回答上述两个问题

hash 模式

  1. hash 是 url 中 hash(#) 及后面的部分,常用锚点在页面内做导航,改变 url 中的 hash 部分不会引起页面的刷新
  2. 通过 hashchange 事件监听 URL 的改变。改变 URL 的方式只有以下几种:通过浏览器导航栏的前进后退、通过<a>标签、通过window.location,这几种方式都会触发hashchange事件

history 模式

  1. history 提供了 pushStatereplaceState 两个方法,这两个方法改变 URL 的 path 部分不会引起页面刷新
  2. 通过 popchange 事件监听 URL 的改变。需要注意只在通过浏览器导航栏的前进后退改变 URL 时会触发popstate事件,通过<a>标签和pushState/replaceState不会触发popstate方法。但我们可以拦截<a>标签的点击事件和pushState/replaceState的调用来检测 URL 变化,也是可以达到监听 URL 的变化,相对hashchange显得略微复杂

JS实现前端路由

基于 hash 实现

由于三种改变 hash 的方式都会触发hashchange方法,所以只需要监听hashchange方法。需要在DOMContentLoaded后,处理一下默认的 hash 值

// 页面加载完不会触发 hashchange,这里主动触发一次 hashchange 事件,处理默认hash
window.addEventListener("DOMContentLoaded", onLoad);
// 监听路由变化
window.addEventListener("hashchange", onHashChange);
// 路由变化时,根据路由渲染对应 UI
function onHashChange() {
  switch (location.hash) {
    case "#/home":
      routerView.innerHTML = "This is Home";
      return;
    case "#/about":
      routerView.innerHTML = "This is About";
      return;
    case "#/list":
      routerView.innerHTML = "This is List";
      return;
    default:
      routerView.innerHTML = "Not Found";
      return;
  }
}

hash实现demo

基于 history 实现

因为 history 模式下,<a>标签和pushState/replaceState不会触发popstate方法,我们需要对<a>的跳转和pushState/replaceState做特殊处理。

  • <a>作点击事件,禁用默认行为,调用pushState方法并手动触发popstate的监听事件
  • pushState/replaceState可以重写 history 的方法并通过派发事件能够监听对应事件
var _wr = function (type) {
  var orig = history[type];
  return function () {
    var e = new Event(type);
    e.arguments = arguments;
    var rv = orig.apply(this, arguments);
    window.dispatchEvent(e); 
    return rv;
  };
};
// 重写pushstate事件
history.pushState = _wr("pushstate");

function onLoad() {
  routerView = document.querySelector("#routeView");
  onPopState();
  // 拦截 <a> 标签点击事件默认行为
  // 点击时使用 pushState 修改 URL并更新手动 UI,从而实现点击链接更新 URL 和 UI 的效果。
  var linkList = document.querySelectorAll("a[href]");
  linkList.forEach((el) =>
    el.addEventListener("click", function (e) {
      e.preventDefault();
      history.pushState(null, "", el.getAttribute("href"));
      onPopState();
    })
  );
}
// 监听pushstate方法
window.addEventListener("pushstate", onPopState());
// 页面加载完不会触发 hashchange,这里主动触发一次 popstate 事件,处理默认pathname
window.addEventListener("DOMContentLoaded", onLoad);
// 监听路由变化
window.addEventListener("popstate", onPopState);
// 路由变化时,根据路由渲染对应 UI
function onPopState() {
  switch (location.pathname) {
    case "/home":
      routerView.innerHTML = "This is Home";
      return;
    case "/about":
      routerView.innerHTML = "This is About";
      return;
    case "/list":
      routerView.innerHTML = "This is List";
      return;
    default:
      routerView.innerHTML = "Not Found";
      return;
  }
}

history 实现 demo

react-router的理解

image.png

在 v4 之后,我们在 View 层直接从react-router-dom中引入BrowserRouter/HashRouterBrowserRouter/HashRouter又分别使用了react-router提供的 Router 组件和 history 提供的createBrowserHistory/createHashHistory方法。

react-router v3/v4/v6的应用

v3v4v6

history

在上文中说到,BrowserRouter使用history库提供的createBrowserHistory创建的history对象改变路由状态和监听路由变化。

❓那么 history 对象需要提供哪些功能讷?

  • 监听路由变化的listen方法以及对应的清理监听unlisten方法
  • 改变路由的push方法
// 创建和管理listeners的方法 
export const EventEmitter = () => {
  const events = [];
  return {
    subscribe(fn) {
      events.push(fn);
      return function () {
        events = events.filter((handler) => handler !== fn);
      };
    },
    emit(arg) {
      events.forEach((fn) => fn && fn(arg)); 
    }
  }
}

BrowserHistory

const createBrowserHistory = () => {
  const EventBus = EventEmitter();
  // 初始化location
  let location = {
    pathname: "/"
  };
  // 路由变化时的回调
  const handlePop = function () {
    const currentLocation = {
      pathname: window.location.pathname
    };
    EventBus.emit(currentLocation); // 路由变化时执行回调
  };
  // 定义history.push方法
  const push = (path) => {
    const history = window.history;
    // 为了保持state栈的一致性
    history.pushState(null, "", path);
    // 由于push并不触发popstate,我们需要手动调用回调函数
    location = { pathname: path };
    EventBus.emit(location);
  };

  const listen = (listener) => EventBus.subscribe(listener);

  // 处理浏览器的前进后退
  window.addEventListener("popstate", handlePop);

  // 返回history
  const history = {
    location,
    listen,
    push
  };
  return history;
};

上述代码实现简单版本的 history,只有监听路由变化的listen/unlisten方法以及改变路由的push方法,详细的BrowserHistory源码

HashHistory

const createHashHistory = () => {
  const EventBus = EventEmitter();
  let location = {
    pathname: "/"
  };
  // 路由变化时的回调
  const handlePop = function () {
    const currentLocation = {
      pathname: window.location.hash.slice(1)
    };
    EventBus.emit(currentLocation); // 路由变化时执行回调
  };
  // 不用手动执行回调,因为hash改变会触发hashchange事件
  const push = (path) => window.location.hash = path
  const listen = (listener: Function) => EventBus.subscribe(listener);
  // 监听hashchange事件
  window.addEventListener("hashchange", handlePop);
  // 返回的history上有个listen方法
  const history = {
    location,
    listen,
    push
  };
  return history;
};

BrowserHistory一样,hashHistory也是极简版,详细的hashHistory源码

react-router丐版

image.png

Router

Router 接受一个 history 属性,用history.listen创建监听者,使用 context 传递 history 和location 数据

export default class Router extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      location: props.history.location // 将history的location挂载到state上
    };
    this.unlisten = props.history.listen((location) => {
      this.setState({ location });
    });
  }
  componentDidMount() {}
  componentWillUnmount() {
    this.unlisten();
  }
  render() {
    const { history, children } = this.props;
    const { location } = this.state;
    return (
      <RouterContext.Provider
        value={{
          history,
          location
        }}
      >
        {children}
      </RouterContext.Provider>
    );
  }
}

Router源码

BrowserRouter/HashRouter

只是给 Router 组件传递 history 属性

BrowserRouter

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

HashRouter

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

BrowserRouter源码/HashRouter源码

Route

Route可以接收component/render/children,但是它们渲染的优先级是不一样的。

v4/v5三个优先级不同

直接使用Route组件时,每个Route组件都会被渲染,会根据路由规则进行判断是否需要把组件渲染出来,目前代码中使用的正则来做匹配

export default class Route extends React.Component<IProps> {
  render() {
    return (
      <RouterContext.Consumer>
        {(context) => {
          const pathname = context.location.pathname;
          const {
            path,
            component: Component,
            exact = false,
            render,
            children
          } = this.props;
          const props = { ...context };
          const reg = pathToRegExp(path, [], { end: exact });
          // 判断url是否匹配
          if (!reg.test(pathname)) return null;
          if (Component) return <Component {...props} />;
          if (render) return render();
          if (children) return children;
        }}
      </RouterContext.Consumer>
    );
  }
}

Route源码

Link

在 Link 中,我们使用<a>标签来做跳转,但是 a 标签会使页面重新刷新,所以需要阻止 a 标签的默认行为,使用 context 中 history 的 push 方法

export default class Link extends React.Component<IProps> {
  render() {
    const { to, children } = this.props;
    return (
      <RouterContext.Consumer>
        {(context) => {
          return (
            <a
              href={to}
              onClick={(event) => {
                // 阻止a标签的默认行为
                event.preventDefault();
                context.history.push(to);
              }}
            >
              {children}
            </a>
          );
        }}
      </RouterContext.Consumer>
    );
  }
}

Link源码

Switch

Route 组件的功能是只要 path 匹配上当前路由就渲染组件,也就意味着如果多个 Route 的 path 都匹配上了当前路由,这几个组件都会渲染,例如/home/1能够匹配上/home/1/home,所以需要一个组件来控制匹配上一个 Route 就返回,所以 Switch 组件诞生了。

它的功能就是即使多个 Route 的 path 都匹配上了当前路由,也只渲染第一个匹配上的组件。

要实现该功能,把 Switch 的 children 拿出来循环,找出第一个匹配的 child,记录下当前的 child ,把其他的 child 全部干掉

export default class Switch extends React.Component {
  render() {
    return (
      <RouterContext.Consumer>
        {(context) => {
          const location = context.location;
          let element, match; // 两个变量记录第一次匹配上的子元素和match属性
          React.Children.forEach(this.props.children, (child) => {
            // 先检测下match是否已经匹配到了, 如果已经匹配过了,直接跳过
            if (!match && React.isValidElement(child)) {
              element = child;
              const { path, exact } = child.props;
              const reg = pathToRegExp(path, [], { end: exact });
              if (reg.test(location.pathname)) match = true;
            }
          });
          // <Switch>组件的返回值只是匹配上元素的拷贝,其他元素被忽略了
          // 如果一个都没匹配上,返回null
          return match ? React.cloneElement(element, { location }) : null;
        }}
      </RouterContext.Consumer>
    );
  }
}

Switch源码

到现在 react-router 的核心组件以及 API 都实现完成,线上demo

总结

在本文中,从前端路由入手,分析了原生的 hash/history 的路由实现,react-router 底层依赖和上层使用,实现了简版的 react-router

需要注意的是,hash 模式下三种改变 url 的方法都会触发 hashchange 时间,而 history 模式下只有浏览器前进后退会触发popstatepushState/replaceState以及<a>标签都不会。<a>标签的默认行为会触发页面刷新,所以在实现路由时需要用e.preventDefault阻止默认行为。