前端路由策略:Memory、Hash 与 History

文章来源声明: 原文作者:triumph_passion; 来源站点:掘金; 原文链接:https://juejin.cn/post/7691165634234810406; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

能快速厘清三种路由策略的取舍边界,尤其把刷新 404 与服务端回退的成因讲得清楚。适合正在做路由选型或排查部署回退问题的前端工程师。

前端路由策略:Memory、Hash 与 History ----------------------------

前端路由可以理解为:根据当前路径,匹配并渲染对应的组件或页面。对于单页应用,站内导航通常不重新加载整个 HTML 文档,而是由 JavaScript 更新界面。

Memory、Hash、History 的主要区别在于路径存在哪里、导航记录由谁管理,以及刷新时服务器会收到什么请求。它们可以共用同一套路由匹配和渲染逻辑。

一、路由器的共同结构

一个路由器的基本流程是:

导航操作 → 更新地址状态 → 匹配路由规则 → 提取参数 → 渲染组件树

例如访问 /users/42,匹配规则 /users/:id,提取 id = "42",再渲染用户详情组件。如果存在嵌套路由,匹配结果可能是 AppLayout → UserLayout → UserDetail,而不只是单个组件。

这里要分清两层职责:地址管理决定如何读写路径,路由匹配决定路径对应什么界面。三种策略主要解决前者。

二、Memory:自己维护导航历史

Memory Router 把路径和历史记录保存在 JavaScript 内存中,不修改浏览器地址栏。核心数据结构是一个数组和一个当前位置索引:

<span>let</span> entries = [<span>"/"</span>];
<span>let</span> index = <span>0</span>;

<span>function</span> <span>push</span>(<span>path</span>) {
  entries.<span>splice</span>(index + <span>1</span>); <span>// 删除当前位置之后的前进记录</span>
  entries.<span>push</span>(path);
  index++;
  <span>notify</span>(entries[index]);
}

<span>function</span> <span>replace</span>(<span>path</span>) {
  entries[index] = path;
  <span>notify</span>(entries[index]);
}

<span>function</span> <span>go</span>(<span>delta</span>) {
  <span>const</span> next = index + delta;
  <span>if</span> (next < <span>0</span> || next >= entries.<span>length</span>) <span>return</span>;
  index = next;
  <span>notify</span>(entries[index]);
}

以上是原理示意,notify 表示通知上层重新匹配和渲染。真实实现还会保存查询参数、自定义状态和记录标识。

假设历史是 [/, /users, /users/42],后退到 /users 再跳转到 /settings,历史就变为 [/, /users, /settings]。与浏览器一样,新的导航会截断原来的前进分支。

Memory 适合自动化测试、无浏览器环境,以及需要与宿主 URL 隔离的嵌入式界面。它的前进后退由自己的 API 控制,默认不与浏览器按钮联动。React Router 的 MemoryRouter 就采用这种内存记录方式。

刷新时,浏览器请求的仍是地址栏中的真实页面地址,内存路径不会发送给服务器。因此,Memory 不会因内部路径产生 History 模式那种 404,但如果没有额外持久化和恢复逻辑,刷新后会重新初始化路由,也无法直接通过 URL 分享当前内部页面。

三、Hash:把路径放在 Fragment 中

Hash Router 使用 URL 的 # 后部分保存路由地址:

https://example.com/app/#/users/42

一种基础实现是读取 location.hash,通过修改 hash 导航,再监听 hashchange 更新界面:

<span>function</span> <span>readPath</span>(<span></span>) {
  <span>return</span> location.<span>hash</span>.<span>slice</span>(<span>1</span>) || <span>"/"</span>;
}

<span>function</span> <span>push</span>(<span>path</span>) {
  location.<span>hash</span> = path;
}

<span>window</span>.<span>addEventListener</span>(<span>"hashchange"</span>, <span>() =></span> {
  <span>notify</span>(<span>readPath</span>());
});

<span>notify</span>(<span>readPath</span>()); <span>// 首次加载也需要匹配</span>

Fragment 的关键特性是不会随 HTTP 请求发送给服务器。所以上述 URL 刷新时,服务器收到的是 GET /app/,返回应用入口后,前端再读取 /users/42 并渲染页面。这正是 Hash 通常不需要为内部路由配置服务器回退的原因。MDN:Hash routing

Hash 变化通常会形成浏览器历史记录,因此能够使用浏览器前进后退。不过,入口 /app/ 本身必须可以访问;Hash 并不能解决入口不存在的问题。

另一个细节是:在 /#/users?tab=info 中,tab=info 位于 Fragment 内,需要路由器自行解析,不能从 location.search 读取。Hash 还占用了原生页面锚点的位置,锚点跳转需要额外约定。

上述代码展示的是经典实现。现代路由库也可能通过 History API 写入带 hash 的 URL,此时不能只等待 hashchange,还需要主动通知和处理历史遍历。模式由地址形式决定,不等于固定使用某一个事件。

四、History:使用真实 URL 路径

History Router 使用普通路径,例如 /users/42,通过 History API 修改 URL 和浏览器历史记录:

<span>function</span> <span>readPath</span>(<span></span>) {
  <span>return</span> location.<span>pathname</span> + location.<span>search</span> + location.<span>hash</span>;
}

<span>function</span> <span>push</span>(<span>path, state = <span>null</span></span>) {
  history.<span>pushState</span>(state, <span>""</span>, path);
  <span>notify</span>(<span>readPath</span>());
}

<span>function</span> <span>replace</span>(<span>path, state = <span>null</span></span>) {
  history.<span>replaceState</span>(state, <span>""</span>, path);
  <span>notify</span>(<span>readPath</span>());
}

<span>window</span>.<span>addEventListener</span>(<span>"popstate"</span>, <span>() =></span> {
  <span>notify</span>(<span>readPath</span>());
});

<span>notify</span>(<span>readPath</span>());

pushState 添加记录,replaceState 替换当前记录;二者都不会立即请求目标页面,也不会触发 popstate,所以代码需要主动通知。用户在这些同文档历史记录之间前进、后退时,再通过 popstate 同步界面。此外,pushState 要求目标 URL 与当前页面同源,即使只修改 hash,也不会触发 hashchange。MDN:pushState、popstate

路由库的链接组件通常会拦截普通站内点击,阻止浏览器默认加载文档,再调用导航 API。新标签页、下载和外链等操作则应保留原生行为。

为什么刷新会出现 404?

前端跳转到 /users/42 时,pushState 只更新地址,组件由前端渲染。刷新或直接访问这个地址时,浏览器会真正发出 GET /users/42。

如果服务器只有 index.html 和静态资源,没有 /users/42 对应的文件或服务端路由,就会返回 404。此时前端应用尚未加载,无法接管这个请求。

对于纯 SPA,需要让服务器将应用路径回退到 index.html。例如在应用部署于域名根目录、静态资源位于 /assets/ 的前提下:

location /api/ {
    proxy_pass http://backend;
}

location /assets/ {
    try_files $uri =404;
}

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

这里的 backend 需替换为实际后端或已配置的 upstream。其他静态资源目录也应按项目单独处理,避免缺失的脚本被返回成 HTML。

回退通常是内部处理,浏览器地址仍保留 /users/42,前端启动后才能恢复对应页面。如果使用 SSR,则应由服务端匹配请求并渲染,而不是简单返回同一份静态入口。

五、策略选择与工程边界

维度MemoryHashHistory
路径来源内存记录URL FragmentURL 正常路径
浏览器前进后退默认不联动支持支持
刷新后恢复当前路由需额外实现从 hash 恢复从 URL 恢复,需服务端支持
服务端能否看到内部路径不能不能能
典型场景测试、独立路由容器无法配置路由回退的静态托管常规 Web 应用、SSR

能够配置服务器或托管平台的 Web 应用,通常选择 History;无法配置应用路径回退时,Hash 更方便;不需要把导航暴露给浏览器 URL 的环境,可以使用 Memory。

策略本身不决定 SEO 效果:History 提供正常路径,但内容能否被索引还取决于渲染方式等因素。服务器返回 index.html 也不意味着该业务路径真实存在,应用仍需处理未匹配路由;需要正确 HTTP 404 时,还要服务端配合。

无论采用哪种策略,生产实现都应处理部署子路径、参数编码、导航请求竞态和滚动恢复。前端路由守卫只能控制界面访问流程,真正的数据权限必须由后端校验。