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函数时也会触发事件回调函数,同时会将当前是前进还是后退传入给函数。

// createBrowserHistorylet globalHistory = window.history;// 原生popstate事件里调用自己的listenersfunction 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>();// 调用自己的listenersfunction 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里没有这么简单,里面有一些复杂的判断,过段时间再对它写一篇源码浅析。