前端路由原理

前端路由的本质是维护一份“URL → 页面”的映射:当 URL 变化时阻止浏览器进行整页导航,解析当前地址、匹配路由配置,再渲染对应视图。Vue Router、React Router 等库的核心流程都建立在这套浏览器能力之上。

1. 路由解决了什么问题?

传统多页应用中,每次导航都会向服务器请求一份新的 HTML:

点击链接 → 浏览器请求 /users → 服务器返回 HTML → 浏览器重新加载整页

单页应用(SPA)通常只在首次访问时加载入口 HTML,之后由 JavaScript 接管站内导航:

点击链接
  → 修改地址栏但不触发整页请求
  → 读取并标准化当前 URL
  → 在路由表中查找匹配项
  → 执行守卫、加载数据和代码
  → 更新路由状态并渲染页面组件

因此,路由库不只是一个 path → component 字典。完整实现还要处理浏览历史、动态参数、嵌套路由、重定向、导航守卫、懒加载、滚动位置和异步导航竞态。

2. 浏览器为什么不会刷新?

浏览器原生导航,例如修改 location.href 或点击普通跨页链接,会创建文档请求并重新加载页面。前端路由使用的是两类只改变地址或历史记录、不重新加载文档的能力:

  • 修改 URL 的 Hash,并监听 hashchange
  • 调用 History API 的 pushStatereplaceState,并监听 popstate

路由库通常还会拦截站内 <a> 的点击事件,先 preventDefault() 阻止浏览器默认导航,再调用自己的 push()。新标签页、下载链接、外链以及带组合键的点击不应被拦截。

3. Hash 模式

Hash 模式的 URL 形如 https://example.com/#/users/42?tab=profile# 后的片段标识符不会作为 HTTP 请求路径发送给服务器,因此服务器始终看到的是 /

工作过程

  1. 跳转时设置 location.hash 或调用路由库的 push()
  2. 浏览器新增一条历史记录,但不重新请求 HTML。
  3. 浏览器触发 hashchange
  4. 路由器读取 location.hash,匹配路由并更新视图。
function getHashPath() {
  return window.location.hash.slice(1) || "/";
}

function onLocationChange() {
  render(matchRoute(getHashPath()));
}

window.addEventListener("hashchange", onLocationChange);
window.addEventListener("DOMContentLoaded", onLocationChange);

function push(path) {
  window.location.hash = path;
}

特点

  • 无须配置服务端回退,直接刷新深层地址通常不会出现 404。
  • 部署在静态文件服务器、旧 WebView 或无法控制服务端时更省心。
  • URL 中带 #,不够自然;片段标识符原本还承担页内锚点语义。
  • 服务端收不到 Hash 路径,不适合依赖服务端按路由输出 HTML 的场景。

4. History 模式

History 模式使用正常路径,例如 https://example.com/users/42。核心 API 是:

history.pushState({ from: "/" }, "", "/users/42");
history.replaceState(null, "", "/login");

它们会修改地址和浏览器历史栈,但不会触发页面加载,也不会自动触发 popstate,所以路由器在调用后必须主动执行一次导航逻辑。

function transitionTo(url, { replace = false } = {}) {
  const next = new URL(url, window.location.href);
  const method = replace ? "replaceState" : "pushState";

  history[method](null, "", next.href);
  render(matchRoute(next));
}

window.addEventListener("popstate", () => {
  render(matchRoute(new URL(window.location.href)));
});

popstate 主要在用户点击前进、后退,或代码调用 history.go()back()forward() 激活另一条历史记录时触发。pushState()replaceState() 自己不会触发它,这是手写路由时最常见的遗漏。

为什么刷新会 404?

/users/42 页面内切换路由时,前端已经加载,可以自行匹配该路径。但直接打开或刷新这个地址时,浏览器会真的请求:

GET /users/42 HTTP/1.1

若服务器把它当作文件路径,而该文件又不存在,就会返回 404。SPA 部署需要把未知的前端路由回退到入口 HTML,再由客户端路由接管:

location / {
  try_files $uri $uri/ /index.html;
}

回退规则不能无差别吞掉静态资源和 API 的 404,否则脚本加载失败或接口路径错误可能被伪装成 200 text/html。应先匹配真实静态文件和 /api 等后端路径,只让前端页面路由回退到 index.html

5. 路由器内部如何完成一次导航?

5.1 解析和标准化 URL

路由器会把地址拆成 pathnamesearchhash,并处理 base 路径、编码、尾斜杠和相对地址。路由匹配通常只使用路径部分,查询参数和 Hash 作为附加路由状态保留。

例如:

/users/42/orders/7?from=message#detail

可以得到:

{
  pathname: "/users/42/orders/7",
  query: { from: "message" },
  hash: "#detail"
}

5.2 编译并匹配路由表

声明式路由通常会被编译成可匹配结构:

const routes = [
  {
    path: "/users/:userId",
    component: User,
    children: [
      { path: "orders/:orderId", component: OrderDetail },
    ],
  },
  { path: "*", component: NotFound },
];

/users/42/orders/7 匹配后得到的不只是一个组件,而是一条父子记录链及参数:

{
  matched: [UserRoute, OrderDetailRoute],
  params: { userId: "42", orderId: "7" }
}

静态路径通常比动态参数优先,动态参数又比通配符优先。成熟路由库会对路由分支评分或排序,避免声明顺序意外改变匹配结果。

5.3 执行导航管线

匹配成功后,路由器通常按顺序执行:

  1. 判断是否离开当前页面,并执行离开守卫。
  2. 执行全局前置守卫、权限检查和重定向。
  3. 加载目标路由的异步组件与必要数据。
  4. 若导航没有被取消,提交新的路由状态。
  5. 通知视图层更新,并处理标题、滚动位置等副作用。

守卫的返回结果一般有三类:继续、取消、重定向。要限制重定向次数,避免 /a → /b → /a 形成死循环。

5.4 驱动视图渲染

路由器会维护一个可订阅的当前路由状态。URL 变化并完成导航后,它更新该状态;路由出口组件订阅变化,并按照 matched 记录逐层渲染。

当前路由状态变化
  → 根路由出口渲染 User
  → User 内部的子路由出口渲染 OrderDetail

这解释了嵌套路由为何既需要嵌套的配置,也需要对应层级的路由出口。

6. 浏览历史与pushreplace

浏览器历史可以理解为一个记录栈和当前指针:

[/] → [/list] → [/detail]
                    ↑ 当前记录
  • push:在当前记录后新增一项;适合普通页面跳转,用户可以后退。
  • replace:替换当前项;适合登录重定向、纠正非法参数,避免用户后退到无效页面。
  • back/forward/go:移动历史指针,由 popstatehashchange 通知路由器。

如果用户后退后再 push 新地址,原先的“前进”记录会被丢弃,这与浏览器普通导航一致。

7. 异步导航、懒加载与竞态

路由懒加载本质是动态 import():构建工具把页面模块拆成独立 chunk,导航到该路由时才下载。

const routes = [
  { path: "/settings", component: () => import("./Settings.js") },
];

它能减小首屏包体积,但会把部分成本推迟到导航阶段。常见优化包括加载状态、错误重试,以及对高概率下一页使用 prefetch

异步过程还会产生竞态:用户先进入 A,A 的数据尚未返回就跳到 B;如果 A 最后才完成并提交状态,页面可能被旧导航覆盖。路由器需要为每次导航生成唯一标识或使用 AbortController,提交前确认它仍是最新导航:

let navigationId = 0;

async function navigate(url) {
  const id = ++navigationId;
  const route = matchRoute(new URL(url, location.href));
  const component = await loadRouteComponent(route);

  if (id !== navigationId) return;
  commitRoute({ route, component });
}

8. History、Hash 与 Memory 路由对比

模式地址来源变化通知是否需要服务端回退典型场景
Hashlocation.hashhashchange静态托管、旧环境、无法配置服务器
Historylocation.pathnamepopstatepushState 后主动更新常规 SPA、需要自然 URL
Memory内存中的记录数组路由器内部通知测试、React Native、非浏览器环境

Memory 路由不读取地址栏,刷新后状态也不会天然保留。它不是浏览器 SPA 的常规部署模式。

9. SPA、SSR 与服务端路由的关系

在纯 SPA 中,服务端通常只负责返回入口 HTML,客户端路由负责后续页面切换。

在 SSR 或同构应用中,首次请求 /users/42 时,服务端也需要使用同一套路由语义完成匹配、取数并输出 HTML;客户端加载后再“接管”页面,之后的站内导航才走客户端路由。如果服务端和客户端的匹配结果不同,就可能产生 hydration 不一致。

因此,History API 只解决“如何改变和感知 URL”,并不自动提供 SSR。SSR 还需要服务端路由匹配、数据加载、HTML 渲染以及客户端接管。

10. 高频误区

pushState() 会触发popstate 吗?

不会。路由库调用 pushState() 后要主动匹配并渲染;popstate 用来感知历史记录被激活。

前端路由守卫能代替后端鉴权吗?

不能。前端守卫只能控制界面导航,代码和请求都可能被绕过。接口必须在服务端校验身份与权限。

Hash 模式完全没有服务端请求吗?

只有 Hash 的变化不会被发送给服务器;首次文档、脚本、样式和接口请求仍然正常发生。

History 模式为什么开发环境正常,部署后刷新 404?

开发服务器通常默认提供 SPA fallback,生产服务器若没有配置同样的回退规则,深层路径就会被当作真实文件请求。

路由切换等于组件一定销毁吗?

不一定。框架可能复用相同组件实例,路由缓存也可能保留页面。依赖路由参数的数据和副作用,应监听参数变化并在合适时机清理。

面试总结

前端路由的本质是让 URL 成为应用状态的一部分,并维护 URL 到视图的映射。Hash 模式通过改变片段标识符并监听 hashchange 工作,片段不会发送给服务器,所以无须深层路径回退;History 模式通过 pushStatereplaceState 修改历史记录,用 popstate 感知前进后退,URL 更自然,但直接访问深层路径时需要服务器回退到入口 HTML。路由器在此之上还会完成 URL 解析、路由匹配、参数提取、守卫与重定向、异步组件加载、竞态取消、状态提交和嵌套视图渲染。前端守卫只改善导航体验,真正的权限校验必须由服务端完成。