从多页到 SPA:用 50 行代码理解前端路由的本质
前言
学 React Router、Vue Router 的时候,我们很容易记住一堆 API:
useNavigate<Link>router.push()beforeEach()
但如果别人追问一句:
前端路由为什么能做到"URL 变了,但页面没整页刷新"?
很多人就会开始卡壳。
这篇文章不讲框架源码,我们回到最底层,用一个几十行的 HashRouter,把前端路由的本质彻底理顺。
你读完应该能搞明白 3 件事:
- 点击一个普通链接时,浏览器到底做了什么。
- 为什么传统多页应用会"白一下"。
- 为什么 SPA 可以只换内容、不换整页。
一、先别急着讲框架,先看普通链接
我们先看最传统的页面跳转:
html
<a href="/about">关于我们</a>
点下去之后,浏览器大概会经历这样一套流程:
- 读取当前链接里的 URL。
- 通过 HTTP/HTTPS 向服务器发起请求。
- 服务器返回新的
text/html。 - 浏览器拿到响应后重新解析 HTML、构建 DOM、渲染页面。
- 如果这是一次正常导航,浏览器会往历史记录里插入一条记录。
可以把它理解成:
text
点击链接
-> 浏览器发请求
-> 服务器返回新页面
-> 浏览器销毁旧页面并渲染新页面
-> 历史栈新增一条记录
一个容易混淆的点:不是 navigator,而是 history
很多初学者会把这几个对象混在一起:
location:当前地址信息history:浏览历史记录navigator:浏览器和设备环境信息
所以"浏览历史"这件事,真正相关的是 history,不是 navigator。
比如:
js
console.log(location.href) // 当前完整 URL
console.log(history.length) // 历史记录条数
console.log(navigator.userAgent) // 浏览器信息
这个区分在学前端路由时特别重要,因为我们操作路由,本质上经常是在操作:
- URL
- 历史记录
- 对应页面内容
二、传统多页应用,问题到底出在哪
传统多页应用不是不能用,而是它的切换成本有点高。
比如网站里有这几个页面:
/index.html/about.html/contact.html
每点一次导航,浏览器都要重新请求一个完整 HTML 页面。即使页面里只有中间内容区变了,顶部导航、页脚、公共样式、公共脚本也可能跟着重新加载、重新执行、重新渲染。
这就会带来几个问题:
- 页面切换时容易出现白屏或闪烁。
- 重复加载公共资源,有浪费。
- 在移动端体验上,不够像 App 那样顺滑。
这也是为什么后面大家越来越喜欢 SPA。
三、SPA 的核心思路:只换内容,不换整页
SPA 全称是 Single Page Application,单页应用。
它的思路其实不复杂:
- 整个站点只先加载一次主页面。
- 后续切换"页面"时,不再让浏览器整页刷新。
- 而是由 JavaScript 根据当前 URL,决定把什么内容渲染到页面容器里。
可以把它想成这样:
text
传统多页:
/about -> 请求 about.html -> 浏览器重建整页
SPA:
/about -> 不重新请求整个页面
-> 只替换 #app 或 #container 里的内容
问题来了:
如果不重新请求整个页面,那 URL 怎么变?
因为用户需要:
- 地址栏能看出自己在哪个页面
- 前进后退能用
- 页面能分享给别人
也就是说,URL 必须变。
但同时,页面又不能整页刷新。
这就是前端路由要解决的核心矛盾。
四、突破口就在 URL 的 hash 部分
先看一个完整 URL:
text
https://www.example.com/user/123?a=1&b=2#profile
它可以拆成:
text
protocol : https
host : www.example.com
path : /user/123
query : ?a=1&b=2
hash : #profile
重点是最后的 #profile。
1. hash 原本是干嘛的
最早它是用来做页面内锚点跳转的:
html
<a href="#bottom">跳到底部</a>
<div style="height: 1200px"></div>
<div id="bottom">这里是底部</div>
点击这个链接时,浏览器会直接滚动到页面里的 #bottom 位置。
2. hash 最关键的特性
修改 hash,URL 会变,但浏览器不会重新向服务器请求整个页面。
这句话非常关键。
比如:
js
location.hash = '#/about'
这时会发生:
- 地址栏变化了
- 页面没有整页刷新
- 浏览器会触发
hashchange事件
这不就刚好满足 SPA 的核心诉求了吗?
- URL 变了。
- 页面没刷新。
- 我们还能监听变化,然后手动替换内容。
五、Hash 路由的本质
说白了,Hash 路由就干了三件事:
- 让不同的
hash对应不同内容。 - 监听
hashchange。 - 在回调里把对应页面内容渲染出来。
比如:
#/对应首页#/about对应关于页#/profile对应个人页
一旦 hash 改变,就切换容器中的内容,而不是让浏览器重载整个 HTML。
六、手写一个 50 行左右的 HashRouter
下面直接上一个最小可运行版本。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hash Router Demo</title>
<style>
body {
font-family: sans-serif;
padding: 24px;
}
nav a {
margin-right: 12px;
}
#container {
margin-top: 24px;
padding: 16px;
border: 1px solid #ddd;
border-radius: 8px;
}
</style>
</head>
<body>
<nav>
<a href="#/">首页</a>
<a href="#/about">关于</a>
<a href="#/profile">我的</a>
</nav>
<div id="container"></div>
<script>
class HashRouter {
constructor(container) {
this.container = container
this.routes = {}
window.addEventListener('hashchange', () => this.load())
window.addEventListener('DOMContentLoaded', () => this.load())
}
register(path, handler) {
this.routes[path] = handler
}
load() {
const path = location.hash.slice(1) || '/'
const handler = this.routes[path] || this.routes['/404']
if (handler) {
this.container.innerHTML = handler()
}
}
}
const container = document.querySelector('#container')
const router = new HashRouter(container)
router.register('/', () => '<h1>首页</h1><p>这里是首页内容。</p>')
router.register('/about', () => '<h1>关于页</h1><p>这里是关于页内容。</p>')
router.register('/profile', () => '<h1>个人页</h1><p>这里是个人页内容。</p>')
router.register('/404', () => '<h1>404</h1><p>页面不存在。</p>')
</script>
</body>
</html>
这段代码的核心逻辑非常朴素:
routes:保存路径和渲染函数的映射关系register():注册路由hashchange:监听地址变化load():根据当前 hash 找到对应内容并渲染
七、把这 50 行代码彻底看懂
1. 路由表本质上就是一个映射表
js
this.routes[path] = handler
这就是在建立一一对应关系:
text
'/about' -> 关于页渲染函数
'/profile' -> 个人页渲染函数
框架里看起来很高级的路由配置,本质上也逃不开这个思路。
2. 为什么要监听 hashchange
因为 hash 改变时,浏览器不会整页刷新,所以我们必须自己接管"切换页面内容"这件事。
js
window.addEventListener('hashchange', () => this.load())
当用户点击:
html
<a href="#/about">关于</a>
地址栏会从:
text
http://example.com/#/
变成:
text
http://example.com/#/about
此时浏览器不会重载页面,但会触发 hashchange,然后我们就能执行 load()。
3. 为什么 location.hash.slice(1) 要去掉 #
因为拿到的原始值是:
js
location.hash // "#/about"
而我们注册路由时,通常希望使用:
js
'/about'
所以要把最前面的 # 去掉。
4. 为什么首次打开页面也要调用一次 load()
如果只监听 hashchange,那只有 hash 变化时才会渲染。
但用户第一次打开页面时,可能地址已经是:
text
http://example.com/#/profile
这时候如果不主动执行一次 load(),页面内容是不会自动显示的。
所以这里我额外监听了 DOMContentLoaded,确保首屏也能根据 URL 渲染。
八、它和 React Router、Vue Router 到底是什么关系
如果你能看懂上面的例子,再回头看 React Router 或 Vue Router,会轻松很多。
它们做的事情,本质上还是这些:
| 能力 | 手写 HashRouter | 路由框架 |
|---|---|---|
| 保存路径映射 | routes 对象 |
路由配置表 |
| 监听 URL 变化 | hashchange |
hashchange 或 History API |
| 切换页面内容 | innerHTML |
组件渲染 |
| 导航跳转 | <a href="#/..."> |
<Link>、router.push() |
区别只是封装程度不同:
- 你手写的是"原理版"
- 框架给你的是"工程版"
工程版会额外处理很多问题,比如:
- 嵌套路由
- 路由守卫
- 懒加载
- 参数解析
- 404 页面
- 与组件状态联动
但底层思路并没有突然变魔法。
九、再往前一步:为什么后来大家更常说 History 路由
Hash 路由已经很好理解,也很好上手,但它有一个明显特征:
text
http://example.com/#/about
URL 里会带 #。
很多现代前端项目更常见的是:
text
http://example.com/about
这通常就是基于 History API 做的路由,比如:
history.pushState()history.replaceState()popstate事件
它能把 URL 改得更自然,但需要服务端配合兜底,不然你直接刷新 /about 时,服务器可能会去找真实的 /about 文件,结果返回 404。
所以可以这样理解:
- Hash 路由:前端自己玩,改 hash,不容易依赖后端
- History 路由:URL 更好看,但通常需要服务端配合
这也是为什么很多教程都会先讲 Hash 路由,再讲 History 路由。
十、面试时怎么回答"什么是前端路由"
如果面试官问你:
什么是前端路由?
你可以这样答:
前端路由本质上是 URL 和页面内容之间的映射关系。
在 SPA 中,路由切换不会让浏览器重新请求整个 HTML 页面,而是前端监听 URL 的变化,再把对应组件或 DOM 内容渲染出来。
Hash 路由一般基于
hashchange,History 路由一般基于pushState、replaceState和popstate。
如果再追问:
为什么 hash 改了页面不会刷新?
可以补一句:
因为 hash 主要用于标识页面内锚点,它的变化通常不会触发浏览器重新请求文档,但会触发
hashchange事件,所以很适合早期 SPA 做前端路由。
总结
最后把这篇文章压缩成 5 句话:
- 传统多页跳转,本质是浏览器重新请求并重建整个页面。
- SPA 的目标是 URL 变,但页面不整页刷新。
- hash 的变化不会重新请求页面,却能触发
hashchange。 - HashRouter 的本质就是"路由表 + 事件监听 + 内容替换"。
- React Router、Vue Router 只是把这套机制做成了更强的工程化封装。
如果你已经把这篇里的 HashRouter 看懂了,再去看框架路由,就不会只停留在"会用 API",而是真正知道它为什么能工作。