history.pushState(
{ fn: function () {} },
'',
location.href + 'abc'
)
// DOMException: Failed to execute 'pushState' on 'History':
// function() {} could not be cloned.
除了通过调用 pushState()、replaceState() 使 URL 的 Fragment 部分发生变化,不会触发 hashchange 事件之外,其他任何方式致使 Fragment 发生改变,都会触发该事件,包括 history.forward()、history.back()、location.hash、<a href="#anchor">、操作浏览器后退/前进按钮、修改地址栏 Fragment 值等方式。
一、前言
理论上说,每个有效的 URL 都指向一个唯一的资源。这个资源可以是一个 HTML 页面,一个 CSS 文档,一幅图像等。在地址栏键入完整的 URL 地址,浏览器就会将对应资源展示出来。
为了在多个 URL 之间往返,浏览器厂商定义了一种可存储浏览器会话历史(下称“历史记录”)的机制,每访问新的 URL 就会在历史记录中增加一个新的历史记录条目。当前“历史条目”可通过 History 对象(即
window.history
)获取,该对象包括了back()
、forward()
、go()
等方法。在很早以前,不同 URL 之间进行切换,都是需要重新加载资源的。直到 Ajax 的出现,打破了这种限制。Ajax 技术允许通过 JavaScript 脚本向服务器发起请求,服务器接收到请求,将数据返回客户端(浏览器),然后根据响应数据按需操作 DOM 以实现局部刷新。这个过程页面并不会重新加载,只会更新 DOM 的局部,因此 URL 并没有发生变化。但是 Ajax 局部刷新的能力,似乎与一个 URL 对应一个资源相悖。于是......就出现了一种解决方案,既可以实现页面局部刷新,也会修改 URL。
那就是 URL 中的
#
模式,例如:#
号表示网页的一个位置,跟在#
号后面的字符串称为“锚”。当锚发生变化,若页面中存在这样一个位置(可通过锚点或标签元素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()
方法。上面提到了一些词语,有必要说明一下:
下面将按历史顺序一一介绍...
二、URL 的 # 号
其实前面刚提到,
#
表示页面中的一个位置。比如:上述 URL 中,
#usage
表示https://github.com/toFrankie/csscomb-mini
页面的usage
位置。URL 上跟在
#
后面的所有字符串,被称为 Fragment(锚,或片段标识符),所以此 URL 的锚为usage
。1. location.hash 属性
打印
window.location
结果如下:其中
location.hash
值为#usage
,它是由# + Fragment
组成的字符串。2. 修改 URL hash 值
修改
hash
值就会直接体现在地址栏上,并且在历史记录中会产生一条新记录。比如,执行history.length
可以看到length
的变化。history.length
表示历史记录中的记录个数。可以通过以下几种方式去修改:
3. location.hash、location.href 与 location.replace()
前面两个方法都可读可写,其中
location.hash
绝对不会重载页面。这跟它的设计初衷有关,前面提过了,不再赘述。而location.href
和location.replace()
若只是 URL 的 Fragment 部分发生,也不会重载页面,而其他情况总会重载页面。通过
location.href
、location.hash
方式去“修改” URL,历史记录都会新增一条新记录。由于history.length
是历史记录数量的体现,因此也会随之改变。而location.replace()
则是用新记录覆盖当前记录,因此history.length
不会发生变化。注意点:
一句话总结:若新旧 URL 之间仅仅 Fragment 部分发生改变,以上几种方法都会在历史记录新增一条记录,且不会重载页面。
4. Fragment 的位置
前面提到,
# + Fragment
表示网页的一个位置,用于指导浏览器的行为。当 Fragment 的值发生改变,页面会滚动至对应位置。当然,前提是这个位置存在于页面中,否则也是不会发生滚动的。那么这个“位置”,如何设置呢?
讲真的,天天用框架写页面,最原始的反而忘了。有两种方式:
请注意,
<a>
标签的name
属性在 HTML5 中已废弃,请使用 HTML 全局属性id
来代替。后者在整个 DOM 中必须是唯一的。常用于查询节点、样式选择器、作为页面 Fragment 的位置。再看一例子:
上述示例,作者本人会经常混淆(希望你们不会),顺道提一下。简单来说,
href="usage"
是为了修改 URL,当 URL 的hash
变成#usage
,浏览器就会滚动至对应位置(即锚点为usage
或id
属性为usage
的元素所在位置)。5. hashchange 事件
若在全局注册了
hashchange
事件监听器,只要 URL 的 Fragment 发生变化,将会被事件处理程序捕获到,事件对象包含了newURL
和oldURL
等该事件特有的属性。其余的,在下文对比popstate
事件时再详细介绍。三、History 对象
前面提到,每个标签都有一个独立的历史记录,里面维护着一条或多条记录。每条记录保存了对应 URL 的一些状态,仅能在当前页面的
window.history
对象读取到。(这里不再赘述,若概念有混淆的,请回到开头再看一遍)在 HTML5 之前,History 对象主要包含以下属性和方法:
1. history.length
只读,该属性返回当前会话的历史记录个数。由于
history.length
是历史记录数量的体现,那么当历史记录发生变化时,它才会随之改变。注意以下几点:
这里描述的场景很多,原因是此前对某些场景没有完全弄清楚(如果你没有这个困扰,简单略过即可)。
既然
history.length
是只读的,换句话说,就是我们无法“直接”操作历史记录(比如删除某个历史记录),事实上我们也访问不到。2. history.back()
它的作用同浏览器的后退按钮,通俗地讲就是后退至上一页。等价于
history.go(-1)
。3. history.forward()
它的作用同浏览器的前进按钮,通俗地讲就是前往下一页。等价于
history.go(1)
。4. history.go()
该方法接受一个
delta
参数(可选),通过当前页面的相对位置加载某个页面。一般来说,参数可缺省、为
0
、为负整数(表示后退)、正整数(表示前进)。比如说,
history.go(-2)
会历史记录里后退两个页面。相应地,history.go(2)
会前进两个页面。其中
history.go(1)
作用同history.forward()
,history.go(-1)
作用同history.back()
。若缺省
delta
或者delta
为0
,会重新加载当前页面。此时作用同location.reload()
或者浏览器的刷新按钮。若
delta
数值部分超出了历史记录的范围,不会执行任何操作。既不会后退至历史记录的第一个页面,也不会前往历史记录里最顶端的页面。它会默默地失败,且不会报错。假设历史记录只有 5 条,然后你试图后退/前进 10 个页面,这就属于超出范围。若
delta
不是Number
类型,内部先进行隐式类型转换成对应的Number
值,再执行go()
方法。比如,history.go(true)
相当于history.go(1)
,history.go(NaN)
相当于history.go(0)
。5. 小结
就
back()
、forward()
、go()
三个方法,简单总结一下:四、HTML5 History API
History API 作为 HTML5 的新特性之一,解决了 Fragment 的一些痛点,包括 URL 分享,SEO 优化等都得到了很好的解决。这些新特性都内置于 History 对象之中:
1. history.state
只读,该属性返回当前页面的状态值。
只有通过
pushState()
和replaceState()
方法产生的历史记录,这个属性才会有相应的值,否则为null
。2. history.scrollRestoration
可读写,该属性允许 Web 应用程序在历史导航上显式地设置默认滚动恢复行为。此属性可以是自动的(
auto
)或者手动的(manual
)。3. history.pushState()
在当前位置,总会产生一条新的记录,并保存在历史记录里面,而且
history.length
也会增加。若新旧 URL 不相同的情况下,也伴随着 URL 的变化。伪代码:
语法
state
- 可以是任意值,通常为(可序列化)对象。它可以通过history.state
获取到,或者在popstate
事件的事件对象中体现。title
- 请忽视该参数的作用,它几乎被所有浏览器所忽略,但不得不传。通常,会传递''
、null
或undefined
。url
- (可选)新 URL,它最终体现在地址栏的 URL 上。请注意,新 URL 与当前页面 URL 必须是同源的(即location.origin
相同),否则将会抛出错误。注意点
参数
state
是可序列化对象,怎么理解?个人猜测是那些可作用域
JSON.stringify()
方法的原始值或引用值,具体没去深究。举个例子,下面这个将会抛出错误:较为冷门的东西,参数
url
也支持 绝对 URL 和相对 URL。举些例子:另外,使用
history.pushState()
可以改变referrer
。4. history.replaceState()
参数约定与
pushState()
完全一致,语法如下:replaceState()
也总会产生一条新记录,并用新记录替换掉当前页面对应的历史记录。伪代码...
5. pushState 与 replaceState 区别
还是伪代码哈:
如果用
Array.prototype.splice()
来类比的话,可以这样:简单来说,
pushState()
和replaceState()
区别如下:另外,对于历史记录及其数量,
history.replaceState()
与location.replace()
表现是一致的,只是后者有可能会重载页面。6. popstate 事件
调用
pushState()
、replaceState()
方法的话,既不会触发popstate
事件监听器,也不会触发hashchange
事件监听器(即使新旧 URL 只是 Fragment 部分不同)。这个也是 History API 的优点之一。其余的下一节介绍...
五、hashchange 和 popstate 事件
1. hashchange 事件
IE8 及以上浏览器都支持
hashchange
事件。注册事件监听器,如下:除了通过调用
pushState()
、replaceState()
使 URL 的 Fragment 部分发生变化,不会触发hashchange
事件之外,其他任何方式致使 Fragment 发生改变,都会触发该事件,包括history.forward()
、history.back()
、location.hash
、<a href="#anchor">
、操作浏览器后退/前进按钮、修改地址栏 Fragment 值等方式。2. popstate 事件
只有通过点击浏览器后退/前进按钮,或者通过脚本调用
history.back()
、history.forward()
、history.go()
(go(0)
除外)方法,popstate
事件才会被触发。另外,不同浏览器在加载页面时处理
popstate
事件的形式可能存在差异。3. 小结
下面总结了很多条,很大可能会记不住,没关系:
简化记忆:
其实常用的方法只有三个:
history.pushState()
、history.replaceState()
、location.hash
。最重要的是,通常一个项目不会两者混用,不然得多乱啊。例如 React 、Vue 提供的路由系统只能二选一:所以,就简化成两条:
pushState()
、replaceState()
时,不会触发popstate
事件。其他 URL 的变化都会触发此事件。hashchange
事件。六、比较
History 模式和 Hash 模式,在不重载页面的前提下,实现了局部刷新的能力。
从某种程度来说, 调用
pushState()
和window.location= "#foo"
基本上一样, 他们都会在当前的历史记录中创建和激活一个新的历史条目。但是pushState()
有以下优势:新的 URL 可以是任何和当前 URL 同源的 URL。但是设置
window.location
只会在你只设置 Fragment 的时候才会使当前的 URL。非强制修改 URL。相反,设置
window.location = '#foo'
仅仅会在锚的值不是#foo
情况下创建一条新的历史记录。可以在新的历史记录中关联任何数据。
window.location = "#foo"
形式的操作,你只可以将所需数据写入锚的字符串中。注意:
pushState()
不会造成hashchange
事件调用,即使新旧 URL 只是 Fragment 不同。七、React Router
在 React 的路由系统中,修改路由、监听路由实际上是由 history 库中
createBrowserHistory()
或createHashHistory()
方法所构造的history
对象(有别于window.history
对象)去操作的。在 React 中,路由操作有这几种方法。
其中,
props.history.go()
实际上就是调用了window.history.go()
方法。前面两个方法,在不同路由模式下,调用的能力是不一样的。在 React Router 中,路由更新以加载不同的组件,是通过 React
Context
实现的,即Provider/Consumer
的模式。当路由更新时,Provider
的value
属性会发生变化,使得对应消费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 的变化以及页面的跳转。未完待续...