你以为每天用的Vue-Router、React-Router只是配置几个路径就完事了?今天带你从0到1实现一个Hash路由,把前端路由的那点事儿彻底整明白。
前言:一个让前端开发质变的“小东西”
还记得早年用jQuery开发多页面应用的日子吗?每次点击链接,整个页面刷新,白屏闪烁,用户干等着。移动端时代,这种体验直接劝退。
直到单页应用(SPA) 的出现,才真正让Web应用有了App般的流畅体验。而这一切的核心,就是前端路由。
今天我们不调库不框架,直接从浏览器最基础的API出发,手写一个Hash路由,把路由原理吃透。
说白了,前端路由就是在不刷新页面的前提下,让URL的变化与UI的更新建立起一一对应的关系。
一、传统多页面 vs 单页应用:到底差在哪?
1.1 多页面的“笨拙”
传统模式下,页面跳转是这样的:
<!-- index.html -->
<a href="/about.html">关于我们</a>
用户点击后:
- 浏览器向服务器发起新请求
- 服务器返回完整的HTML
- 浏览器重新解析、渲染整个页面
- 白屏闪现,资源重新加载
这种方式的问题在于:明明只是内容区域变了,却要重新加载整个页面。导航栏、页脚、侧边栏这些公共部分也得跟着重绘。
1.2 SPA的“优雅”
单页应用只加载一次HTML,后续所有的页面切换都在同一个页面内通过JavaScript动态完成:
<!-- 只有一个页面,内容动态替换 -->
<div id="container"></div>
<script>
// 根据URL变化,动态修改container的内容
</script>
用户体验瞬间提升:无白屏、无刷新、丝滑切换。
但问题来了:URL怎么变?浏览器怎么知道当前展示的是哪个“页面”?
二、Hash路由:前端路由的“开山鼻祖”
2.1 先搞懂URL和Location对象
在深入Hash之前,我们必须先把浏览器的地址栏给“解剖”了。一个完整的URL长这样:
http://www.baidu.com:80/u/123?a=1&b=2#page1
└─┬─┘ └────┬────┘ └┬┘ └─┬──┘ └───┬────┘ └─┬──┘
协议 主机名 端口 路径 查询字符串 Hash
在浏览器中,我们可以通过全局对象 window.location 来操控这一切。它就像一把手术刀,能精准地切割URL的每个部分:
// 假设当前地址是:http://localhost:5500/index.html#/page1?name=test
console.log(location.protocol); // "http:"
console.log(location.hostname); // "localhost"
console.log(location.port); // "5500"
console.log(location.pathname); // "/index.html"
console.log(location.search); // "?name=test"
console.log(location.hash); // "#/page1" <-- 我们今天的主角
其中,hash 属性对应URL中从 # 开始一直到末尾的部分。它有一个极其特殊的“性格”:
- 改变它,页面不会刷新(浏览器不会向服务器发请求)。
- 改变它,浏览器历史记录会增加(支持前进/后退)。
- 改变它,会触发
hashchange事件。
这正是我们实现前端路由的“天选之子”!
2.2 为什么是Hash?(特性回顾)
要实现前端路由,核心要解决两个问题:
- 改变URL但不触发页面刷新
- 能监听到URL的变化并执行对应的逻辑
Hash(#后面的部分)天生就是为此设计的:
| 特性 | Hash的表现 |
|---|---|
| 改变hash | 不会触发页面刷新 |
| 改变hash | 会在浏览器历史中留下记录 |
| 改变hash | 会触发 hashchange 事件 |
// 改变hash的三种方式
location.hash = '#/page1' // 方式一:JS赋值
location.href = '#/page2' // 方式二:重写href
<a href="#/page3"> // 方式三:点击锚点链接
2.3 Hash的“前世今生”
你可能不知道,Hash最初的设计是为了页面内锚点定位(也就是我们常说的“电梯直达”):
<!-- 锚点链接:在长页面内“坐电梯” -->
<a href="#bottom">去到底部</a>
<div style="height:200vh"></div>
<a name="bottom">底部内容</a>
点击链接,页面滚动到对应位置,页面本身不会刷新。这个特性被前端开发者“借用”过来,成就了第一代前端路由。
一个冷知识:Hash路由的诞生,本质上是开发者对浏览器特性的“创造性利用”。
三、完整流程图解:从点击到渲染,这期间发生了什么?
光知道API不够,我们必须把整个数据流(Data Flow) 在脑子里跑通。当用户点击一个带有 #/page1 的链接时,浏览器到底走过了怎样的历程?
┌─────────────────────────────────────────────────────────────────┐
│ 1. 用户交互层 │
│ 用户点击 <a href="#/page1"> 或执行 location.hash = '#/page1' │
└──────────────────────────┬──────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ 2. 浏览器底层行为 │
│ ① 更新地址栏 URL(加入 #/page1) │
│ ② 不向服务器发起 HTTP 请求(Hash 改变的特性) │
│ ③ 将这次变更推入浏览器历史记录栈(方便后退) │
└──────────────────────────┬──────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ 3. 事件触发层 │
│ 浏览器自动触发 window 上的 'hashchange' 事件 │
│ (事件对象中携带了 oldURL 和 newURL) │
└──────────────────────────┬──────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ 4. 路由拦截层(我们的代码) │
│ ① 监听器执行 this.load() │
│ ② 读取 location.hash.slice(1) 提取纯净路径 '/page1' │
│ ③ 去路由映射表 this.routes 中查找匹配项 │
└──────────────────────────┬──────────────────────────────────────┘
▼
┌────────────────┴────────────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────────┐
│ 5a. 匹配成功 │ │ 5b. 匹配失败(404) │
│ 执行回调函数 │ │ 渲染 404 提示页面 │
│ container.innerHTML = '页面一'│ │
└─────────────────┘ └─────────────────────┘
整个流程环环相扣,核心就在于 location.hash 这个“信号兵” ——它既要承载路径信息,又要触发 hashchange 事件,把控制权交还给我们的JavaScript代码。
四、手写HashRouter:从0到1实现一个路由
有了理论基础和流程图,我们来实战。下面是一个完整的Hash路由实现,包含了路由注册、监听、渲染的核心逻辑。
4.1 核心代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hash路由实战</title>
<style>
/* 加点样式让界面更清晰 */
nav ul { display: flex; gap: 20px; list-style: none; padding: 10px; background: #f5f5f5; }
nav a { text-decoration: none; color: #333; padding: 8px 16px; border-radius: 4px; }
nav a:hover { background: #e0e0e0; }
#container { padding: 20px; min-height: 200px; border: 1px dashed #ccc; margin-top: 10px; }
</style>
</head>
<body>
<header>
<nav>
<ul>
<!-- 注意:这里的 href 包含了 #,这就是 Hash 路由的入口 -->
<li><a href="#/page1">页面一</a></li>
<li><a href="#/page2">页面二</a></li>
<li><a href="#/page3">页面三</a></li>
</ul>
</nav>
</header>
<!-- 内容挂载点:所有页面内容动态渲染到这里 -->
<div id="container">请点击上方导航</div>
<script>
class HashRouter {
constructor() {
// 存储所有路由映射:{ '/page1': fn, '/page2': fn, ... }
this.routes = {};
// 【关键步骤1】监听hash变化
window.addEventListener('hashchange', this.load.bind(this));
// 【关键步骤2】页面首次加载时手动触发一次(因为首次加载不触发 hashchange)
window.addEventListener('load', this.load.bind(this));
console.log('🚀 HashRouter 初始化完成,当前hash:', location.hash || '(空)');
}
/**
* 注册路由
* @param {string} path - 路由路径,如 '/page1'
* @param {Function} callback - 路径匹配时执行的回调
*/
register(path, callback) {
this.routes[path] = callback;
}
/**
* 加载路由:根据当前hash执行对应的回调
* 这是整个路由的“发动机”
*/
load() {
// 【关键步骤3】从 location.hash 中提取路径,去掉 # 号
// 如果 hash 为空,默认为根路径 '/'
let path = location.hash.slice(1) || '/';
console.log('📍 当前路径:', path);
// 【关键步骤4】从路由表中查找对应的处理函数
const handler = this.routes[path];
if (handler) {
// 【关键步骤5】执行回调,更新UI
handler.call(this);
} else {
// 【关键步骤6】404处理:找不到对应路由
console.warn('⚠️ 未找到路由:', path);
document.getElementById('container').innerHTML = `
<h2>404</h2>
<p>页面 "${path}" 不存在</p>
`;
}
}
}
// ============= 使用 =============
const router = new HashRouter();
const container = document.getElementById('container');
// 注册三个页面
router.register('/page1', function() {
container.innerHTML = `
<h2>📄 页面一</h2>
<p>这是首页,欢迎来到Hash路由的世界!</p>
<p>当前时间:${new Date().toLocaleString()}</p>
`;
});
router.register('/page2', function() {
container.innerHTML = `
<h2>📄 页面二</h2>
<p>这是关于我们页面。</p>
<p>我们是一群热爱前端技术的开发者。</p>
`;
});
router.register('/page3', function() {
container.innerHTML = `
<h2>📄 页面三</h2>
<p>这是联系我们页面。</p>
<p>邮箱:hello@example.com</p>
`;
});
// 设置默认路由(访问根路径时跳转到page1)
router.register('/', function() {
// 这里改变 hash,会再次触发 hashchange,形成闭环
location.hash = '#/page1';
});
</script>
</body>
</html>
4.2 关键点深度解读
① 为什么用 bind(this)?
window.addEventListener('hashchange', this.load.bind(this));
在 addEventListener 中,回调函数的默认 this 指向 window。用 bind(this) 把 load 方法的 this 永久绑定为当前的 router 实例。这样在 load 内部才能通过 this.routes 访问到路由表。
② 为什么要监听 load 事件?
这是一个经典必坑点! hashchange 事件只在 hash 改变时触发,页面首次加载时不会触发。如果用户直接访问 http://example.com/#/page2,页面加载后什么都不会发生。所以必须用 load 事件(或直接在构造函数末尾调用 this.load())来执行首次渲染。
③ 关于 slice(1) 的细节
location.hash 返回的是带 # 的字符串,如 "#/page1"。slice(1) 从索引1开始截取,得到 "/page1"。加上 || '/' 是为了处理空hash的情况("" 转为 "/")。
五、深入细节:你可能忽略的“坑”与“最佳实践”
5.1 location.hash 的自动编码
浏览器会自动对 hash 进行URL编码。如果你的路径包含中文,比如 #/用户中心,location.hash 返回的可能是 #/%E7%94%A8%E6%88%B7%E4%B8%AD%E5%BF%83。但在 hashchange 事件中,浏览器会自动解码,所以直接用 slice(1) 拿到的通常已经是解码后的字符串。为了保险,建议使用 decodeURIComponent(location.hash.slice(1))。
5.2 页面内锚点冲突
如果你的页面中既有 #/page1 路由,又有 id="page1" 的锚点元素,浏览器会优先滚动到锚点位置,可能导致路由失效。最佳实践是:给路由路径加上固定前缀,如 #/route/page1,和锚点区分开。
5.3 手动触发 vs 事件触发
请区分这两种改变hash的方式:
// 方式一:手动赋值(会触发 hashchange)
location.hash = '#/page1';
// 方式二:通过 history API(也会触发,但行为略有不同)
history.pushState(null, '', '#/page1');
在我们的 HashRouter 中,推荐使用 location.hash = xxx,因为它最直接且会同步触发 hashchange,形成“改变 → 监听 → 渲染”的完美闭环。
六、Hash路由的局限与History路由的登场
Hash路由虽然简单好用,但也有它的短板:
| 问题 | 说明 |
|---|---|
URL带#不美观 | 对用户和SEO都不够友好,显得“不专业” |
| 服务端无法识别 | #后面的内容不会被发送到服务器,服务端收不到真实路径 |
| 锚点冲突 | 页面内的锚点定位和路由会互相干扰,需要加前缀规避 |
于是,History路由(基于 history.pushState + popstate)应运而生:
// History模式核心API
history.pushState(null, '', '/about') // 改变URL,不刷新页面
window.addEventListener('popstate', () => {
// 监听浏览器前进/后退
})
但History模式需要服务端配合(所有路由都指向同一个HTML),否则刷新页面会404。
一句话总结:Hash路由适合前端独立部署、无需服务端配合的场景;History路由适合需要美观URL且能控制服务端的正式项目。
七、从手写路由到框架路由
理解了Hash路由的原理,你会发现Vue-Router、React-Router其实就是在做三件事:
- 监听URL变化(hashchange 或 popstate)
- 维护路由映射表(路径 → 组件)
- 根据匹配结果渲染对应组件
// Vue-Router的hash模式本质上就是
const router = new VueRouter({
mode: 'hash', // 切换成 history 就换了一种监听方式
routes: [
{ path: '/home', component: Home },
{ path: '/about', component: About }
]
})
框架帮我们封装了细节,但底层原理从未改变。 框架的本质,不过是对原生API的优雅封装和工程化增强。
八、写在最后
回到最初的问题:前端路由到底是什么?
我的理解是:前端路由是一种在浏览器端实现URL与UI状态同步的技术方案。 它让Web应用在不刷新页面的前提下,实现了多“页面”的切换体验。
今天我们从浏览器的历史记录聊到Hash路由的实现,从手写代码到框架原理,核心就三句话:
- Hash改变不刷新,这是基础
- hashchange事件监听,这是桥梁
- 路由表匹配渲染,这是灵魂
下次你在项目里配置路由时,不妨想想背后发生了什么。技术学习的本质,就是不断把“黑盒”变成“白盒”的过程。