前端路由

652 阅读3分钟

前言

前端路由是用来解决:如何通过切换浏览器地址匹配和渲染对应的页面组件的问题。

总览

路由中有三个基本的概念 route, routes, router。

1、 route,它是一条路由,由这个英文单词也可以看出来,它是单数, Home按钮 => home内容, 这是一条route, about按钮 => about 内容, 这是另一条路由。

2、 routes 是一组路由,把上面的每一条路由组合起来,形成一个数组。[{home 按钮 =>home内容 }, { about按钮 => about 内容}]

3、 router 是一个机制,相当于一个管理者,它来管理路由。因为routes 只是定义了一组路由,它放在哪里是静止的,当真正来了请求,怎么办? 就是当用户点击home 按钮的时候,怎么办?这时router 就起作用了,它到routes 中去查找,去找到对应的 home 内容,所以页面中就显示了 home 内容。

4、客户端中的路由,实际上就是dom 元素的显示和隐藏。当页面中显示home 内容的时候,about 中的内容全部隐藏,反之也是一样。客户端路由有两种实现方式:基于hash基于html5 history api。

怎么实现客户端路由 —— History API

调用 History API 修改 URL

History 接口允许操作浏览器的曾经在标签页或者框架里访问的会话历史记录。

具体细节可参考:History - Web APIs | MDN

  1. pushState: 创建一个新的 URL,并跳转至该 URL(可通过 back 回到修改之前的 URL)
  2. replaceState:替换当前 URL 为指定 URL(通过 back 无法再次回到被替换掉的 URL);
  3. back:返回后一个 URL
  4. forward:返回前一个 URL
  5. go:跳转到指定页面的 URL

这些 API 有一个共同的特点:可以修改浏览器地址栏显示的 URL,但不会重新加载页面。

注:在调用 go 方法时,如果没有传参则会与调用 location.reload() 一样,会重新加载页面。

调用 history.pushState 可以实现更新 URL,但不刷新页面

window.history.pushState({page: 1}, '', "/detail");

监听 URL(路由) 变化,匹配路由并渲染对应 UI 组件

如何在 URL 更新时,根据新的路由去匹配和渲染对应的 UI 组件?

在同一 HTML 文档的两个历史记录条目之间导航时会触发 popstate 事件,很容易想到我们可以通过监听 popstate 事件来处理 URL (路由)更新后,根据当前路由匹配并渲染相应 UI 组件的事宜。更多细节可以参考下面的 MDN 文档。

Window: popstate event - Web APIs | MDN

从上面的 demo 我们可以知道可以通过 history.pushState() 或者 history.replaceState() 去更新 URL,但是浏览器并没有重新渲染与之对应的 UI 组件。因为调用 history.pushState()history.replaceState() 不会触发 popstate 事件,也就是说浏览器并没有监听到 URL 已经发生改变,自然也不会做出后续的动作。

没有条件创建条件,例如可以在每次调用 history.pushState()history.replaceState() 之后主动的去触发 popstate 事件。

history.pushState(state, '', url);

let popStateEvent = new PopStateEvent('popstate', { state: state });
dispatchEvent(popStateEvent);

之后我们就可以通过监听 popstate 事件去处理 URL 更新后的一系列事情了。

window.onpopstate = function(event) {
  // URL changed 
  // 根据当前路由匹配相应组件
  // 渲染相应 UI 组件
  // ...
};

上述两个关键步骤就是一个客户端路由实现的核心思想。

history路由

举例:www.baidu.com/home?t=2023…

hash路由

举例:www.baidu.com#/home?t=2023…

路由参数拼接:兼容history路由和hash路由

方法一:

/**
 * 判断是否是以下值之一: null undefined NaN 和 任意长度的空字符串
 * 以免将当成字符串渲染到页面上
 * @param {*} value
 * @returns {boolean}
 */
export const isEmptyValue = value => {
    return value === null || value === undefined || 
        Number.isNaN(value)||(typeof value === 'string' && !value.trim())
}

// url拼接参数
export const getUrlWithParams = (url: string = '', params: any = {}) => {
    let lastUrl = url
    if (params && Object.keys(params).length > 0) {
        const customParams = Object.keys(params)
            .map((item) => `${item}=${isEmptyValue(params[item])?'':params[item]}`)
            .join('&')

        // hash路由标识符#的下标
        const hashTagIndex = url.indexOf('#')
        // 因为url可能有多个参数,比如'https://baidu.com?t=123#/page?q=456',这里我们取第一个参数开始标识符?的下标
        const paramsStartIndex = url.indexOf('?')
        // 参数连接标识符
        const connectTag = paramsStartIndex !== -1 ? '&' : '?'
        // 1、 history路由;2、hash路由,参数在#右边;3、hash路由,参数在#左边;4、hash路由,没有参数;5、hash路由,#左右两边都有参数

        lastUrl =
            hashTagIndex === -1 || paramsStartIndex > hashTagIndex
                ? `${url}${connectTag}${customParams}`
                : url.split('#').join(`${connectTag}${customParams}#`)
    }
    return lastUrl
}

方法二:

/**
 * 判断是否是以下值之一: null undefined NaN 和 任意长度的空字符串
 * 以免将当成字符串渲染到页面上
 * @param {*} value
 * @returns {boolean}
 */
export const isEmptyValue = value => {
    return value === null || value === undefined || 
        Number.isNaN(value)||(typeof value === 'string' && !value.trim())
}

/**
 * url 添加/更新 参数
 * @param {string} url url
 * @param {Record<string,string>} params 参数对象
 * @returns {string} 更新后的url
 */
export const setUrlParams = (url, params) => {
    const [base = '', search = ''] = url.split('?')
    const index = search.indexOf('#')
    const hash = index === -1 ? '' : search.slice(index)
    const qs = hash ? search.slice(0, index) : search
    const prevQuery = qs.split('&').reduce((acc, item) => {
        const [key, val] = item.split('=')
        const name = key ? decodeURIComponent(key) : ''
        const value = val ? decodeURIComponent(val) : ''
        const param = name ? { [name]: value } : {}
        return Object.assign(acc, param)
    }, {})
    const nextQuery = Object.assign({}, prevQuery, params || {})
    const nextParams = Object.keys(nextQuery).map(key => {
        const value = isEmptyValue(nextQuery[key]) ? '' : nextQuery[key]
        return [encodeURIComponent(key), encodeURIComponent(value)].join('=')
    })
    const nextSearch = nextParams.length ? `?${nextParams.join('&')}` : ''
    const result = [base, nextSearch, hash].join('')
    return result
}

方法三:

     /**
       * 获取URL中的所有查询参数
       * @param {string} url
       * @returns {Record<string,string>}
       */
      const getUrlParams = (url) => {
        const params = Object.create(null);
        if (typeof url !== "string") {
          return params;
        }
        const href = url.trim();
        const index = href.indexOf("?");
        if (index < 0) {
          return params;
        }
        const search = href.slice(index + 1);
        const qs = search.split("#")[0] || "";
        // eslint-disable-next-line arrow-body-style
        const decode = (value) => {
          return decodeURIComponent(String(value).replace(/\+/g, "%20"));
        };
        const reducer = (acc, item) => {
          if (!item.length) {
            return acc;
          }
          const i = item.indexOf("=");
          const name = decode(i === -1 ? item : item.slice(0, i));
          const value = decode(i === -1 ? "" : item.slice(i + 1));
          return Object.assign(acc, { [name]: value });
        };
        // eslint-disable-next-line prettier/prettier
        return qs.split("&").filter(Boolean).reduce(reducer, params);
      };

      const genPairs = (key, val) => {
        const name = encodeURIComponent(String(key));
        const value = encodeURIComponent(String(val));
        return `${name}=${value}`;
      };

      // 生成新的url
      const genUrl = (url, params, filter) => {
        /** @type {typeof filter} */
        const defaultFilter = (val) => val !== null && val !== undefined;
        const includes = typeof filter === "function" ? filter : defaultFilter;
        const href = String(url).trim();
        const prevParams = getUrlParams(href);
        const nextParams = Object.assign({}, prevParams, params || {});
        const keys = Object.keys(nextParams).filter((key) =>
          includes(nextParams[key], key, nextParams)
        );
        const qs = keys.map((key) => genPairs(key, nextParams[key])).join("&");
        const index = href.indexOf("?");
        const search = index < 0 ? "" : href.slice(index + 1);
        let base = "";
        let hash = "";
        if (search) {
          const i = search.indexOf("#");
          hash = i < 0 ? "" : search.slice(i);
          base = href.slice(0, index);
        } else {
          const i = href.indexOf("#");
          hash = i < 0 ? "" : href.slice(i);
          base = i < 0 ? href : href.slice(0, i);
        }
        const lastUrl = [base, qs ? `?${qs}` : "", hash].join("");
        console.log(lastUrl);
        return lastUrl;
      };

测试用例:

const params = {
    'name':'jack',
    'age':123,
    'marry':true,
    'partner':null,
    'level':undefined
}

let url1= `https://baidu.com`
getUrlWithParams(url1,params)
setUrlParams(url1,params)
genUrl(url1, params);
console.log("");

let url2 = `https://baidu.com?t=123`
getUrlWithParams(url2,params)
setUrlParams(url2,params)
genUrl(url2, params);
console.log("");

let url3 = `https://baidu.com?t=123&q=456`
getUrlWithParams(url3,params)
setUrlParams(url3,params)
genUrl(url3, params);
console.log("");

let url4=`https://baidu.com#/page`
getUrlWithParams(url4,params)
setUrlParams(url4,params)
genUrl(url4, params);
console.log("");

let url5=`https://baidu.com?t=123#/page`
getUrlWithParams(url5,params)
setUrlParams(url5,params)
genUrl(url5, params);
console.log("");

let url6=`https://baidu.com?t=123&tt=333#/page?q=456&qq=666`
getUrlWithParams(url6,params)
setUrlParams(url6,params)
genUrl(url6, params);
console.log("");

let url7=`https://baidu.com#/page?q=456&qq=666`
getUrlWithParams(url7,params)
setUrlParams(url7,params)
genUrl(url7, params);
console.log("");

手写单页应用hash/history路由

请你手写前端路由hash&history,你会不会写?

前端路由简介以及vue-router实现原理

手写React-Router源码,深入理解其原理

前端面试100道手写题(5)—— Router路由

参考:

React Router 机制解析

图解 history api 和 React Router 实现原理

浅谈前端路由原理hash和history

[实践系列] 前端路由

带你了解路由的底层原理,用原生js手写一个路由

10分钟彻底搞懂单页面应用路由

vue-router 基本使用

面试官: 你了解前端路由吗?

前端路由模式详解(hash和history)

React Router v6 使用指南

阿里P7:你了解路由吗?

你好,谈谈你对前端路由的理解

「原创」图解 React-router 带你深入理解路由本质

一文读尽前端路由、后端路由、单页面应用、多页面应用

面试官为啥总是喜欢问前端路由实现方式?

前端面试题 - 96. hash 和 history 的区别?

从理解路由到实现一套Router(路由)