从"整个页面刷新"到"丝滑切换":手写一个 HashRouter 彻底搞懂前端路由
推荐理由:不贴 Vue Router / React Router 的 API 文档,而是回到 2013 年,手写一个最原始的 HashRouter,把"前端路由为什么用
#"这件事彻底讲清楚。
你打开淘宝,从首页点进商品详情,页面"唰"一下就出来了。没有白屏,没有闪烁,丝滑得像原生 App。
你打开一个传统网站,点击导航链接------页面白了一下,顶部进度条转了两圈,然后整个页面重新出现。
同样是网页,为什么体验差别这么大?
因为前者用了前端路由------URL 变了,但页面没有重新加载。浏览器历史记录里多了一条,但 HTTP 请求没有发出去。
这篇文章就用 原生 JavaScript 手写一个 HashRouter ,把前端路由的底层原理彻底讲清楚。读完你会知道:为什么 URL 里有个 #,页面就不会刷新?hashchange 事件到底做了什么?那些"现代路由库"的底层,不过是一层更漂亮的封装。
第一步:传统多页面的"痛"
先看一段最原始的页面跳转代码:
html
<!-- demo/index.html --- 传统多页面跳转 -->
<header>
<nav>
<ul>
<li><a href="http://127.0.0.1:5500/demo/index.html">首页</a></li>
<li><a href="http://127.0.0.1:5500/demo/about.html">关于我们</a></li>
</ul>
</nav>
</header>
<main>
<h1>来罗</h1>
</main>
点"关于我们",浏览器做了什么?
每一次导航都是一次完整的 HTTP 请求-响应循环。服务器返回一整个 HTML 文档,浏览器清空当前页面,重新解析、重新渲染。
在 PC 时代,这没问题------网速快,页面简单。
到了移动时代,问题就来了:网速可能只有 3G,页面却越来越复杂。每次点链接都要等服务器响应,白屏那一下足够让用户关掉页面。
核心矛盾:URL 必须变(不同页面需要不同 URL),但每次变 URL 都触发完整刷新------能不能让 URL 变了,却不发 HTTP 请求?
第二步:SPA 的思路------URL 和 DOM 解耦
单页应用(Single Page Application) 的想法很简单:
传统方式:URL 变了 → 发 HTTP 请求 → 服务器返回整个新页面 → DOM 全部重建
SPA 方式:URL 变了 → JavaScript 拦截 → 只替换需要变化的那块 DOM
一句话定义:前端路由 = JavaScript 接管 URL 变化,把"URL → 新页面"变成"URL → DOM 替换",绕过服务器。
核心思路有了,但技术上怎么实现?URL 变化通常意味着浏览器要导航------怎么阻止这个默认行为?
答案藏在 URL 的一个特殊部分:Hash(#)。
第三步:Hash 为什么能"变而不发"?
先看 URL 的结构:
bash
https://www.example.com/path?query=abc#/page1
└─┬──┘ └────┬─────┘ └─┬┘ └──┬──┘ └──┬──┘
protocol host path query hash
Hash 就是 # 开始的那一段。 它有个特殊性质:
Hash 的变化不会触发浏览器向服务器发请求。页面不会刷新。
这是浏览器规范里定死的------Hash 最初设计用于"锚点链接":在长页面内部跳转到某个位置(比如"回到顶部"),当然不应该触发整页刷新。
html
<!-- demo2/demo.html --- Hash 的原始用途:锚点 -->
<a name="top"></a>
<a href="#bottom">去到底部</a>
<div style="height: 200vh;"></div>
<a href="#top">回到顶部</a>
<div style="height: 300vh;"></div>
<a name="bottom"></a>
<script>
// 🔑 浏览器原生支持监听 hash 变化
window.addEventListener('hashchange', function(event) {
console.log('hash 改变了');
console.log(event.newURL); // 新的完整 URL
console.log(event.oldURL); // 旧的完整 URL
})
</script>
打开这个页面,点击"去到底部"------URL 末尾多了 #bottom,页面滚动到相应位置,但浏览器没有发任何 HTTP 请求。
前端工程师很快就意识到:既然 Hash 变了不刷新,那我监听它的变化,在这个事件里用 JavaScript 替换页面内容,不就是"前端路由"了吗?
这就是 Hash 路由的核心原理------借锚点的壳,做路由的事。
第四步:手写一个 HashRouter
现在我们来实现一个最简单的 Hash 路由。需求很简单:
- 点击
#/page1→ 显示"页面一" - 点击
#/page2→ 显示"页面二" - 点击
#/page3→ 显示"页面三" - 页面不刷新,只替换容器里的内容
先看完整代码,再拆解关键设计:
html
<!-- demo2/index.html --- 手写 HashRouter -->
<header>
<nav>
<ul>
<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() {
// 🔑 路由表:hash 路径 → 回调函数
// 这就是"前端路由"的本质------一个映射表
this.router = {}
// 🔑 监听 hashchange,绑定 load 方法
// ⚠️ 必须用 bind(this),否则 load 里的 this 指向 window
window.addEventListener('hashchange', this.load.bind(this))
}
// 注册路由:把路径和对应的渲染逻辑存起来
register(hash, callback) {
this.router[hash] = callback
}
// 路由匹配:根据当前 hash 找到对应的回调并执行
load() {
let hash = location.hash.slice(1) // 去掉开头的 #
let handler = this.router[hash]
if (handler) {
handler.call(this) // 执行注册的回调
}
}
}
// 使用 HashRouter
let router = new HashRouter()
let container = document.getElementById('container')
router.register('/page1', function() {
container.innerHTML = '<h1>页面一</h1>'
})
router.register('/page2', function() {
container.innerHTML = '<h1>页面二</h1>'
})
router.register('/page3', function() {
container.innerHTML = '<h1>页面三</h1>'
})
</script>
逐层拆解关键设计
第一层 --- 路由表 this.router
javascript
// 🔑 前端路由的本质:一个对象,key 是路径,value 是渲染函数
this.router = {
'/page1': function() { container.innerHTML = '<h1>页面一</h1>' },
'/page2': function() { container.innerHTML = '<h1>页面二</h1>' },
'/page3': function() { container.innerHTML = '<h1>页面三</h1>' },
}
这和 Vue Router 里的 routes 配置、React Router 里的 <Route path="/page1" component={Page1} /> 本质上是同一回事。无非是"这个 URL 对应那个组件",只是现代框架帮你做了更多:嵌套路由、懒加载、导航守卫......但核心数据结构就是一个映射表。
第二层 --- hashchange 事件
这是整个机制的发动机。浏览器在 Hash 变化时触发它,我们把路由匹配逻辑挂在上面。
javascript
window.addEventListener('hashchange', this.load.bind(this))
为什么不是 window.addEventListener('hashchange', this.load)?这就引出了本文最重要的一个坑。
⚠️ 重点坑:this 绑定------前端路由里的经典 Bug
javascript
// ❌ 错误写法
window.addEventListener('hashchange', this.load)
// load 方法内部
load() {
console.log(this) // 输出:window,不是 HashRouter 实例!
let hash = location.hash.slice(1)
let handler = this.router[hash] // ❌ window.router 是 undefined → 报错
}
为什么 this 会指向 window?
这是 JavaScript 事件机制的核心规则:事件处理函数中的 this,默认指向触发事件的 DOM 元素 。hashchange 事件挂在 window 上,所以 this 就是 window。
我们真正需要的是 this 指向 HashRouter 实例------因为路由表 this.router 存在实例上。
三种解决方案:
| 方案 | 代码 | 原理 |
|---|---|---|
bind |
this.load.bind(this) |
返回一个新函数,this 永久绑定为指定值 |
call |
包裹一层:() => this.load.call(this) |
每次调用时手动指定 this |
| 箭头函数 | window.addEventListener('hashchange', () => this.load()) |
箭头函数没有自己的 this,从外层作用域继承 |
本文代码使用 bind,因为它在构造阶段一次性完成绑定,后续每次事件触发时 this 已经是正确的,没有额外开销。
这个坑不是 HashRouter 特有的------所有在事件回调里需要访问实例属性的场景,都会遇到
this丢失问题。理解了这一点,才算真正理解了 JavaScript 的this机制。
第五步:Hash 路由的完整生命周期
把上面所有环节串起来,一次完整的 Hash 路由导航长这样:
对比传统多页面的流程:
makefile
传统: 点击 → HTTP请求 → 等待响应 → 整页刷新 → 白屏 → 渲染
Hash: 点击 → hashchange → DOM替换 → 完成
没有了 HTTP 往返和整页重绘,用户体验直接从"等待"变成"瞬切"。
第六步:Hash 路由的局限
知道原理还不够,作为一个合格的前端,你还应该知道它有哪些硬伤:
| 局限 | 说明 |
|---|---|
| URL 不美观 | /#/page1 比 /page1 丑,用户看到 # 会觉得奇怪 |
| SEO 不友好 | 搜索引擎爬虫通常忽略 # 后的内容,SPA 页面难以被收录 |
| 锚点冲突 | 如果页面本身就要用 # 做锚点定位,会和路由功能打架 |
| 服务端无感知 | # 后的内容不会发送到服务器,服务端日志只有 /,无法做首屏 SSR |
这些局限催生了 History API 路由 (pushState + popstate),也就是 Vue Router 的 history 模式和 React Router 的 BrowserRouter。但那是另一篇文章了------Hash 路由仍然是理解前端路由最好的起点,因为它的原理最直接、最透明。
金句:前端路由不是在"跳页面",而是在"换 DOM"。Hash 给了我们一个不刷新就能改 URL 的入口,剩下的就是一个映射表 + 一个事件监听。
记住这句就够了。
下次你写 SPA 的时候,不管用的是 Vue Router 还是 React Router,脑子里应该有一个画面:一个路由映射表、一个容器 DOM、一个 hashchange(或 popstate)事件。 万变不离其宗。
一个开放问题 :如果你的 SPA 需要同时支持锚点定位(页面内的 #section1)和 Hash 路由(#/page1),你会怎么设计?在评论区聊聊你的方案。