ZHANGYU.dev

October 14, 2023

前端路由的实现原理

JavaScript3.3 min to read

react-router这样的库,核心实现依赖的是一个名为history的库。

history库其实就是对于window.history的一个封装,是基于原生API实现的。

window.history API

window.history提供了5个方法。

我们主要会是用pushStatereplaceState

pushState

pushState方法接收3个参数

  1. 第一个参数为state对象。
  2. 第二个参数为标题,不过这个参数浏览器并没有使用,应该传入一个空字符串防止未来API发生变化。
  3. 第三个参数为url,这个参数会实时的显示在浏览器的地址栏上。

state对象我们可以通过window.history.state来获取,默认情况下值为null

在当前页面打开控制台

输入window.history.pushState({state:0},"","/page"),可以看到浏览器的地址变成了/page

在控制台输入window.history.state,可以获取到当前的{state: 0}

输入window.history.back(),即可返回之前到上一个页面。

replaceState

replaceStatepushState不同的地方在于它会替换掉当前的历史记录。

打开控制台,输入window.history.replaceState({state:1},"","/replace"),可以看到浏览器的地址变成了/replace

在控制台输入window.history.state,可以获取到当前的{state: 1}

输入window.history.back(),回到的是上上个页面,因为上一个页面被我们替换掉了。

监听历史记录的变化

浏览器提供了一个popstate的事件来监听历史记录的变化,不过它不能监听pushStatereplaceState的变化,也无法知道当前是前进还是后退,只能监听gobackforward,和手动点击浏览器前进和后退按钮发生的历史记录变化。

window.addEventListener("popstate", event => {
	console.log(event)
})

history源码浅析

由于原生的监听略有缺陷,所以history这个库就解决了原生的问题。

它将API统一到一个history对象,同时自行实现listener的功能,在调用pushreplace函数时也会触发事件回调函数,同时会将当前是前进还是后退传入给函数。

// createBrowserHistory

let globalHistory = window.history;

// 原生popstate事件里调用自己的listeners
function handlePop() {
  let nextAction = Action.Pop;
  let [nextIndex, nextLocation] = getIndexAndLocation();
  // 调用自己的listeners
  applyTx(nextAction);
}

window.addEventListener('popstate', handlePop);

let action = Action.Pop;
let [index, location] = getIndexAndLocation();
let listeners = createEvents<Listener>();

// 调用自己的listeners
function applyTx(nextAction: Action, nextLocation: Location) {
  action = nextAction;
  location = nextLocation;
  listeners.call({ action, location });
}

function push(to: To, state?: any) {
  let nextAction = Action.Push;
  let nextLocation = getNextLocation(to, state);

  let [historyState, url] = getHistoryStateAndUrl(nextLocation, index + 1);
  globalHistory.pushState(historyState, '', url);
  // push的时候调用listeners
  applyTx(nextAction);
}

可以看到,只是创建了一个自己的listeners,在pushreplace的时候在手动调用一下,就解决了原生不触发的问题。

createHashHistorycreateBrowserHistory基本一致,只是额外增加了hashchange的事件监听。

手写React Router

基于上面的原理,其实我们已经可以简单的写一个路由了。

下面是一个很简单的20行实现的例子。

import React, { useLayoutEffect, useState } from "react";
import { createBrowserHistory } from "history";

const historyRef = React.createRef();

const Router = (props) => {
  const { children } = props;
  if (!historyRef.current) {
    historyRef.current = createBrowserHistory();
  }

  const [state, setState] = useState({
    action: historyRef.current.action,
    location: historyRef.current.location,
  });

  useLayoutEffect(() => historyRef.current.listen(setState), []);

  const {
    location: { pathname },
  } = state;

  const routes = React.Children.toArray(children);
  return routes.find((route) => route.props.path === pathname) ?? null;
};

const Route = (props) => props.children;

function App() {
  return (
    <Router>
      <Route path="/">
        <div onClick={() => historyRef.current.push("/page1")}>index</div>
      </Route>
      <Route path="/page1">
        <div onClick={() => historyRef.current.back()}>page1</div>
      </Route>
    </Router>
  );
}

其实简单来说就是根据不同的pathname展示不同的元素了,不过在react-router里没有这么简单,里面有一些复杂的判断,过段时间再对它写一篇源码浅析。