深入浅出前端路由体系:从 URL 到渲染的完整链路
前端路由要把内存里的 UI 状态和地址栏的 URL 始终对齐,从 hash 到 History 再到导航标准化,匹配打分、导航守卫、懒加载与部署 fallback 才是真正吃功夫的地方。
本文是前端体系化系列的第 3 篇。
在上一篇《深入浅出微前端架构》中,路由是主应用分发子应用的核心机制,activeRule 本质上就是一层路由匹配,在本文中我会尽可能的把路由本身摊开讲透:它从哪来、内部怎么工作、四种主流方案怎么选,以及如何在生产环境保证稳定性。
概述
设想一个场景:我们现在在某个管理后台的用户详情页停留了 10 分钟,随手把地址栏的链接发给了同事。同事点开后,你希望他看到什么?
-
预期结果:和你看到完全一致的页面,包括
/users/42?tab=security里隐式的信息“第 42 个用户的安全设置标签页”。 -
最坏结果:一个白屏的 404,或者跳回首页,一切状态归零。
这个差异背后就是前端路由体系的全部价值。URL 是 Web 平台的一等公民,它可被分享、收藏、回退、被搜索引擎索引。而单页应用(SPA)的一切状态都活在内存里,刷新即失忆。所以前端路由要解决的,就是让 内存中的 UI 状态与地址栏中的 URL 始终保持同步。
用一个数学公式来概括,就是:
UI = f(URL)输入 URL,输出应当呈现的界面。但工程上的路由远不止映射这一件事,它还需要处理导航拦截、参数解析、嵌套匹配、按需加载、权限守卫、滚动恢复等一系列副作用。理解这些机制的分层,是我们从会用路由到能架构路由的分水岭。
历史回顾
MPA 时代
在多页应用(MPA)时代,每个 URL 对应服务器上的一个 HTML 文档。点击一个 <a href="/users">,浏览器会:
-
卸载当前页面的全部 DOM、JavaScript 状态、滚动位置;
-
向服务器请求新文档;
-
重新解析 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 由若干组件构成,路由器对每一部分都有对应的概念:
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 路由需要。
路由器的职责
一个完整的路由器内部可以拆成五层:
-
匹配层:把路由表里的模式(
/users/:id)编译为正则或查找树,对当前路径做匹配并提取参数; -
导航层:提供
<Link>、navigate()、<Redirect>等声明式与编程式导航 API,并拦截浏览器默认行为; -
监听层:订阅
popstate(History 模式)或hashchange(Hash 模式),响应浏览器前进/后退; -
渲染层:根据匹配结果渲染嵌套的视图链(layout → outlet → page);
-
副作用层:导航守卫、数据预取、滚动恢复、标题更新、埋点上报。
导航的生命周期
以 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,也不会触发 hashchange。popstate 只在历史遍历时触发,包括用户点击前进/后退,以及脚本调用 history.back()/forward()/go()。因此,基于 History API 的路由中,navigate() 在 pushState/replaceState 后必须自己调用渲染管线,popstate 监听器则负责对历史记录遍历,两条路径最终汇合到同一个 render()
初始加载也要手动渲染一次。若使用 hash 路由,还要注意 hashchange 与 popstate 可能同时触发,需要去重。
匹配算法
路由表里同时存在 /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/1 | https://a.com/users/1 |
| 触发事件 | hashchange | popstate(程序导航需手动驱动渲染) |
| 服务器配置 | 无需 | 必须配置 fallback 到 index.html |
| 刷新/深链直达 | 天然可用,hash fragment 不发送 | 依赖服务器 rewrite |
| SEO | fragment 不入索引,需额外处理 | 真实路径,对爬虫友好 |
| 协议约束 | 无 | pushState 仅允许同源路径 |
| 典型场景 | 无服务器控制权、嵌入第三方页面、企业内网老系统 | 现代产品的默认选择 |
在选型上,遵守能配服务器就用 History,配不了就用 Hash 的策略。History 模式的 URL 干净、可索引、可被服务端日志正确归因,这些长期收益值得我们多写一行 nginx 配置。
Router 对比
| 维度 | React Router v6/v7 | Vue Router 4 | TanStack Router | Next.js App Router |
|---|---|---|---|---|
| 所属生态 | React | Vue | React(内核框架无关) | Next.js |
| 路由定义 | JSX/配置式 | 配置式 + 模板 | 配置式 + 类型推导 | 文件系统约定 |
| 匹配策略 | 相对路径 + 打分排序 | 打分排序 | 模糊匹配树 + 打分 | 目录结构即路由树 |
| 类型安全 | 部分(v7 增强) | 弱 | 极强(path 与 search 全类型化) | 强(可生成类型) |
| 数据加载 | loader / action(v7 框架模式) | 组件内自行请求 | 内置 loader + 意图预取 | RSC 服务端并行取数 |
| 嵌套布局 | Outlet | router-view | Outlet | layout.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 引入即可运行:
// 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'); // 渲染:用户 42History Router:拦截链接 + popstate
Hash 模式靠 hashchange 兜底,History 模式则要主动拦截一切站内导航:
// 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 模式上线前最容易被遗忘的一环:
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 中的落地:
// 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 参数的校验:
// 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.state,popstate 之后等渲染完成再恢复。真正麻烦的地方是不同路由的诉求不一样,回退列表页要恢复之前浏览的位置,前进到详情页要回到页面顶部,这两种逻辑常常要在同一个渲染管线里分别处理。传统 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.tech,dev.miaokit.tech),我有用 CDN 做缓存,即使发版之后某个 chunk 没有了,CDN 会帮我兜底。