在单页面应用(SPA)中,路由系统是连接 URL 与应用状态的桥梁。本文将采用路由状态与视图分离的设计理念,聚焦于路由的状态管理层实现,从零构建一个简洁但功能完整的路由系统。
路由系统的核心设计
1. 路由表的设计
路由表是路由系统的配置中心,定义了 URL 路径与组件的映射关系。
typescript
interface Component {
name: string;
}
type LazyComponent = () => Promise<{ default: Component }>;
export interface RouteConfig {
path: string; // 路径段,如 "user" 或 ":id"
component: Component | LazyComponent; // 组件(支持同步/异步)
children?: RouteConfig[]; // 子路由(嵌套路由)
}
- 组件懒加载支持 :
component可以是同步的组件对象,也可以是返回 Promise 的函数,支持按需加载。 - 嵌套路由 : 通过
children字段支持路由嵌套,这对应了页面的层级结构。
2. 路由状态管理
路由状态管理负责维护历史记录栈和当前位置,这是路由系统的核心。这里模拟了浏览器 history 的状态,通过 订阅 模式去通知对应的回调。
typescript
class RouterHistory {
private stack: string[] = ["/"]; // 历史栈,初始为根路径
private current = 0; // 当前位置指针
private listeners: Array<(path: string) => void> = []; // 监听器
get currentPath() {
return this.stack[this.current];
}
push(path: string) {
// 清除当前位置之后的历史
this.stack = this.stack.slice(0, this.current + 1);
this.stack.push(path);
this.current++;
this.notify();
}
replace(path: string) {
this.stack[this.current] = path;
this.notify();
}
back() {
if (this.current > 0) {
this.current--;
this.notify();
}
}
forward() {
if (this.current < this.stack.length - 1) {
this.current++;
this.notify();
}
}
listen(fn: (path: string) => void) {
this.listeners.push(fn);
return () => {
this.listeners = this.listeners.filter((l) => l !== fn);
};
}
private notify() {
this.listeners.forEach((fn) => fn(this.currentPath));
}
}
3. 路由匹配
根据 path,获取一个完整的路由。可以通过 path-to-regex 等工具实现比较完善的路由匹配。
4. 懒加载与路由取消
现代路由系统必须支持按需加载组件,以优化应用性能。同时,当用户快速切换路由时,需要能够取消正在加载的路由。
懒加载的实现
在路由配置中,component 可以是一个返回 Promise 的函数:
typescript
const routes = [
{
path: "dashboard",
// 懒加载: 只有访问该路由时才加载组件
component: () => import("./Dashboard"),
},
];
当检测到 component 是函数时,会调用它并等待加载完成:
typescript
let component = route.component;
if (typeof component === "function") {
try {
// 调用函数,获取异步加载的组件
component = await(component as LazyComponent)().default;
// 加载完成后检查是否已被取消
if (signal.aborted) {
console.log("Route loading cancelled after load");
return; // 取消本次路由更新
}
} catch (error) {
if (signal.aborted) {
console.log("Route loading cancelled during load");
return;
}
throw error;
}
}
路由取消的实现
为什么需要路由取消?
javascript
// 场景 1: 快速切换路由
用户点击 /pageA -> 开始加载组件 A
用户立即点击 /pageB -> 需要取消 A 的加载,开始加载 B
// 场景 2: 权限验证失败
用户访问 /admin -> 开始加载
守卫检测到未登录 -> 取消加载,重定向到 /login
// 场景 3: 异步组件加载慢
用户访问 /slow-page -> 开始加载(需要 3 秒)
用户等待 1 秒后点击 /other -> 需要取消慢速加载
本文通过 AbortController 机制实现取消异步
typescript
class Router {
private abortController: AbortController | null = null;
private async matchRoute(pathname: string) {
// 如果有上一次的加载,取消它
if (this.abortController) {
this.abortController.abort(); // 发送取消信号
}
// 创建新的控制器
this.abortController = new AbortController();
const signal = this.abortController.signal;
// ... 加载组件 ...
// 在异步操作的关键点检查取消状态
if (signal.aborted) {
return; // 被取消,放弃后续操作
}
}
}
5. 与浏览器联动
为了保持代码简洁和易于理解,本文实现的 RouterHistory 是纯内存模式,不涉及与浏览器的交互。这种设计有几个好处:
- 易于测试:可以在 Node.js 环境(如 Deno)中直接运行测试,无需模拟浏览器 API
- 逻辑清晰:专注于路由状态管理的核心逻辑,不被浏览器 API 的细节干扰
- 灵活扩展:读者可以根据实际需求选择不同的浏览器联动方式
实际应用中,你需要将路由状态与浏览器 URL 同步。浏览器提供了两种主流方案:
History API 模式
原理 :使用 HTML5 History API 操作浏览器历史记录栈,URL 形如 /user/123(无 # 符号)。
核心浏览器 API:
javascript
// 添加/替换 新的历史记录
history.pushState(state, title, url);
history.replaceState(state, title, url);
// 前进/后退
history.back();
history.forward();
history.go(n);
// history.back/forward/go 会触发 popstate 事件
window.addEventListener("popstate", (event) => {
// 用户点击浏览器前进/后退时触发
console.log("当前路径:", window.location.pathname);
});
优点:
- URL 更美观,无
#符号 - 完整的历史栈操作
Hash 模式
原理 :通过 URL 的 hash 部分(#)实现路由,形如 /#/user/123。Hash 的特点是不会触发浏览器刷新
核心浏览器 API:
javascript
// 修改 hash (会自动触发 hashchange 事件)
window.location.hash = "/user/123";
// 监听 hash 变化
window.addEventListener("hashchange", (event) => {
const newPath = window.location.hash.slice(1); // 去掉 #
const oldPath = new URL(event.oldURL).hash.slice(1);
console.log(`从 ${oldPath} 切换到 ${newPath}`);
});
接入视图层
到目前为止,我们实现的路由系统只负责状态管理,还不能自动渲染组件。要让路由系统真正工作,需要将路由状态与具体的 UI 框架连接起来。
主流的路由库(React Router、Vue Router)都采用了视图占位组件 的设计模式:通过一个特殊的组件(如 <RouterView> 或 <Outlet>)作为"插槽",根据当前路由状态渲染对应的组件。
这种设计的核心思想是:
- 路由系统维护匹配结果数组
matches - 视图组件根据自己的"深度"(嵌套层级)从
matches中取出对应的组件并渲染 - 通过依赖注入机制(Vue 的 provide/inject,React 的 Context)传递路由实例和深度信息
Vue Router 风格实现
Vue Router 使用 <router-view> 组件作为视图占位符,支持嵌套路由的自动渲染。
核心实现
typescript
class VueRouterView {
name = "RouterView";
setup() {
// 1. 获取当前组件的嵌套深度
// 父组件会通过 provide 注入深度信息
// 根组件的深度为 0,每嵌套一层 +1
const depth = this.inject("routerViewDepth", 0);
// 2. 为子组件提供新的深度值
this.provide("routerViewDepth", depth + 1);
// 3. 获取路由实例
const router = this.inject("router") as Router;
// 4. 返回渲染函数
return () => {
// 获取当前的匹配结果数组
const matches = router.getMatches();
// 根据深度取出对应的匹配项
const matched = matches[depth];
if (!matched) {
// 没有匹配到组件,渲染空
return null;
}
// 渲染对应深度的组件
return this.h(matched.component, {
key: matched.path, // 使用 path 作为 key,路由变化时重新渲染
});
};
}
}
嵌套渲染原理
假设有如下路由配置和匹配结果:
typescript
// 路由配置
const routes = [
{
path: "user",
component: UserLayout,
children: [
{
path: "profile",
component: UserProfile,
},
],
},
];
// 当访问 /user/profile 时,matches 为:
matches = [
{ path: "/user", component: UserLayout }, // depth 0
{ path: "/user/profile", component: UserProfile }, // depth 1
];
渲染过程:
xml
<div id="app">
<router-view /> <!-- depth = 0 -->
</div>
第一层 <router-view> (depth=0):
↓ 从 matches[0] 取出 UserLayout
↓ 渲染 UserLayout 组件
<UserLayout>
<div class="user-layout">
<router-view /> <!-- depth = 1 -->
</div>
</UserLayout>
第二层 <router-view> (depth=1):
↓ 从 matches[1] 取出 UserProfile
↓ 渲染 UserProfile 组件
<UserProfile>
<div class="user-profile">
用户资料页面
</div>
</UserProfile>
最终渲染结果:
<div id="app">
<div class="user-layout">
<div class="user-profile">
用户资料页面
</div>
</div>
</div>
React Router 风格实现
React Router 使用 <Outlet> 组件(或早期版本的 <Route>)作为视图占位符。
核心实现
typescript
import { createContext, useContext, useState, useEffect, useMemo } from "react";
// 1. 创建 Context 传递路由信息
const RouterContext = createContext<{
router: Router;
depth: number;
}>({
router: null as any,
depth: 0,
});
// 2. Outlet 组件
function Outlet() {
// 获取当前深度和路由实例
const { router, depth } = useContext(RouterContext);
// 获取匹配结果
const matches = router.getMatches();
const matched = matches[depth];
if (!matched) {
return null;
}
const Component = matched.component;
// 为子组件提供新的深度
return (
<RouterContext.Provider value={{ router, depth: depth + 1 }}>
<Component key={matched.path} />
</RouterContext.Provider>
);
}
// 3. 根路由组件
function RouterProvider({
router,
children,
}: {
router: Router;
children: React.ReactNode;
}) {
// 监听路由变化,强制重新渲染
const [, forceUpdate] = useState(0);
useEffect(() => {
return router.history.listen(() => {
forceUpdate((v) => v + 1);
});
}, [router]);
return (
<RouterContext.Provider value={{ router, depth: 0 }}>
{children}
</RouterContext.Provider>
);
}
两种实现的对比
| 特性 | Vue Router | React Router |
|---|---|---|
| 视图组件 | <router-view> |
<Outlet> |
| 依赖注入 | provide / inject |
Context |
| 深度追踪 | 通过 inject 获取并递增 | 通过 Context 传递 |
| 响应式更新 | Vue 的响应式系统自动处理 | 需要手动监听并调用 forceUpdate |
| 渲染函数 | setup() 返回渲染函数 |
函数组件直接返回 JSX |
参考资源
本文使用的完整代码
ts
// ==================== 1. 路由表结构 ====================
interface Component {
name: string;
}
type LazyComponent = () => Promise<{ default: Component }>;
export interface RouteConfig {
path: string;
component: Component | LazyComponent;
children?: RouteConfig[];
}
interface RouteMatch {
path: string;
component: Component;
}
// ==================== 2. 路由状态管理 ====================
class RouterHistory {
private stack: string[] = ["/"];
private current = 0;
private listeners: Array<(path: string) => void> = [];
get currentPath() {
return this.stack[this.current];
}
push(path: string) {
// 清除当前位置之后的历史
this.stack = this.stack.slice(0, this.current + 1);
this.stack.push(path);
this.current++;
this.notify();
}
replace(path: string) {
this.stack[this.current] = path;
this.notify();
}
back() {
if (this.current > 0) {
this.current--;
this.notify();
}
}
forward() {
if (this.current < this.stack.length - 1) {
this.current++;
this.notify();
}
}
listen(fn: (path: string) => void) {
this.listeners.push(fn);
return () => {
this.listeners = this.listeners.filter((l) => l !== fn);
};
}
private notify() {
this.listeners.forEach((fn) => fn(this.currentPath));
}
}
// ==================== 3. 路由匹配 ====================
export class Router {
private routes: RouteConfig[];
private history: RouterHistory;
private currentMatches: RouteMatch[] = [];
// 4. 路由取消
private abortController: AbortController | null = null;
constructor(routes: RouteConfig[]) {
this.routes = routes;
this.history = new RouterHistory();
this.history.listen(async (path) => {
await this.matchRoute(path);
});
}
// 匹配路由并生成 matched 数组
private async matchRoute(pathname: string) {
// 取消上一次的路由加载
if (this.abortController) {
this.abortController.abort();
}
this.abortController = new AbortController();
const signal = this.abortController.signal;
const matches: RouteMatch[] = [];
let routes = this.routes;
let currentPath = "";
// TODO 可以使用 path-to-regex 等工具实现更复杂的 path 匹配
const segments = pathname.split("/").filter(Boolean);
for (const segment of segments) {
const route = routes.find((r) => {
const routeSegment = r.path.replace("/", "");
return routeSegment === segment || routeSegment.startsWith(":");
});
if (!route) break;
currentPath += "/" + segment;
// 3. 懒加载处理
let component = route.component;
if (typeof component === "function") {
try {
component = (await (component as LazyComponent)()).default;
// 加载完成后再次检查
if (signal.aborted) {
console.log("Route loading cancelled after load");
return;
}
} catch (error) {
if (signal.aborted) {
console.log("Route loading cancelled during load");
return;
}
throw error;
}
}
matches.push({ path: currentPath, component });
if (route.children) {
routes = route.children;
} else {
break;
}
}
this.currentMatches = matches;
}
getMatches() {
return this.currentMatches;
}
push(path: string) {
this.history.push(path);
}
replace(path: string) {
this.history.replace(path);
}
back() {
this.history.back();
}
forward() {
this.history.forward();
}
}