从"整个页面刷新"到"丝滑切换":手写一个 HashRouter 彻底搞懂前端路由

从"整个页面刷新"到"丝滑切换":手写一个 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>

点"关于我们",浏览器做了什么?

sequenceDiagram participant U as 用户 participant B as 浏览器 participant S as 服务器 U->>B: 点击链接 B->>S: GET /about.html S-->>B: 200 OK + HTML B->>B: 清空页面,重新渲染整个 DOM Note over B: 白屏闪烁

每一次导航都是一次完整的 HTTP 请求-响应循环。服务器返回一整个 HTML 文档,浏览器清空当前页面,重新解析、重新渲染。

在 PC 时代,这没问题------网速快,页面简单。

到了移动时代,问题就来了:网速可能只有 3G,页面却越来越复杂。每次点链接都要等服务器响应,白屏那一下足够让用户关掉页面。

核心矛盾:URL 必须变(不同页面需要不同 URL),但每次变 URL 都触发完整刷新------能不能让 URL 变了,却不发 HTTP 请求?


第二步:SPA 的思路------URL 和 DOM 解耦

单页应用(Single Page Application) 的想法很简单:

复制代码
传统方式:URL 变了 → 发 HTTP 请求 → 服务器返回整个新页面 → DOM 全部重建
SPA 方式:URL 变了 → JavaScript 拦截 → 只替换需要变化的那块 DOM
graph LR A[用户点击] --> B{URL 变了} B -->|传统| C[HTTP 请求服务器] C --> D[整页刷新] B -->|SPA| E[JS 拦截] E --> F[局部 DOM 替换] F --> G[页面丝滑切换]

一句话定义:前端路由 = 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 路由导航长这样:

sequenceDiagram participant U as 用户 participant B as 浏览器 participant HR as HashRouter participant DOM as DOM U->>B: 点击 #/page2 链接 B->>B: URL hash 改变,不发 HTTP 请求 B->>HR: 触发 hashchange 事件 HR->>HR: load() 被调用 HR->>HR: 从 this.router 取出回调 HR->>DOM: container.innerHTML = '页面二' DOM-->>U: 页面内容丝滑切换

对比传统多页面的流程:

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),你会怎么设计?在评论区聊聊你的方案。


相关推荐
用户938515635072 小时前
从零理解 React Router v6:每一个 API 都是怎么工作的
前端·javascript·全栈
kyriewen4 小时前
别再这样写条件渲染了——你的React组件里藏着这5种定时炸弹
前端·javascript·react.js
用户938515635075 小时前
从"坐电梯"到"前端路由"——深入理解 Hash 路由原理
前端·typescript·全栈
梦想CAD控件5 小时前
网页端CAD的图形选择、编辑与夹点操作教程
前端·javascript·node.js
月月大王的3D日记6 小时前
Three.js 入门系列(8):六种光源全解析 —— 关灯了,开光!
前端·javascript
自然 醒6 小时前
v-tooltip自定义指令封装
前端·javascript·vue.js
其美杰布-富贵-李8 小时前
第 10 篇:灯光与阴影
javascript·three.js
用户9385156350710 小时前
React 组件设计的三个层次:从类型约束到状态归属,再到纯展示
typescript·全栈
小高00710 小时前
🔥🔥🔥TypeScript 7 正式版来了:别只看 10 倍速度,这 4 个迁移坑更值得注意
前端·javascript·面试