toFrankie / blog

种一棵树,最好的时间是十年前。其次,是现在。
21 stars 1 forks source link

History 对象及事件监听详解 #273

Open toFrankie opened 1 year ago

toFrankie commented 1 year ago

配图源自 Freepik

一、前言

理论上说,每个有效的 URL 都指向一个唯一的资源。这个资源可以是一个 HTML 页面,一个 CSS 文档,一幅图像等。在地址栏键入完整的 URL 地址,浏览器就会将对应资源展示出来。

为了在多个 URL 之间往返,浏览器厂商定义了一种可存储浏览器会话历史(下称“历史记录”)的机制,每访问新的 URL 就会在历史记录中增加一个新的历史记录条目。当前“历史条目”可通过 History 对象(即 window.history)获取,该对象包括了 back()forward()go() 等方法。

在很早以前,不同 URL 之间进行切换,都是需要重新加载资源的。直到 Ajax 的出现,打破了这种限制。Ajax 技术允许通过 JavaScript 脚本向服务器发起请求,服务器接收到请求,将数据返回客户端(浏览器),然后根据响应数据按需操作 DOM 以实现局部刷新。这个过程页面并不会重新加载,只会更新 DOM 的局部,因此 URL 并没有发生变化。但是 Ajax 局部刷新的能力,似乎与一个 URL 对应一个资源相悖。于是......就出现了一种解决方案,既可以实现页面局部刷新,也会修改 URL。

那就是 URL 中的 # 模式,例如:

http://www.example.com/index.html#user

# 号表示网页的一个位置,跟在 # 号后面的字符串称为“锚”。当锚发生变化,若页面中存在这样一个位置(可通过锚点或标签元素 id 属性设置),浏览器会使页面自动滚动至对应位置。这种机制的好处是,仅用于指导浏览器的动作,而对服务器是完全无用的。例如,请求上述网址,HTTP 请求的服务器地址是:http://www.example.com/index.html(不会包含 #user)。

相比 http://www.example.com/index.html/user 这种形式,URL 上带 # 号除了看着不顺眼之外,对于分享 URL 或 SEO 来说也是一个问题(对此 Google 还提出了一种优化 SEO 的方案,即 URL 中带上 "#!"详见)。后来 HTML5 中提供了另外一种解决方案。它同样是可以修改 URL 且不触发页面重载,而且可以修改 URL 中 Origin 后面的任意路径(即 /index.html/user),这点 # 模式是做不到的。他们将这种能力内置在 History 对象下,包含 history.pushState()history.replaceState() 方法。

上面提到了一些词语,有必要说明一下:

  • 历史记录 是指在浏览器中每个标签(窗口)的会话历史(下称“历史记录”)。它由浏览器某个线程维护着,而且标签之间的历史记录是相互独立的,且无法通过 JavaScript 脚本读取。

    当标签关闭或者退出浏览器,会话结束,历史记录也随之被销毁(没错,这里的“历史记录”,并不是指浏览器应用的“历史记录”功能)。

  • 历史条目 浏览器每访问一个新的 URL,就会产生一条记录(下称“记录”),并保存至“历史记录”。这条记录,仅能在当前页面的 window.history 对象读取到。

    举个例子,假设当前历史记录里有 3 条不同页面的记录(假设用数组 [A, B, C] 表示,真正如何表示不去深究,非本文讨论范围),若当前处于 C 页面,那么通过 window.history 读取到数据,是指 C 页面的记录信息。而 AB 页面的信息是获取不对的,除非后退并在对应页面内执行脚本。

  • 新的 URL 请注意,这个“新”是相对的。由于下文经常提到,因此有必要说明一下。

    假设在 A 页面跳转到 B 页面,这个 B 就是“新的 URL”。若在 B 中也有一个链接指向 A 页面,点击的时候,这个 A 也是“新的 URL”,因为它是相较于当前页面 URL 所得出来的结论。因此,这个过程会产生 3 条记录,所以历史记录将会是 [A, B, A]

下面将按历史顺序一一介绍...

二、URL 的 # 号

其实前面刚提到,# 表示页面中的一个位置。比如:

https://github.com/toFrankie/csscomb-mini#usage

上述 URL 中,#usage 表示 https://github.com/toFrankie/csscomb-mini 页面的 usage 位置。

URL 上跟在 # 后面的所有字符串,被称为 Fragment,或片段标识符),所以此 URL 的锚为 usage

1. location.hash 属性

打印 window.location 结果如下:

{
  hash: '#usage'
  host: 'github.com'
  hostname: 'github.com'
  href: 'https://github.com/toFrankie/csscomb-mini#usage'
  origin: 'https://github.com'
  pathname: '/toFrankie/csscomb-mini'
  port: ''
  protocol: 'https:'
  search: ''
}

其中 location.hash 值为 #usage,它是由 # + Fragment 组成的字符串。

如果 URL 中不存在 Fragment,location.hash 会返回一个空字符串('')。

// 1. https://github.com/toFrankie/csscomb-mini
window.location.hash // ""

// 2. https://github.com/toFrankie/csscomb-mini#
window.location.hash // ""

// 3. https://github.com/toFrankie/csscomb-mini#/
window.location.hash // "#/"

// 4. https://github.com/toFrankie/csscomb-mini#usage
window.location.hash // "#usage"

2. 修改 URL hash 值

修改 hash 值就会直接体现在地址栏上,并且在历史记录中会产生一条新记录。比如,执行 history.length 可以看到 length 的变化。history.length 表示历史记录中的记录个数。

可以通过以下几种方式去修改:

// 1. 直接给该属性赋值
window.location.hash = '#usage' // # 号可省略

// 2. 给 window.location 赋值,请注意 # 是不能省略,否则不仅是修改 Fragment 了
window.location = '#usage'
window.location.href = '#usage'

// 3. 请注意,只修改 Fragment 部分,否则会重新加载页面。类似 history.replaceState 作用
window.location.replace('https://github.com/toFrankie/csscomb-mini#/usage')

// 4. 通过 <a> 标签设置 href 属性,且不能省略 # 号
<a href="#usage"></a>

请注意,多次设置同一个 Fragment 时,仅首次有效,重复的部分可以理解为是无效的。

3. location.hash、location.href 与 location.replace()

前面两个方法都可读可写,其中 location.hash 绝对不会重载页面。这跟它的设计初衷有关,前面提过了,不再赘述。而 location.hreflocation.replace() 若只是 URL 的 Fragment 部分发生,也不会重载页面,而其他情况总会重载页面。

通过 location.hreflocation.hash 方式去“修改” URL,历史记录都会新增一条新记录。由于 history.length 是历史记录数量的体现,因此也会随之改变。而 location.replace() 则是用新记录覆盖当前记录,因此 history.length 不会发生变化。

注意点:

  • 以上三种方式(包括 <a> 标签形式)去修改 URL,只有在新旧 URL 不相同的情况下,才会新增一条记录。

  • 其中 location.hreflocation.replace() 方法,若 URL 中包含 Fragment 部分,且新旧 URL 之间仅 Fragment 部分发生变化,也不会重载页面。

  • 不管新旧 URL 是否一致(URL 不含 Fragment 时),location.href 总会重载页面。

  • 当新旧 URL 相同时,location.href 作用等同于 location.reload()history.go(0)。虽说是重新加载页面,但多数是从浏览器缓存中加载,除非页面缓存失效或过期了。

  • 对于 location.href 我们通常会赋予一个完整的 URL 地址,但它是支持“相对路径”形式的 URL 的。(详见:绝对 URL 和相对 URL

  • 上面是指写操作,并不是读操作哈。

一句话总结:若新旧 URL 之间仅仅 Fragment 部分发生改变,以上几种方法都会在历史记录新增一条记录,且不会重载页面。

4. Fragment 的位置

前面提到,# + Fragment 表示网页的一个位置,用于指导浏览器的行为。当 Fragment 的值发生改变,页面会滚动至对应位置。当然,前提是这个位置存在于页面中,否则也是不会发生滚动的。

那么这个“位置”,如何设置呢?

讲真的,天天用框架写页面,最原始的反而忘了。有两种方式:

  • 使用锚点,即利用 <a> 标签的 name 属性(不推荐)
  • 使用标签 id 属性(推荐)

请注意,<a> 标签的 name 属性在 HTML5 中已废弃,请使用 HTML 全局属性 id 来代替。后者在整个 DOM 中必须是唯一的。常用于查询节点、样式选择器、作为页面 Fragment 的位置。

<!-- 1. 锚点 -->
<a name="usage"></a>

<!-- 2. 设置 id 属性 -->
<div id="usage"></div>

<!-- 这种也是可以的,但这种不称为锚点 -->
<a id="usage"></a>

再看一例子:

<!-- 1. 在点击 a 标签时,会修改 hash 属性为 #usage,但不会滚动至 a 标签 -->
<a href="#usage"></a>

<!-- 2. 以下情况,除了修改 hash 值,页面也会随之滚动至 a 标签 -->
<a name="usage" href="#usage"></a>
<a id="usage" href="#usage"></a>

上述示例,作者本人会经常混淆(希望你们不会),顺道提一下。简单来说,href="usage" 是为了修改 URL,当 URL 的 hash 变成 #usage,浏览器就会滚动至对应位置(即锚点为 usageid 属性为 usage 的元素所在位置)。

5. hashchange 事件

若在全局注册了 hashchange 事件监听器,只要 URL 的 Fragment 发生变化,将会被事件处理程序捕获到,事件对象包含了 newURLoldURL 等该事件特有的属性。其余的,在下文对比 popstate 事件时再详细介绍。

三、History 对象

前面提到,每个标签都有一个独立的历史记录,里面维护着一条或多条记录。每条记录保存了对应 URL 的一些状态,仅能在当前页面的 window.history 对象读取到。(这里不再赘述,若概念有混淆的,请回到开头再看一遍)

在 HTML5 之前,History 对象主要包含以下属性和方法:

  • history.length
  • history.back()
  • history.forward()
  • history.go()

1. history.length

只读,该属性返回当前会话的历史记录个数。由于 history.length 是历史记录数量的体现,那么当历史记录发生变化时,它才会随之改变。

注意以下几点:

  • 若“主动”打开浏览器的新标签,就会产生一条记录,尽管它可能是一个空标签页,即 history.length1。当键入新 URL 并回车,此时 history.length 就会变为 2

  • 若浏览器的标签是通过类似 <a target="_blank"> 形式自动创建的话,新标签的 history.length1(不是 2 哦)。此时原标签的历史记录不会受到影响,它们是相互独立的。这种情况就类似于在微信里打开一个链接,进入页面的 history.length1

  • 不管以任何方式刷新页面,历史记录和 history.length 都不会改变。

  • 在地址栏键入新的 URL,历史记录会增加 1

  • 一般情况下,若新旧 URL 相同,此时历史记录不会发生变化,history.length 也不会。特殊情况是,history.pushState()history.replaceState() 方法总会产生一条新记录,即使新旧 URL 相同也会。

  • 点击浏览器前进/后退/刷新按钮,或者调用 history.back()history.forward()history.go() 方法,不会使历史记录和 history.length 值发生变化。这些操作只会退回/前往历史记录中某个具体的页面。但会触发 popstatehashchange 事件(若有注册的话)。

这里描述的场景很多,原因是此前对某些场景没有完全弄清楚(如果你没有这个困扰,简单略过即可)。

既然 history.length 是只读的,换句话说,就是我们无法“直接”操作历史记录(比如删除某个历史记录),事实上我们也访问不到。

2. history.back()

它的作用同浏览器的后退按钮,通俗地讲就是后退至上一页。等价于 history.go(-1)

若当前页面是历史记录的第一个页时,调用此方法不执行任何操作。此时浏览器后退按钮也是置灰的,是不可操作的。换句话说,此方法仅在 history.length > 1 时有效。

3. history.forward()

它的作用同浏览器的前进按钮,通俗地讲就是前往下一页。等价于 history.go(1)

若当前页面是历史记录里最顶端的页面时,调用此方法不执行任何操作。此时浏览器前进按钮也是置灰的,是不可操作的。

4. history.go()

该方法接受一个 delta 参数(可选),通过当前页面的相对位置加载某个页面。

window.history.go(delta)

一般来说,参数可缺省、为 0、为负整数(表示后退)、正整数(表示前进)。

5. 小结

back()forward()go() 三个方法,简单总结一下:

  • 仅调用以上三个方法,不会使得历史记录或 history.length 发生改变。

  • 调用以上三个方法,通常是从浏览器缓存中加载页面。在 Network 选项卡中往往可以看到类似 from disk cache 的字样。

  • 当超出了当前标签的历史记录范围,调用以上三个方法都不会执行任何操作,默默地失败且不报错。

  • 请注意,若后退/前进时,只是锚点发生变化,是不会重新加载页面。

四、HTML5 History API

History API 作为 HTML5 的新特性之一,解决了 Fragment 的一些痛点,包括 URL 分享,SEO 优化等都得到了很好的解决。这些新特性都内置于 History 对象之中:

  • history.state
  • history.scrollRestoration
  • history.pushState()
  • history.replaceState()

1. history.state

只读,该属性返回当前页面的状态值。

const currentState = history.state

只有通过 pushState()replaceState() 方法产生的历史记录,这个属性才会有相应的值,否则为 null

请注意,history.state 的返回值是一份拷贝值

2. history.scrollRestoration

可读写,该属性允许 Web 应用程序在历史导航上显式地设置默认滚动恢复行为。此属性可以是自动的(auto)或者手动的(manual)。

3. history.pushState()

在当前位置,总会产生一条新的记录,并保存在历史记录里面,而且 history.length 也会增加。若新旧 URL 不相同的情况下,也伴随着 URL 的变化。

请注意,它并不会重载页面。同样的还有 history.pushState() 方法。

伪代码:

// 假设历史记录(称为 histories)有 5 个页面,当前处于最后一个页面,即 5 位置。
const histories = [1, 2, 3, 4, 5]

// 若后退 2 页
history.go(-2) // 此时,我们的页面处于历史记录中的 3 位置。

// 插入一个新记录,假设新记录称为 6
history.pushState(
  { state: 'new' },      // 通常是对象,可通过 history.state 获取
  'custom title',        // 几乎所有浏览器都会忽略此参数,所以是没用的
  'https://xxx.com'      // 该 URL 必须跟当前网页是同源的,否则会报错。
)

// 执行 pushState() 方法后,不会加载页面
window.location.href     // "https://xxx.com"
window.document.URL      // "https://xxx.com"
window.document.title    // 这还是原来的标题,而不是 "custom title"
window.history.state     // { state: 'new' }
window.history.length    // 4
histories                // [1, 2, 3, 6]

语法

history.pushState(state, title[, url])

注意点

参数 state 是可序列化对象,怎么理解?

个人猜测是那些可作用域 JSON.stringify() 方法的原始值或引用值,具体没去深究。举个例子,下面这个将会抛出错误:

history.pushState(
  { fn: function () {} }, 
  '', 
  location.href + 'abc'
)
// DOMException: Failed to execute 'pushState' on 'History': 
// function() {} could not be cloned.

较为冷门的东西,参数 url 也支持 绝对 URL 和相对 URL。举些例子:

// 假设当前 URL 如下,它的 Origin 是 https://developer.mozilla.org
// https://developer.mozilla.org/zh-CN/docs/Web/API/History/pushState

// 1️⃣ 完整网站,可理解为绝对路径,将会变成:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript
history.pushState({}, '', 'https://developer.mozilla.org/zh-CN/docs/Web/JavaScript')

// 2️⃣ 含 / 可理解为相对路径,相对于当前 Origin,将会变成 https://developer.mozilla.org/zh-CN/docs/Web/JavaScript
history.pushState({}, '', '/zh-CN/docs/Web/JavaScript')

// 3️⃣ 若为 ../xxx 形式,相对于当前 URL,将会变成 https://developer.mozilla.org/zh-CN/docs/Web/API/History/go
history.pushState({}, '', '../History/go')

// 4️⃣ 若为字符串,将会变成 https://developer.mozilla.org/zh-CN/docs/Web/API/History/hhh
history.pushState({}, '', 'hhh')

另外,使用 history.pushState() 可以改变 referrer

4. history.replaceState()

参数约定与 pushState() 完全一致,语法如下:

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

replaceState() 也总会产生一条新记录,并用新记录替换掉当前页面对应的历史记录。

伪代码...

const histories = [1, 2, 3, 4, 5] // 当前处于 5 位置

history.replaceState({}, '', 'new-url') // 创建一个新记录,假设称为 6

// 新记录 6 会替换记录 5
histories // 历史记录,将会变为 [1, 2, 3, 4, 6]
history.length // 5,未发生变化

5. pushState 与 replaceState 区别

还是伪代码哈:

// 假设历史记录里,有 5 条记录,并处于历史记录的顶端,即第五个位置
const histories = [1, 2, 3, 4, 5]

// 后退 2 个页面,即当前处于第三个位置
history.go(-2)

// 使用 replaceState 产生一条新记录(假设称为 6),
// 它的作用是用新记录替换当前记录,因此记录 3 被新记录 6 所替换
// 但仍处于历史记录的第三个位置
history.replace('6', '', 'new-url-6')
histories // [1, 2, 6, 4, 5]
history.length // 5

// 使用 pushState 产生一条新记录(假设称为 7),
// 它的作用是在当前记录后面添加一条新记录,
// 它会删除当前记录后面的所有记录,然后再往后追加一条,
// 同时,它的位置也会前往至历史记录顶端,即第四个位置。
history.replace('7', '', 'new-url-7')
histories // [1, 2, 6, 7]
history.length // 4

如果用 Array.prototype.splice() 来类比的话,可以这样:

const arr = [1, 2, 3, 4, 5]

// pushState 类似于
arr.splice(curIndex + 1, 1000, newItem)

// replaceState 类似于
arr.splice(curIndex, 1, newItem)

// 注释:
// arr 表示历史记录
// curIndex 表示当前记录的位置,
// 1000 只是为了表达删除完 curIndex + 1 后面的所有项,可用 arr.length 等替代
// newItem 表示新记录

简单来说,pushState()replaceState() 区别如下:

  • 两者都会产生新的记录。
  • 前者会先移除当前记录后面的所有记录,并将新记录追加到历史记录顶端。而后者仅会用新记录替换当前记录,后面的记录并不受影响(若有)。
  • 两者都会使得历史记录发生变化。后者不会使得 history.length 发生改变。

另外,对于历史记录及其数量,history.replaceState()location.replace() 表现是一致的,只是后者有可能会重载页面。

6. popstate 事件

调用 pushState()replaceState() 方法的话,既不会触发 popstate 事件监听器,也不会触发 hashchange 事件监听器(即使新旧 URL 只是 Fragment 部分不同)。这个也是 History API 的优点之一。

其余的下一节介绍...

五、hashchange 和 popstate 事件

1. hashchange 事件

IE8 及以上浏览器都支持 hashchange 事件。注册事件监听器,如下:

function listener(e) {
  // 可通过 e.newURL 和 e.oldURL 获取完整的新旧 URL 值(只读)
  // do something...
}

// 通过 DOM2 注册(更推荐)
window.addEventListener('hashchange', listener)

// 通过 DOM0 注册
window.onhashchange = listener

对于事件监听器的兼容性,可看:细读 JavaScript 事件详解

除了通过调用 pushState()replaceState() 使 URL 的 Fragment 部分发生变化,不会触发 hashchange 事件之外,其他任何方式致使 Fragment 发生改变,都会触发该事件,包括 history.forward()history.back()location.hash<a href="#anchor">、操作浏览器后退/前进按钮、修改地址栏 Fragment 值等方式。

本文提到的 Fragment 均指 URL 上跟在 # 后面的所有字符串。

2. popstate 事件

需要注意的是,调用 history.pushState()history.replaceState() 不会触发 popstate 事件。

只有通过点击浏览器后退/前进按钮,或者通过脚本调用 history.back()history.forward()history.go()go(0) 除外)方法,popstate 事件才会被触发。

function listener(e) {
  // 通过 e.state 可以获取当前记录的状态对象对应的拷贝值。
  // 非 pushState、replaceState 产生的记录,该属性值都为 null。
}

// 通过 DOM2 注册(更推荐)
window.addEventListener('popstate', listener)

// 通过 DOM0 注册
window.onpopstate = listener

另外,不同浏览器在加载页面时处理 popstate 事件的形式可能存在差异。

3. 小结

下面总结了很多条,很大可能会记不住,没关系:

  • 通过 back()forward()go() 或浏览器后退/前进按钮切换的过程,一定会触发 popstate 事件。若伴随着 Fragment 的变化,也会触发 hashchange 事件。(与记录产生的方式无关)

  • 在调用 pushState()replaceState() 时,既不会触发 popstate 事件,也不会触发 hashchange 事件(即使包括 Fragment 发生改变)。

  • 除了 pushState()replaceState(),其他任何方式致使 Fragment 发生改变,都会触发 hashchange 事件。

  • 通过 location.hash = 'foo' 方式致使 Fragment 发生改变,会触发 hashchange 事件,而不会触发 popstate 事件。

  • 而通过 window.location = '#foo'<a href="#foo"> 形式致使 Fragment 发生改变,同时触发 hashchangepopstate 事件。

简化记忆:

其实常用的方法只有三个:history.pushState()history.replaceState()location.hash。最重要的是,通常一个项目不会两者混用,不然得多乱啊。例如 React 、Vue 提供的路由系统只能二选一:

  • History 模式:使用 HTML5 History API,更符合未来发展的方向
  • Hash 模式:利用 location.hashhashchange 事件实现,兼容性较好,且服务端无需额外的配置。

所以,就简化成两条:

六、比较

History 模式和 Hash 模式,在不重载页面的前提下,实现了局部刷新的能力。

从某种程度来说, 调用 pushState() 和 window.location= "#foo" 基本上一样, 他们都会在当前的历史记录中创建和激活一个新的历史条目。但是 pushState() 有以下优势:

注意: pushState() 不会造成 hashchange 事件调用,即使新旧 URL 只是 Fragment 不同。

更多...

七、React Router

在 React 的路由系统中,修改路由、监听路由实际上是由 history 库中 createBrowserHistory()createHashHistory() 方法所构造的 history 对象(有别于 window.history 对象)去操作的。

在 React 中,路由操作有这几种方法。

  • props.history.push() - 新增一条历史记录

  • props.history.replace() - 新增一条记录,并替换当前记录

  • props.history.go() - 后退/前进

  • props.history.goBack() - 即 props.history.go(-1)

  • props.history.goForward() - 即 props.history.go(1)

其中,props.history.go() 实际上就是调用了 window.history.go() 方法。前面两个方法,在不同路由模式下,调用的能力是不一样的。

BrowserRouter 模式下,对应 window.history.pushState()window.history.replaceState() 方法。

HashRouter 模式下,对应 window.location.hashwindow.location.replace() 方法。

在 React Router 中,路由更新以加载不同的组件,是通过 React Context 实现的,即 Provider/Consumer 的模式。当路由更新时,Providervalue 属性会发生变化,使得对应消费 Consumer 的组件得以更新。

前面我们提到过,调用 history.pushState()history.replaceState() 并不会触发 popstate 事件监听函数。那么 React Router 是怎么知道 URL 发生变化的呢?

首先在选择使用 <BrowserRouter><HashHistory> 组件时,它内部设置了一个监听器,这个监听器的回调函数里面有一个 setState() 方法。当我们在 React 组件中使用 props.history.push() 方法去跳转页面时,它除了会执行 window.history.pushState() 使得 URL 发生改变之外,还会执行前面提到的监听器,那么监听器的回调函数也会被执行,既然里面有 setState() 操作,就会使得 <BrowserRouter><HashHistory> 组件执行一次更新,那么该组件的 Provider 就会更新,React Router 的 Consumer 们根据 URL 来匹配对应的路由,以加载相应的组件。因此,我们就能在浏览器中看到 URL 的变化以及页面的跳转。

未完待续...