ZHANGYU.dev

October 15, 2023

The Principles Behind Front-end Routing

JavaScript5.0 min to read

Libraries like react-router fundamentally depend on a package called history.

history is essentially a wrapper for the native window.history API.

window.history API

window.history provides five methods:

We mainly use pushState and replaceState.

pushState

The pushState method takes three arguments.

  1. The first argument is the state object.
  2. The second argument is the title, which is currently unused by the browser. To future-proof your code, it's advisable to pass an empty string here.
  3. The third argument is url, which is displayed in the browser's address bar in real-time.

The state object can be accessed via window.history.state and defaults to null.

To demonstrate, open the browser's console on the current page and type window.history.pushState({state:0},"","/page"). You'll notice the browser address changes to /page.

Run window.history.state in the console; you'll see {state: 0}.

Run window.history.back() to go back a page.

replaceState

The key difference between replaceState and pushState is that replaceState replaces the current history record.

Open your console and type window.history.replaceState({state:1},"","/replace"). You'll notice the browser address changes to /replace.

Type window.history.state in the console to retrieve the current {state: 1}.

Type window.history.back() to navigate to the previous page because the last one was replaced by us.

Tracking History Changes

The browser provides a popstate event to listen to history changes. However, this cannot track changes made by pushState or replaceState, nor can it determine the direction of navigation (forward or backward). It tracks only changes via go, back, forward, and browser navigation buttons.

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

A Brief Dive into history's Source Code

The history library solves the limitations of native listeners. It unifies these various APIs into a single history object and independently implements listener functionality. When calling push or replace, it triggers the associated event callback functions and passes in the direction of navigation.

// createBrowserHistory

let globalHistory = window.history;

// Call it own listeners in the native popstate event
function handlePop() {
   let nextAction = Action.Pop;
   let [nextIndex, nextLocation] = getIndexAndLocation();
   // Call it own listeners
   applyTx(nextAction);
}

window.addEventListener('popstate', handlePop);

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

// Call it own 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);
   // Call listeners when push
   applyTx(nextAction);
}

You'll notice that it merely creates its own listeners array and manually invokes them during push and replace, thereby addressing the issues of native APIs not triggering these events.

createHashHistory is almost identical to createBrowserHistory, but it additionally listens for hashchange events.

Implementing React Router from Scratch

Based on these principles, we can already write a simple router.

Below is a straightforward 20-line implementation example.

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>
  );
}

In essence, different pathname are used to display different elements. However, react-router includes more complex conditions and logic. A more detailed analysis of its source code will be published soon.