从 URL 输入到页面渲染:手写 HashRouter 彻底搞懂前端路由原理

108 阅读11分钟

从浏览器地址栏到页面内容,这中间到底发生了什么?为什么传统网站点个链接就要白屏等待,而现代应用却能丝滑切换?本文将带你从浏览器底层原理入手,一步步拆解前端路由的来龙去脉,并手写一个可运行的 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() 就启动完整的前端路由系统。

从"点链接就整页重画"到"点链接只换一块内容",这就是前端路由要解决的全部问题。