The Principles Behind Front-end Routing
View on Github Oct 15, 2023
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:
go
: Navigate to a specific page; the parameter is a number.go(1)
goes forward one page,go(-1)
goes back one page.back
: Equivalent togo(-1)
.forward
: Equivalent togo(1)
.pushState
: Adds a new history record.replaceState
: Replaces the current history record.
We mainly use pushState
and replaceState
.
pushState
The pushState
method takes three arguments.
- The first argument is the
state
object. - 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.
- 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.