你有没有注意过:打开淘宝、刷B站、看知乎时,页面切换总是行云流水、一气呵成;但偶尔遇到一些老式网站,每点一个链接页面就要"白一下"重新加载。这背后的秘密,就是前端路由------从传统的多页面架构到现代单页应用的进化。本文通过两个亲手可运行的 demo 项目,带你彻底理解这段技术史。
一、回到底层:浏览器是怎么"看到"一个网页的?
在讲路由之前,我们先回到最底层的问题:当你在浏览器地址栏输入一个网址,按下回车,发生了什么?
这个过程的本质是一段接力赛:
markdown
1. URL 解析 ← 浏览器拆解你输入的地址(协议、主机、路径、参数......)
2. HTTP 请求 ← 浏览器向服务器发送请求:"请把 /index.html 给我"
3. 服务器响应 ← 服务器运行后端程序,生成 HTML 文本,返回给浏览器
4. 浏览器渲染 ← 浏览器拿到 HTML,构建 DOM 树,绘制像素到屏幕上
5. 历史记录插入 ← 浏览器在浏览历史中插入一条新记录(用于"前进/后退")
这一切的核心,是URL 和资源的对应关系------一个地址对应一个 HTML 页面。这种模式从 1990 年代万维网诞生起就已经确立,被称为 **"万物互联"**的基石:只要有一个链接,就能跳转到互联网上的任何一个角落。
二、demo 文件夹:传统多页面的"落日余晖"
来看 demo/ 文件夹里的两个 HTML 文件。它们是一个极简的传统多页面网站:
index.html(首页):
html
<nav>
<ul>
<li><a href="http://127.0.0.1:5500/fe/history/demo/index.html">首页</a></li>
<li><a href="http://127.0.0.1:5500/fe/history/demo/about.html">关于我们</a></li>
</ul>
</nav>
<main><h1>首页</h1></main>
about.html(关于页)结构几乎一致,只是标题换成了"关于我们"。两个页面的导航栏一模一样------在传统模式中,每个页面都必须各自写一遍导航栏、页脚等公共部分。
当你点击"关于我们"的链接时,浏览器执行了全套操作:发起新的 HTTP 请求 → 服务器重新返回一个完整的 HTML → 整个页面重新渲染 。这被称为 MPA(Multi-Page Application,多页面应用)。
传统模式的痛点
在 PC 互联网时代,多页面模式运转了几十年,没什么大问题。但到了移动互联网时代,这个"整页刷新"的设计成了性能杀手:
- 白屏闪烁:网速稍慢,页面就会白一下再出现新内容。对手机用户而言,这种体验堪称灾难。
- 重复传输:导航栏、底部栏、Logo、样式文件......这些公共资源每次都要重新传输和渲染,浪费了大量带宽。
- 状态丢失:页面跳转后,之前的滚动位置、表单填写内容、播放进度等全部丢失。
- 服务器压力:每次请求都要占用服务器计算资源去拼接完整的 HTML 页面。
这就引出了核心矛盾:在今天的设备上,有没有办法只替换变化的部分,而不重新渲染整个页面?
三、demo2/demo.html:Hash 的"前世"------锚点链接
在讨论前端路由之前,先看 demo2/demo.html,它展示了 hash(哈希)最原始的用途------锚点链接。
html
<a name="top"></a>
<a href="#bottom">去到底部</a>
<div style="height: 200vh; background-color: yellow;"></div>
<a href="#top">回到顶部</a>
<div style="height: 300vh; background-color: red;"></div>
<a name="bottom"></a>
这里的 #bottom 和 #top 就是 hash------URL 中 # 号及之后的部分。它的原始作用是页内导航 :点击"去到底部",浏览器自动滚动到 <a name="bottom"> 所在的位置,就像坐电梯直达某一层。
关键特性 :当 hash 改变时,浏览器不会重新请求服务器,也不会刷新页面。页面还是那个页面,只是滚动位置变了。
再看看脚本部分:
js
window.addEventListener('hashchange', function(event) {
console.log('hash 改变了');
console.log(event.newURL); // 新的 URL(hash 部分变了)
console.log(event.oldURL); // 旧的 URL
})
浏览器提供了一个 hashchange 事件------只要 # 后面的部分发生了变化,这个事件就会被触发。正是这个机制,让开发者敏锐地意识到:如果把 hash 当作"页面标识"而不是"位置锚点",不就能在不刷新页面的情况下切换内容了吗?
一个天才般的技术思路就此诞生。
四、demo2/index.html:Hash 路由的"开天辟地"
看 demo2/index.html,它用不到 60 行的 JavaScript,手动实现了一个完整的前端路由系统。这代表了从多页面向单页面应用的关键一跃。
页面结构:极简中的革命性
html
<nav>
<ul>
<li><a href="#/page1">页面一</a></li>
<li><a href="#/page2">页面二</a></li>
<li><a href="#/page3">页面三</a></li>
</ul>
</nav>
<div id="container"></div>
注意这里链接的变化------不再是传统的 href="index.html",而是 href="#/page1"。点击这些链接时:
- URL 变了(从
/#/page1变为/#/page2) - 但页面不会刷新
- 导航栏、页脚等公共部分保持不动
- 只有
<div id="container">里的内容会被替换
这就是 SPA(Single Page Application,单页应用) 的核心思想:整个网站只有"一个页面",通过动态切换 DOM 内容来模拟页面跳转。
HashRouter 类:一个手工打造的路由引擎
js
class HashRouter {
constructor() {
this.routers = {}; // 路由表:hash → 回调函数
window.addEventListener('hashchange', this.load.bind(this));
}
register(hash, callback) {
this.routers[hash] = callback; // 注册一个路由规则
}
load() {
let hash = location.hash.slice(1); // 获取 # 后面的部分(去掉 #)
let handler = this.routers[hash]; // 从路由表中找到对应的处理函数
handler.call(this); // 执行处理函数
}
}
这个四十多行的类,浓缩了整个前端路由最原始也是最核心的逻辑。让我们逐层解剖:
第一层:路由表 this.routers
this.routers 是一个普通的 JavaScript 对象,本质是 key → value 映射:
json
{ "/page1": callback1, "/page2": callback2, "/page3": callback3 }
这和传统后端的路由表在概念上是完全一致的------只不过传统后端路由返回的是整个 HTML 页面 ,而前端路由返回的是一个回调函数,执行后更新某个 DOM 区域。
第二层:hashchange 事件监听
js
window.addEventListener('hashchange', this.load.bind(this));
这里有三层技术细节值得深挖:
-
hashchange事件:浏览器在 hash 变化时自动触发。这让我们不用自己写轮询去检查 URL 有没有变------把主动权交给了浏览器。 -
this.load.bind(this):这是一个经典的 JavaScriptthis绑定问题。当hashchange事件触发时,事件处理函数中的this默认指向触发事件的对象------window。但我们希望this指向 HashRouter 实例,才能访问this.routers。bind(this)创建了一个新函数,其中this被永久锁定为当前实例,无论将来谁调用这个函数。 -
与之相关的
call、apply、bind三个方法,是 JavaScript 函数式编程中"手动指定 this"的三种武器。call和apply是临时借用的------立即执行并改变 this;bind是永久绑定的------返回一个绑定了 this 的新函数但不立即执行。
第三层:load() 方法------路由的分发核心
js
load() {
let hash = location.hash.slice(1); // 例如 "/page1"
let handler = this.routers[hash]; // 从路由表查找
handler.call(this); // 执行,确保回调函数中 this 正确
}
location.hash 是浏览器提供的 API,返回当前 URL 的 hash 部分(包括 #)。slice(1) 去掉第一个字符 #,得到 /page1。
然后从 this.routers 对象中查找对应的处理函数------这本质上是一个 O(1) 的哈希查找,效率极高。
最后 handler.call(this) 确保处理函数中如果使用了 this,它指向的是 HashRouter 实例而非全局对象。
第四层:注册与启动
js
let router = new HashRouter();
let container = document.getElementById('container');
router.register('/page1', () => container.innerHTML = '页面一');
router.register('/page2', () => container.innerHTML = '页面二');
router.register('/page3', () => container.innerHTML = '页面三');
注册阶段将三个路径和处理函数一一绑定。这里处理函数只是简单地修改了 #container 的文本内容,但在真实项目中,处理函数可以做任何复杂操作------发起 AJAX 请求获取数据、渲染一整个组件树、更新页面标题......
为什么这被称为"开天辟地"?
这个几十行的 demo 解决了传统多页面的所有核心痛点:
| 传统 MPA | SPA + Hash 路由 |
|---|---|
| 每次切换都整页刷新,白屏闪烁 | 只替换变化区域,无白屏 |
| 公共部分(导航栏等)每次重新渲染 | 公共部分保持不变 |
| 页面状态丢失 | 状态始终保持在内存中 |
| 服务器每次返回完整 HTML | 服务器只返回数据(JSON),渲染由前端完成 |
| 浏览器下载大量重复资源 | 只传输变化的数据 |
URL 和资源的对应关系被完整保留------每个 hash 值对应一个"虚拟页面"的内容------但实现方式从"重新请求整页"变成了"局部替换 DOM"。
五、从前端路由到现代框架:后面的故事
demo 中的 HashRouter 是前端路由的"石器时代"实现。但它蕴含的核心思想------路由表映射、事件驱动、DOM 动态替换------至今仍是 React Router、Vue Router 等现代路由库的基础。
后续的发展脉络大致如下:
-
History API(HTML5) :
pushState和replaceState让开发者可以在不刷新页面的情况下改变 URL 的路径部分(而不只是 hash),实现了example.com/page1这样的"干净 URL"。 -
React 组件化 :路由不再只是"替换 innerHTML",而是挂载和卸载组件。路由匹配到一个路径,就渲染对应的 React 组件,未匹配的页面组件完全不存在于 DOM 树中。
-
路由懒加载:非当前页面的代码根本不需要下载,只有用户真正访问时才动态加载------首屏速度大幅提升。
-
嵌套路由与动态路由 :
/products/:id可以匹配任意产品 ID,路由参数被注入组件,一条规则覆盖无数页面。
六、总结:理解底层,方能驾驭框架
从 demo/ 的传统 <a href="xxx.html"> 链接,到 demo2/demo.html 的锚点 hash,再到 demo2/index.html 的手工 HashRouter------这三个 demo 串起了一条完整的技术进化链。
核心 takeaways:
- 多页面(MPA) 是 web 的原生模式,URL 和 HTML 文件一一对应,简单但每次切换都要整页刷新。
- Hash 的原始用途 是页内锚点导航,改变 hash 不会触发页面刷新,这一特性被开发者创造性利用。
- Hash 路由 通过
hashchange事件监听 + 路由表映射 + DOM 动态替换,在不刷新页面的情况下实现了"页面切换"的效果------这就是 SPA 的基石。 bind/call/apply是 JavaScript 中手动指定this三种方式,理解它们对阅读任何前端框架源码都至关重要。- 今天的 React Router、Vue Router 在概念上和这个小 demo 并无本质区别------只是用更健壮的方式,把"路由映射 + DOM 替换"这个模式工程化了。
当你下次在 React 项目里写下
<Route path="/about" component={About} />时,不妨想想那个不到 60 行的 HashRouter------它背后的核心逻辑,和你写的这行代码是相通的。理解底层,方能驾驭框架。