2025年|浏览器前端路由实现

问题关键点

路由,hash路由,History路由,无刷新,SPA

路由是什么

  • 路由(Routing) 是指根据不同的 URL 地址(路径)来显示不同的内容或页面的机制。
  • 服务端路由(Server Routing):URL 发送到服务器,由服务器决定返回哪个页面或数据。
  • 前端路由(Client-side Routing):在浏览器端,根据 URL 的变化动态切换页面内容,而无需重新加载整个页面。

路由(routing)就是通过互联的网络把信息从源地址传输到目的地址的活动。对于 Web 开发来说,路由的实质是 URL 到对应的处理程序的映射。Web路由既可以由服务端,也可以由前端实现。其中前端路由根据实现方式的不同,可以分为Hash路由和History路由。
前端路由对于服务端路由来说,最显著的特点是页面可以在无刷新的情况下进行页面的切换。基于前端路由的这一特点,诞生了一种无刷新的单页应用开发模式SPA。SPA通过前端路由避免了页面的切换打断用户体验,让Web应用的体验更接近一个桌面应用程序。

前端路由的实现方式和实现原理

  • 前端路由主要通过 监听 URL 的变化 来控制页面内容的渲染,常见实现方式有两种:
    方法一:Hash 模式
  • URL 中带 #(例如:https://music.163.com/?from=infinity#/song?id=5308028)
  • 浏览器不会向服务器发送 # 后面的内容
  • 通过监听 window.onhashchange 来捕捉 URL 变化
    1
    2
    3
    4
    5
    6
    7
      foo://example.com:8042/over/there?name=ferret#nose
    \_/ \______________/\_________/ \_________/ \__/
    | | | | |
    scheme authority path query fragment
    | _____________________|__
    / \ / \
    urn:example:animal:ferret:nose
    fragment 本质是用来标识次级资源,fragment 有以下特点:
  • 修改 fragment 的内容不会触发网页重载。
  • 修改 fragment 的内容会改变浏览器的历史记录。
  • 修改 fragment 的内容会触发浏览器的 onhashchange 事件。
  • 基于 fragment 的以上特点,可实现基于 Hash 的前端路由。
    原理:
  • 用户点击链接 → 改变 hash
  • hashchange 事件触发
  • 根据 hash 渲染对应的页面内容
  • 优点:兼容性好,不会刷新页面, 无需服务端配置
  • 缺点:URL不够美观,无法完全利用浏览器历史API,服务端无法获取hash部分内容,可能和锚点功能冲突,SEO不友好。
    M1
    1
    2
    3
    4
    window.addEventListener('hashchange', () => {
    const route = location.hash.slice(1); // 去掉 #
    render(route);
    });
    M2
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    /**
    * 解析 hash
    * @param hash
    * @returns
    */
    function parseHash(hash) {
    // 去除 # 号
    hash = hash.replace(/^#/, "");

    // 简单解析示例
    const parsed = hash.split("?");
    //如果 hash 中没有 ?,search 会是 undefined。
    // 返回 hash 的 path 和 query
    return {
    pathname: parsed[0],
    search: parsed[1],
    };

    //如果URL hash是#page?foo=1&bar=2
    //const parsedHashRes = parseHash("#page?foo=1&bar=2");
    //console.log(parsedHashRes);
    // { pathname: "page", search: "foo=1&bar=2" }

    //const params = new URLSearchParams(parsedHashRes.search);
    //console.log(params.get("foo")) // "1"
    //console.log(params.get("bar")) // "2"
    /**
    for (const [key, value] of params) {
    console.log(key, value);
    }
    **/
    //foo 1
    //bar 2
    }

    /**
    * 监听 hash 变化
    * @returns
    */
    function onHashChange() {
    // 解析 hash
    const { pathname, search } = parseHash(location.hash);

    // 切换页面内容
    switch (pathname) {
    case "/home":
    document.body.innerHTML = `Hello ${search}`;
    return;
    default:
    return;
    }
    }

    window.addEventListener("hashchange", onHashChange);
    方法二:History 模式(HTML5 History API)
  • URL 看起来像正常路径(例如:http://example.com/home)
  • 利用 history.pushState() 和 history.replaceState() 修改浏览器历史记录
  • history.pushState:将给定的 Data 添加到当前标签页的历史记录栈中。
  • history.replaceState:将给定的 Data 更新到历史记录栈中最新的一条记录中。
  • 监听 popstate 事件来检测浏览器前进/后退
    原理:
  • 用户点击链接 → 调用 pushState 改变 URL
  • 渲染对应页面内容
  • 用户按浏览器回退 → 触发 popstate 事件 → 渲染对应页面
  • 优点:前端监控友好,SEO相对Hash路由友好,服务端可获取完整的链接和参数。
  • 缺点:兼容性稍弱。,刷新页面会请求服务器,服务器需要配置重定向到入口页(各 path 均指向同一个 HTML如 index.html)
    M1
    1
    2
    3
    4
    5
    6
    7
    8
    window.addEventListener('popstate', () => {
    render(location.pathname);
    });

    function navigate(path) {
    history.pushState({}, '', path);
    render(path);
    }
    M2
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    function onHistoryChange() {
    const {pathname, search} = location;
    switch(){
    case "/home":
    document.body.innnerHTML = `Hello ${search.replace(/^\?/,"")}`
    return;
    default:
    document.body.innerHTML = "hello world";
    return;
    }
    }

    function pushState(target) {
    history.pushState(null, "", target);
    onHistoryChange()
    }

    setTimeout(() => {
    pushState("/home?name=HZSDD")
    },3000)

    setTimeout(() => {
    history.back()
    },6000)

    window.addEventListener("popstate", onHistoryChange)

    前端路由和服务端路由的区别

    特性 前端路由 服务端路由
    页面刷新 页面不刷新,只渲染局部 每次请求 URL 都刷新页面
    URL 变化 浏览器地址栏变化,但请求不发送到服务器 URL 变化时,浏览器向服务器发请求
    响应速度 快,局部渲染 慢,需要重新加载整个页面
    SEO 不太友好(除非使用 SSR) 好,搜索引擎可直接抓取
    技术实现 JS + hash/history API 服务器端路由框架(Express、Django、Rails 等)

前端路由的优势

  • 体验更流畅:页面不刷新,用户操作即时响应
  • 减少服务器压力:不需要每次请求都重新渲染整个页面
  • 灵活控制页面渲染:可以按需加载组件或模块,提高性能
  • 便于构建单页应用(SPA):结合 Vue、React、Angular 等框架使用

    前端路由的不足

  • 首次加载慢:单页应用需要一次性加载 JS 包
  • SEO不友好:搜索引擎抓取困难,需要 SSR 或 prerender 解决
  • 浏览器刷新问题:History 模式下刷新会向服务器发请求,服务器需配置重定向
  • JS 依赖:如果用户禁用 JS,页面可能无法正常访问

参考资料:

  1. 剑指前端 Offer-前端路由实现
  2. chatgpt 4
  3. Hash嵌套路由解析 -> *Prac.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    function parseHash(hash) {
    hash = hash.replace(/^#/, "");
    const [pathname, search] = hash.split("?");
    const query = {};

    if (search) {
    const params = new URLSearchParams(search);

    for (const [key, value] of params) {
    const keys = key.split(/[\[\]]+/).filter(Boolean);
    let parsedValue;

    // 改进的类型转换逻辑
    if (value === "") {
    parsedValue = "";
    } else if (!isNaN(value) && value.trim() !== "" && value.trim() !== "NaN") {
    // 避免将 "NaN" 转换为数字
    const num = Number(value);
    parsedValue = String(num) === value.trim() ? num : value;
    } else if (value.toLowerCase() === "true") {
    parsedValue = true;
    } else if (value.toLowerCase() === "false") {
    parsedValue = false;
    } else if (value === "null") {
    parsedValue = null;
    } else if (value === "undefined") {
    parsedValue = undefined;
    } else {
    parsedValue = value;
    }

    let current = query;
    for (let i = 0; i < keys.length; i++) {
    const k = keys[i];

    // 处理数组语法:tags[] 或 tags[0]
    const isArrayKey = k === "" || (!isNaN(k) && k !== "");

    if (i === keys.length - 1) {
    // 最终赋值
    if (isArrayKey) {
    // 如果是数组语法,确保父级是数组
    const parentKey = keys[i - 1];
    if (i > 0 && current[parentKey] && !Array.isArray(current[parentKey])) {
    current[parentKey] = [current[parentKey]];
    }
    if (Array.isArray(current)) {
    current.push(parsedValue);
    } else {
    current[k] = parsedValue;
    }
    } else if (current[k] !== undefined) {
    // 处理重复键名
    if (!Array.isArray(current[k])) {
    current[k] = [current[k]];
    }
    current[k].push(parsedValue);
    } else {
    current[k] = parsedValue;
    }
    } else {
    // 遍历嵌套对象
    if (!current[k] || typeof current[k] !== "object") {
    current[k] = isArrayKey ? [] : {};
    }
    current = current[k];
    }
    }
    }
    }

    return { pathname, query };
    }

    // 增强的测试用例
    const testCases = [
    "#page?foo=1&bar=2&bar=3&tags[]=a&tags[]=b&empty&flag=true&count=0&user[name]=Alice&user[age]=20",
    "#home?ids[0]=1&ids[1]=2&filter[name]=John&filter[active]=true",
    "#test?single=hello&number=42&bool=false&nullval=null",
    "#empty",
    "#path?arr=1&arr=2&obj[a]=1&obj[b]=2"
    ];

    testCases.forEach(test => {
    console.log(test);
    console.log(parseHash(test));
    console.log("---");
    });