从多页到 SPA:用 50 行代码理解前端路由的本质

从多页到 SPA:用 50 行代码理解前端路由的本质

前言

学 React Router、Vue Router 的时候,我们很容易记住一堆 API:

  • useNavigate
  • <Link>
  • router.push()
  • beforeEach()

但如果别人追问一句:

前端路由为什么能做到"URL 变了,但页面没整页刷新"?

很多人就会开始卡壳。

这篇文章不讲框架源码,我们回到最底层,用一个几十行的 HashRouter,把前端路由的本质彻底理顺。

你读完应该能搞明白 3 件事:

  1. 点击一个普通链接时,浏览器到底做了什么。
  2. 为什么传统多页应用会"白一下"。
  3. 为什么 SPA 可以只换内容、不换整页。

一、先别急着讲框架,先看普通链接

我们先看最传统的页面跳转:

html 复制代码
<a href="/about">关于我们</a>

点下去之后,浏览器大概会经历这样一套流程:

  1. 读取当前链接里的 URL。
  2. 通过 HTTP/HTTPS 向服务器发起请求。
  3. 服务器返回新的 text/html
  4. 浏览器拿到响应后重新解析 HTML、构建 DOM、渲染页面。
  5. 如果这是一次正常导航,浏览器会往历史记录里插入一条记录。

可以把它理解成:

text 复制代码
点击链接
-> 浏览器发请求
-> 服务器返回新页面
-> 浏览器销毁旧页面并渲染新页面
-> 历史栈新增一条记录

很多初学者会把这几个对象混在一起:

  • 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 页面。即使页面里只有中间内容区变了,顶部导航、页脚、公共样式、公共脚本也可能跟着重新加载、重新执行、重新渲染。

这就会带来几个问题:

  1. 页面切换时容易出现白屏或闪烁。
  2. 重复加载公共资源,有浪费。
  3. 在移动端体验上,不够像 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 的核心诉求了吗?

  1. URL 变了。
  2. 页面没刷新。
  3. 我们还能监听变化,然后手动替换内容。

五、Hash 路由的本质

说白了,Hash 路由就干了三件事:

  1. 让不同的 hash 对应不同内容。
  2. 监听 hashchange
  3. 在回调里把对应页面内容渲染出来。

比如:

  • #/ 对应首页
  • #/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 路由一般基于 pushStatereplaceStatepopstate

如果再追问:

为什么 hash 改了页面不会刷新?

可以补一句:

因为 hash 主要用于标识页面内锚点,它的变化通常不会触发浏览器重新请求文档,但会触发 hashchange 事件,所以很适合早期 SPA 做前端路由。


总结

最后把这篇文章压缩成 5 句话:

  1. 传统多页跳转,本质是浏览器重新请求并重建整个页面。
  2. SPA 的目标是 URL 变,但页面不整页刷新。
  3. hash 的变化不会重新请求页面,却能触发 hashchange
  4. HashRouter 的本质就是"路由表 + 事件监听 + 内容替换"。
  5. React Router、Vue Router 只是把这套机制做成了更强的工程化封装。

如果你已经把这篇里的 HashRouter 看懂了,再去看框架路由,就不会只停留在"会用 API",而是真正知道它为什么能工作。

相关推荐
八号当铺1 小时前
我做了一个多端基金收益助手:从养基宝数据到 Web、桌面端、浏览器插件和 IDE 插件
前端·人工智能·github
Zldaisy3d1 小时前
连续纤维增材制造的机翼已飞上天,复材打印在低空飞行器上还需翻过几道坎?
java·前端·数据库
__sjfzllv___1 小时前
在职前端Leader学习/转行 AI Agent -DAY24
前端
喜欢睡觉1 小时前
从"白一下"到"丝滑切换"——前端路由的进化史
前端
玉宇夕落1 小时前
React 多种路由学习:HashRouter 源码级剖析
前端
他们叫我秃子1 小时前
前端开发转 Go 全栈(三):从函数到 error,Go 连“失败”都要明确返回
前端·后端·go
Canace1 小时前
GPT-5.6 到底怎么选?一文搞懂 Sol、Terra、Luna 和 Ultra
前端·人工智能·chatgpt
用户059540174461 小时前
LangChain Memory 踩坑实录:10种异常场景差点搞崩生产,我们写了一套自动化测试保命
前端·css
名字还没想好☜1 小时前
React useImperativeHandle 实战:让父组件安全地调用子组件的方法
前端·javascript·react.js·react·forwardref