从浏览器地址栏到页面内容,这中间到底发生了什么?为什么传统网站点个链接就要白屏等待,而现代应用却能丝滑切换?本文将带你从浏览器底层原理入手,一步步拆解前端路由的来龙去脉,并手写一个可运行的 HashRouter,彻底搞懂 SPA 路由的本质。
📖 浏览历史 History
浏览器会自动记录你访问过的每一个网页。点左上角的"←"(后退)或"→"(前进)按钮,就能在记录里来回翻页;点地址栏也能看到最近访问的网址。这份记录就叫浏览历史(History)。
在正式讲路由之前,先认识一个前提概念:navigator 对象。浏览器自带一个"环境身份证"叫 navigator,里面存着浏览器名称、系统类型、语言、是否联网等信息。前端用它在页面加载时判断环境(是不是手机、断没断网),从而决定怎么展示。比如 PWA 离线时 navigator.onLine === false,就可以切换成离线提示页。
一、传统多页面:为什么每次都要"整页刷新" 🔄
🌐 浏览器访问 URL 的完整过程
当你在地址栏输入网址回车后,浏览器内部发生了什么?
输入 URL → 解析指路 → HTTP 发请求 → 服务器返回 HTML → 浏览器解析渲染 → 记入历史
一步一步拆开:
- URL 是"访问代理":它只负责指路,标明要找的资源在哪个服务器上。比如
http://127.0.0.1:5501/about.html就告诉浏览器"去这个地址的服务器、取这个文件" - HTTP 协议发起请求(Request):浏览器按 URL 的指向,跟服务器说"我要 about.html"
- 服务器响应(Response)返回 text/html:服务器平时处于"伺服"状态(待命应答),收到请求后把完整网页源码打包返回
- 浏览器解析并渲染页面:拿到 HTML 后,把标签翻译成你看到的文字、图片、按钮
- 插入一条浏览历史:渲染完,浏览器记一笔——"刚才访问了这个地址",让你以后能点"←"退回去
😫 感受"整页刷新"
假设有两个独立网页 index.html 和 about.html,点击导航栏切换时:
<!-- index.html 的导航栏 -->
<nav>
<a href="index.html">首页</a>
<a href="about.html">关于我们</a> <!-- 点击 → 当前页面整页卸载,请求新页面 -->
</nav>
点击"关于我们"后发生的完整过程:
当前 index.html 整页擦除
→ 浏览器发请求要 about.html
→ 服务器返回完整的新 HTML(包括导航栏、页脚…全部重来)
→ 浏览器从零开始解析、渲染整个 about.html
→ 地址栏变了(可以按"←"退回)
痛点:导航栏、页脚根本没变,却也跟着一起重画——这就像只想换沙发,结果把整个家拆掉重建。网速慢时,中间还会出现白屏等待("白板期"),体验很差。
顺便澄清一个常见误区:target="_blank" 只是控制"在当前标签页跳转还是新开一个标签页",不管开不开新窗口,加载的都是完整页面(整页渲染),痛点依旧。不能靠它解决 MPA 的问题。
SPA 就是为了解决这个"无缘无故整页重画"问题而生的。
二、SPA:单页应用的思路 💡
📱 App 给网页上了一课
手机 App 切换界面为什么顺滑?因为 App 不会整个页面重新加载——它只更新变化的那一小块,就像翻卡片而不是重新装修。网页能不能也这样?
这就是 SPA(Single Page Application,单页应用) 要解决的:整个网站只有一个 html 文件(壳子),点击链接时,只替换发生变化的那块局部内容,导航栏之类的公共部分纹丝不动。
🧩 SPA 需要解决两个问题
问题一:内容怎么换?—— DOM 编程
页面上的每个标签(<h1>、<p>、<div>)在 JS 眼里都是一个"活的对象",可以随时创建、删除、替换——这叫 "DOM 编程"。
思路就是:页面里准备一个"容器"<div id="container">,根据当前 url 决定往里面放什么内容:
url = /index.html → 把首页 DOM 放进 #container
url = /about.html → 把关于页 DOM 放进 #container
写成伪代码就是:
// 根据 url 决定容器里显示什么——这就是 DOM 编程的实际操作
if (url === '/index.html') container.innerHTML = '<h1>首页</h1>';
if (url === '/about.html') container.innerHTML = '<h1>关于</h1>';
这背后隐藏了一个根本变化:传统多页开发里,"该显示哪个页面"是服务器决定的——你请求什么 URL,服务器就返回哪个 HTML,前端没有独立的路由。SPA 时代前后端分离,服务器只管数据,前端自己管路由,URL→页面内容的对应关系要前端自己维护——后面手写 HashRouter 时的 this.routers 路由表,就是用来干这件事的。之前提到的 navigator 对象在其中也有一席之地:SPA 加载时可以先用它判断环境(是否离线、是否手机),再决定走哪条路由。
问题二:url 怎么变?—— 必须变 url,而且不能刷新页面
只换内容不改 url 的话,会出大问题:后退键失灵、一刷新就回到初始页、链接没法分享给朋友。必须让地址栏也跟着变——但又不能像传统方式那样"url 变了就发请求"。答案藏在 URL 的 # 号里。
三、Hash:让 URL 变、但不刷新 🔗
🧬 URL 的组成部分
http(s)://www.baidu.com/u/123?a=1&b=2#page1
协议 域名 路径 查询参数 hash(#开始)
# 号后面的部分(含 # 本身)就叫 hash。浏览器处理 hash 时有一条特殊规矩:hash 变了不会重新发请求,页面不会刷新。
⚓ hash 的原始用途:锚点链接
在 SPA 流行之前,hash 的唯一用途是"页面内跳转"。来感受一下锚点链接的代码:
<a name="top"></a> <!-- 旧写法:用 name 做定位标记 -->
<a href="#bottom">跳转到底部</a> <!-- 点击 → hash 变成 #bottom -->
<div style="height:200vh; background: pink;"></div>
<div style="height:300vh; background: cyan;"></div>
<a href="#top">回到顶部</a>
<a name="bottom"></a> <!-- 旧写法;现在推荐用 id 替代 name -->
点击链接后:地址栏 hash 变成 #bottom → 浏览器自动滚到 name="bottom" 的位置 → 全程不刷新、不发请求。hash 就像页面里的"书签",点一下就到。
这就是 hash 用在 SPA 路由里的根本原因:它能在改 url 的同时,不让页面刷新。
🚀 从锚点到前端路由
既然改 hash 不刷新页面,那我们只要:
- 用
#/page1、#/about这种 hash 值来代表"页面资源"(而不是页面位置) - 监听 hash 变化,变了就把容器里的内容换掉
这样就实现了:
| 传统锚点 | 前端路由 | |
|---|---|---|
| hash 代表什么 | 页内位置(书签) | 页面资源(哪个页面) |
| hash 变了做什么 | 浏览器自动滚过去 | JS 查出内容并替换容器 |
| 共同点 | 改 url 但不发请求、不刷新页面 | 同左 |
这样既满足"url 和资源一一对应",又不会整页刷新——这就是前端路由。
四、hashchange 事件:hash 一变化就自动干活 🎧
hashchange 是浏览器内置的事件,专门负责"当 hash 变化时喊一声"。在上面锚点链接的基础上加一段监听代码:
<script>
// 给浏览器装一个"耳朵":hash 变了就喊我
window.addEventListener('hashchange', function(event) {
console.log('hash 变了!');
console.log(event.oldURL); // 变化前的完整地址,如 http://...index.html
console.log(event.newURL); // 变化后的完整地址,如 http://...index.html#bottom
});
</script>
不管 hash 怎么变的——点链接、JS 改 location.hash、按后退键、手输地址——都会触发 hashchange。所以 SPA 里只需要在这一个地方写"换内容"的逻辑,所有跳转方式都走这条道。
🎨 hashchange 触发后要做的事:换内容
hashchange 告诉 JS"该换内容了",JS 根据当前的 hash 决定换什么内容。这个换内容的动作分两种层次:
- DOM 替换(原生 JS):直接操作容器,比如
container.innerHTML = '新内容'——把旧 DOM 删掉,塞新 DOM - 组件替换(框架):组件 = 一段 HTML + JS 逻辑 + 样式,像一个独立积木。换路由时直接把这块积木整块换掉。本质上最后还是 DOM 操作,只是封装了一层
两者本质相同:都是把容器里的内容换掉。
五、手写一个 HashRouter:从零实现前端路由 🛠️
现在就差最后一步——把所有理论写进一个能真正跑起来的页面里。我逐块拆解。
5.1 壳子和挂载点 🏗️
<nav>
<!-- hash 路由的链接写法:href="#/ 开头",点击只改 hash,不刷新 -->
<a href="#/page1">页面一</a>
<a href="#/page2">页面二</a>
<a href="#/page3">页面三</a>
</nav>
<!-- 挂载点:整个 SPA 唯一会变的地方,所有"页面"的内容都往这个 div 里塞 -->
<div id="container"></div>
注意和第一节 MPA 的导航栏对比:链接从 href="about.html" 变成了 href="#/page1"——这正是"改 url 但不发请求"的关键。
5.2 路由类 HashRouter:constructor 部分 🏛️
class HashRouter {
constructor() {
this.routers = {}; // 路由表:{"#/page1": 换内容函数1, "#/page2": 换内容函数2, ...}
// 这个表实现了"url 和资源一一对应"——先空着,等 register 来填
window.addEventListener('hashchange', this.load.bind(this));
// ↑ 监听 hash 变化。每次 hash 变,浏览器就喊 load() 来干活
// 为什么要 bind(this)?
}
}
为什么必须 bind(this)? 这就是 this 指向的坑。对比两种写法:
// 写法 A:没 bind —— load 触发时 this = window ❌
window.addEventListener('hashchange', this.load);
// 写法 B:有 bind —— load 触发时 this = 实例 ✅
window.addEventListener('hashchange', this.load.bind(this));
原因:addEventListener 触发回调时,会"裸调"函数(没有 对象.方法() 的形式),
this 默认指向 window。但 load 里要访问 this.routers(路由表存这里),
this 如果不是实例就全废了。bind(this) 的作用就是提前把 this 绑死为实例——先绑好,未来触发时不管谁调用,this 永远是实例。
bind和call/apply的区别:call/apply是手动指定 this 并立刻执行;bind只绑定 this 并返回新函数,不立刻执行。hash 变化是"未来才会发生"的事,所以用 bind 最合适。
5.3 register:登记路由表 📝
register(hash, callback) {
this.routers[hash] = callback;
// 参数 hash:地址栏的钥匙,如 "/page1"(注意!不带 #,因为 load 里会去掉 #)
// 参数 callback:这个钥匙对应的"换内容"动作(一个函数)
// 效果:往路由表里记一笔 "/page1 → 把内容换成页面一"
}
5.4 load:hash 变了,查表执行 🔍
load() {
let hash = location.hash.slice(1); // 拿到当前 hash,"#/page1" 去掉第 0 位 # → "/page1"
let handler = this.routers[hash]; // 查路由表:这个 hash 对应的换内容函数
if (handler) {
handler.call(this); // 找到了才执行!不判断直接执行的话,
} // 刚打开页面时没 hash,handler 是 undefined,会报 TypeError
}
注意:.slice(1) 和 if (handler) 这两个保护,就是实践时踩坑后补上的。
handler.call(this) 为什么要 .call(this) 而不是直接 handler()?
在这个 demo 里,两种写法效果一样——因为回调 () => container.innerHTML = '...' 是通过闭包直接访问外部 container 变量,没有用 this。但用 .call(this) 是一个预留的模式:真实项目里,handler 回调经常需要访问路由实例(比如 this.routers、this.redirect() 等路由方法),用 .call(this) 把实例作为 this 传进去,handler 里就能通过 this 拿到路由实例。
一个小缺陷:刚打开页面时
!hash成立,if的空分支什么都不做,容器是空的。这是演示用,等你点链接才会有内容切换。
5.5 注册路由表 + 启动 🚀
let router = new HashRouter(); // 实例化(构造函数跑一遍,监听生效)
let container = document.getElementById('container'); // 拿挂载点
// 登记"url 和资源一一对应"的映射表
router.register('/page1', () => container.innerHTML = '页面一');
router.register('/page2', () => container.innerHTML = '页面二');
router.register('/page3', () => container.innerHTML = '页面三');
这里注册的 key 是 /page1(没 #),和 load 里 .slice(1) 去掉 # 后的结果正好对上。
如果注册时写 #/page1(带 #),那 load 里就得用 location.hash 直接查,不用 .slice(1)——两种写法二选一。
5.6 两个踩过的坑 🕳️
| 坑 | 原因 | 解决 |
|---|---|---|
# 号对不上 | location.hash 返回的是 "#/page1"(带 #),而注册 key 写了 "/page1"(没 #),查表找不到 | 统一用 .slice(1) 去掉 # |
| handler 为空报错 | 刚打开页面时没 hash,查表得 undefined,直接 .call() 会报 TypeError | 加 if (handler) 判断 |
5.7 完整运行流程(串起来)⏳
点击"页面一"链接
→ 地址栏 hash 变成 #/page1(关键:页面不刷新!)
→ 触发 hashchange 事件
→ 浏览器调用 load()
→ load 取出 hash,去掉 # 得 "/page1"
→ 查路由表 this.routers['/page1'],找到换内容函数
→ 执行函数 → container.innerHTML = '页面一'
→ 全程导航栏没动,只有 #container 里的文字变了
🎯 总结
回顾一下这篇笔记:浏览器记录每次跳转叫 History;url 和页面内容的对应关系叫路由;传统 MPA 里路由由服务器控制,每次跳转都要整页重画;SPA 把路由交给前端自己管,hash 让 url 能变又不刷新,hashchange 事件自动通知我们 hash 变了,HashRouter 类把所有东西——路由表、监听、查表、执行——包成一个整体,一行 new HashRouter() 就启动完整的前端路由系统。
从"点链接就整页重画"到"点链接只换一块内容",这就是前端路由要解决的全部问题。