从页面白屏到丝滑切换:手撕一个Hash路由,彻底搞懂前端路由原理

126 阅读10分钟

你以为每天用的Vue-Router、React-Router只是配置几个路径就完事了?今天带你从0到1实现一个Hash路由,把前端路由的那点事儿彻底整明白。

前言:一个让前端开发质变的“小东西”

还记得早年用jQuery开发多页面应用的日子吗?每次点击链接,整个页面刷新,白屏闪烁,用户干等着。移动端时代,这种体验直接劝退。

直到单页应用(SPA)  的出现,才真正让Web应用有了App般的流畅体验。而这一切的核心,就是前端路由。

今天我们不调库不框架,直接从浏览器最基础的API出发,手写一个Hash路由,把路由原理吃透。

说白了,前端路由就是在不刷新页面的前提下,让URL的变化与UI的更新建立起一一对应的关系。


一、传统多页面 vs 单页应用:到底差在哪?

1.1 多页面的“笨拙”

传统模式下,页面跳转是这样的:

<!-- index.html -->
<a href="/about.html">关于我们</a>

用户点击后:

  1. 浏览器向服务器发起新请求
  2. 服务器返回完整的HTML
  3. 浏览器重新解析、渲染整个页面
  4. 白屏闪现,资源重新加载

这种方式的问题在于:明明只是内容区域变了,却要重新加载整个页面。导航栏、页脚、侧边栏这些公共部分也得跟着重绘。

1.2 SPA的“优雅”

单页应用只加载一次HTML,后续所有的页面切换都在同一个页面内通过JavaScript动态完成:

<!-- 只有一个页面,内容动态替换 -->
<div id="container"></div>
<script>
  // 根据URL变化,动态修改container的内容
</script>

用户体验瞬间提升:无白屏、无刷新、丝滑切换。

但问题来了:URL怎么变?浏览器怎么知道当前展示的是哪个“页面”?


二、Hash路由:前端路由的“开山鼻祖”

2.1 先搞懂URL和Location对象

在深入Hash之前,我们必须先把浏览器的地址栏给“解剖”了。一个完整的URL长这样:

http://www.baidu.com:80/u/123?a=1&b=2#page1
└─┬─┘ └────┬────┘ └┬┘ └─┬──┘ └───┬────┘ └─┬──┘
 协议      主机名    端口  路径    查询字符串   Hash

在浏览器中,我们可以通过全局对象 window.location 来操控这一切。它就像一把手术刀,能精准地切割URL的每个部分:

// 假设当前地址是:http://localhost:5500/index.html#/page1?name=test

console.log(location.protocol); // "http:"
console.log(location.hostname); // "localhost"
console.log(location.port);     // "5500"
console.log(location.pathname); // "/index.html"
console.log(location.search);   // "?name=test"
console.log(location.hash);     // "#/page1"   <-- 我们今天的主角

其中,hash 属性对应URL中从 # 开始一直到末尾的部分。它有一个极其特殊的“性格”:

  1. 改变它,页面不会刷新(浏览器不会向服务器发请求)。
  2. 改变它,浏览器历史记录会增加(支持前进/后退)。
  3. 改变它,会触发 hashchange 事件。

这正是我们实现前端路由的“天选之子”!

2.2 为什么是Hash?(特性回顾)

要实现前端路由,核心要解决两个问题:

  1. 改变URL但不触发页面刷新
  2. 能监听到URL的变化并执行对应的逻辑

Hash(#后面的部分)天生就是为此设计的:

特性Hash的表现
改变hash不会触发页面刷新
改变hash会在浏览器历史中留下记录
改变hash会触发 hashchange 事件
// 改变hash的三种方式
location.hash = '#/page1'  // 方式一:JS赋值
location.href = '#/page2'  // 方式二:重写href
<a href="#/page3">         // 方式三:点击锚点链接

2.3 Hash的“前世今生”

你可能不知道,Hash最初的设计是为了页面内锚点定位(也就是我们常说的“电梯直达”):

<!-- 锚点链接:在长页面内“坐电梯” -->
<a href="#bottom">去到底部</a>
<div style="height:200vh"></div>
<a name="bottom">底部内容</a>

点击链接,页面滚动到对应位置,页面本身不会刷新。这个特性被前端开发者“借用”过来,成就了第一代前端路由。

一个冷知识:Hash路由的诞生,本质上是开发者对浏览器特性的“创造性利用”。


三、完整流程图解:从点击到渲染,这期间发生了什么?

光知道API不够,我们必须把整个数据流(Data Flow) 在脑子里跑通。当用户点击一个带有 #/page1 的链接时,浏览器到底走过了怎样的历程?

┌─────────────────────────────────────────────────────────────────┐
│                     1. 用户交互层                              │
│  用户点击 <a href="#/page1"> 或执行 location.hash = '#/page1' │
└──────────────────────────┬──────────────────────────────────────┘
                           ▼
┌─────────────────────────────────────────────────────────────────┐
│                     2. 浏览器底层行为                          │
│ ① 更新地址栏 URL(加入 #/page1)                              │
│ ② 不向服务器发起 HTTP 请求(Hash 改变的特性)                 │
│ ③ 将这次变更推入浏览器历史记录栈(方便后退)                  │
└──────────────────────────┬──────────────────────────────────────┘
                           ▼
┌─────────────────────────────────────────────────────────────────┐
│                     3. 事件触发层                              │
│  浏览器自动触发 window 上的 'hashchange' 事件                 │
│  (事件对象中携带了 oldURL 和 newURL)                        │
└──────────────────────────┬──────────────────────────────────────┘
                           ▼
┌─────────────────────────────────────────────────────────────────┐
│                     4. 路由拦截层(我们的代码)                │
│  ① 监听器执行 this.load()                                     │
│  ② 读取 location.hash.slice(1) 提取纯净路径 '/page1'          │
│  ③ 去路由映射表 this.routes 中查找匹配项                      │
└──────────────────────────┬──────────────────────────────────────┘
                           ▼
          ┌────────────────┴────────────────┐
          ▼                                 ▼
┌─────────────────┐              ┌─────────────────────┐
│  5a. 匹配成功    │              │  5b. 匹配失败(404) │
│  执行回调函数    │              │  渲染 404 提示页面   │
│  container.innerHTML = '页面一'│                      │
└─────────────────┘              └─────────────────────┘

整个流程环环相扣,核心就在于 location.hash 这个“信号兵” ——它既要承载路径信息,又要触发 hashchange 事件,把控制权交还给我们的JavaScript代码。


四、手写HashRouter:从0到1实现一个路由

有了理论基础和流程图,我们来实战。下面是一个完整的Hash路由实现,包含了路由注册、监听、渲染的核心逻辑。

4.1 核心代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Hash路由实战</title>
    <style>
        /* 加点样式让界面更清晰 */
        nav ul { display: flex; gap: 20px; list-style: none; padding: 10px; background: #f5f5f5; }
        nav a { text-decoration: none; color: #333; padding: 8px 16px; border-radius: 4px; }
        nav a:hover { background: #e0e0e0; }
        #container { padding: 20px; min-height: 200px; border: 1px dashed #ccc; margin-top: 10px; }
    </style>
</head>
<body>
    <header>
        <nav>
            <ul>
                <!-- 注意:这里的 href 包含了 #,这就是 Hash 路由的入口 -->
                <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() {
                // 存储所有路由映射:{ '/page1': fn, '/page2': fn, ... }
                this.routes = {};
                
                // 【关键步骤1】监听hash变化
                window.addEventListener('hashchange', this.load.bind(this));
                // 【关键步骤2】页面首次加载时手动触发一次(因为首次加载不触发 hashchange)
                window.addEventListener('load', this.load.bind(this));
                
                console.log('🚀 HashRouter 初始化完成,当前hash:', location.hash || '(空)');
            }

            /**
             * 注册路由
             * @param {string} path - 路由路径,如 '/page1'
             * @param {Function} callback - 路径匹配时执行的回调
             */
            register(path, callback) {
                this.routes[path] = callback;
            }

            /**
             * 加载路由:根据当前hash执行对应的回调
             * 这是整个路由的“发动机”
             */
            load() {
                // 【关键步骤3】从 location.hash 中提取路径,去掉 # 号
                // 如果 hash 为空,默认为根路径 '/'
                let path = location.hash.slice(1) || '/';
                console.log('📍 当前路径:', path);
                
                // 【关键步骤4】从路由表中查找对应的处理函数
                const handler = this.routes[path];
                
                if (handler) {
                    // 【关键步骤5】执行回调,更新UI
                    handler.call(this);
                } else {
                    // 【关键步骤6】404处理:找不到对应路由
                    console.warn('⚠️ 未找到路由:', path);
                    document.getElementById('container').innerHTML = `
                        <h2>404</h2>
                        <p>页面 "${path}" 不存在</p>
                    `;
                }
            }
        }

        // ============= 使用 =============
        const router = new HashRouter();
        const container = document.getElementById('container');

        // 注册三个页面
        router.register('/page1', function() {
            container.innerHTML = `
                <h2>📄 页面一</h2>
                <p>这是首页,欢迎来到Hash路由的世界!</p>
                <p>当前时间:${new Date().toLocaleString()}</p>
            `;
        });

        router.register('/page2', function() {
            container.innerHTML = `
                <h2>📄 页面二</h2>
                <p>这是关于我们页面。</p>
                <p>我们是一群热爱前端技术的开发者。</p>
            `;
        });

        router.register('/page3', function() {
            container.innerHTML = `
                <h2>📄 页面三</h2>
                <p>这是联系我们页面。</p>
                <p>邮箱:hello@example.com</p>
            `;
        });

        // 设置默认路由(访问根路径时跳转到page1)
        router.register('/', function() {
            // 这里改变 hash,会再次触发 hashchange,形成闭环
            location.hash = '#/page1';
        });
    </script>
</body>
</html>

4.2 关键点深度解读

① 为什么用 bind(this)?

window.addEventListener('hashchange', this.load.bind(this));

在 addEventListener 中,回调函数的默认 this 指向 window。用 bind(this) 把 load 方法的 this 永久绑定为当前的 router 实例。这样在 load 内部才能通过 this.routes 访问到路由表。

② 为什么要监听 load 事件?

这是一个经典必坑点!  hashchange 事件只在 hash 改变时触发,页面首次加载时不会触发。如果用户直接访问 http://example.com/#/page2,页面加载后什么都不会发生。所以必须用 load 事件(或直接在构造函数末尾调用 this.load())来执行首次渲染。

③ 关于 slice(1) 的细节

location.hash 返回的是带 # 的字符串,如 "#/page1"。slice(1) 从索引1开始截取,得到 "/page1"。加上 || '/' 是为了处理空hash的情况("" 转为 "/")。


五、深入细节:你可能忽略的“坑”与“最佳实践”

5.1 location.hash 的自动编码

浏览器会自动对 hash 进行URL编码。如果你的路径包含中文,比如 #/用户中心,location.hash 返回的可能是 #/%E7%94%A8%E6%88%B7%E4%B8%AD%E5%BF%83。但在 hashchange 事件中,浏览器会自动解码,所以直接用 slice(1) 拿到的通常已经是解码后的字符串。为了保险,建议使用 decodeURIComponent(location.hash.slice(1))。

5.2 页面内锚点冲突

如果你的页面中既有 #/page1 路由,又有 id="page1" 的锚点元素,浏览器会优先滚动到锚点位置,可能导致路由失效。最佳实践是:给路由路径加上固定前缀,如 #/route/page1,和锚点区分开。

5.3 手动触发 vs 事件触发

请区分这两种改变hash的方式:

// 方式一:手动赋值(会触发 hashchange)
location.hash = '#/page1';

// 方式二:通过 history API(也会触发,但行为略有不同)
history.pushState(null, '', '#/page1');

在我们的 HashRouter 中,推荐使用 location.hash = xxx,因为它最直接且会同步触发 hashchange,形成“改变 → 监听 → 渲染”的完美闭环。


六、Hash路由的局限与History路由的登场

Hash路由虽然简单好用,但也有它的短板:

问题说明
URL带#不美观对用户和SEO都不够友好,显得“不专业”
服务端无法识别#后面的内容不会被发送到服务器,服务端收不到真实路径
锚点冲突页面内的锚点定位和路由会互相干扰,需要加前缀规避

于是,History路由(基于 history.pushState + popstate)应运而生:

// History模式核心API
history.pushState(null, '', '/about')  // 改变URL,不刷新页面
window.addEventListener('popstate', () => {
    // 监听浏览器前进/后退
})

但History模式需要服务端配合(所有路由都指向同一个HTML),否则刷新页面会404。

一句话总结:Hash路由适合前端独立部署、无需服务端配合的场景;History路由适合需要美观URL且能控制服务端的正式项目。


七、从手写路由到框架路由

理解了Hash路由的原理,你会发现Vue-Router、React-Router其实就是在做三件事:

  1. 监听URL变化(hashchange 或 popstate)
  2. 维护路由映射表(路径 → 组件)
  3. 根据匹配结果渲染对应组件
// Vue-Router的hash模式本质上就是
const router = new VueRouter({
    mode: 'hash',  // 切换成 history 就换了一种监听方式
    routes: [
        { path: '/home', component: Home },
        { path: '/about', component: About }
    ]
})

框架帮我们封装了细节,但底层原理从未改变。  框架的本质,不过是对原生API的优雅封装和工程化增强。


八、写在最后

回到最初的问题:前端路由到底是什么?

我的理解是:前端路由是一种在浏览器端实现URL与UI状态同步的技术方案。  它让Web应用在不刷新页面的前提下,实现了多“页面”的切换体验。

今天我们从浏览器的历史记录聊到Hash路由的实现,从手写代码到框架原理,核心就三句话:

  • Hash改变不刷新,这是基础
  • hashchange事件监听,这是桥梁
  • 路由表匹配渲染,这是灵魂

下次你在项目里配置路由时,不妨想想背后发生了什么。技术学习的本质,就是不断把“黑盒”变成“白盒”的过程。