⚡前端路由应该理解的一些前置知识

7,802 阅读12分钟

1. URI和URL

URI相比,我们更熟悉URLURL正是使用web浏览器等访问web页面时需要输入的网页地址。比如我们经常输入的 https://www.baidu.com/ 就是 URL

1.1 统一资源标识符

URIUniform Resource Identifier 的缩写。RFC2396分别对这三个单词进行了以下定义:

  • Uniform

规定统一的格式可以方便处理多种不同类型的资源,而不用根据上下文环境来识别资源指定的访问方式。

  • Resopurce

可标识的任何资源。不仅是文档文件,图像或者服务等能够区别于其他类型的,全部可作为资源,它可以是多个对象的集合体。

  • Identifier

标识可标识的对象,也可以称为标识符。

综上所述,URI就是由某个协议方案表示的资源的定位标识符。协议方案是指访问资源时所使用的协议类型名称。HTTP就是协议的一种方案,除此之外,还有ftpfileTELNET30多种标准URI协议方案。

协议方案由互联网号码分配局管理颁布。URI用字符标识某一互联网资源,而URL表示资源的地点,可见URL是URI的子集

1.1.1 URI的通用语法

URI的通用语法由 5 个组件组成:

URI = scheme:[//authority]path[?query][#fragment]

其中,authority 组件可以由以下3个组件组成:

authority = [userinfo@]host[:post]
  • authority 中,userinfo作为登录信息,通常形式为指定用户名和密码,当从服务器端获取资源时作为身份人中凭证使用,userinfo为可选项。
  • 服务器地址 host 在使用绝对路径URI时需指定访问的服务器地址,地址可用为被 DNS 解析的域名,或者是 IPv4 地址以及 IPv6 地址。
  • post 为服务器连接的网络端口号,作为可选项,如果不指定,则自动使用默认的端口号。
  • path 为带层次的文件路径,指定服务器上的文件路径,以访问特定的资源。
  • query 为查询字符串,针对指定路径的文件资源,可使用查询字符串传入任意查询参数。

统一资源标识符 URI 通用语法中列举了几种 URI 例子,如下所示:

ftp://ftp.is.co.za//rfc/rfc1808.txt
http://www.itef.org/rfc/rfc2396.txt
ldap://[2001.db8::7]/c=GB?objectClass?one
mailto:John.Doe@example.com
news:comp.infosystems.www.servers.unix
tel:+1-826-555-1212
telnet://192.0.2.16a:18/
urn:oasis:names:specification:docbook:dxt:xml:4.1.2

1.1.2 URL的通用语法

统一资源定位器URL作为URI的一种,如同网络的门牌,表示了一个互联网资源的 地址,如 https://www.baidu.con 表示通过 HTTP 协议从主机名为 www.baidu.com 的主机上获取首页资源。

URL的语法定义与URI一致:

URL = scheme:[//authority]path[?query][#fragment]

https://www.baidu.com:80/foo/baz?title=moment 为例,其中 https 表示加密的超文本传输协议,www.baidu.com 为服务器的地址,80 为服务器上的都安口号,foo/baz 为资源路径,?title=moment为路径的查询,以 "?" 开头,各个参数是以 "&" 为分割,以等号 "=" 分开参数名称与数据。

1.2.1 URI和URL总结

URL是一种具体的URI,它是URI的一个子集,它不仅唯一标识资源,而且还提供了定位该资源的信息。URI是一种语义上的抽象概念,可以是绝对的,也可以是相对的,而URL则必须提供足够的信息来定位,是绝对的。

总的来说,人的身份证号就是URI,通过身份证号可以让我们能找到具体的一个人。而通过具体的地址去查找某个人,例如广东省广州市天河区某某大学一栋101宿舍的某个叼毛,通过具体的地址也可以找到某个人,也起到了URI的作用,所以URLURI的子集。URL是描述某一资源的具体位置。

2. 浏览器记录

浏览器记录是浏览器中各个页面用户的导航记录。在现代浏览器中,浏览器记录并没有直接的 API 可获取,其可通过 window.history.length 获取当前记录栈的长度信息。浏览器记由浏览器统一管理,并不属某个具体的页面,与页面形式及其内存均无关系。

window.history 对象上存在着诸多属性,通过 lib.dom.d.ts 文件中查看History有以下定义:

interface History {
    readonly length: number;
    scrollRestoration:  "auto" | "manual";
    readonly state: any;
    back(): void;
    forward(): void;
    go(delta?: number): void;
    pushState(data: any, unused: string, url?: string | URL | null): void;
    replaceState(data: any, unused: string, url?: string | URL | null): void;
}

2.1.1 history.pushState

在 HTML 文档中,history.pushState()  方法向当前浏览器会话的历史堆栈中添加一个状态(state)。

history.pushState 方法作为 HTML5 特性的一部分,目前被广泛使用。history.pushState 用于无刷新新增历史栈记录,调用 history.pushState 方法可改变浏览器路径。

pushState() 需要三个参数: 一个状态对象, 一个标题 (目前被忽略), 和一个可选的URL.详情可看 MDN文档。其基本语法如下所示:

pushState(state:Object, title:string,[url:string]):undefined

当设置第三个参数URL时,可改变浏览器URL,且不会刷新浏览器。

history.pushState(null, null, "/foo/bar");
console.log(location.pathname); // /foo/bar

如果URL中包含 Unicode 字符,则浏览器也会将字符按 UTF-8 编码。

history.pushState(null, null, "/中文");
console.log(location.pathname); // /%E4%B8%AD%E6%96%87

通过以下代码示例通过设置state,titleurl创建一条新的历史记录:

// index.html文件
 <body>
    <script>
      const state = { nickname: "moment", age: 7 };
      const title = "";
      const url = "title.html";

      history.pushState(state, title, url);
    </script>
  </body>
  
  // title.html文件
    <body>
    <h1>这个是title页面</h1>
    <div class="age"></div>
    <div class="nickname"></div>
    <script>
      const nickname = document.querySelector(".nickname");
      const age = document.querySelector(".age");

      nickname.innerHTML = window.history.state.nickname;
      age.innerHTML = window.history.state.age;
    </script>
  </body>
  

上例代码通过 pushState 改变浏览器URL,并传进了 state 参数,页面跳转到 title.html 的页面,在 title.html 文件中通过 history.state 获取传进来的数据并通过 innerHTNL 展示出来,结果如下所示:

Snipaste_2022-12-13_11-17-35.png

因为历史栈由浏览器统一管理,不属于某个具体页面,并不存在于页面的内存中,所以历史栈在刷新页面后不会丢失,栈中记录的各 state 对象也为持久化存储,在导航过程中也不会丢失。

histiry.pushState 使用结构化拷贝算法进行序列化存储,会将拷贝后的结果记录在历史栈记录中。结构化拷贝算法除了能拷贝基本类型,还能考悲剧更多的对象类型。相比 JSON 的序列化,这样的序列化更为安全,如循环引用的对象,结构化序列的手段将会序列化成功,而 JSON 的序列化将会报错,原因在于结构化蓄力的手段保存了每一个访问过的对象的记录,遇到复制过的对象会进行跳过。

结构化拷贝算法要注意特殊的场景,如果 history.pushStatestate 对象中有 dom节点、error对象、function函数等,调用 history.pushState方法会抛出异常,且对某些对象的特定属性,如 regExplastIndexObject对象的 settergetter 等,结构化拷贝的过程都会丢失。

2.1.2 历史栈变化

history.pushState 的调用会引起历史栈的变化,浏览器通常会维护一个用户访问过的历史栈,以便用户进行导航。用户通常可以通过浏览器的前进后退按钮或者调用 window.history.go 等方法在历史栈中进行移动,可理解为下图所示的栈指针,不改变历史栈的内容,栈内的记录数量不会发生改变:

image.png

当调用 history.pushState 方法时,历史栈的内容会被修改,行为表现为添加历史栈的栈记录,如上图所示,urlstate会被推到栈顶。历史栈会有一个指针指向栈的其中一个内容,栈指针所指向的内容正是浏览器当前所运行的url所对应的页面。

当调用 history.pushState({a:3},null,'/c') 方法后,则栈记录加1,栈指针也会指向最新的栈记录位置。通过 history.length 能查看当前历史栈的长度。

浏览器提供了两个 API 接口用以调用浏览器本身自带的返回向前,它们分别是 back() 方法和 forward() 方法。其中 back() 方法会在会话历史记录中向后移动一页。如果没有上一页,则此方法调用不执行任何操作。而在会话历史中向前移动一页。它与使用delta参数为 1 时调用 history.go(delta)的效果相同。 还有一个 go 方法从会话历史记录中加载特定页面,你可以使用它在历史记录中前后移动,具体取决于 delta 参数的值,具体语法如下:

window.history.go(delta);

delta 参数可选,相当于当前页面你要去往历史页面的位置。负值表示向后移动,正值表示向前移动。因此,例如:history.go(2) 向前移动两页,history.go(-2) 则向后移动两页。如果未向该函数传参或delta相等于0,则该函数与调用 location,reload() 具有相同的效果。具体有以下示例:

// 向后移动一页 back()
window.history.go(-1);

// 向前移动一页 forward()
window.history.go(1);

// 向后移动两页
window.history.go(-2);

// 向前移动两页
window.history.go(2);

2.1.3 history.replaceState

history.replaceState 的用法与 history.pushState 非常相似,区别在于 history.replaceState 将修改当前的历史记录想而不是新建一个,其语法为:

history.replaceState(stateObj, title[, url]);

history.replaceState 不会改变历史栈中记录的数量,它只会更新当前栈的信息,历史栈的长度不会发生变化。

2.1.4 通过相对路径太黏和修改浏览器记录

history.replaceStatehistory.pushState,除支持绝对路径导航外,还支持相对路径导航:如:

window.history.pushState(null, null, "../one");
window.history.replaceState(null, null, "./one.two");

它们的规则和在 Node,js 中的 url.resolve(),具体可参考 Node.js官方文档 ,两者相对路径的解决规则一致。对于相对路径导航,其遵循以下规则:

  • 如果路径以 "/" 开头,则会替换掉整个路径;
  • 如果路径不以 "/" 开头,则会得到相对当前URI地址的路径(在浏览器无base元素的存在的情况下),跟你混路径解决规则会替换 URL 地址中的最后一级目录,即最后一个 "/" 分隔符后缪按的路径部分,如:
// 当前路径为 /one/two/three
console.log(location.pathname); // /one/two/three
window.history.pushState(null, null, "four");
console.log(location.pathname); // /one/two/four

如果当前路径的最后一个字符为 "/",则可以认为 "/" 后紧接空字符串,执行相对路径导航会替换空字符串部分。

如果路径中含有 "." "..",则表示当前路径及上一级路径。

// 当前路径为 /one/two/three
console.log(location.pathname); // /one/two/three
window.history.pushState(null, null, "./four");
console.log(location.pathname); // /one/two/four
// 当前级路径改为 five 下一级路径改为 six
window.history.pushState(null, null, "./five/././six");
console.log(location.pathname); // /one/two/five/six

对于 ".." 操作符,其表明回到上一句路径。

// 当前路径为 /one/two/three
console.log(location.pathname); // /one/two/three
window.history.pushState(null, null, "../four");
console.log(location.pathname); // /one/four

// 当前路径为 /one
console.log(location.pathname); // /one
window.history.pushState(null, null, "../five/six");
console.log(location.pathname); // /five/six

3. Location

Location 接口表示其链接到的对象的位置(URL)。所做的修改反映在与之相关的对象上。 Document 和 Window 接口都有这样一个链接的 Location,分别通过 Document.locationWindow.location 访问。其中 Document.locationWindow.location指向同一个对象:

console.log(window.location === document.location); // true

通过 lib.dom.d.ts 文件查看 Location 的类型定义,主要有以下属性和方法:

interface Location {
    hash: string;
    host: string;
    hostname: string;
    href: string;
    toString(): string;
    readonly origin: string;
    pathname: string;
    port: string;
    protocol: string;
    search: string;
    assign(url: string | URL): void;
    reload(): void;
    replace(url: string | URL): void;
}

3.1.1 hash

Location 接口的 hash 属性返回一个 USVString,其中会包含 URL 标识中的 '#' 和后面 URL 片段标识符。这里 fragment 不会经过百分比编码(URL 编码)。如果 URL 中没有 fragment,该属性会包含一个空字符串,""。

<a id="myAnchor" href="/moment.com#elegance">Examples</a>
<script>
  const link = document.querySelector("a");
  console.log(link.hash);
</script

如果设置的 location.hash 值与浏览器的 URL 地址的 hash值相同,就不会触发任何事件,也不会添加任何历史记录。或者如果前后两次对 location.hash 设置了相同的值,则第一次 locationn.hash 设置生效,第二次相同的设置不会产生任何事件和历史记录。

3.1.2 host

Location 接口的 host 属性是包含了主机的一段 USVString,其中包含:主机名,如果 URL 的端口号是非空的,还会跟上一个 ':' ,最后是 URL 的端口号。

console.log(location.host);
// 该输出结果在 create-react-app创建的项目中输出的是 localhost:3000
// 在 live Server 打开的文件打开的html文件输出的 127.0.0.1:5500
// 而在普通方式打开的html文件中输出的空字符串

hostname 属性则不携带端口号。

3.1.3 href

Location 接口的 href 属性是一个字符串化转换器 (stringifier), 返回一个包含了完整 URL 的 USVString 值,且允许 href 的更新。

<a id="myAnchor" href="/moment.com#elegance">Examples</a>
<script>
  const link = document.querySelector("a");
  console.log(link.href); // http://127.0.0.1:5500/moment.com#elegance
</script>

3.1.4 replace

history.replaceState 类似,window.location.replace会替换当前的栈记录,但在设置绝对路径中,这意味着用户将不能用 "后退" 按钮再次回到旧页面。

location.href = "https://www.foo.com";
location.href = "https://www.bar.com";
location.replace("https://www.baz.com");

在上面的例子中,由于在 www.bar.com 中调用了 location.href 方法,www.bar.com 的页面记录将替换为 https://www.baz.com 的页面记录。当用户在 https://www.baz.com 页面单机浏览器 "后退" 按钮是,将回到 www.foo.com 页面。

4. 浏览器相关事件

4.1.1 popstate事件

每当激活同一文档中不同的历史记录条目时,popstate 事件就会在对应的 window 对象上触发。如果当前处于激活状态的历史记录条目是由 history.pushState() 方法创建的或者是由 history.replaceState() 方法修改的,则 popstate 事件的 state 属性包含了这个历史记录条目的 state 对象的一个拷贝。

调用 history.replaceState()history.pushState() 不会触发 popstate 事件。当移动栈指针或单机浏览器的 "前进""后退" 按钮时,将触发 popstate 事件,可通过 window.addEventListener 监听该事件。

举个例子🌰,有以下的历史栈,当前历史栈指针正指向 two.html 页面:

image.png

我们通过以下代码来监听 popstate 事件的变化:

window.addEventListener("popstate", function (event) {
  console.log(event);
});

我们通过点击 two.html 页面的前进按钮,前往 three.html 的页面,控制台会有以下输出:

image.png

而回退到 one.html 页面又有以下输出,也正是我们所预料到的:

image.png

history.state 同步的是记录栈中的值,每次导航都会获得新的 state 对象。栈记录中的 state 对象是深拷贝存储在浏览器中的,无论在浏览器进行导航,还是刷新当前页面或是关闭浏览器页签再恢复,历史栈的内容都存在且不会被销毁。

当前后两次设置相同的 location.hash 值时,不会触发两次 popstate 事件。若通过 location.href 设置 hash 值,则无论前后设置的值是否相同,都会触发 popstate 事件。当前后两次设置的值相同时,只添加一个历史栈。

4.2.1 hashchange 事件

hashchange 事件用于监听浏览器值的 hash值变化,其监听方式为:

window.addEventListener("hashchange", function (event) {
  console.log(event);
});

通过下列代码来演示一下在 hashchange 事件的事件相应函数中,可以获取独享 HashChangeEvent,具体有以下属性和方法:

image.png

test.gif

通过上面的动图可以看出,hashchange 事件可以通过设置 location.hash、在地址栏手动修改 hash、调用 window.history.go、在浏览器中单击 "前进""后退" 按钮等方式触发。且每次事件都会得到当前的 url 和旧的 url

值得注意的是,pushState 不会触发 hashChange 事件,即使前后当行的 URLhash 部分发生改变,也是如此。

参考文献

  • 书籍 图解HTTP
  • 书籍 深入理解REact Router 从原理到实践
  • MDN文档

结尾

  • 本篇文章主要讲解了一些 BOM 相关的一些基础知识,这对于我们接下来学习前端路由打下坚实的基础。
  • 在这个系列中接下来的文章,都会讲解 react-router 库的相关知识及源码,敬请关注。
  • 如果需要技术讨论,可以私聊我,或者添加个人微信。