前端路由策略:Memory、Hash 与 History
前端路由可以理解为:根据当前路径,匹配并渲染对应的组件或页面。对于单页应用,站内导航通常不重新加载整个 HTML 文档,而是由 JavaScript 更新界面。
Memory、Hash、History 的主要区别在于路径存在哪里、导航记录由谁管理,以及刷新时服务器会收到什么请求。它们可以共用同一套路由匹配和渲染逻辑。
一、路由器的共同结构
一个路由器的基本流程是:
text
导航操作 → 更新地址状态 → 匹配路由规则 → 提取参数 → 渲染组件树
例如访问 /users/42,匹配规则 /users/:id,提取 id = "42",再渲染用户详情组件。如果存在嵌套路由,匹配结果可能是 AppLayout → UserLayout → UserDetail,而不只是单个组件。
这里要分清两层职责:地址管理决定如何读写路径,路由匹配决定路径对应什么界面。三种策略主要解决前者。
二、Memory:自己维护导航历史
Memory Router 把路径和历史记录保存在 JavaScript 内存中,不修改浏览器地址栏。核心数据结构是一个数组和一个当前位置索引:
js
let entries = ["/"];
let index = 0;
function push(path) {
entries.splice(index + 1); // 删除当前位置之后的前进记录
entries.push(path);
index++;
notify(entries[index]);
}
function replace(path) {
entries[index] = path;
notify(entries[index]);
}
function go(delta) {
const next = index + delta;
if (next < 0 || next >= entries.length) return;
index = next;
notify(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 的 # 后部分保存路由地址:
text
https://example.com/app/#/users/42
一种基础实现是读取 location.hash,通过修改 hash 导航,再监听 hashchange 更新界面:
js
function readPath() {
return location.hash.slice(1) || "/";
}
function push(path) {
location.hash = path;
}
window.addEventListener("hashchange", () => {
notify(readPath());
});
notify(readPath()); // 首次加载也需要匹配
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 和浏览器历史记录:
js
function readPath() {
return location.pathname + location.search + location.hash;
}
function push(path, state = null) {
history.pushState(state, "", path);
notify(readPath());
}
function replace(path, state = null) {
history.replaceState(state, "", path);
notify(readPath());
}
window.addEventListener("popstate", () => {
notify(readPath());
});
notify(readPath());
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/ 的前提下:
nginx
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,则应由服务端匹配请求并渲染,而不是简单返回同一份静态入口。
五、策略选择与工程边界
| 维度 | Memory | Hash | History |
|---|---|---|---|
| 路径来源 | 内存记录 | URL Fragment | URL 正常路径 |
| 浏览器前进后退 | 默认不联动 | 支持 | 支持 |
| 刷新后恢复当前路由 | 需额外实现 | 从 hash 恢复 | 从 URL 恢复,需服务端支持 |
| 服务端能否看到内部路径 | 不能 | 不能 | 能 |
| 典型场景 | 测试、独立路由容器 | 无法配置路由回退的静态托管 | 常规 Web 应用、SSR |
能够配置服务器或托管平台的 Web 应用,通常选择 History;无法配置应用路径回退时,Hash 更方便;不需要把导航暴露给浏览器 URL 的环境,可以使用 Memory。
策略本身不决定 SEO 效果:History 提供正常路径,但内容能否被索引还取决于渲染方式等因素。服务器返回 index.html 也不意味着该业务路径真实存在,应用仍需处理未匹配路由;需要正确 HTTP 404 时,还要服务端配合。
无论采用哪种策略,生产实现都应处理部署子路径、参数编码、导航请求竞态和滚动恢复。前端路由守卫只能控制界面访问流程,真正的数据权限必须由后端校验。