深入浅出前端路由体系:从 URL 到渲染的完整链路

2026-09-16 38 min 13221 字 -- 次阅读
摘要

前端路由要把内存里的 UI 状态和地址栏的 URL 始终对齐,从 hash 到 History 再到导航标准化,匹配打分、导航守卫、懒加载与部署 fallback 才是真正吃功夫的地方。

本文是前端体系化系列的第 3 篇。

在上一篇《深入浅出微前端架构》中,路由是主应用分发子应用的核心机制,activeRule 本质上就是一层路由匹配,在本文中我会尽可能的把路由本身摊开讲透:它从哪来、内部怎么工作、四种主流方案怎么选,以及如何在生产环境保证稳定性

概述

设想一个场景:我们现在在某个管理后台的用户详情页停留了 10 分钟,随手把地址栏的链接发给了同事。同事点开后,你希望他看到什么?

  • 预期结果:和你看到完全一致的页面,包括 /users/42?tab=security 里隐式的信息“第 42 个用户的安全设置标签页”。

  • 最坏结果:一个白屏的 404,或者跳回首页,一切状态归零。

这个差异背后就是前端路由体系的全部价值。URL 是 Web 平台的一等公民,它可被分享、收藏、回退、被搜索引擎索引。而单页应用(SPA)的一切状态都活在内存里,刷新即失忆。所以前端路由要解决的,就是让 内存中的 UI 状态与地址栏中的 URL 始终保持同步

用一个数学公式来概括,就是:

plaintext
UI = f(URL)

输入 URL,输出应当呈现的界面。但工程上的路由远不止映射这一件事,它还需要处理导航拦截、参数解析、嵌套匹配、按需加载、权限守卫、滚动恢复等一系列副作用。理解这些机制的分层,是我们从会用路由到能架构路由的分水岭。

历史回顾

MPA 时代

在多页应用(MPA)时代,每个 URL 对应服务器上的一个 HTML 文档。点击一个 <a href="/users">,浏览器会:

  1. 卸载当前页面的全部 DOM、JavaScript 状态、滚动位置;

  2. 向服务器请求新文档;

  3. 重新解析 HTML、重新下载并执行 CSS/JS、重新渲染整页。

这就是我们在《深入浅出 SPA/MPA》中讨论过的模型。它的代价是明显的:白屏闪烁、上下文丢失、公共资源反复传输。但它有一个 SPA 天生缺失的优点,URL 与页面内容天然的一一对应,分享、后退、书签全都开箱即用。

AJAX

2005 年后 AJAX 兴起,页面可以局部更新了。但很快工程师们发现一个尴尬的事实:视图变了,URL 没变。用户在 Gmail 里点开一封邮件,地址栏纹丝不动,他无法把这封邮件的链接发给任何人,也无法用后退键回到列表。局部更新与全局可寻址之间出现了断层。

hash

HTML 规范中,URL 的 fragment(# 及其后部分)本来用于页内锚点跳转,且有一个鲜为人知的特性:修改 fragment 不会发起任何 HTTP 请求,也不会刷新页面,但会写入历史记录并触发 hashchange 事件

于是乎 https://mail.example.com/#/inbox/123 这样的 URL 开始出现了,fragment 成了藏在地址栏里的 SPA 状态通道,这就是 hash 路由的起源。

History API

hash 路由能用,但 URL 里永远挂着一个 #,服务器永远看不到真实路径。2011 年前后,HTML5 的 History API 开始进入各浏览器:pushState()/replaceState() 允许 JavaScript 在不发起请求、不刷新页面的前提下,把地址栏改成任意同源路径。真实路径路由从此成为可能。

timeline
    title 前端路由演进史
    1991-2004 : MPA 时代 : 每个 URL 对应一个 HTML 文档 : 链接点击即整页刷新
    2005-2010 : AJAX 与 hash 路由 : fragment 成为无刷新状态通道 : Gmail 开创 SPA 雏形
    2011-2015 : History API 普及 : pushState 修改真实路径 : React Router 与 Vue Router 诞生
    2016-2022 : 路由器成熟 : 嵌套路由与懒加载标准化 : 路由与数据加载结合
    2023-至今 : 导航标准化 : Navigation API 试验推进 : View Transitions 让 MPA 复兴

路由器的内部机制

解剖 URL

按 RFC 3986 的定义,一个 URL 由若干组件构成,路由器对每一部分都有对应的概念:

plaintext
https://example.com/users/42?tab=security#notifications
└─┬─┘   └────┬────┘ └──┬───┘ └──────┬───┘└─────┬──────┘
scheme    host      path     query(search)      fragment(hash)
  • path:路由匹配的主战场,/users/42 会被模式 /users/:id 捕获,提取出 params.id = 42

  • query/search:结构化的过滤与状态参数,是URL 即状态理念的核心载体;

  • fragment:hash 路由的全部世界,History 模式下通常只用于滚动定位。

其中fragment 永远不会被发送到服务器。HTTP 请求行只包含 path 和 query。这就是为什么 hash 路由不需要任何服务器配置,而 History 路由需要。

路由器的职责

一个完整的路由器内部可以拆成五层:

  1. 匹配层:把路由表里的模式(/users/:id)编译为正则或查找树,对当前路径做匹配并提取参数;

  2. 导航层:提供 <Link>navigate()<Redirect> 等声明式与编程式导航 API,并拦截浏览器默认行为;

  3. 监听层:订阅 popstate(History 模式)或 hashchange(Hash 模式),响应浏览器前进/后退;

  4. 渲染层:根据匹配结果渲染嵌套的视图链(layout → outlet → page);

  5. 副作用层:导航守卫、数据预取、滚动恢复、标题更新、埋点上报。

导航的生命周期

以 History 模式下点击一个站内链接为例,全链路如下:

flowchart TD
    A["用户导航动作<br/>点击 a 标签 / 调用 navigate / 浏览器后退"] --> B{同源且可拦截?}
    B -- 否 --> C["浏览器默认行为<br/>整页跳转或跨站导航"]
    B -- 是 --> D["preventDefault 拦截"]
    D --> E["解析目标 URL<br/>path + search + hash"]
    E --> F{"匹配层<br/>命中路由?"}
    F -- 否 --> G["fallback 路由或 404"]
    F -- 是 --> H{"守卫层<br/>登录态与权限校验"}
    H -- 拒绝 --> I["重定向到 /login<br/>携带 redirect 参数"]
    H -- 通过 --> J{"目标 chunk 已加载?"}
    J -- 否 --> K["动态 import 懒加载<br/>失败时重试或降级"]
    J -- 是 --> L["数据层<br/>loader 并行预取"]
    K --> L
    L --> M["渲染层<br/>渲染嵌套视图链"]
    M --> N["提交 URL<br/>pushState 或 replaceState"]
    N --> O["副作用层<br/>滚动恢复 / 更新标题 / 埋点"]

注意一个容易出错的地方:pushState()/replaceState() 不会触发 popstate,也不会触发 hashchangepopstate 只在历史遍历时触发,包括用户点击前进/后退,以及脚本调用 history.back()/forward()/go()。因此,基于 History API 的路由中,navigate()pushState/replaceState 后必须自己调用渲染管线,popstate 监听器则负责对历史记录遍历,两条路径最终汇合到同一个 render()

初始加载也要手动渲染一次。若使用 hash 路由,还要注意 hashchangepopstate 可能同时触发,需要去重。

匹配算法

路由表里同时存在 /users/:id/users/new 时,访问 /users/new 该匹配谁?两条规则理论上是匹配的,但实际答案是 /users/new 匹配,采用的是最长静态前段优先策略:主流路由器都会给每条模式打分,静态段得分高于动态参数段,动态段高于通配段。React Router v6 与 Vue Router 4 都实现了类似的打分排序(Vue Router 文档中称之为 scoring:静态段基础分远高于动态段),从而保证 /users/new 优先命中静态规则。理解这一点,你就不会再写出删掉路由顺序就跑通了的玄学代码了。

两种路由模式

下面的时序图对比了 History 模式下三种典型场景的参与者行为,Hash 模式的差异在于第三种场景(深链直达/刷新)不会发生,即 fragment 根本不发送到服务器:

sequenceDiagram
    participant B as 浏览器
    participant S as 服务器
    participant R as 路由器
    Note over B,R: 场景一:应用内导航到 /users/1
    B->>R: 拦截链接点击
    R->>B: pushState 更新地址栏(不发请求)
    R->>R: 匹配并渲染视图
    Note over B,S: 场景二:刷新或深链直达 /users/1
    B->>S: GET /users/1(fragment 不会出现)
    S->>S: 磁盘上无此文件,fallback 策略命中
    S-->>B: 200 + index.html
    R->>R: JS 启动后按当前路径渲染
    Note over B,R: 场景三:浏览器后退按钮
    B->>R: popstate 事件
    R->>R: 从历史栈恢复并渲染

主流方案

路由模式对比

维度Hash 模式History 模式
URL 形态https://a.com/#/users/1https://a.com/users/1
触发事件hashchangepopstate(程序导航需手动驱动渲染)
服务器配置无需必须配置 fallback 到 index.html
刷新/深链直达天然可用,hash fragment 不发送依赖服务器 rewrite
SEOfragment 不入索引,需额外处理真实路径,对爬虫友好
协议约束pushState 仅允许同源路径
典型场景无服务器控制权、嵌入第三方页面、企业内网老系统现代产品的默认选择

在选型上,遵守能配服务器就用 History,配不了就用 Hash 的策略。History 模式的 URL 干净、可索引、可被服务端日志正确归因,这些长期收益值得我们多写一行 nginx 配置。

Router 对比

维度React Router v6/v7Vue Router 4TanStack RouterNext.js App Router
所属生态ReactVueReact(内核框架无关)Next.js
路由定义JSX/配置式配置式 + 模板配置式 + 类型推导文件系统约定
匹配策略相对路径 + 打分排序打分排序模糊匹配树 + 打分目录结构即路由树
类型安全部分(v7 增强)极强(path 与 search 全类型化)强(可生成类型)
数据加载loader / action(v7 框架模式)组件内自行请求内置 loader + 意图预取RSC 服务端并行取数
嵌套布局Outletrouter-viewOutletlayout.tsx 约定
服务端能力v7 框架模式依托 Nuxt可接 SSR原生 RSC / 流式 SSR
适合场景通用 SPA、Remix 渐进迁移Vue 项目事实标准类型敏感的大型 SPA全栈应用、内容站

四个 Router 没有绝对的高下,他们的分化点在于路由与数据的关系:React Router v7(合并了 Remix)与 TanStack Router 把数据加载拉进路由层,用 loader 消除请求瀑布;Next.js App Router 则是更进一步,让取数发生在服务端组件里,随 HTML 流一并下发。

数据加载策略

策略时序瀑布风险缓存与失效典型代表
组件挂载后 fetch先渲染骨架,再请求高:嵌套组件层层串行等待自行管理传统 SPA
路由级 loader渲染前并行预取低:多 loader 并行revalidate / 缓存控制React Router v7、TanStack
RSC 服务端并行服务端取数,随流下发最低:与渲染同程并行服务端缓存 + 细粒度失效Next.js App Router

三级嵌套页面(layout → 列表 → 详情)各需一次请求,组件内 fetch 模式下最坏的情况是三次串行的 RTT。loader 模式下可压到一次并行窗口。这正是路由即数据边界思想的由来。

手写 Router

实现一个 Hash Router

以下代码零依赖,保存为 HTML 引入即可运行:

javascript
// mini-hash-router.js —— 零依赖 hash Router
const routes = [];

// 注册路由:把 '/user/:id' 编译成命名捕获正则
function register(pattern, render) {
  const keys = [];
  const regex = new RegExp(
    '^' +
      pattern.replace(/:([^/]+)/g, (_, key) => {
        keys.push(key);
        return '([^/]+)'; // 参数段匹配单个路径段
      }) +
      '$'
  );
  routes.push({ regex, keys, render });
}

// 匹配当前路径,提取参数对象
function match(path) {
  for (const route of routes) {
    const result = route.regex.exec(path);
    if (result) {
      const params = {};
      route.keys.forEach((key, i) => {
        params[key] = decodeURIComponent(result[i + 1]); // 还原 URL 编码
      });
      return { render: route.render, params };
    }
  }
  return null; // 未命中交给调用方兜底
}

// 渲染入口:hash '#/user/42' -> path '/user/42'
function render() {
  const path = location.hash.slice(1) || '/';
  const matched = match(path) || match('/404');
  document.getElementById('app').innerHTML = matched.render(matched.params);
}

// 编程式导航:赋值 location.hash 自动触发 hashchange 并写入历史
function navigate(hash) {
  if (location.hash !== hash) location.hash = hash;
  else render(); // 同 hash 重复导航时手动渲染
}

window.addEventListener('hashchange', render); // 前进/后退/改 hash 统一入口
window.addEventListener('DOMContentLoaded', render);

// ---- 使用示例 ----
register('/', () => '<h1>首页</h1>');
register('/user/:id', ({ id }) => `<h1>用户 ${id}</h1>`);
register('/404', () => '<h1>404 Not Found</h1>');
navigate('/user/42'); // 渲染:用户 42

History Router:拦截链接 + popstate

Hash 模式靠 hashchange 兜底,History 模式则要主动拦截一切站内导航:

javascript
// mini-history-router.js —— History API 路由器
const routes = [];
const register = (pattern, render) => {
  const keys = [];
  const regex = new RegExp(
    '^' +
      pattern.replace(/:([^/]+)/g, (_, k) => (keys.push(k), '([^/]+)')) +
      '/?$' // 容忍尾斜杠,/user/1/ 与 /user/1 等价
  );
  routes.push({ regex, keys, render });
};
const match = (path) =>
  routes
    .map((r) => ({ r, m: r.regex.exec(path) }))
    .find(({ m }) => m) || null;

function render() {
  const { r, m } = match(location.pathname) || { r: { keys: [], render: () => '<h1>404</h1>' }, m: null };
  const params = {};
  r.keys.forEach((k, i) => (params[k] = m && decodeURIComponent(m[i + 1])));
  document.getElementById('app').innerHTML = r.render(params);
}

// 编程式导航:pushState 不触发 popstate,必须手动渲染
function navigate(to, { replace = false } = {}) {
  history[replace ? 'replaceState' : 'pushState']({ scrollY: window.scrollY }, '', to);
  window.scrollTo(0, 0);
  render();
}

// 全局拦截 <a> 点击,仅接管同源普通左键点击
document.addEventListener('click', (e) => {
  const link = e.target.closest('a');
  if (!link || link.target || e.metaKey || e.ctrlKey || e.shiftKey) return; // 新开页等场景放行
  const url = new URL(link.href, location.href);
  if (url.origin !== location.origin) return; // 跨源放行
  e.preventDefault();
  navigate(url.pathname + url.search + url.hash);
});

window.addEventListener('popstate', render); // 后退/前进

// ---- 使用示例 ----
register('/user/:id', ({ id }) => `<h1>用户 ${id}</h1>`);
navigate('/user/7');

配套的服务器 fallback(以 nginx 为例),这是 History 模式上线前最容易被遗忘的一环:

nginx
server {
  listen 80;
  root /var/www/app;
  location / {
    try_files $uri $uri/ /index.html;  # 文件不存在时回退到 SPA 入口
  }
}

生产级路由架构

我们把五层职责拼成一张架构图,就是一个生产级 SPA 的路由切面:

flowchart LR
    URL["URL 状态<br/>path + params + search + hash"] --> M

    subgraph RouterCore["路由核心"]
        M["匹配层<br/>正则编译 + 优先级打分"]
        G["守卫层<br/>登录态 / 权限 / 灰度"]
        D["数据层<br/>loader 并行预取"]
    end

    subgraph Build["构建期"]
        CS["路由级代码分割<br/>动态 import 产出 chunk"]
        PF["预取策略<br/>hover / 视口内触发"]
    end

    subgraph View["渲染层"]
        L1["Layout 链"]
        L2["Outlet 嵌套"]
        VT["View Transition<br/>可选转场动画"]
    end

    M --> G --> D --> L1 --> L2 --> VT
    CS -.-> M
    PF -.-> CS

对应的路由级懒加载在 React 中的落地:

javascript
// App.jsx —— 路由级代码分割:每个路由一个独立 chunk
import { lazy, Suspense } from 'react';

// 动态 import:构建工具会为每个页面生成独立 chunk
const UserDetail = lazy(() => import('./pages/UserDetail'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <Routes>
        {/* webpackPrefetch / vite prefetch:空闲时预取下一个可能访问的页面 */}
        <Route path="/users/:id" element={<UserDetail />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  );
}

路由守卫

路由守卫是鉴权体系在前端的实现(鉴权全链路可回看系列的 SSO 一文)。下面是一个框架无关的守卫实现,其重点在 redirect 参数的校验:

javascript
// route-guard.js —— 登录守卫 + 开放重定向防护
function isSafeRedirect(target) {
  if (typeof target !== 'string') return false;
  let decoded;
  try {
    decoded = decodeURIComponent(target); // 先解码,防止 %2F%2F 绕过
  } catch {
    return false; // 非法编码直接拒绝
  }
  // 只允许"以单个 / 开头的站内相对路径"
  // 同时拦截 //evil.com(协议相对)与 /\evil.com(浏览器会当 // 处理)
  return /^\/(?!\/)/.test(decoded) && !decoded.includes('\\');
}

function beforeEach(to, { isLoggedIn }) {
  const needAuth = to.meta?.public !== true;
  if (!needAuth || isLoggedIn) return true;

  const redirect = encodeURIComponent(to.fullPath); // 登录后回跳原页面
  return { path: '/login', query: { redirect } };
}

// 登录成功时的跳转侧,必须同样校验
function afterLogin(searchParams) {
  const target = searchParams.get('redirect') || '/';
  location.replace(isSafeRedirect(target) ? target : '/'); // 不安全一律回首页
}

常见问题

在工程实践中,我们更常见的并非是前端代码引发的事故,而是部署环境的事故,因为代码我们有各种手段去拦截,不限于单元测试、UI 测试、E2E 测试、人工测试、回归测试等一系列的手段。但是部署环境的时候,可能往往被我们低估了。

深链 404

深链 404 几乎是 History 模式的第一事故源,“我本地好好的,用户从微信里点开就是 404”。原因其实不复杂,比如 /users/1 在前端是路由,在服务器眼里却是一个不存在的静态资源。nginx 里写一行 try_files $uri $uri/ /index.html; 就能解决大部分场景。

但真实部署往往不止一层。

CDN 有它自己的回退配置,对象存储有 index document 和 error document 的设置,如果前面还有负载均衡,也要确认非静态资源请求最终落到了应用而不是被当作 404 直接返回。

我们在配置的时候有个常见误区:ALB 并不是 nginx,它没有 try_files 那种重写语法,指望在 ALB 规则里做 SPA fallback 通常行不通,要么靠 CloudFront Function 或 Lambda@Edge,要么让后端应用自己兜住。三层里任何一层漏配,用户看到的就是同一个 404。

检查验收方式很简单,执行 curl -I https://your.domain/app/users/1 应该返回 200 且 content-type: text/html,同时地址栏 URL 保持不变。

base path

base path 决定了你的应用能部署在哪。

当应用不在根路径部署,而是在 https://cdn.example.com/app/ 下面时,我们的配置就需要有一些匹配的变化,比如 createWebHistory('/app/')<BrowserRouter basename="/app">、Vite 的 base 配置、以及 nginx 的 fallback 路径,这四处必须对齐。少对齐一处,要么资源 404,要么路由匹配不上,要么刷新页面直接挂掉。

在工程实践中我倾向于把 base 提取成一个构建时的环境变量,四个地方引用同一个来源,这样无论怎么变,都只需要改一处。

上线前记得检查构建产物里 JS/CSS 的引用前缀是否正确,直接访问 /app/users/1 刷新能不能拿到 200,应用内跳转会不会把 /app 前缀丢掉。

滚动位置恢复

这对于用户来说是一种非常好的体验,HTML 规范给 History API 配了一个 history.scrollRestoration,默认是 auto,浏览器会尝试在历史遍历时恢复滚动位置。

但 SPA 的问题是异步渲染,如果浏览器想恢复,但是 DOM 高度还没到位,这个结果就是回到顶部。所以大多数框架会把 scrollRestoration 设为 manual,应用自己接管。接管的逻辑并不复杂,在 pushState 之前把当前 scrollY 存进 history.statepopstate 之后等渲染完成再恢复。真正麻烦的地方是不同路由的诉求不一样,回退列表页要恢复之前浏览的位置,前进到详情页要回到页面顶部,这两种逻辑常常要在同一个渲染管线里分别处理。传统 SPA 框架的 <ScrollRestoration> 组件,本质就是把这套逻辑封了一层。

URL 规范化

URL 的规范会切实的影响到我们站点的 SEO(感兴趣可以参考之前的 SEO 实践文章)。

假如 /users/1/users/1//USERS/1 如果都能返回 200,搜索引擎会认为这是三个页面(或判定为重复内容),权重被稀释。所以在实际 SEO 优化中,路由匹配层需要统一剥离尾斜杠、统一大小写,服务端需要对非规范形式做 301 到规范形式,页面上用 <link rel="canonical"> 指向唯一地址。只有这三步都做到位,我们页面的 SEO 才算是做的比较好的。

微前端

在之前的《深入浅出微前端架构》中写到过,主应用与 N 个子应用都在监听 popstate 时,一次后退可能触发 N+1 次渲染。qiankun 的解法是 activeRule 匹配 + 子应用路由 base 隔离,而更彻底的无界/iframe 方案则干脆用浏览器的多历史栈隔离。无论哪种方案,其核心原则都是,在同一时刻只允许一个应用拥有路由控制权

chunk 加载失败

发版频率高的团队迟早会遇到这个问题。旧页面的懒加载 chunk 哈希在新版本里已经不存在,用户点击导航时抛 ChunkLoadError,页面白屏。一般采用的兜底方案是对动态 import 做一层重试,重试一次仍失败就提示用户刷新。Vite 用户可以直接监听 vite:preloadError。这段代码平时看不出价值,但发版越频繁,这个代码为我们展示的价值就越高。

不过,这种问题我过去基本没有遇到过,原因是,之前的团队是将编译产物上传到了对象存储中,前端只需要渲染 html 即可,对象存储中的产物文件是不会删除的,所以无论用户在哪个版本,都不会遇到这种问题,现在的团队没有这套逻辑,所以有遇到过,所以后来我选择了不配置任何懒加载(因为项目不大,即使不懒加载也不会有用户体验上的很大差异)。至于现在你在阅读的这个博客站点以及其他我的工具站(如 miaokit.techdev.miaokit.tech),我有用 CDN 做缓存,即使发版之后某个 chunk 没有了,CDN 会帮我兜底。

参考文档

评论