浏览器路由 API 详解

3,324 阅读11分钟

最新一直在看关于 Vue 和 React 路由这块的知识,最终发现这些路由框架的模块功能的实现都是基于浏览器原生路由 API 的。本着追根溯源的初心,于是就想着将浏览器原生的路由 API 整体梳理一遍,以便更加顺畅的理解 Vue-Router 和 React-Router 的相关实现和原理。

背景

浏览器的主要功能就是根据输入的 URL 在窗口加载对应的文档,与此同时,浏览器会记录一个 tab 窗口载入过的所有文档,同时会提供 "前进"、"后退" 和 "刷新" 的功能,以便用户可以在这些已经记录的文档之间进行切换浏览和重载当前页面获取最新的浏览信息。

这些功能的实现最早是在服务器端实现的,因为那时候的引用都是前后端不分离的,页面内容也是动态生成的,所以这些页面的跳转、切换、刷新都是在服务端实现的。后来出现了 SPA(Single Page Application 单页应用),页面都是通过 JavaScript 动态生成和载入到页面的,并且可以在无刷新的情况下加载页面最新的状态信息,这时候如果要提供上述的功能就需要自己进行处理(因为此时的页面都是现实在同一个大的框架页面里面的,根本不存在页面的跳转切换),所以催生了各种框架对应的 Router 实现。

在浏览器中实现前端路由主要有两种方式:一个是我们常用的 hash,另一个是 HTML5 提供的 history。其实还有另外一种利用 stack 实现的方式适用于 Node.js 服务器端,这里我们着重说一下浏览器提供的 hash 和 history 吧,stack 具体怎么实现等我们说到 x-Router 源码的时候再详细说一下。

Hash

在浏览器 URL 地址栏,我们总会发现像这样的地址:react.docschina.org/docs/react-… React 官网关于 lazy 的一个地址)。大家肯定发现:这串 URL 的最后有以 # 号开始的一串标识,那它到底是起着什么样的作用呢?肯定不会平白无故的出现吧。

hash 特性

你可以直接在浏览器中打开这个链接地址,你是不是发现页面会自动滚动到(页面顶部定位到)标题为 React.lazy 的部分文档。你再将页面往上滚动,肯定会发现上面还有部分的文档内容。此时,你修改地址栏的地址为 react.docschina.org/docs/react-… React.Suspense 部分。

Apr-10-2019 07-21-23.gif

在早些年,hash 作为 URL 的一部分主要用来定位文档中的文档片段。在上面的例子中,我们通过在 URL 后面添加 #reactlazy 和 #reactsuspense 定位到了文档对应标题为 React.lazy 和 React.Suspense 的部分。那他们到底是怎么做到的呢?

通过审核元素我们发现:在 React.lazy 和 React.Suspense 对应的标题部分分别都有一个 h3 标签,而且标签的 id 属性对应就是我们在 URL 地址栏输入的 hash 值部分(只是少了 # 号)。

image.png

hash 定位文档片段

可能有同学会有疑惑:为什么 hash 是通过元素上面的 id 属性来定位文档的?
前面我们提到过,URL 中的 hash 部分是用来定位文档中的文档片段的。大家想想:所需要定位的文档片段肯定是唯一的,不然定位肯定是不准确了,那这个定位文档就有点鸡肋了,在文档中标识唯一的属性只有是 id 了,如果是我,我也会通过 hash 匹配元素的 id 来定位文档。
现在来验证一下我们的猜想:

  • 1、首先在新的 tab 窗口打开 react.docschina.org/docs/react-… 页面,然后在审核元素下找到上图所展示的 DOM 元素,修改其中的 h3 标签的 id 属性值为 reactlazy1,接着在 URL 地址栏追加 #reactlazy hash 值并按下回车键,此时页面并没有定位到标题为 React.lazy 的文档片段,最后将 URL 地址栏的 #reactlazy hash 值改成 #reactlazy1 hash 值并按下回车键,此时页面并没有定位到标题为 React.lazy 的文档片段,这一系列的表现说明 hash 定位还是和元素的 id 属性值还是有关联的;

image.png

  • 2、依然是在新的 tab 窗口打开 react.docschina.org/docs/react-… 页面,然后将页面手动滚动到标题为 React.lazy 的文档片段,将鼠标放在标题上会出现一个锚点的图标,点击图标发现页面定位到了标题为 React.lazy 的文档片段并且 URL 地址栏变成了 react.docschina.org/docs/react-… #reactlazy hash 值。此时再回头看看我们前面给出的截图发现 id 属性值为 reactlazy 的 h3 标签中有一个 href 属性值为 #reactlazy 的 a 标签,其实我们在页面上看到的锚点图标就是这个 a 标签的展示。当我们点击锚点图标就是点击了 a 链接,然后将 url 定位到了 id 属性值为 reactlazy 的 h3 标签,还是很好的说明了 hash 定位还是和元素的 id 属性值还是有关联的;

image.png

image.png
MDN 官方文档上有明确的定义,但是我们还是通过两个方面来证明了我们的推论,乍一看好像说了很多没有用的东西,其实这样反复的推敲更有利于我们深刻的理解相关的知识点以及为什么是这样,而不是那样!

hash 路由

hash 的存在除了可以通过设置文档中元素的 ID 来定位文档片段之外,还可以设置为任意的字符串来表示路由。在 Vue、React 等现代前端框架中,为了实现功能完备的 SPA 应用都配备了对应的路由系统。在这些路由系统都会提供 hash 路由模式。

在 hash 模式下,hash 会支持任意的字符串来表示对应的 URL。这些路由系统针对 hash 模式的实现基本都是大同小异:在设置 location.hash 属性值后,应用就会想尽一切办法检测状态值变化,以便能够读取出存储在片段标识符中的状态并相应地更新自己的状态。支持 HTML5 的浏览器一旦发现片段标识符发生了变化,就会在 Window 对象上触发 hashchange 事件,这时就会触发对象的函数处理逻辑 —— 对 location.hash 的值进行解析,然后使用该值包含的状态信息重新渲染应用。

这里只是提到了一个基础的思路,路由系统的具体实现,后续会娓娓道来!

hash 事件

在支持 HTML5 的浏览器中,当 URL 的 hash 值变化时会触发 hashchange 事件,我们可以通过监听这个事件来说一些处理:

// 在 window 下监听 hashchange 事件
window.onhashchange = function() {
  // 当事件触发时输出当前的 hash 值
  console.log(window.location.hash)
}

在不支持 HTML5 的浏览器中,我们可以通过 100ms 轮询监听 url 变化来模拟:

(function(window){
  // 如果浏览器不支持原生实现的事件,则开始模拟,否则退出。
  if ( "onhashchange" in window.document.body ) return;
  
  var location = window.location,
      oldUrl = location.href,
      oldHash = location.hash;

  // 每隔 100ms 检查 hash 是否发生变化
  setInterval(function() {
    var newUrl = location.href,
        newHash = location.hash;

    // hash 发生变化且全局注册有 onhashchange 方法(这个名字是为了和模拟的事件名保持统一);
    if (newHash !== oldHash && typeof window.onhashchange === "function"  ) {
      // 执行方法
      window.onhashchange({
        type: "hashchange",
        oldURL: oldUrl,
        newURL: newUrl
      });

      oldUrl = newUrl;
      oldHash = newHash;
    }
  }, 100);
})(window)

⚠️注意:设置 location.hash 属性会更新显示在地址栏中的 URL,同时会在浏览器的历史记录中添加一条记录。

History

为了标准化管理浏览器历史管理,HTML5 定义了相对复杂的 API —— history。

history api

1、history 里面新增了两个 API,history.pushState() 和 history.replaceState()。这两个 API 都接受同样的参数:

它们之间的不同之处是:history.pushState() 方法是将新状态添加到浏览器的历史记录中,也就是说还可以通过点击 "后退" 按钮,退到前一个页面;history.replaceState() 是用新的状态代替当前的历史状态,也就是说没有更多的历史记录,"后退" 按钮不能操作了,页面不能 "后退" 了。

⚠️注意:当执行这两个 API 时,浏览器的 URL 地址栏会变化,但是页面内容不会刷新!

  • 状态对象(state<Object | Null>):** 一个 JavaScript 对象,该对象包含用于恢复当前文档所需的所有信息。可以是任何能够通过 JSON.stringify() 方法转换成相应字符串形式的对象,也可以是其他类似 Date、RegExp 这样特定的本地类型。
  • 标题(title<String | Null>):**浏览器可以使用它标识浏览历史记录中保存的状态,可以传一个空字符串,也可以传入一个简短的标题,标明将要进入的状态。
  • 地址(URL):**用来表示当前状态的位置。新的 URL 不一定是绝对路径;如果是相对路径,它将以当前 URL 为基准(类似 #reactlazy 这样的 hash);传入的 URL 与当前 URL 应该是同源的,否则 pushState() 会抛出异常。该参数是可选的;不指定的话则为文档当前 URL。

为此,我们可以利用语雀网站做一系列的实验:

window.history.pushState(null, null, "https://www.yuque.com/dashboard/?name=littleLane");
// result: https://www.yuque.com/dashboard/?name=littleLane

window.history.pushState(null, null, "https://www.yuque.com/dashboard/name/littleLane");
//result: https://www.yuque.com/dashboard/name/littleLane

window.history.pushState(null, null, "?name=littleLane");
//result: https://www.yuque.com/dashboard?name=littleLane

window.history.pushState(null, null, "name=littleLane");
//result: https://www.yuque.com/dashboard/name=littleLane

window.history.pushState(null, null, "/name/littleLane");
//result: https://www.yuque.com/dashboard/name/littleLane

在控制台中执行上面一系列语句时,浏览器的 URL 变化成了我们备注的 result 的结果,但是页面并没有发生重渲染,还有当我们每次执行 pushState 时,浏览器历史都会添加一条记录,大家可以通过 "后退" 按钮进行查看。大家执行完上面的测试语句后,还可以将 pushState 替换成 replaceState 再次进行一轮测试,此时新的浏览记录都会代替当前的历史记录,还是可以通过 "后退" 按钮进行查看。

⚠️注意:这里的 url 不支持跨域,当我们把 www.yuque.com 换成 www.baidu.com 时就会报错。

image.png

2、除了上面新增的 API,history 对象上还有表示浏览历史列表数量的 length 属性,还定义了 back()、forward() 和 go() 进行浏览记录切换的方法。

History 对象的 back() 和 forward() 方法与浏览器的 "后退" 和 "前进" 按钮功能一样:它们可以使浏览器在浏览历史中后退或前进跳转一格。而 go() 方法会接受一个整数,可以在浏览历史列表中向前(接受正整数参数)或向后(接受负整数参数)跳过任意多个页。比如 history.go(-1) 就会向后跳转一页,history.go(0) 就是刷新当前页,history.go(1) 就会向前跳转一页。

history 事件 - popstate

当用户通过 "前进" 和 "后退" 按钮浏览保存的历史状态时,浏览器会在 Window 对象上触发一个 popstate 事件。与该事件相关的事件对象有一个 state 属性,该属性包含传递给 pushState() 方法的状态对象的副本。

// 在 window 下监听 onpopstate 事件
window.onpopstate = function(state) {
  // 当 onpopstate 事件 (用户通过 "前进" 和 "后退" 按钮切换浏览记录) 触发时输出当前状态
  console.log(state)
}

Location

Window 对象的 location 属性和 Document 对象的 location 属性引用的都是 Location 对象,它用来表示该窗口中当前显示的文档的 URL,并定义了方法来使窗口载入新的文档。

window.location === document.location  // 总是返回 true

解析 URL

Location 对象的 href 属性是一个字符串,表示当前 URL 的完整文本。Location 对象的 toString() 方法返回 href 属性的值,因此在会隐式调用 toString() 的情况下,可以使用 location 代替 location.href。

该对象的 protocol、host、hostname、port、pathname 和 search 分别表示 URL 的各个部分,它们因此被称为 URL 分解属性。一般我们用的比较多的就是提取 URL 里面的参数了:

// 获取地址栏参数
const getUrlParame = (paramName) => {
    const urlParams = {};
    let params = window.location.search.substring(1);
    if (!params) {
        return;
    }
    params = params.split('&');
    for (let i = 0; i < params.length; i += 1) {
        let item = params[i];
        item = item.split('=');
        urlParams[item[0]] = decodeURIComponent(item[1]);
    }
    if (paramName) {
        return urlParams[paramName];
    }
    return urlParams;
};

载入新文档

Location 对象的 assign() 方法可以使窗口载入并显示指定的 url 中的文档。replace() 方法也有类似的功能,但是它会在新文档载入之前将当前文档从浏览历史中删除,就是说 "后退" 按钮并不会将浏览器带到原始的文档。

Location 对象还定义可 reload() 方法用来重新载入当前文档。

总结

上述的内容我们主要了解了在浏览器中支持的两种路由模式 —— hash 和 history,然后对它们各自的特性、api 和对应的事件做了详细的讲解,后面又说到了浏览器路由中至关重要的对象 —— Location,这一系列的内容为我们后续理解 Vue-Router、React-Router 等路由系统的实现和阅读源码打下了坚实的基础。