浏览器路由系统的一种实践

在单页面应用(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纯内存模式,不涉及与浏览器的交互。这种设计有几个好处:

  1. 易于测试:可以在 Node.js 环境(如 Deno)中直接运行测试,无需模拟浏览器 API
  2. 逻辑清晰:专注于路由状态管理的核心逻辑,不被浏览器 API 的细节干扰
  3. 灵活扩展:读者可以根据实际需求选择不同的浏览器联动方式

实际应用中,你需要将路由状态与浏览器 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();
  }
}
相关推荐
老前端的功夫3 小时前
Vue 插槽深度解析:从基础到高级架构设计
前端
pre_lee3 小时前
vue2响应式原理
前端
小奶包他干奶奶3 小时前
如何使用vscode和express开发node.js
前端·node.js
庞囧3 小时前
通俗易懂讲 React 原理-第二集:Fiber
前端
beckyye3 小时前
给web增加简单的ai对话功能
前端·ai·通义千问·qwen
青衫码上行4 小时前
【Java Web学习 | 第1篇】前端 - HTML
java·前端·学习
元直数字电路验证4 小时前
HTML 标签及推荐嵌套结构
前端·javascript·html
charlie1145141914 小时前
HTML 理论笔记
开发语言·前端·笔记·学习·html·1024程序员节