<h2 id="6.1">浏览器的JavaScript引擎</h2>
## 浏览器的组成
浏览器的核心是两部分:渲染引擎和JavaScript解释器(又称JavaScript引擎)。
(1)渲染引擎
渲染引擎的主要作用是,将网页从代码”渲染“为用户视觉上可以感知的平面文档。不同的浏览器有不同的渲染引擎。
- Firefox:Gecko引擎
- Safari:WebKit引擎
- Chrome:Blink引擎
渲染引擎处理网页,通常分成四个阶段。
1. 解析代码:HTML代码解析为DOM,CSS代码解析为CSSOM(CSS Object Model)
1. 对象合成:将DOM和CSSOM合成一棵渲染树(render tree)
1. 布局:计算出渲染树的布局(layout)
1. 绘制:将渲染树绘制到屏幕
以上四步并非严格按顺序执行,往往第一步还没完成,第二步和第三步就已经开始了。所以,会看到这种情况:网页的HTML代码还没下载完,但浏览器已经显示出内容了。
(2)JavaScript引擎
JavaScript引擎的主要作用是,读取网页中的JavaScript代码,对其处理后运行。
本节主要介绍JavaScript引擎的工作方式。
## JavaScript代码嵌入网页的方法
JavaScript代码只有嵌入网页,才能运行。网页中嵌入JavaScript代码有多种方法。
### 直接添加代码块
通过`<script>`标签,可以直接将JavaScript代码嵌入网页。
```html
<script>
// some JavaScript code
</script>
```
`<script>`标签有一个`type`属性,用来指定脚本类型。不过,如果嵌入的是JavaScript脚本,HTML5推荐`type`属性。
对JavaScript脚本来说,`type`属性可以设为两种值。
- `text/javascript`:这是默认值,也是历史上一贯设定的值。如果你省略`type`属性,默认就是这个值。对于老式浏览器,设为这个值比较好。
- `application/javascript`:对于较新的浏览器,建议设为这个值。
### 加载外部脚本
`script`标签也可以指定加载外部的脚本文件。
```html
<script src="example.js"></script>
```
如果脚本文件使用了非英语字符,还应该注明编码。
```html
<script charset="utf-8" src="example.js"></script>
```
加载外部脚本和直接添加代码块,这两种方法不能混用。下面代码的`console.log`语句直接被忽略。
```html
<script charset="utf-8" src="example.js">
console.log('Hello World!');
</script>
```
为了防止攻击者篡改外部脚本,`script`标签允许设置一个`integrity`属性,写入该外部脚本的Hash签名,用来验证脚本的一致性。
```html
<script src="/assets/application.js"
integrity="sha256-TvVUHzSfftWg1rcfL6TIJ0XKEGrgLyEq6lEpcmrG9qs=">
</script>
```
上面代码中,`script`标签有一个`integrity`属性,指定了外部脚本`/assets/application.js`的SHA265签名。一旦有人改了这个脚本,导致SHA265签名不匹配,浏览器就会拒绝加载。
除了JavaScript脚本,外部的CSS样式表也可以设置这个属性。
### 行内代码
除了上面两种方法,HTML语言允许在某些元素的事件属性和`a`元素的`href`属性中,直接写入JavaScript。
```html
<div onclick="alert('Hello')"></div>
<a href="javascript:alert('Hello')"></a>
```
这种写法将HTML代码与JavaScript代码混写在一起,非常不利于代码管理,不建议使用。
## script标签的工作原理
正常的网页加载流程是这样的。
1. 浏览器一边下载HTML网页,一边开始解析
1. 解析过程中,发现script标签
1. 暂停解析,网页渲染的控制权转交给JavaScript引擎
1. 如果script标签引用了外部脚本,就下载该脚本,否则就直接执行
1. 执行完毕,控制权交还渲染引擎,恢复往下解析HTML网页
也就是说,加载外部脚本时,浏览器会暂停页面渲染,等待脚本下载并执行完成后,再继续渲染。原因是JavaScript可以修改DOM(比如使用`document.write`方法),所以必须把控制权让给它,否则会导致复杂的线程竞赛的问题。
如果外部脚本加载时间很长(比如一直无法完成下载),就会造成网页长时间失去响应,浏览器就会呈现“假死”状态,这被称为“阻塞效应”。
为了避免这种情况,较好的做法是将script标签都放在页面底部,而不是头部。这样即使遇到脚本失去响应,网页主体的渲染也已经完成了,用户至少可以看到内容,而不是面对一张空白的页面。
如果某些脚本代码非常重要,一定要放在页面头部的话,最好直接将代码嵌入页面,而不是连接外部脚本文件,这样能缩短加载时间。
将脚本文件都放在网页尾部加载,还有一个好处。在DOM结构生成之前就调用DOM,JavaScript会报错,如果脚本都在网页尾部加载,就不存在这个问题,因为这时DOM肯定已经生成了。
```html
<head>
<script>
console.log(document.body.innerHTML);
</script>
</head>
```
上面代码执行时会报错,因为此时`body`元素还未生成。
一种解决方法是设定`DOMContentLoaded`事件的回调函数。
```html
<head>
<script>
document.addEventListener(
'DOMContentLoaded',
function(event) {
console.log(document.body.innerHTML);
}
);
</script>
</head>
```
另一种解决方法是,使用`script`标签的`onload`属性。当script标签指定的外部脚本文件下载和解析完成,会触发一个load事件,可以把所需执行的代码,放在这个事件的回调函数里面。
```html
<script src="jquery.min.js" onload="console.log(document.body.innerHTML)">
</script>
```
但是,如果将脚本放在页面底部,就可以完全按照正常的方式写,上面两种方式都不需要。
```html
<body>
<!-- 其他代码 -->
<script>
console.log(document.body.innerHTML);
</script>
</body>
```
如果有多个script标签,比如下面这样。
```html
<script src="1.js"></script>
<script src="2.js"></script>
```
浏览器会同时平行下载`1.js`和`2.js`,但是,执行时会保证先执行`1.js`,然后再执行`2.js`,即使后者先下载完成,也是如此。也就是说,脚本的执行顺序由它们在页面中的出现顺序决定,这是为了保证脚本之间的依赖关系不受到破坏。
当然,加载这两个脚本都会产生“阻塞效应”,必须等到它们都加载完成,浏览器才会继续页面渲染。
Gecko和Webkit引擎在网页被阻塞后,会生成第二个线程解析文档,下载外部资源,但是不会修改DOM,网页还是处于阻塞状态。
解析和执行CSS,也会产生阻塞。Firefox会等到脚本前面的所有样式表,都下载并解析完,再执行脚本;Webkit则是一旦发现脚本引用了样式,就会暂停执行脚本执行,等到样式表下载并解析完,再恢复执行。
此外,对于来自同一个域名的资源,比如脚本文件、样式表文件、图片文件等,浏览器一般最多同时下载六个(IE11允许同时下载13个)。如果是来自不同域名的资源,就没有这个限制。所以,通常把静态文件放在不同的域名之下,以加快下载速度。
## defer属性
为了解决脚本文件下载阻塞网页渲染的问题,一个方法是加入defer属性。
```html
<script src="1.js" defer></script>
<script src="2.js" defer></script>
```
`defer`属性的作用是,告诉浏览器,等到DOM加载完成后,再执行指定脚本。
1. 浏览器开始解析HTML网页
2. 解析过程中,发现带有`defer`属性的script标签
3. 浏览器继续往下解析HTML网页,同时并行下载script标签中的外部脚本
4. 浏览器完成解析HTML网页,此时再执行下载的脚本
有了`defer`属性,浏览器下载脚本文件的时候,不会阻塞页面渲染。下载的脚本文件在`DOMContentLoaded`事件触发前执行(即刚刚读取完`</html>`标签),而且可以保证执行顺序就是它们在页面上出现的顺序。
对于内置而不是连接外部脚本的script标签,以及动态生成的script标签,`defer`属性不起作用。
## async属性
解决“阻塞效应”的另一个方法是加入`async`属性。
```html
<script src="1.js" async></script>
<script src="2.js" async></script>
```
`async`属性的作用是,使用另一个进程下载脚本,下载时不会阻塞渲染。
1. 浏览器开始解析HTML网页
2. 解析过程中,发现带有`async`属性的`script`标签
3. 浏览器继续往下解析HTML网页,同时并行下载`script`标签中的外部脚本
4. 脚本下载完成,浏览器暂停解析HTML网页,开始执行下载的脚本
5. 脚本执行完毕,浏览器恢复解析HTML网页
`async`属性可以保证脚本下载的同时,浏览器继续渲染。需要注意的是,一旦采用这个属性,就无法保证脚本的执行顺序。哪个脚本先下载结束,就先执行那个脚本。另外,使用`async`属性的脚本文件中,不应该使用`document.write`方法。
`defer`属性和`async`属性到底应该使用哪一个?
一般来说,如果脚本之间没有依赖关系,就使用`async`属性,如果脚本之间有依赖关系,就使用`defer`属性。如果同时使用`async`和`defer`属性,后者不起作用,浏览器行为由`async`属性决定。
## 重流和重绘
渲染树转换为网页布局,称为“布局流”(flow);布局显示到页面的这个过程,称为“绘制”(paint)。它们都具有阻塞效应,并且会耗费很多时间和计算资源。
页面生成以后,脚本操作和样式表操作,都会触发重流(reflow)和重绘(repaint)。用户的互动,也会触发,比如设置了鼠标悬停(`a:hover`)效果、页面滚动、在输入框中输入文本、改变窗口大小等等。
重流和重绘并不一定一起发生,重流必然导致重绘,重绘不一定需要重流。比如改变元素颜色,只会导致重绘,而不会导致重流;改变元素的布局,则会导致重绘和重流。
大多数情况下,浏览器会智能判断,将“重流”和“重绘”只限制到相关的子树上面,最小化所耗费的代价,而不会全局重新生成网页。
作为开发者,应该尽量设法降低重绘的次数和成本。比如,尽量不要变动高层的DOM元素,而以底层DOM元素的变动代替;再比如,重绘table布局和flex布局,开销都会比较大。
```javascript
var foo = document.getElementById(‘foobar’);
foo.style.color = ‘blue’;
foo.style.marginTop = ‘30px’;
```
上面的代码只会导致一次重绘,因为浏览器会累积DOM变动,然后一次性执行。
下面的代码则会导致两次重绘。
```javascript
var foo = document.getElementById(‘foobar’);
foo.style.color = ‘blue’;
var margin = parseInt(foo.style.marginTop);
foo.style.marginTop = (margin + 10) + ‘px’;
```
下面是一些优化技巧。
- 读取DOM或者写入DOM,尽量写在一起,不要混杂
- 缓存DOM信息
- 不要一项一项地改变样式,而是使用CSS class一次性改变样式
- 使用document fragment操作DOM
- 动画时使用absolute定位或fixed定位,这样可以减少对其他元素的影响
- 只在必要时才显示元素
- 使用`window.requestAnimationFrame()`,因为它可以把代码推迟到下一次重流时执行,而不是立即要求页面重流
- 使用虚拟DOM(virtual DOM)库
下面是一个`window.requestAnimationFrame()`对比效果的例子。
```javascript
// 重绘代价高
function doubleHeight(element) {
var currentHeight = element.clientHeight;
element.style.height = (currentHeight * 2) + ‘px’;
}
all_my_elements.forEach(doubleHeight);
// 重绘代价低
function doubleHeight(element) {
var currentHeight = element.clientHeight;
window.requestAnimationFrame(function () {
element.style.height = (currentHeight * 2) + ‘px’;
});
}
all_my_elements.forEach(doubleHeight);
```
## 脚本的动态嵌入
除了用静态的`script`标签,还可以动态嵌入`script`标签。
```javascript
['1.js', '2.js'].forEach(function(src) {
var script = document.createElement('script');
script.src = src;
document.head.appendChild(script);
});
```
这种方法的好处是,动态生成的`script`标签不会阻塞页面渲染,也就不会造成浏览器假死。但是问题在于,这种方法无法保证脚本的执行顺序,哪个脚本文件先下载完成,就先执行哪个。
如果想避免这个问题,可以设置async属性为`false`。
```javascript
['1.js', '2.js'].forEach(function(src) {
var script = document.createElement('script');
script.src = src;
script.async = false;
document.head.appendChild(script);
});
```
上面的代码依然不会阻塞页面渲染,而且可以保证`2.js`在`1.js`后面执行。不过需要注意的是,在这段代码后面加载的脚本文件,会因此都等待`2.js`执行完成后再执行。
我们可以把上面的写法,封装成一个函数。
```javascript
(function() {
var scripts = document.getElementsByTagName('script')[0];
function load(url) {
var script = document.createElement('script');
script.async = true;
script.src = url;
scripts.parentNode.insertBefore(script, scripts);
}
load('//apis.google.com/js/plusone.js');
load('//platform.twitter.com/widgets.js');
load('//s.thirdpartywidget.com/widget.js');
}());
```
上面代码中,`async`属性设为`true`,是因为加载的脚本没有互相依赖关系。而且,这样就不会造成堵塞。
此外,动态嵌入还有一个地方需要注意。动态嵌入必须等待CSS文件加载完成后,才会去下载外部脚本文件。静态加载就不存在这个问题,`script`标签指定的外部脚本文件,都是与CSS文件同时并发下载的。
## 加载使用的协议
如果不指定协议,浏览器默认采用HTTP协议下载。
```html
<script src="example.js"></script>
```
上面的`example.js`默认就是采用HTTP协议下载,如果要采用HTTPs协议下载,必需写明(假定服务器支持)。
```html
<script src="https://example.js"></script>
```
但是有时我们会希望,根据页面本身的协议来决定加载协议,这时可以采用下面的写法。
```html
<script src="//example.js"></script>
```
## JavaScript虚拟机
JavaScript是一种解释型语言,也就是说,它不需要编译,可以由解释器实时运行。这样的好处是运行和修改都比较方便,刷新页面就可以重新解释;缺点是每次运行都要调用解释器,系统开销较大,运行速度慢于编译型语言。为了提高运行速度,目前的浏览器都将JavaScript进行一定程度的编译,生成类似字节码(bytecode)的中间代码,以提高运行速度。
早期,浏览器内部对JavaScript的处理过程如下:
1. 读取代码,进行词法分析(Lexical analysis),将代码分解成词元(token)。
2. 对词元进行语法分析(parsing),将代码整理成“语法树”(syntax tree)。
3. 使用“翻译器”(translator),将代码转为字节码(bytecode)。
4. 使用“字节码解释器”(bytecode interpreter),将字节码转为机器码。
逐行解释将字节码转为机器码,是很低效的。为了提高运行速度,现代浏览器改为采用“即时编译”(Just In Time compiler,缩写JIT),即字节码只在运行时编译,用到哪一行就编译哪一行,并且把编译结果缓存(inline cache)。通常,一个程序被经常用到的,只是其中一小部分代码,有了缓存的编译结果,整个程序的运行速度就会显著提升。
不同的浏览器有不同的编译策略。有的浏览器只编译最经常用到的部分,比如循环的部分;有的浏览器索性省略了字节码的翻译步骤,直接编译成机器码,比如chrome浏览器的V8引擎。
字节码不能直接运行,而是运行在一个虚拟机(Virtual Machine)之上,一般也把虚拟机称为JavaScript引擎。因为JavaScript运行时未必有字节码,所以JavaScript虚拟机并不完全基于字节码,而是部分基于源码,即只要有可能,就通过JIT(just in time)编译器直接把源码编译成机器码运行,省略字节码步骤。这一点与其他采用虚拟机(比如Java)的语言不尽相同。这样做的目的,是为了尽可能地优化代码、提高性能。下面是目前最常见的一些JavaScript虚拟机:
- [Chakra](http://en.wikipedia.org/wiki/Chakra_(JScript_engine\))(Microsoft Internet Explorer)
- [Nitro/JavaScript Core](http://en.wikipedia.org/wiki/WebKit#JavaScriptCore) (Safari)
- [Carakan](http://dev.opera.com/articles/view/labs-carakan/) (Opera)
- [SpiderMonkey](https://developer.mozilla.org/en-US/docs/SpiderMonkey) (Firefox)
- [V8](http://en.wikipedia.org/wiki/V8_(JavaScript_engine\)) (Chrome, Chromium)
## 单线程模型
### 含义
首先,明确一个观念:JavaScript只在一个线程上运行,不代表JavaScript引擎只有一个线程。事实上,JavaScript引擎有多个线程,其中单个脚本只能在一个线程上运行,其他线程都是在后台配合。JavaScript脚本在一个线程里运行。这意味着,一次只能运行一个任务,其他任务都必须在后面排队等待。
JavaScript之所以采用单线程,而不是多线程,跟历史有关系。JavaScript从诞生起就是单线程,原因是不想让浏览器变得太复杂,因为多线程需要共享资源、且有可能修改彼此的运行结果,对于一种网页脚本语言来说,这就太复杂了。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。
单线程模型带来了一些问题,主要是新的任务被加在队列的尾部,只有前面的所有任务运行结束,才会轮到它执行。如果有一个任务特别耗时,后面的任务都会停在那里等待,造成浏览器失去响应,又称“假死”。为了避免“假死”,当某个操作在一定时间后仍无法结束,浏览器就会跳出提示框,询问用户是否要强行停止脚本运行。
如果排队是因为计算量大,CPU忙不过来,倒也算了,但是很多时候CPU是闲着的,因为IO设备(输入输出设备)很慢(比如Ajax操作从网络读取数据),不得不等着结果出来,再往下执行。JavaScript语言的设计者意识到,这时CPU完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。这种机制就是JavaScript内部采用的Event Loop。
### 消息队列
JavaScript运行时,除了一根运行线程,系统还提供一个消息队列(message queue),里面是各种需要当前程序处理的消息。新的消息进入队列的时候,会自动排在队列的尾端。
运行线程只要发现消息队列不为空,就会取出排在第一位的那个消息,执行它对应的回调函数。等到执行完,再取出排在第二位的消息,不断循环,直到消息队列变空为止。
每条消息与一个回调函数相联系,也就是说,程序只要收到这条消息,就会执行对应的函数。另一方面,进入消息队列的消息,必须有对应的回调函数。否则这个消息就会遗失,不会进入消息队列。举例来说,鼠标点击就会产生一条消息,报告`click`事件发生了。如果没有回调函数,这个消息就遗失了。如果有回调函数,这个消息进入消息队列。等到程序收到这个消息,就会执行click事件的回调函数。
另一种情况是`setTimeout`会在指定时间向消息队列添加一条消息。如果消息队列之中,此时没有其他消息,这条消息会立即得到处理;否则,这条消息会不得不等到其他消息处理完,才会得到处理。因此,`setTimeout`指定的执行时间,只是一个最早可能发生的时间,并不能保证一定会在那个时间发生。
一旦当前执行栈空了,消息队列就会取出排在第一位的那条消息,传入程序。程序开始执行对应的回调函数,等到执行完,再处理下一条消息。
### Event Loop
所谓Event Loop,指的是一种内部循环,用来一轮又一轮地处理消息队列之中的消息,即执行对应的回调函数。[Wikipedia](http://en.wikipedia.org/wiki/Event_loop)的定义是:“**Event Loop是一个程序结构,用于等待和发送消息和事件**(a programming construct that waits for and dispatches events or messages in a program)”。可以就把Event Loop理解成动态更新的消息队列本身。
下面是一些常见的JavaScript任务。
- 执行JavaScript代码
- 对用户的输入(包含鼠标点击、键盘输入等等)做出反应
- 处理异步的网络请求
所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。同步任务指的是,在JavaScript执行进程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是,不进入JavaScript执行进程、而进入“任务队列”(task queue)的任务,只有“任务队列”通知主进程,某个异步任务可以执行了,该任务(采用回调函数的形式)才会进入JavaScript进程执行。
以Ajax操作为例,它可以当作同步任务处理,也可以当作异步任务处理,由开发者决定。如果是同步任务,主线程就等着Ajax操作返回结果,再往下执行;如果是异步任务,该任务直接进入“任务队列”,JavaScript进程跳过Ajax操作,直接往下执行,等到Ajax操作有了结果,JavaScript进程再执行对应的回调函数。
也就是说,虽然JavaScript只有一根进程用来执行,但是并行的还有其他进程(比如,处理定时器的进程、处理用户输入的进程、处理网络通信的进程等等)。这些进程通过向任务队列添加任务,实现与JavaScript进程通信。
想要理解Event Loop,就要从程序的运行模式讲起。运行以后的程序叫做"进程"(process),一般情况下,一个进程一次只能执行一个任务。如果有很多任务需要执行,不外乎三种解决方法。
1. **排队。**因为一个进程一次只能执行一个任务,只好等前面的任务执行完了,再执行后面的任务。
2. **新建进程。**使用fork命令,为每个任务新建一个进程。
3. **新建线程。**因为进程太耗费资源,所以如今的程序往往允许一个进程包含多个线程,由线程去完成任务。
如果某个任务很耗时,比如涉及很多I/O(输入/输出)操作,那么线程的运行大概是下面的样子。
![synchronous mode](http://image.beekka.com/blog/201310/2013102002.png)
上图的绿色部分是程序的运行时间,红色部分是等待时间。可以看到,由于I/O操作很慢,所以这个线程的大部分运行时间都在空等I/O操作的返回结果。这种运行方式称为"同步模式"(synchronous I/O)。
如果采用多线程,同时运行多个任务,那很可能就是下面这样。
![synchronous mode](http://image.beekka.com/blog/201310/2013102003.png)
上图表明,多线程不仅占用多倍的系统资源,也闲置多倍的资源,这显然不合理。
![asynchronous mode](http://image.beekka.com/blog/201310/2013102004.png)
上图主线程的绿色部分,还是表示运行时间,而橙色部分表示空闲时间。每当遇到I/O的时候,主线程就让Event Loop线程去通知相应的I/O程序,然后接着往后运行,所以不存在红色的等待时间。等到I/O程序完成操作,Event Loop线程再把结果返回主线程。主线程就调用事先设定的回调函数,完成整个任务。
可以看到,由于多出了橙色的空闲时间,所以主线程得以运行更多的任务,这就提高了效率。这种运行方式称为"[异步模式](http://en.wikipedia.org/wiki/Asynchronous_I/O)"(asynchronous I/O)。
这正是JavaScript语言的运行方式。单线程模型虽然对JavaScript构成了很大的限制,但也因此使它具备了其他语言不具备的优势。如果部署得好,JavaScript程序是不会出现堵塞的,这就是为什么node.js平台可以用很少的资源,应付大流量访问的原因。
如果有大量的异步任务(实际情况就是这样),它们会在“消息队列”中产生大量的消息。这些消息排成队,等候进入主线程。本质上,“消息队列”就是一个“先进先出”的数据结构。比如,点击鼠标就产生一系列消息(各种事件),`mousedown`事件排在`mouseup`事件前面,`mouseup`事件又排在`click`事件的前面。
<h2 id="6.2">定时器</h2>
JavaScript提供定时执行代码的功能,叫做定时器(timer),主要由`setTimeout()`和`setInterval()`这两个函数来完成。它们向任务队列添加定时任务。
## setTimeout()
`setTimeout`函数用来指定某个函数或某段代码,在多少毫秒之后执行。它返回一个整数,表示定时器的编号,以后可以用来取消这个定时器。
```javascript
var timerId = setTimeout(func|code, delay)
```
上面代码中,`setTimeout`函数接受两个参数,第一个参数`func|code`是将要推迟执行的函数名或者一段代码,第二个参数`delay`是推迟执行的毫秒数。
```javascript
console.log(1);
setTimeout('console.log(2)',1000);
console.log(3);
```
上面代码的输出结果就是1,3,2,因为`setTimeout`指定第二行语句推迟1000毫秒再执行。
需要注意的是,推迟执行的代码必须以字符串的形式,放入setTimeout,因为引擎内部使用eval函数,将字符串转为代码。如果推迟执行的是函数,则可以直接将函数名,放入setTimeout。一方面eval函数有安全顾虑,另一方面为了便于JavaScript引擎优化代码,setTimeout方法一般总是采用函数名的形式,就像下面这样。
```javascript
function f(){
console.log(2);
}
setTimeout(f,1000);
// 或者
setTimeout(function (){console.log(2)},1000);
```
如果省略`setTimeout`的第二个参数,则该参数默认为0。
除了前两个参数,setTimeout还允许添加更多的参数。它们将被传入推迟执行的函数(回调函数)。
```javascript
setTimeout(function(a,b){
console.log(a+b);
},1000,1,1);
```
上面代码中,setTimeout共有4个参数。最后那两个参数,将在1000毫秒之后回调函数执行时,作为回调函数的参数。
IE 9.0及以下版本,只允许setTimeout有两个参数,不支持更多的参数。这时有三种解决方法。第一种是在一个匿名函数里面,让回调函数带参数运行,再把匿名函数输入setTimeout。
```javascript
setTimeout(function() {
myFunc("one", "two", "three");
}, 1000);
```
上面代码中,myFunc是真正要推迟执行的函数,有三个参数。如果直接放入setTimeout,低版本的IE不能带参数,所以可以放在一个匿名函数。
第二种解决方法是使用bind方法,把多余的参数绑定在回调函数上面,生成一个新的函数输入setTimeout。
```javascript
setTimeout(function(arg1){}.bind(undefined, 10), 1000);
```
上面代码中,bind方法第一个参数是undefined,表示将原函数的this绑定全局作用域,第二个参数是要传入原函数的参数。它运行后会返回一个新函数,该函数不带参数。
第三种解决方法是自定义setTimeout,使用apply方法将参数输入回调函数。
```html
<!--[if lte IE 9]><script>
(function(f){
window.setTimeout =f(window.setTimeout);
window.setInterval =f(window.setInterval);
})(function(f){return function(c,t){
var a=[].slice.call(arguments,2);return f(function(){c.apply(this,a)},t)}
});
</script><![endif]-->
```
除了参数问题,setTimeout还有一个需要注意的地方:如果被setTimeout推迟执行的回调函数是某个对象的方法,那么该方法中的this关键字将指向全局环境,而不是定义时所在的那个对象。
```javascript
var x = 1;
var o = {
x: 2,
y: function(){
console.log(this.x);
}
};
setTimeout(o.y,1000);
// 1
```
上面代码输出的是1,而不是2,这表示`o.y`的this所指向的已经不是o,而是全局环境了。
再看一个不容易发现错误的例子。
```javascript
function User(login) {
this.login = login;
this.sayHi = function() {
console.log(this.login);
}
}
var user = new User('John');
setTimeout(user.sayHi, 1000);
```
上面代码只会显示undefined,因为等到user.sayHi执行时,它是在全局对象中执行,所以this.login取不到值。
为了防止出现这个问题,一种解决方法是将user.sayHi放在函数中执行。
```javascript
setTimeout(function() {
user.sayHi();
}, 1000);
```
上面代码中,sayHi是在user作用域内执行,而不是在全局作用域内执行,所以能够显示正确的值。
另一种解决方法是,使用bind方法,将绑定sayHi绑定在user上面。
```javascript
setTimeout(user.sayHi.bind(user), 1000);
```
HTML 5标准规定,setTimeout的最短时间间隔是4毫秒。为了节电,对于那些不处于当前窗口的页面,浏览器会将时间间隔扩大到1000毫秒。另外,如果笔记本电脑处于电池供电状态,Chrome和IE 9以上的版本,会将时间间隔切换到系统定时器,大约是15.6毫秒。
## setInterval()
`setInterval`函数的用法与`setTimeout`完全一致,区别仅仅在于`setInterval`指定某个任务每隔一段时间就执行一次,也就是无限次的定时执行。
```html
<input type="button" onclick="clearInterval(timer)" value="stop">
<script>
var i = 1
var timer = setInterval(function() {
console.log(2);
}, 1000);
</script>
```
上面代码表示每隔1000毫秒就输出一个2,直到用户点击了停止按钮。
与`setTimeout`一样,除了前两个参数,`setInterval`方法还可以接受更多的参数,它们会传入回调函数,下面是一个例子。
```javascript
function f(){
for (var i=0;i<arguments.length;i++){
console.log(arguments[i]);
}
}
setInterval(f, 1000, "Hello World");
// Hello World
// Hello World
// Hello World
// ...
```
如果网页不在浏览器的当前窗口(或tab),许多浏览器限制setInteral指定的反复运行的任务最多每秒执行一次。
下面是一个通过`setInterval`方法实现网页动画的例子。
```javascript
var div = document.getElementById('someDiv');
var opacity = 1;
var fader = setInterval(function() {
opacity -= 0.1;
if (opacity >= 0) {
div.style.opacity = opacity;
} else {
clearInterval(fader);
}
}, 100);
```
上面代码每隔100毫秒,设置一次`div`元素的透明度,直至其完全透明为止。
`setInterval`的一个常见用途是实现轮询。下面是一个轮询URL的Hash值是否发生变化的例子。
```javascript
var hash = window.location.hash;
var hashWatcher = setInterval(function() {
if (window.location.hash != hash) {
updatePage();
}
}, 1000);
```
setInterval指定的是“开始执行”之间的间隔,并不考虑每次任务执行本身所消耗的时间。因此实际上,两次执行之间的间隔会小于指定的时间。比如,setInterval指定每100ms执行一次,每次执行需要5ms,那么第一次执行结束后95毫秒,第二次执行就会开始。如果某次执行耗时特别长,比如需要105毫秒,那么它结束后,下一次执行就会立即开始。
为了确保两次执行之间有固定的间隔,可以不用setInterval,而是每次执行结束后,使用setTimeout指定下一次执行的具体时间。
```javascript
var i = 1;
var timer = setTimeout(function() {
alert(i++);
timer = setTimeout(arguments.callee, 2000);
}, 2000);
```
上面代码可以确保,下一个对话框总是在关闭上一个对话框之后2000毫秒弹出。
根据这种思路,可以自己部署一个函数,实现间隔时间确定的setInterval的效果。
```javascript
function interval(func, wait){
var interv = function(){
func.call(null);
setTimeout(interv, wait);
};
setTimeout(interv, wait);
}
interval(function(){
console.log(2);
},1000);
```
上面代码部署了一个interval函数,用循环调用setTimeout模拟了setInterval。
HTML 5标准规定,setInterval的最短间隔时间是10毫秒,也就是说,小于10毫秒的时间间隔会被调整到10毫秒。
## clearTimeout(),clearInterval()
setTimeout和setInterval函数,都返回一个表示计数器编号的整数值,将该整数传入clearTimeout和clearInterval函数,就可以取消对应的定时器。
```javascript
var id1 = setTimeout(f,1000);
var id2 = setInterval(f,1000);
clearTimeout(id1);
clearInterval(id2);
```
setTimeout和setInterval返回的整数值是连续的,也就是说,第二个setTimeout方法返回的整数值,将比第一个的整数值大1。利用这一点,可以写一个函数,取消当前所有的setTimeout。
```javascript
(function() {
var gid = setInterval(clearAllTimeouts, 0);
function clearAllTimeouts() {
var id = setTimeout(function() {}, 0);
while (id > 0) {
if (id !== gid) {
clearTimeout(id);
}
id--;
}
}
})();
```
运行上面代码后,实际上再设置任何setTimeout都无效了。
下面是一个clearTimeout实际应用的例子。有些网站会实时将用户在文本框的输入,通过Ajax方法传回服务器,jQuery的写法如下。
```javascript
$('textarea').on('keydown', ajaxAction);
```
这样写有一个很大的缺点,就是如果用户连续击键,就会连续触发keydown事件,造成大量的Ajax通信。这是不必要的,而且很可能会发生性能问题。正确的做法应该是,设置一个门槛值,表示两次Ajax通信的最小间隔时间。如果在设定的时间内,发生新的keydown事件,则不触发Ajax通信,并且重新开始计时。如果过了指定时间,没有发生新的keydown事件,将进行Ajax通信将数据发送出去。
这种做法叫做debounce(防抖动)方法,用来返回一个新函数。只有当两次触发之间的时间间隔大于事先设定的值,这个新函数才会运行实际的任务。假定两次Ajax通信的间隔不小于2500毫秒,上面的代码可以改写成下面这样。
```javascript
$('textarea').on('keydown', debounce(ajaxAction, 2500))
```
利用setTimeout和clearTimeout,可以实现debounce方法。该方法用于防止某个函数在短时间内被密集调用,具体来说,debounce方法返回一个新版的该函数,这个新版函数调用后,只有在指定时间内没有新的调用,才会执行,否则就重新计时。
```javascript
function debounce(fn, delay){
var timer = null; // 声明计时器
return function(){
var context = this;
var args = arguments;
clearTimeout(timer);
timer = setTimeout(function(){
fn.apply(context, args);
}, delay);
};
}
// 用法示例
var todoChanges = _.debounce(batchLog, 1000);
Object.observe(models.todo, todoChanges);
```
现实中,最好不要设置太多个setTimeout和setInterval,它们耗费CPU。比较理想的做法是,将要推迟执行的代码都放在一个函数里,然后只对这个函数使用setTimeout或setInterval。
## 运行机制
setTimeout和setInterval的运行机制是,将指定的代码移出本次执行,等到下一轮Event Loop时,再检查是否到了指定时间。如果到了,就执行对应的代码;如果不到,就等到再下一轮Event Loop时重新判断。这意味着,setTimeout指定的代码,必须等到本次执行的所有代码都执行完,才会执行。
每一轮Event Loop时,都会将“任务队列”中需要执行的任务,一次执行完。setTimeout和setInterval都是把任务添加到“任务队列”的尾部。因此,它们实际上要等到当前脚本的所有同步任务执行完,然后再等到本次Event Loop的“任务队列”的所有任务执行完,才会开始执行。由于前面的任务到底需要多少时间执行完,是不确定的,所以没有办法保证,setTimeout和setInterval指定的任务,一定会按照预定时间执行。
```javascript
setTimeout(someTask,100);
veryLongTask();
```
上面代码的setTimeout,指定100毫秒以后运行一个任务。但是,如果后面立即运行的任务(当前脚本的同步任务))非常耗时,过了100毫秒还无法结束,那么被推迟运行的someTask就只有等着,等到前面的veryLongTask运行结束,才轮到它执行。
这一点对于setInterval影响尤其大。
```javascript
setInterval(function(){
console.log(2);
},1000);
(function (){
sleeping(3000);
})();
```
上面的第一行语句要求每隔1000毫秒,就输出一个2。但是,第二行语句需要3000毫秒才能完成,请问会发生什么结果?
结果就是等到第二行语句运行完成以后,立刻连续输出三个2,然后开始每隔1000毫秒,输出一个2。也就是说,setIntervel具有累积效应,如果某个操作特别耗时,超过了setInterval的时间间隔,排在后面的操作会被累积起来,然后在很短的时间内连续触发,这可能或造成性能问题(比如集中发出Ajax请求)。
为了进一步理解JavaScript的单线程模型,请看下面这段伪代码。
```javascript
function init(){
{ 耗时5ms的某个操作 }
触发mouseClickEvent事件
{ 耗时5ms的某个操作 }
setInterval(timerTask,10);
{ 耗时5ms的某个操作 }
}
function handleMouseClick(){
耗时8ms的某个操作
}
function timerTask(){
耗时2ms的某个操作
}
```
请问调用init函数后,这段代码的运行顺序是怎样的?
- **0-15ms**:运行init函数。
- **15-23ms**:运行handleMouseClick函数。请注意,这个函数是在5ms时触发的,应该在那个时候就立即运行,但是由于单线程的关系,必须等到init函数完成之后再运行。
- **23-25ms**:运行timerTask函数。这个函数是在10ms时触发的,规定每10ms运行一次,即在20ms、30ms、40ms等时候运行。由于20ms时,JavaScript线程还有任务在运行,因此必须延迟到前面任务完成时再运行。
- **30-32ms**:运行timerTask函数。
- **40-42ms**:运行timerTask函数。
## setTimeout(f,0)
### 含义
`setTimeout`的作用是将代码推迟到指定时间执行,如果指定时间为`0`,即`setTimeout(f, 0)`,那么会立刻执行吗?
答案是不会。因为上一段说过,必须要等到当前脚本的同步任务和“任务队列”中已有的事件,全部处理完以后,才会执行`setTimeout`指定的任务。也就是说,setTimeout的真正作用是,在“消息队列”的现有消息的后面再添加一个消息,规定在指定时间执行某段代码。`setTimeout`添加的事件,会在下一次`Event Loop`执行。
`setTimeout(f, 0)`将第二个参数设为`0`,作用是让`f`在现有的任务(脚本的同步任务和“消息队列”指定的任务)一结束就立刻执行。也就是说,`setTimeout(f, 0)`的作用是,尽可能早地执行指定的任务。而并不是会立刻就执行这个任务。
```javascript
setTimeout(function () {
console.log('你好!');
}, 0);
```
上面代码的含义是,尽可能早地显示“你好!”。
`setTimeout(f, 0)`指定的任务,最早也要到下一次Event Loop才会执行。请看下面的例子。
```javascript
setTimeout(function() {
console.log("Timeout");
}, 0);
function a(x) {
console.log("a() 开始运行");
b(x);
console.log("a() 结束运行");
}
function b(y) {
console.log("b() 开始运行");
console.log("传入的值为" + y);
console.log("b() 结束运行");
}
console.log("当前任务开始");
a(42);
console.log("当前任务结束");
// 当前任务开始
// a() 开始运行
// b() 开始运行
// 传入的值为42
// b() 结束运行
// a() 结束运行
// 当前任务结束
// Timeout
```
上面代码说明,`setTimeout(f, 0)`必须要等到当前脚本的所有同步任务结束后才会执行。
即使消息队列是空的,0毫秒实际上也是达不到的。根据[HTML 5标准](http://www.whatwg.org/specs/web-apps/current-work/multipage/timers.html#timers),`setTimeOut`推迟执行的时间,最少是4毫秒。如果小于这个值,会被自动增加到4。这是为了防止多个`setTimeout(f, 0)`语句连续执行,造成性能问题。
另一方面,浏览器内部使用32位带符号的整数,来储存推迟执行的时间。这意味着`setTimeout`最多只能推迟执行2147483647毫秒(24.8天),超过这个时间会发生溢出,导致回调函数将在当前任务队列结束后立即执行,即等同于`setTimeout(f, 0)`的效果。
### 应用
setTimeout(f,0)有几个非常重要的用途。它的一大应用是,可以调整事件的发生顺序。比如,网页开发中,某个事件先发生在子元素,然后冒泡到父元素,即子元素的事件回调函数,会早于父元素的事件回调函数触发。如果,我们先让父元素的事件回调函数先发生,就要用到setTimeout(f, 0)。
```javascript
var input = document.getElementsByTagName('input[type=button]')[0];
input.onclick = function A() {
setTimeout(function B() {
input.value +=' input';
}, 0)
};
document.body.onclick = function C() {
input.value += ' body'
};
```
上面代码在点击按钮后,先触发回调函数A,然后触发函数C。在函数A中,setTimeout将函数B推迟到下一轮Loop执行,这样就起到了,先触发父元素的回调函数C的目的了。
用户自定义的回调函数,通常在浏览器的默认动作之前触发。比如,用户在输入框输入文本,keypress事件会在浏览器接收文本之前触发。因此,下面的回调函数是达不到目的的。
```javascript
document.getElementById('input-box').onkeypress = function(event) {
this.value = this.value.toUpperCase();
}
```
上面代码想在用户输入文本后,立即将字符转为大写。但是实际上,它只能将上一个字符转为大写,因为浏览器此时还没接收到文本,所以`this.value`取不到最新输入的那个字符。只有用setTimeout改写,上面的代码才能发挥作用。
```javascript
document.getElementById('my-ok').onkeypress = function() {
var self = this;
setTimeout(function() {
self.value = self.value.toUpperCase();
}, 0);
}
```
上面代码将代码放入setTimeout之中,就能使得它在浏览器接收到文本之后触发。
由于setTimeout(f,0)实际上意味着,将任务放到浏览器最早可得的空闲时段执行,所以那些计算量大、耗时长的任务,常常会被放到几个小部分,分别放到setTimeout(f,0)里面执行。
```javascript
var div = document.getElementsByTagName('div')[0];
// 写法一
for (var i = 0xA00000; i < 0xFFFFFF; i++) {
div.style.backgroundColor = '#' + i.toString(16);
}
// 写法二
var timer;
var i=0x100000;
function func() {
timer = setTimeout(func, 0);
div.style.backgroundColor = '#' + i.toString(16);
if (i++ == 0xFFFFFF) clearTimeout(timer);
}
timer = setTimeout(func, 0);
```
上面代码有两种写法,都是改变一个网页元素的背景色。写法一会造成浏览器“堵塞”,因为JavaScript执行速度远高于DOM,会造成大量DOM操作“堆积”,而写法二就不会,这就是`setTimeout(f, 0)`的好处。
另一个使用这种技巧的例子是代码高亮的处理。如果代码块很大,一次性处理,可能会对性能造成很大的压力,那么将其分成一个个小块,一次处理一块,比如写成`setTimeout(highlightNext, 50)`的样子,性能压力就会减轻。
## 正常任务与微任务
正常情况下,JavaScript的任务是同步执行的,即执行完前一个任务,然后执行后一个任务。只有遇到异步任务的情况下,执行顺序才会改变。
这时,需要区分两种任务:正常任务(task)与微任务(microtask)。它们的区别在于,“正常任务”在下一轮Event Loop执行,“微任务”在本轮Event Loop的所有任务结束后执行。
```javascript
console.log(1);
setTimeout(function() {
console.log(2);
}, 0);
Promise.resolve().then(function() {
console.log(3);
}).then(function() {
console.log(4);
});
console.log(5);
// 1
// 5
// 3
// 4
// 2
```
上面代码的执行结果说明,`setTimeout(fn, 0)`在`Promise.resolve`之后执行。
这是因为`setTimeout`语句指定的是“正常任务”,即不会在当前的Event Loop执行。而Promise会将它的回调函数,在状态改变后的那一轮Event Loop指定为微任务。所以,3和4输出在5之后、2之前。
除了`setTimeout`,正常任务还包括各种事件(比如鼠标单击事件)的回调函数。微任务目前主要就是Promise。
<h2 id="6.3">window对象</h2>
## 概述
JavaScript的所有对象都存在于一个运行环境之中,这个运行环境本身也是对象,称为“顶层对象”。这就是说,JavaScript的所有对象,都是“顶层对象”的下属。不同的运行环境有不同的“顶层对象”,在浏览器环境中,这个顶层对象就是`window`对象(`w`为小写)。
所有浏览器环境的全局变量,都是`window`对象的属性。
```javascript
var a = 1;
window.a // 1
```
上面代码中,变量`a`是一个全局变量,但是实质上它是`window`对象的属性。声明一个全局变量,就是为`window`对象的同名属性赋值。
可以简单理解成,`window`就是指当前的浏览器窗口。
从语言设计的角度看,所有变量都是`window`对象的属性,其实不是很合理。因为`window`对象有自己的实体含义,不适合当作最高一层的顶层对象。这个设计失误与JavaScript语言匆忙的设计过程有关,最早的设想是语言内置的对象越少越好,这样可以提高浏览器的性能。因此,语言设计者Brendan Eich就把`window`对象当作顶层对象,所有未声明就赋值的变量都自动变成`window`对象的属性。这种设计使得编译阶段无法检测未声明变量,但到了今天已经没有办法纠正了。
## 窗口的大小和位置
浏览器提供一系列属性,用来获取浏览器窗口的大小和位置。
(1)window.screenX,window.screenY
`window.screenX`和`window.screenY`属性,返回浏览器窗口左上角相对于当前屏幕左上角(`(0, 0)`)的水平距离和垂直距离,单位为像素。
(2)window.innerHeight,window.innerWidth
`window.innerHeight`和`window.innerWidth`属性,返回网页在当前窗口中可见部分的高度和宽度,即“视口”(viewport),单位为像素。
当用户放大网页的时候(比如将网页从100%的大小放大为200%),这两个属性会变小。因为这时网页的像素大小不变,只是每个像素占据的屏幕空间变大了,因为可见部分(视口)就变小了。
注意,这两个属性值包括滚动条的高度和宽度。
(3)window.outerHeight,window.outerWidth
`window.outerHeight`和`window.outerWidth`属性返回浏览器窗口的高度和宽度,包括浏览器菜单和边框,单位为像素。
(4)window.pageXOffset属性,window.pageYOffset属性
`window.pageXOffset`属性返回页面的水平滚动距离,`window.pageYOffset`属性返回页面的垂直滚动距离,单位都为像素。
## window对象的属性
### window.closed
`window.closed`属性返回一个布尔值,表示指定窗口是否关闭,通常用来检查通过脚本新建的窗口。
```javascript
popup.closed // false
```
上面代码检查跳出窗口是否关闭。
### window.opener
`window.opener`属性返回打开当前窗口的父窗口。如果当前窗口没有父窗口,则返回`null`。
```javascript
var windowA = window.opener;
```
通过`opener`属性,可以获得父窗口的的全局变量和方法,比如`windowA.window.propertyName`和`windowA.window.functionName()`。
该属性只适用于两个窗口属于同源的情况(参见《同源政策》一节),且其中一个窗口由另一个打开。
### window.name
`window.name`属性用于设置当前浏览器窗口的名字。
```javascript
window.name = 'Hello World!';
console.log(window.name)
// "Hello World!"
```
各个浏览器对这个值的储存容量有所不同,但是一般来说,可以高达几MB。
它有一个重要特点,就是只要是本窗口打开的网页,都能读写该属性,不管这些网页是否属于同一个网站。所以,可以把值存放在该属性内,然后让另一个网页读取,从而实现跨域通信(详见《同源政策》一节)。
该属性只能保存字符串,且当浏览器窗口关闭后,所保存的值就会消失。因此局限性比较大,但是与iframe窗口通信时,非常有用。
### window.location
`window.location`返回一个`location`对象,用于获取窗口当前的URL信息。它等同于`document.location`对象。
```javascript
window.location === document.location // true
```
## 框架窗口
`window.frames`属性返回一个类似数组的对象,成员为页面内所有框架窗口,包括`frame`元素和`iframe`元素。`window.frames[0]`表示页面中第一个框架窗口,`window.frames['someName']`则是根据框架窗口的`name`属性的值(不是`id`属性),返回该窗口。另外,通过`document.getElementById()`方法也可以引用指定的框架窗口。
```javascript
var frame = document.getElementById('theFrame');
var frameWindow = frame.contentWindow;
// 等同于 frame.contentWindow.document
var frameDoc = frame.contentDocument;
// 获取子窗口的变量和属性
frameWindow.function()
```
`window.length`属性返回当前页面中所有框架窗口总数。
```javascript
window.frames.length === window.length // true
```
`window.frames.length`与`window.length`应该是相等的。
由于传统的`frame`窗口已经不建议使用了,这里主要介绍`iframe`窗口。
需要注意的是,`window.frames`的每个成员对应的是框架内的窗口(即框架的`window`对象)。如果要获取每个框架内部的DOM树,需要使用`window.frames[0].document`的写法。
```javascript
var iframe = window.getElementsByTagName('iframe')[0];
var iframe_title = iframe.contentWindow.title;
```
上面代码用于获取`iframe`页面的标题。
`iframe`元素遵守同源政策,只有当父页面与框架页面来自同一个域名,两者之间才可以用脚本通信,否则只有使用window.postMessage方法。
`iframe`窗口内部,使用`window.parent`引用父窗口。如果当前页面没有父窗口,则`window.parent`属性返回自身。因此,可以通过`window.parent`是否等于`window.self`,判断当前窗口是否为`iframe`窗口。
```javascript
if (window.parent != window.self) {
// 当前窗口是子窗口
}
```
## navigator对象
Window对象的navigator属性,指向一个包含浏览器相关信息的对象。
**(1)navigator.userAgent属性**
navigator.userAgent属性返回浏览器的User-Agent字符串,用来标示浏览器的种类。下面是Chrome浏览器的User-Agent。
```javascript
navigator.userAgent
// "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/29.0.1547.57 Safari/537.36"
```
通过userAgent属性识别浏览器,不是一个好办法。因为必须考虑所有的情况(不同的浏览器,不同的版本),非常麻烦,而且无法保证未来的适用性,更何况各种上网设备层出不穷,难以穷尽。所以,现在一般不再识别浏览器了,而是使用“功能识别”方法,即逐一测试当前浏览器是否支持要用到的JavaScript功能。
不过,通过userAgent可以大致准确地识别手机浏览器,方法就是测试是否包含“mobi”字符串。
```javascript
var ua = navigator.userAgent.toLowerCase();
if (/mobi/i.test(ua)) {
// 手机浏览器
} else {
// 非手机浏览器
}
```
如果想要识别所有移动设备的浏览器,可以测试更多的特征字符串。
```javascript
/mobi|android|touch|mini/i.test(ua)
```
**(2)navigator.plugins属性**
navigator.plugins属性返回一个类似数组的对象,成员是浏览器安装的插件,比如Flash、ActiveX等。
## window.screen对象
`window.screen`对象包含了显示设备的信息。
`screen.height`和`screen.width`两个属性,一般用来了解设备的分辨率。
```javascript
// 显示设备的高度,单位为像素
screen.height // 1920
// 显示设备的宽度,单位为像素
screen.width // 1080
```
上面代码显示,某设备的分辨率是1920x1080。
除非调整显示器的分辨率,否则这两个值可以看作常量,不会发生变化。显示器的分辨率与浏览器设置无关,缩放网页并不会改变分辨率。
下面是根据屏幕分辨率,将用户导向不同网页的代码。
```javascript
if ((screen.width <= 800) && (screen.height <= 600)) {
window.location.replace('small.html');
} else {
window.location.replace('wide.html');
}
```
`screen.availHeight`和`screen.availWidth`属性返回屏幕可用的高度和宽度,单位为像素。它们的值为屏幕的实际大小减去操作系统某些功能占据的空间,比如系统的任务栏。
`screen.colorDepth`属性返回屏幕的颜色深度,一般为16(表示16-bit)或24(表示24-bit)。
## window对象的方法
### window.moveTo(),window.moveBy()
`window.moveTo`方法用于移动浏览器窗口到指定位置。它接受两个参数,分别是窗口左上角距离屏幕左上角的水平距离和垂直距离,单位为像素。
```javascript
window.moveTo(100, 200)
```
上面代码将窗口移动到屏幕`(100, 200)`的位置。
`window.moveBy`方法将窗口移动到一个相对位置。它接受两个参数,分布是窗口左上角向右移动的水平距离和向下移动的垂直距离,单位为像素。
```javascript
window.moveBy(25, 50)
```
上面代码将窗口向右移动25像素、向下移动50像素。
### window.open(), window.close()
`window.open`方法用于新建另一个浏览器窗口,并且返回该窗口对象。
```javascript
var popup = window.open('somefile.html');
```
`open`方法的第一个参数是新窗口打开的网址,此外还可以加上第二个参数,表示新窗口的名字,以及第三个参数用来指定新窗口的参数,形式是一个逗号分隔的`property=value`字符串。
下面是一个例子。
```javascript
var popup = window.open(
'somepage.html',
'DefinitionsWindows',
'height=200,width=200,location=no,resizable=yes,scrollbars=yes'
);
```
注意,如果在第三个参数中设置了一部分参数,其他没有被设置的`yes/no`参数都会被设成No,只有`titlebar`和关闭按钮除外(它们的值默认为yes)。
`open`方法返回新窗口的引用。
```javascript
var windowB = window.open('windowB.html', 'WindowB');
windowB.window.name // "WindowB"
```
由于`open`这个方法很容易被滥用,许多浏览器默认都不允许脚本新建窗口。因此,有必要检查一下打开新窗口是否成功。
```javascript
if (popup === null) {
// 新建窗口失败
}
```
`window.close`方法用于关闭当前窗口,一般用来关闭`window.open`方法新建的窗口。
```javascript
popup.close()
```
`window.closed`属性用于检查当前窗口是否被关闭了。
```javascript
if ((popup !== null) && !popup.closed) {
// 窗口仍然打开着
}
```
### window.print()
`print`方法会跳出打印对话框,同用户点击菜单里面的“打印”命令效果相同。
页面上的打印按钮代码如下。
```javascript
document.getElementById('printLink').onclick = function() {
window.print();
}
```
非桌面设备(比如手机)可能没有打印功能,这时可以这样判断。
```javascript
if (typeof window.print === 'function') {
// 支持打印功能
}
```
### URL的编码/解码方法
JavaScript提供四个URL的编码/解码方法。
- decodeURI()
- decodeURIComponent()
- encodeURI()
- encodeURIComponent()
### window.getComputedStyle方法
getComputedStyle方法接受一个HTML元素作为参数,返回一个包含该HTML元素的最终样式信息的对象。详见《DOM》一章的CSS章节。
### window.matchMedia方法
window.matchMedia方法用来检查CSS的mediaQuery语句。详见《DOM》一章的CSS章节。
### window.focus()
`focus`方法会激活指定当前窗口,使其获得焦点。
```javascript
if ((popup !== null) && !popup.closed) {
popup.focus();
}
```
上面代码先检查`popup`窗口是否依然存在,确认后激活该窗口。
当前窗口获得焦点时,会触发`focus`事件;当前窗口失去焦点时,会触发`blur`事件。
## window对象的事件
### window.onerror
浏览器脚本发生错误时,会触发window对象的error事件。我们可以通过`window.onerror`属性对该事件指定回调函数。
```javascript
window.onerror = function (message, filename, lineno, colno, error) {
console.log("出错了!--> %s", error.stack);
};
```
error事件的回调函数,一共可以有五个参数,它们的含义依次如下。
- 出错信息
- 出错脚本的网址
- 行号
- 列号
- 错误对象
老式浏览器只支持前三个参数。
需要注意的是,如果脚本网址与网页网址不在同一个域(比如使用了CDN),浏览器根本不会提供详细的出错信息,只会提示出错,错误类型是“Script error.”,行号为0,其他信息都没有。这是浏览器防止向外部脚本泄漏信息。一个解决方法是在脚本所在的服务器,设置Access-Control-Allow-Origin的HTTP头信息。
```bash
Access-Control-Allow-Origin:*
```
然后,在网页的script标签中设置crossorigin属性。
```html
<script crossorigin="anonymous" src="//example.com/file.js"></script>
```
上面代码的`crossorigin="anonymous"`表示,读取文件不需要身份信息,即不需要cookie和HTTP认证信息。如果设为`crossorigin="use-credentials"`,就表示浏览器会上传cookie和HTTP认证信息,同时还需要服务器端打开HTTP头信息Access-Control-Allow-Credentials。
并不是所有的错误,都会触发JavaScript的error事件(即让JavaScript报错),只限于以下三类事件。
- JavaScript语言错误
- JavaScript脚本文件不存在
- 图像文件不存在
以下两类事件不会触发JavaScript的error事件。
- CSS文件不存在
- iframe文件不存在
## alert(),prompt(),confirm()
`alert()`、`prompt()`、`confirm()`都是浏览器与用户互动的全局方法。它们会弹出不同的对话框,要求用户做出回应。
需要注意的是,`alert()`、`prompt()`、`confirm()`这三个方法弹出的对话框,都是浏览器统一规定的式样,是无法定制的。
`alert`方法弹出的对话框,只有一个“确定”按钮,往往用来通知用户某些信息。
```javascript
// 格式
alert(message);
// 实例
alert('Hello World');
```
用户只有点击“确定”按钮,对话框才会消失。在对话框弹出期间,浏览器窗口处于冻结状态,如果不点“确定”按钮,用户什么也干不了。
`prompt`方法弹出的对话框,在提示文字的下方,还有一个输入框,要求用户输入信息,并有“确定”和“取消”两个按钮。它往往用来获取用户输入的数据。
```javascript
// 格式
var result = prompt(text[, default]);
// 实例
var result = prompt('您的年龄?', 25)
```
上面代码会跳出一个对话框,文字提示为“您的年龄?”,要求用户在对话框中输入自己的年龄(默认显示25)。
`alert`方法的参数只能是字符串,没法使用CSS样式,但是可以用`\n`指定换行。
```javascript
alert('本条提示\n分成两行');
```
`prompt`方法的返回值是一个字符串(有可能为空)或者`null`,具体分成三种情况。
1. 用户输入信息,并点击“确定”,则用户输入的信息就是返回值。
2. 用户没有输入信息,直接点击“确定”,则输入框的默认值就是返回值。
3. 用户点击了“取消”(或者按了Esc按钮),则返回值是`null`。
`prompt`方法的第二个参数是可选的,但是如果不提供的话,IE浏览器会在输入框中显示`undefined`。因此,最好总是提供第二个参数,作为输入框的默认值。
`confirm`方法弹出的对话框,除了提示信息之外,只有“确定”和“取消”两个按钮,往往用来征询用户的意见。
```javascript
// 格式
var result = confirm(message);
// 实例
var result = confirm("你最近好吗?");
```
上面代码弹出一个对话框,上面只有一行文字“你最近好吗?”,用户选择点击“确定”或“取消”。
`confirm`方法返回一个布尔值,如果用户点击“确定”,则返回`true`;如果用户点击“取消”,则返回`false`。
```javascript
var okay = confirm('Please confirm this message.');
if (okay) {
// 用户按下“确定”
} else {
// 用户按下“取消”
}
```
`confirm`的一个用途是,当用户离开当前页面时,弹出一个对话框,问用户是否真的要离开。
```javascript
window.onunload = function() {
return confirm('你确定要离开当面页面吗?');
}
```
<h2 id="6.4">history对象</h2>
## 概述
浏览器窗口有一个`history`对象,用来保存浏览历史。
比如,当前窗口先后访问了三个地址,那么`history`对象就包括三项,`history.length`属性等于3。
```javascript
history.length // 3
```
`history`对象提供了一系列方法,允许在浏览历史之间移动。
- `back()`:移动到上一个访问页面,等同于浏览器的后退键。
- `forward()`:移动到下一个访问页面,等同于浏览器的前进键。
- `go()`:接受一个整数作为参数,移动到该整数指定的页面,比如`go(1)`相当于`forward()`,`go(-1)`相当于`back()`。
```javascript
history.back();
history.forward();
history.go(-2);
```
如果移动的位置超出了访问历史的边界,以上三个方法并不报错,而是默默的失败。
以下命令相当于刷新当前页面。
```javascript
history.go(0);
```
常见的“返回上一页”链接,代码如下。
```javascript
document.getElementById('backLink').onclick = function () {
window.history.back();
}
```
注意,返回上一页时,页面通常是从浏览器缓存之中加载,而不是重新要求服务器发送新的网页。
## history.pushState(),history.replaceState()
HTML5为history对象添加了两个新方法,history.pushState() 和 history.replaceState(),用来在浏览历史中添加和修改记录。所有主流浏览器都支持该方法(包括IE10)。
```javascript
if (!!(window.history && history.pushState)){
// 支持History API
} else {
// 不支持
}
```
上面代码可以用来检查,当前浏览器是否支持History API。如果不支持的话,可以考虑使用Polyfill库[History.js]( https://github.com/browserstate/history.js/)。
history.pushState方法接受三个参数,依次为:
- **state**:一个与指定网址相关的状态对象,popstate事件触发时,该对象会传入回调函数。如果不需要这个对象,此处可以填null。
- **title**:新页面的标题,但是所有浏览器目前都忽略这个值,因此这里可以填null。
- **url**:新的网址,必须与当前页面处在同一个域。浏览器的地址栏将显示这个网址。
假定当前网址是`example.com/1.html`,我们使用pushState方法在浏览记录(history对象)中添加一个新记录。
```javascript
var stateObj = { foo: "bar" };
history.pushState(stateObj, "page 2", "2.html");
```
添加上面这个新记录后,浏览器地址栏立刻显示`example.com/2.html`,但并不会跳转到2.html,甚至也不会检查2.html是否存在,它只是成为浏览历史中的最新记录。假定这时你访问了google.com,然后点击了倒退按钮,页面的url将显示2.html,但是内容还是原来的1.html。你再点击一次倒退按钮,url将显示1.html,内容不变。
> 注意,pushState方法不会触发页面刷新。
如果 pushState 的url参数,设置了一个当前网页的#号值(即hash),并不会触发hashchange事件。如果设置了一个非同域的网址,则会报错。
```javascript
// 报错
history.pushState(null, null, 'https://twitter.com/hello');
```
上面代码中,pushState想要插入一个非同域的网址,导致报错。这样设计的目的是,防止恶意代码让用户以为他们是在另一个网站上。
`history.replaceState`方法的参数与`pushState`方法一模一样,区别是它修改浏览历史中当前页面的值。下面的例子假定当前网页是example.com/example.html。
```javascript
history.pushState({page: 1}, "title 1", "?page=1");
history.pushState({page: 2}, "title 2", "?page=2");
history.replaceState({page: 3}, "title 3", "?page=3");
history.back(); // url显示为http://example.com/example.html?page=1
history.back(); // url显示为http://example.com/example.html
history.go(2); // url显示为http://example.com/example.html?page=3
```
## history.state属性
history.state属性保存当前页面的state对象。
```javascript
history.pushState({page: 1}, "title 1", "?page=1");
history.state
// { page: 1 }
```
## popstate事件
每当同一个文档的浏览历史(即history对象)出现变化时,就会触发popstate事件。需要注意的是,仅仅调用pushState方法或replaceState方法 ,并不会触发该事件,只有用户点击浏览器倒退按钮和前进按钮,或者使用JavaScript调用back、forward、go方法时才会触发。另外,该事件只针对同一个文档,如果浏览历史的切换,导致加载不同的文档,该事件也不会触发。
使用的时候,可以为popstate事件指定回调函数。这个回调函数的参数是一个event事件对象,它的state属性指向pushState和replaceState方法为当前url所提供的状态对象(即这两个方法的第一个参数)。
```javascript
window.onpopstate = function(event) {
console.log("location: " + document.location);
console.log("state: " + JSON.stringify(event.state));
};
// 或者
window.addEventListener('popstate', function(event) {
console.log("location: " + document.location);
console.log("state: " + JSON.stringify(event.state));
});
```
上面代码中的event.state,就是通过pushState和replaceState方法,为当前url绑定的state对象。
这个state对象也可以直接通过history对象读取。
```javascript
var currentState = history.state;
```
另外,需要注意的是,当页面第一次加载的时候,在onload事件发生后,Chrome和Safari浏览器(Webkit核心)会触发popstate事件,而Firefox和IE浏览器不会。
## URLSearchParams API
URLSearchParams API用于处理URL之中的查询字符串,即问号之后的部分。没有部署这个API的浏览器,可以用[url-search-params](url-search-params)这个垫片库。
```javascript
var paramsString = 'q=URLUtils.searchParams&topic=api'
var searchParams = new URLSearchParams(paramsString);
```
URLSearchParams有以下方法,用来操作某个参数。
- `has()`:返回一个布尔值,表示是否具有某个参数
- `get()`:返回指定参数的第一个值
- `getAll()`:返回一个数组,成员是指定参数的所有值
- `set()`:设置指定参数
- `delete()`:删除指定参数
- `append()`:在查询字符串之中,追加一个键值对
- `toString()`:返回整个查询字符串
```javascript
var paramsString = "q=URLUtils.searchParams&topic=api"
var searchParams = new URLSearchParams(paramsString);
searchParams.has('topic') // true
searchParams.get('topic') // "api"
searchParams.getAll('topic') // ["api"]
searchParams.get('foo') // null,注意Firefox返回空字符串
searchParams.set('foo', 2);
searchParams.get('foo') // 2
searchParams.append('topic', 'webdev');
searchParams.toString() // "q=URLUtils.searchParams&topic=api&foo=2&topic=webdev"
searchParams.append('foo', 3);
searchParams.getAll('foo') // [2, 3]
searchParams.delete('topic');
searchParams.toString() // "q=URLUtils.searchParams&foo=2&foo=3"
```
URLSearchParams还有三个方法,用来遍历所有参数。
- `key()`:遍历所有参数名
- `values()`:遍历所有参数值
- `entries()`:遍历所有参数的键值对
上面三个方法返回的都是Iterator对象。
```javascript
var searchParams = new URLSearchParams('key1=value1&key2=value2');
for(var key of searchParams.keys()) {
console.log(key);
}
// key1
// key2
for(var value of searchParams.values()) {
console.log(value);
}
// value1
// value2
for(var pair of searchParams.entries()) {
console.log(pair[0]+ ', '+ pair[1]);
}
// key1, value1
// key2, value2
```
在Chrome浏览器之中,`URLSearchParams`实例本身就是Iterator对象,与`entries`方法返回值相同。所以,可以写成下面的样子。
```javascript
for (var p of searchParams) {
console.log(p);
}
```
下面是一个替换当前URL的例子。
```javascript
// URL: https://example.com?version=1.0
var params = new URLSearchParams(location.search.slice(1));
params.set('version', 2.0);
window.history.replaceState({}, '', `${location.pathname}?${params}`);
// URL: https://example.com?version=2.0
```
`URLSearchParams`实例可以当作POST数据发送,所有数据都会URL编码。
```javascript
let params = new URLSearchParams();
params.append('api_key', '1234567890');
fetch('https://example.com/api', {
method: 'POST',
body: params
}).then(...)
```
DOM的`a`元素节点的`searchParams`属性,就是一个`URLSearchParams`实例。
```javascript
var a = document.createElement('a');
a.href = 'https://example.com?filter=api';
a.searchParams.get('filter') // "api"
```
`URLSearchParams`还可以与`URL`接口结合使用。
```javascript
var url = new URL(location);
var foo = url.searchParams.get('foo') || 'somedefault';
```
<h2 id="6.5">Cookie</h2>
## 概述
Cookie是服务器保存在浏览器的一小段文本信息,每个Cookie的大小一般不能超过4KB。浏览器每次向服务器发出请求,就会自动附上这段信息。
Cookie保存以下几方面的信息。
- Cookie的名字
- Cookie的值
- 到期时间
- 所属域名(默认是当前域名)
- 生效的路径(默认是当前网址)
举例来说,如果当前URL是`www.example.com`,那么Cookie的路径就是根目录`/`。这意味着,这个Cookie对该域名的根路径和它的所有子路径都有效。如果路径设为`/forums`,那么这个Cookie只有在访问`www.example.com/forums`及其子路径时才有效。
浏览器可以设置不接受Cookie,也可以设置不向服务器发送Cookie。`window.navigator.cookieEnabled`属性返回一个布尔值,表示浏览器是否打开Cookie功能。
`document.cookie`属性返回当前网页的Cookie。
```javascript
// 读取当前网页的所有cookie
var allCookies = document.cookie;
```
由于`document.cookie`返回的是分号分隔的所有Cookie,所以必须手动还原,才能取出每一个Cookie的值。
```javascript
var cookies = document.cookie.split(';');
for (var i = 0; i < cookies.length; i++) {
// cookies[i] name=value形式的单个Cookie
}
```
`document.cookie`属性是可写的,可以通过它为当前网站添加Cookie。
```javascript
document.cookie = 'fontSize=14';
```
Cookie的值必须写成`key=value`的形式。注意,等号两边不能有空格。另外,写入Cookie的时候,必须对分号、逗号和空格进行转义(它们都不允许作为Cookie的值),这可以用`encodeURIComponent`方法达到。
但是,`document.cookie`一次只能写入一个Cookie,而且写入并不是覆盖,而是添加。
```javascript
document.cookie = 'test1=hello';
document.cookie = 'test2=world';
document.cookie
// test1=hello;test2=world
```
`document.cookie`属性读写行为的差异(一次可以读出全部Cookie,但是只能写入一个Cookie),与服务器与浏览器之间的Cookie通信格式有关。浏览器向服务器发送Cookie的时候,是一行将所有Cookie全部发送。
```http
GET /sample_page.html HTTP/1.1
Host: www.example.org
Cookie: cookie_name1=cookie_value1; cookie_name2=cookie_value2
Accept: */*
```
上面的头信息中,`Cookie`字段是浏览器向服务器发送的Cookie。
服务器告诉浏览器需要储存Cookie的时候,则是分行指定。
```http
HTTP/1.0 200 OK
Content-type: text/html
Set-Cookie: cookie_name1=cookie_value1
Set-Cookie: cookie_name2=cookie_value2; expires=Sun, 16 Jul 3567 06:23:41 GMT
```
上面的头信息中,`Set-Cookie`字段是服务器写入浏览器的Cookie,一行一个。
如果仔细看浏览器向服务器发送的Cookie,就会意识到,Cookie协议存在问题。对于服务器来说,有两点是无法知道的。
- Cookie的各种属性,比如何时过期。
- 哪个域名设置的Cookie,因为Cookie可能是一级域名设的,也可能是任意一个二级域名设的。
## Cookie的属性
除了Cookie本身的内容,还有一些可选的属性也是可以写入的,它们都必须以分号开头。
```http
Set-Cookie: value[; expires=date][; domain=domain][; path=path][; secure]
```
上面的`Set-Cookie`字段,用分号分隔多个属性。它们的含义如下。
(1)value属性
`value`属性是必需的,它是一个键值对,用于指定Cookie的值。
(2)expires属性
`expires`属性用于指定Cookie过期时间。它的格式采用`Date.toUTCString()`的格式。
如果不设置该属性,或者设为`null`,Cookie只在当前会话(session)有效,浏览器窗口一旦关闭,当前Session结束,该Cookie就会被删除。
浏览器根据本地时间,决定Cookie是否过期,由于本地时间是不精确的,所以没有办法保证Cookie一定会在服务器指定的时间过期。
(3)domain属性
`domain`属性指定Cookie所在的域名,比如`example.com`或`.example.com`(这种写法将对所有子域名生效)、`subdomain.example.com`。
如果未指定,默认为设定该Cookie的域名。所指定的域名必须是当前发送Cookie的域名的一部分,比如当前访问的域名是`example.com`,就不能将其设为`google.com`。只有访问的域名匹配domain属性,Cookie才会发送到服务器。
(4)path属性
`path`属性用来指定路径,必须是绝对路径(比如`/`、`/mydir`),如果未指定,默认为请求该Cookie的网页路径。
只有`path`属性匹配向服务器发送的路径,Cokie才会发送。这里的匹配不是绝对匹配,而是从根路径开始,只要`path`属性匹配发送路径的一部分,就可以发送。比如,`path`属性等于`/blog`,则发送路径是`/blog`或者`/blogroll`,Cookie都会发送。`path`属性生效的前提是`domain`属性匹配。
(5)secure
`secure`属性用来指定Cookie只能在加密协议HTTPS下发送到服务器。
该属性只是一个开关,不需要指定值。如果通信是HTTPS协议,该开关自动打开。
(6)max-age
`max-age`属性用来指定Cookie有效期,比如`60 * 60 * 24 * 365`(即一年31536e3秒)。
(7)HttpOnly
`HttpOnly`属性用于设置该Cookie不能被JavaScript读取,详见下文的说明。
以上属性可以同时设置一个或多个,也没有次序的要求。如果服务器想改变一个早先设置的Cookie,必须同时满足四个条件:Cookie的`key`、`domain`、`path`和`secure`都匹配。也就是说,如果原始的Cookie是用如下的`Set-Cookie`设置的。
```http
Set-Cookie: key1=value1; domain=example.com; path=/blog
```
改变上面这个cookie的值,就必须使用同样的`Set-Cookie`。
```http
Set-Cookie: key1=value2; domain=example.com; path=/blog
```
只要有一个属性不同,就会生成一个全新的Cookie,而不是替换掉原来那个Cookie。
```http
Set-Cookie: key1=value2; domain=example.com; path=/
```
上面的命令设置了一个全新的同名Cookie,但是`path`属性不一样。下一次访问`example.com/blog`的时候,浏览器将向服务器发送两个同名的Cookie。
```http
Cookie: key1=value1; key1=value2
```
上面代码的两个Cookie是同名的,匹配越精确的Cookie排在越前面。
浏览器设置这些属性的写法如下。
```javascript
document.cookie = 'fontSize=14; '
+ 'expires=' + someDate.toGMTString() + '; '
+ 'path=/subdirectory; '
+ 'domain=*.example.com';
```
另外,这些属性只能用来设置Cookie。一旦设置完成,就没有办法读取这些属性的值。
删除一个Cookie的简便方法,就是设置`expires`属性等于0,或者等于一个过去的日期。
```javascript
document.cookie = 'fontSize=;expires=Thu, 01-Jan-1970 00:00:01 GMT';
```
上面代码中,名为`fontSize`的Cookie的值为空,过期时间设为1970年1月1月零点,就等同于删除了这个Cookie。
## Cookie的限制
浏览器对Cookie数量的限制,规定不一样。目前,Firefox是每个域名最多设置50个Cookie,而Safari和Chrome没有域名数量的限制。
所有Cookie的累加长度限制为4KB。超过这个长度的Cookie,将被忽略,不会被设置。
由于Cookie可能存在数量限制,有时为了规避限制,可以将cookie设置成下面的形式。
```http
name=a=b&c=d&e=f&g=h
```
上面代码实际上是设置了一个Cookie,但是这个Cookie内部使用`&`符号,设置了多部分的内容。因此,读取这个Cookie的时候,就要自行解析,得到多个键值对。这样就规避了cookie的数量限制。
## 同源政策
浏览器的同源政策规定,两个网址只要域名相同和端口相同,就可以共享Cookie。
注意,这里不要求协议相同。也就是说,`http://example.com`设置的Cookie,可以被`https://example.com`读取。
## HTTP-Only Cookie
设置cookie的时候,如果服务器加上了`HTTPOnly`属性,则这个Cookie无法被JavaScript读取(即`document.cookie`不会返回这个Cookie的值),只用于向服务器发送。
```http
Set-Cookie: key=value; HttpOnly
```
上面的这个Cookie将无法用JavaScript获取。进行AJAX操作时,`XMLHttpRequest`对象也无法包括这个Cookie。这主要是为了防止XSS攻击盗取Cookie。
<h2 id="6.6">Web Storage:浏览器端数据储存机制</h2>
## 概述
这个API的作用是,使得网页可以在浏览器端储存数据。它分成两类:sessionStorage和localStorage。
sessionStorage保存的数据用于浏览器的一次会话,当会话结束(通常是该窗口关闭),数据被清空;localStorage保存的数据长期存在,下一次访问该网站的时候,网页可以直接读取以前保存的数据。除了保存期限的长短不同,这两个对象的属性和方法完全一样。
它们很像cookie机制的强化版,能够动用大得多的存储空间。目前,每个域名的存储上限视浏览器而定,Chrome是2.5MB,Firefox和Opera是5MB,IE是10MB。其中,Firefox的存储空间由一级域名决定,而其他浏览器没有这个限制。也就是说,在Firefox中,`a.example.com`和`b.example.com`共享5MB的存储空间。另外,与Cookie一样,它们也受同域限制。某个网页存入的数据,只有同域下的网页才能读取。
通过检查window对象是否包含sessionStorage和localStorage属性,可以确定浏览器是否支持这两个对象。
```javascript
function checkStorageSupport() {
// sessionStorage
if (window.sessionStorage) {
return true;
} else {
return false;
}
// localStorage
if (window.localStorage) {
return true;
} else {
return false;
}
}
```
## 操作方法
### 存入/读取数据
sessionStorage和localStorage保存的数据,都以“键值对”的形式存在。也就是说,每一项数据都有一个键名和对应的值。所有的数据都是以文本格式保存。
存入数据使用setItem方法。它接受两个参数,第一个是键名,第二个是保存的数据。
```javascript
sessionStorage.setItem("key","value");
localStorage.setItem("key","value");
```
读取数据使用getItem方法。它只有一个参数,就是键名。
```javascript
var valueSession = sessionStorage.getItem("key");
var valueLocal = localStorage.getItem("key");
```
### 清除数据
removeItem方法用于清除某个键名对应的数据。
```javascript
sessionStorage.removeItem('key');
localStorage.removeItem('key');
```
clear方法用于清除所有保存的数据。
```javascript
sessionStorage.clear();
localStorage.clear();
```
### 遍历操作
利用length属性和key方法,可以遍历所有的键。
```javascript
for(var i = 0; i < localStorage.length; i++){
console.log(localStorage.key(i));
}
```
其中的key方法,根据位置(从0开始)获得键值。
```javascript
localStorage.key(1);
```
## storage事件
当储存的数据发生变化时,会触发storage事件。我们可以指定这个事件的回调函数。
```javascript
window.addEventListener("storage",onStorageChange);
```
回调函数接受一个event对象作为参数。这个event对象的key属性,保存发生变化的键名。
```javascript
function onStorageChange(e) {
console.log(e.key);
}
```
除了key属性,event对象的属性还有三个:
- oldValue:更新前的值。如果该键为新增加,则这个属性为null。
- newValue:更新后的值。如果该键被删除,则这个属性为null。
- url:原始触发storage事件的那个网页的网址。
值得特别注意的是,该事件不在导致数据变化的当前页面触发。如果浏览器同时打开一个域名下面的多个页面,当其中的一个页面改变sessionStorage或localStorage的数据时,其他所有页面的storage事件会被触发,而原始页面并不触发storage事件。可以通过这种机制,实现多个窗口之间的通信。所有浏览器之中,只有IE浏览器除外,它会在所有页面触发storage事件。
<h2 id="6.7">同源政策</h2>
浏览器安全的基石是”同源政策“([same-origin policy](https://en.wikipedia.org/wiki/Same-origin_policy))。很多开发者都知道这一点,但了解得不全面。
本节详细介绍”同源政策“的各个方面,以及如何规避它。
![](http://www.ruanyifeng.com/blogimg/asset/2016/bg2016040801.jpg)
## 概述
### 含义
1995年,同源政策由 Netscape 公司引入浏览器。目前,所有浏览器都实行这个政策。
最初,它的含义是指,A网页设置的 Cookie,B网页不能打开,除非这两个网页“同源”。所谓“同源”指的是”三个相同“。
> - 协议相同
> - 域名相同
> - 端口相同
举例来说,`http://www.example.com/dir/page.html`这个网址,协议是`http://`,域名是`www.example.com`,端口是`80`(默认端口可以省略)。它的同源情况如下。
- `http://www.example.com/dir2/other.html`:同源
- `http://example.com/dir/other.html`:不同源(域名不同)
- `http://v2.www.example.com/dir/other.html`:不同源(域名不同)
- `http://www.example.com:81/dir/other.html`:不同源(端口不同)
- `https://www.example.com/dir/page.html`:不同源(协议不同)
### 目的
同源政策的目的,是为了保证用户信息的安全,防止恶意的网站窃取数据。
设想这样一种情况:A网站是一家银行,用户登录以后,又去浏览其他网站。如果其他网站可以读取A网站的 Cookie,会发生什么?
很显然,如果 Cookie 包含隐私(比如存款总额),这些信息就会泄漏。更可怕的是,Cookie 往往用来保存用户的登录状态,如果用户没有退出登录,其他网站就可以冒充用户,为所欲为。因为浏览器同时还规定,提交表单不受同源政策的限制。
由此可见,”同源政策“是必需的,否则 Cookie 可以共享,互联网就毫无安全可言了。
### 限制范围
随着互联网的发展,“同源政策”越来越严格。目前,如果非同源,共有三种行为受到限制。
> (1) Cookie、LocalStorage 和 IndexDB 无法读取。
>
> (2) DOM 无法获得。
>
> (3) AJAX 请求不能发送。
虽然这些限制是必要的,但是有时很不方便,合理的用途也受到影响。下面,我将详细介绍,如何规避上面三种限制。
## Cookie
Cookie 是服务器写入浏览器的一小段信息,只有同源的网页才能共享。但是,两个网页一级域名相同,只是二级域名不同,浏览器允许通过设置`document.domain`共享 Cookie。
举例来说,A网页是`http://w1.example.com/a.html`,B网页是`http://w2.example.com/b.html`,那么只要设置相同的`document.domain`,两个网页就可以共享Cookie。
```javascript
document.domain = 'example.com';
```
现在,A网页通过脚本设置一个 Cookie。
```javascript
document.cookie = "test1=hello";
```
B网页就可以读到这个 Cookie。
```javascript
var allCookie = document.cookie;
```
注意,这种方法只适用于 Cookie 和 iframe 窗口,LocalStorage 和 IndexDB 无法通过这种方法,规避同源政策,而要使用下文介绍的PostMessage API。
另外,服务器也可以在设置Cookie的时候,指定Cookie的所属域名为一级域名,比如`.example.com`。
```http
Set-Cookie: key=value; domain=.example.com; path=/
```
这样的话,二级域名和三级域名不用做任何设置,都可以读取这个Cookie。
## iframe
如果两个网页不同源,就无法拿到对方的DOM。典型的例子是`iframe`窗口和`window.open`方法打开的窗口,它们与父窗口无法通信。
比如,父窗口运行下面的命令,如果`iframe`窗口不是同源,就会报错。
```javascript
document.getElementById("myIFrame").contentWindow.document
// Uncaught DOMException: Blocked a frame from accessing a cross-origin frame.
```
上面命令中,父窗口想获取子窗口的DOM,因为跨源导致报错。
反之亦然,子窗口获取主窗口的DOM也会报错。
```javascript
window.parent.document.body
// 报错
```
如果两个窗口一级域名相同,只是二级域名不同,那么设置上一节介绍的`document.domain`属性,就可以规避同源政策,拿到DOM。
对于完全不同源的网站,目前有三种方法,可以解决跨域窗口的通信问题。
> - 片段识别符(fragment identifier)
> - window.name
> - 跨文档通信API(Cross-document messaging)
### 片段识别符
片段标识符(fragment identifier)指的是,URL的`#`号后面的部分,比如`http://example.com/x.html#fragment`的`#fragment`。如果只是改变片段标识符,页面不会重新刷新。
父窗口可以把信息,写入子窗口的片段标识符。
```javascript
var src = originURL + '#' + data;
document.getElementById('myIFrame').src = src;
```
子窗口通过监听`hashchange`事件得到通知。
```javascript
window.onhashchange = checkMessage;
function checkMessage() {
var message = window.location.hash;
// ...
}
```
同样的,子窗口也可以改变父窗口的片段标识符。
```javascript
parent.location.href= target + “#” + hash;
```
### window.name
浏览器窗口有`window.name`属性。这个属性的最大特点是,无论是否同源,只要在同一个窗口里,前一个网页设置了这个属性,后一个网页可以读取它。
父窗口先打开一个子窗口,载入一个不同源的网页,该网页将信息写入`window.name`属性。
```javascript
window.name = data;
```
接着,子窗口跳回一个与主窗口同域的网址。
```javascript
location = 'http://parent.url.com/xxx.html';
```
然后,主窗口就可以读取子窗口的`window.name`了。
```javascript
var data = document.getElementById('myFrame').contentWindow.name;
```
这种方法的优点是,`window.name`容量很大,可以放置非常长的字符串;缺点是必须监听子窗口`window.name`属性的变化,影响网页性能。
### window.postMessage
上面两种方法都属于破解,HTML5为了解决这个问题,引入了一个全新的API:跨文档通信 API(Cross-document messaging)。
这个API为`window`对象新增了一个`window.postMessage`方法,允许跨窗口通信,不论这两个窗口是否同源。
举例来说,父窗口`aaa.com`向子窗口`bbb.com`发消息,调用`postMessage`方法就可以了。
```javascript
var popup = window.open('http://bbb.com', 'title');
popup.postMessage('Hello World!', 'http://bbb.com');
```
`postMessage`方法的第一个参数是具体的信息内容,第二个参数是接收消息的窗口的源(origin),即“协议 + 域名 + 端口”。也可以设为`*`,表示不限制域名,向所有窗口发送。
子窗口向父窗口发送消息的写法类似。
```javascript
window.opener.postMessage('Nice to see you', 'http://aaa.com');
```
父窗口和子窗口都可以通过`message`事件,监听对方的消息。
```javascript
window.addEventListener('message', function(e) {
console.log(e.data);
},false);
```
`message`事件的事件对象`event`,提供以下三个属性。
> - `event.source`:发送消息的窗口
> - `event.origin`: 消息发向的网址
> - `event.data`: 消息内容
下面的例子是,子窗口通过`event.source`属性引用父窗口,然后发送消息。
```javascript
window.addEventListener('message', receiveMessage);
function receiveMessage(event) {
event.source.postMessage('Nice to see you!', '*');
}
```
上面代码有几个地方需要注意。首先,`receiveMessage`函数里面没有过滤信息的来源,任意网址发来的信息都会被处理。其次,`postMessage`方法中指定的目标窗口的网址是一个星号,表示该信息可以向任意网址发送。通常来说,这两种做法是不推荐的,因为不够安全,可能会被恶意利用。
`event.origin`属性可以过滤不是发给本窗口的消息。
```javascript
window.addEventListener('message', receiveMessage);
function receiveMessage(event) {
if (event.origin !== 'http://aaa.com') return;
if (event.data === 'Hello World') {
event.source.postMessage('Hello', event.origin);
} else {
console.log(event.data);
}
}
```
### LocalStorage
通过`window.postMessage`,读写其他窗口的 LocalStorage 也成为了可能。
下面是一个例子,主窗口写入iframe子窗口的`localStorage`。
```javascript
window.onmessage = function(e) {
if (e.origin !== 'http://bbb.com') {
return;
}
var payload = JSON.parse(e.data);
localStorage.setItem(payload.key, JSON.stringify(payload.data));
};
```
上面代码中,子窗口将父窗口发来的消息,写入自己的LocalStorage。
父窗口发送消息的代码如下。
```javascript
var win = document.getElementsByTagName('iframe')[0].contentWindow;
var obj = { name: 'Jack' };
win.postMessage(JSON.stringify({key: 'storage', data: obj}), 'http://bbb.com');
```
加强版的子窗口接收消息的代码如下。
```javascript
window.onmessage = function(e) {
if (e.origin !== 'http://bbb.com') return;
var payload = JSON.parse(e.data);
switch (payload.method) {
case 'set':
localStorage.setItem(payload.key, JSON.stringify(payload.data));
break;
case 'get':
var parent = window.parent;
var data = localStorage.getItem(payload.key);
parent.postMessage(data, 'http://aaa.com');
break;
case 'remove':
localStorage.removeItem(payload.key);
break;
}
};
```
加强版的父窗口发送消息代码如下。
```javascript
var win = document.getElementsByTagName('iframe')[0].contentWindow;
var obj = { name: 'Jack' };
// 存入对象
win.postMessage(JSON.stringify({key: 'storage', method: 'set', data: obj}), 'http://bbb.com');
// 读取对象
win.postMessage(JSON.stringify({key: 'storage', method: "get"}), "*");
window.onmessage = function(e) {
if (e.origin != 'http://aaa.com') return;
// "Jack"
console.log(JSON.parse(e.data).name);
};
```
## AJAX
同源政策规定,AJAX请求只能发给同源的网址,否则就报错。
除了架设服务器代理(浏览器请求同源服务器,再由后者请求外部服务),有三种方法规避这个限制。
> - JSONP
> - WebSocket
> - CORS
### JSONP
JSONP是服务器与客户端跨源通信的常用方法。最大特点就是简单适用,老式浏览器全部支持,服务器改造非常小。
它的基本思想是,网页通过添加一个`<script>`元素,向服务器请求JSON数据,这种做法不受同源政策限制;服务器收到请求后,将数据放在一个指定名字的回调函数里传回来。
首先,网页动态插入`<script>`元素,由它向跨源网址发出请求。
```javascript
function addScriptTag(src) {
var script = document.createElement('script');
script.setAttribute("type","text/javascript");
script.src = src;
document.body.appendChild(script);
}
window.onload = function () {
addScriptTag('http://example.com/ip?callback=foo');
}
function foo(data) {
console.log('Your public IP address is: ' + data.ip);
};
```
上面代码通过动态添加`<script>`元素,向服务器`example.com`发出请求。注意,该请求的查询字符串有一个`callback`参数,用来指定回调函数的名字,这对于JSONP是必需的。
服务器收到这个请求以后,会将数据放在回调函数的参数位置返回。
```javascript
foo({
"ip": "8.8.8.8"
});
```
由于`<script>`元素请求的脚本,直接作为代码运行。这时,只要浏览器定义了`foo`函数,该函数就会立即调用。作为参数的JSON数据被视为JavaScript对象,而不是字符串,因此避免了使用`JSON.parse`的步骤。
### WebSocket
WebSocket是一种通信协议,使用`ws://`(非加密)和`wss://`(加密)作为协议前缀。该协议不实行同源政策,只要服务器支持,就可以通过它进行跨源通信。
下面是一个例子,浏览器发出的WebSocket请求的头信息(摘自[维基百科](https://en.wikipedia.org/wiki/WebSocket))。
```http
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Origin: http://example.com
```
上面代码中,有一个字段是`Origin`,表示该请求的请求源(origin),即发自哪个域名。
正是因为有了`Origin`这个字段,所以WebSocket才没有实行同源政策。因为服务器可以根据这个字段,判断是否许可本次通信。如果该域名在白名单内,服务器就会做出如下回应。
```http
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat
```
### CORS
CORS是跨源资源分享(Cross-Origin Resource Sharing)的缩写。它是W3C标准,是跨源AJAX请求的根本解决方法。相比JSONP只能发`GET`请求,CORS允许任何类型的请求。
下一节将详细介绍,如何通过CORS完成跨源AJAX请求。
<h2 id="6.8">AJAX</h2>
浏览器与服务器之间,采用HTTP协议通信。用户在浏览器地址栏键入一个网址,或者通过网页表单向服务器提交内容,这时浏览器就会向服务器发出HTTP请求。
1999年,微软公司发布IE浏览器5.0版,第一次引入新功能:允许JavaScript脚本向服务器发起HTTP请求。这个功能当时并没有引起注意,直到2004年Gmail发布和2005年Google Map发布,才引起广泛重视。2005年2月,AJAX这个词第一次正式提出,指围绕这个功能进行开发的一整套做法。从此,AJAX成为脚本发起HTTP通信的代名词,W3C也在2006年发布了它的国际标准。
具体来说,AJAX包括以下几个步骤。
1. 创建AJAX对象
1. 发出HTTP请求
1. 接收服务器传回的数据
1. 更新网页数据
概括起来,就是一句话,AJAX通过原生的`XMLHttpRequest`对象发出HTTP请求,得到服务器返回的数据后,再进行处理。
AJAX可以是同步请求,也可以是异步请求。但是,大多数情况下,特指异步请求。因为同步的Ajax请求,对浏览器有”堵塞效应“。
## XMLHttpRequest对象
`XMLHttpRequest`对象用来在浏览器与服务器之间传送数据。
```javascript
var ajax = new XMLHttpRequest();
ajax.open('GET', 'http://www.example.com/page.php', true);
```
上面代码向指定的服务器网址,发出GET请求。
然后,AJAX指定回调函数,监听通信状态(`readyState`属性)的变化。
```javascript
ajax.onreadystatechange = handleStateChange;
```
一旦拿到服务器返回的数据,AJAX不会刷新整个网页,而是只更新相关部分,从而不打断用户正在做的事情。
注意,AJAX只能向同源网址(协议、域名、端口都相同)发出HTTP请求,如果发出跨源请求,就会报错(详见《同源政策》和《CORS机制》两节)。
虽然名字里面有`XML`,但是实际上,XMLHttpRequest可以报送各种数据,包括字符串和二进制,而且除了HTTP,它还支持通过其他协议传送(比如File和FTP)。
下面是`XMLHttpRequest`对象的典型用法。
```javascript
var xhr = new XMLHttpRequest();
// 指定通信过程中状态改变时的回调函数
xhr.onreadystatechange = function(){
// 通信成功时,状态值为4
if (xhr.readyState === 4){
if (xhr.status === 200){
console.log(xhr.responseText);
} else {
console.error(xhr.statusText);
}
}
};
xhr.onerror = function (e) {
console.error(xhr.statusText);
};
// open方式用于指定HTTP动词、请求的网址、是否异步
xhr.open('GET', '/endpoint', true);
// 发送HTTP请求
xhr.send(null);
```
`open`方法的第三个参数是一个布尔值,表示是否为异步请求。如果设为`false`,就表示这个请求是同步的,下面是一个例子。
```javascript
var request = new XMLHttpRequest();
request.open('GET', '/bar/foo.txt', false);
request.send(null);
if (request.status === 200) {
console.log(request.responseText);
}
```
## XMLHttpRequest实例的属性
### readyState
`readyState`是一个只读属性,用一个整数和对应的常量,表示XMLHttpRequest请求当前所处的状态。
- 0,对应常量`UNSENT`,表示XMLHttpRequest实例已经生成,但是`open()`方法还没有被调用。
- 1,对应常量`OPENED`,表示`send()`方法还没有被调用,仍然可以使用`setRequestHeader()`,设定HTTP请求的头信息。
- 2,对应常量`HEADERS_RECEIVED`,表示`send()`方法已经执行,并且头信息和状态码已经收到。
- 3,对应常量`LOADING`,表示正在接收服务器传来的body部分的数据,如果`responseType`属性是`text`或者空字符串,`responseText`就会包含已经收到的部分信息。
- 4,对应常量`DONE`,表示服务器数据已经完全接收,或者本次接收已经失败了。
在通信过程中,每当发生状态变化的时候,`readyState`属性的值就会发生改变。这个值每一次变化,都会触发`readyStateChange`事件。
```javascript
if (ajax.readyState == 4) {
// Handle the response.
} else {
// Show the 'Loading...' message or do nothing.
}
```
上面代码表示,只有`readyState`变为4时,才算确认请求已经成功,其他值都表示请求还在进行中。
### onreadystatechange
`onreadystatechange`属性指向一个回调函数,当`readystatechange`事件发生的时候,这个回调函数就会调用,并且XMLHttpRequest实例的`readyState`属性也会发生变化。
另外,如果使用`abort()`方法,终止XMLHttpRequest请求,`onreadystatechange`回调函数也会被调用。
```javascript
var xmlhttp = new XMLHttpRequest();
xmlhttp.open( 'GET', 'http://example.com' , true );
xmlhttp.onreadystatechange = function () {
if ( XMLHttpRequest.DONE != xmlhttp.readyState ) {
return;
}
if ( 200 != xmlhttp.status ) {
return;
}
console.log( xmlhttp.responseText );
};
xmlhttp.send();
```
### response
`response`属性为只读,返回接收到的数据体(即body部分)。它的类型可以是ArrayBuffer、Blob、Document、JSON对象、或者一个字符串,这由`XMLHttpRequest.responseType`属性的值决定。
如果本次请求没有成功或者数据不完整,该属性就会等于`null`。
### responseType
`responseType`属性用来指定服务器返回数据(`xhr.response`)的类型。
- "":字符串(默认值)
- "arraybuffer":ArrayBuffer对象
- "blob":Blob对象
- "document":Document对象
- "json":JSON对象
- "text":字符串
text类型适合大多数情况,而且直接处理文本也比较方便,document类型适合返回XML文档的情况,blob类型适合读取二进制数据,比如图片文件。
```javascript
var xhr = new XMLHttpRequest();
xhr.open('GET', '/path/to/image.png', true);
xhr.responseType = 'blob';
xhr.onload = function(e) {
if (this.status == 200) {
var blob = new Blob([this.response], {type: 'image/png'});
// 或者
var blob = oReq.response;
}
};
xhr.send();
```
如果将这个属性设为ArrayBuffer,就可以按照数组的方式处理二进制数据。
```javascript
var xhr = new XMLHttpRequest();
xhr.open('GET', '/path/to/image.png', true);
xhr.responseType = 'arraybuffer';
xhr.onload = function(e) {
var uInt8Array = new Uint8Array(this.response);
for (var i = 0, len = binStr.length; i < len; ++i) {
// var byte = uInt8Array[i];
}
};
xhr.send();
```
如果将这个属性设为“json”,支持JSON的浏览器(Firefox>9,chrome>30),就会自动对返回数据调用JSON.parse() 方法。也就是说,你从xhr.response属性(注意,不是xhr.responseText属性)得到的不是文本,而是一个JSON对象。
XHR2支持Ajax的返回类型为文档,即xhr.responseType="document" 。这意味着,对于那些打开CORS的网站,我们可以直接用Ajax抓取网页,然后不用解析HTML字符串,直接对XHR回应进行DOM操作。
### responseText
`responseText`属性返回从服务器接收到的字符串,该属性为只读。如果本次请求没有成功或者数据不完整,该属性就会等于`null`。
如果服务器返回的数据格式是JSON,就可以使用`responseText`属性。
```javascript
var data = ajax.responseText;
data = JSON.parse(data);
```
### responseXML
`responseXML`属性返回从服务器接收到的Document对象,该属性为只读。如果本次请求没有成功,或者数据不完整,或者不能被解析为XML或HTML,该属性等于null。
返回的数据会被直接解析为DOM对象。
```javascript
/* 返回的XML文件如下
<?xml version="1.0" encoding="utf-8" standalone="yes" ?>
<book>
<chapter id="1">(Re-)Introducing JavaScript</chapter>
<chapter id="2">JavaScript in Action</chapter>
</book>
*/
var data = ajax.responseXML;
var chapters = data.getElementsByTagName('chapter');
```
如果服务器返回的数据,没有明示`Content-Type`头信息等于`text/xml`,可以使用`overrideMimeType()`方法,指定XMLHttpRequest对象将返回的数据解析为XML。
### status
`status`属性为只读属性,表示本次请求所得到的HTTP状态码,它是一个整数。一般来说,如果通信成功的话,这个状态码是200。
- 200, OK,访问正常
- 301, Moved Permanently,永久移动
- 304, Not Modified,未修改
- 307, Temporary Redirect,暂时重定向
- 401, Unauthorized,未授权
- 403, Forbidden,禁止访问
- 404, Not Found,未发现指定网址
- 500, Internal Server Error,服务器发生错误
基本上,只有2xx和304的状态码,表示服务器返回是正常状态。
```javascript
if (ajax.readyState == 4) {
if ( (ajax.status >= 200 && ajax.status < 300)
|| (ajax.status == 304) ) {
// Handle the response.
} else {
// Status error!
}
}
```
### statusText
`statusText`属性为只读属性,返回一个字符串,表示服务器发送的状态提示。不同于`status`属性,该属性包含整个状态信息,比如”200 OK“。
### timeout
`timeout`属性等于一个整数,表示多少毫秒后,如果请求仍然没有得到结果,就会自动终止。如果该属性等于0,就表示没有时间限制。
```javascript
var xhr = new XMLHttpRequest();
xhr.ontimeout = function () {
console.error("The request for " + url + " timed out.");
};
xhr.onload = function() {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
callback.apply(xhr, args);
} else {
console.error(xhr.statusText);
}
}
};
xhr.open("GET", url, true);
xhr.timeout = timeout;
xhr.send(null);
}
```
### 事件监听接口
XMLHttpRequest第一版,只能对`onreadystatechange`这一个事件指定回调函数。该事件对所有情况作出响应。 XMLHttpRequest第二版允许对更多的事件指定回调函数。
- onloadstart 请求发出
- onprogress 正在发送和加载数据
- onabort 请求被中止,比如用户调用了`abort()`方法
- onerror 请求失败
- onload 请求成功完成
- ontimeout 用户指定的时限到期,请求还未完成
- onloadend 请求完成,不管成果或失败
```javascript
xhr.onload = function() {
var responseText = xhr.responseText;
console.log(responseText);
// process the response.
};
xhr.onerror = function() {
console.log('There was an error!');
};
```
注意,如果发生网络错误(比如服务器无法连通),`onerror`事件无法获取报错信息,所以只能显示报错。
### withCredentials
`withCredentials`属性是一个布尔值,表示跨域请求时,用户信息(比如Cookie和认证的HTTP头信息)是否会包含在请求之中,默认为`false`。即向`example.com`发出跨域请求时,不会发送`example.com`设置在本机上的Cookie(如果有的话)。
如果你需要通过跨域AJAX发送Cookie,需要打开`withCredentials`。
```javascript
xhr.withCredentials = true;
```
为了让这个属性生效,服务器必须显式返回`Access-Control-Allow-Credentials`这个头信息。
```javascript
Access-Control-Allow-Credentials: true
```
`.withCredentials`属性打开的话,不仅会发送Cookie,还会设置远程主机指定的Cookie。注意,此时你的脚本还是遵守同源政策,无法 从`document.cookie`或者HTTP回应的头信息之中,读取这些Cookie。
## XMLHttpRequest实例的方法
### abort()
`abort`方法用来终止已经发出的HTTP请求。
```javascript
ajax.open('GET', 'http://www.example.com/page.php', true);
var ajaxAbortTimer = setTimeout(function() {
if (ajax) {
ajax.abort();
ajax = null;
}
}, 5000);
```
上面代码在发出5秒之后,终止一个AJAX请求。
### getAllResponseHeaders()
`getAllResponseHeaders`方法返回服务器发来的所有HTTP头信息。格式为字符串,每个头信息之间使用`CRLF`分隔,如果没有受到服务器回应,该属性返回`null`。
### getResponseHeader()
`getResponseHeader`方法返回HTTP头信息指定字段的值,如果还没有收到服务器回应或者指定字段不存在,则该属性为`null`。
```html
function getHeaderTime () {
console.log(this.getResponseHeader("Last-Modified"));
}
var oReq = new XMLHttpRequest();
oReq.open("HEAD", "yourpage.html");
oReq.onload = getHeaderTime;
oReq.send();
```
如果有多个字段同名,则它们的值会被连接为一个字符串,每个字段之间使用”逗号+空格“分隔。
### open()
`XMLHttpRequest`对象的`open`方法用于指定发送HTTP请求的参数,它的使用格式如下,一共可以接受五个参数。
```javascript
void open(
string method,
string url,
optional boolean async,
optional string user,
optional string password
);
```
- `method`:表示HTTP动词,比如“GET”、“POST”、“PUT”和“DELETE”。
- `url`: 表示请求发送的网址。
- `async`: 格式为布尔值,默认为`true`,表示请求是否为异步。如果设为`false`,则`send()`方法只有等到收到服务器返回的结果,才会有返回值。
- `user`:表示用于认证的用户名,默认为空字符串。
- `password`:表示用于认证的密码,默认为空字符串。
如果对使用过`open()`方法的请求,再次使用这个方法,等同于调用`abort()`。
下面发送POST请求的例子。
```javascript
xhr.open('POST', encodeURI('someURL'));
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.onload = function() {};
xhr.send(encodeURI('dataString'));
```
上面方法中,open方法向指定URL发出POST请求,send方法送出实际的数据。
下面是一个同步AJAX请求的例子。
```javascript
var request = new XMLHttpRequest();
request.open('GET', '/bar/foo.txt', false);
request.send(null);
if (request.status === 200) {
console.log(request.responseText);
}
```
### send()
`send`方法用于实际发出HTTP请求。如果不带参数,就表示HTTP请求只包含头信息,也就是只有一个URL,典型例子就是GET请求;如果带有参数,就表示除了头信息,还带有包含具体数据的信息体,典型例子就是POST请求。
```javascript
ajax.open('GET'
, 'http://www.example.com/somepage.php?id=' + encodeURIComponent(id)
, true
);
// 等同于
var data = 'id=' + encodeURIComponent(id));
ajax.open('GET', 'http://www.example.com/somepage.php', true);
ajax.send(data);
```
上面代码中,`GET`请求的参数,可以作为查询字符串附加在URL后面,也可以作为`send`方法的参数。
下面是发送POST请求的例子。
```javascript
var data = 'email='
+ encodeURIComponent(email)
+ '&password='
+ encodeURIComponent(password);
ajax.open('POST', 'http://www.example.com/somepage.php', true);
ajax.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
ajax.send(data);
```
如果请求是异步的(默认为异步),该方法在发出请求后会立即返回。如果请求为同步,该方法只有等到收到服务器回应后,才会返回。
注意,所有XMLHttpRequest的监听事件,都必须在`send()`方法调用之前设定。
`send`方法的参数就是发送的数据。多种格式的数据,都可以作为它的参数。
```javascript
void send();
void send(ArrayBufferView data);
void send(Blob data);
void send(Document data);
void send(String data);
void send(FormData data);
```
如果发送`Document`数据,在发送之前,数据会先被串行化。
发送二进制数据,最好使用`ArrayBufferView`或`Blob`对象,这使得通过Ajax上传文件成为可能。
下面是一个上传`ArrayBuffer`对象的例子。
```javascript
function sendArrayBuffer() {
var xhr = new XMLHttpRequest();
var uInt8Array = new Uint8Array([1, 2, 3]);
xhr.open('POST', '/server', true);
xhr.onload = function(e) { ... };
xhr.send(uInt8Array.buffer);
}
```
FormData类型可以用于构造表单数据。
```javascript
var formData = new FormData();
formData.append('username', '张三');
formData.append('email', 'zhangsan@example.com');
formData.append('birthDate', 1940);
var xhr = new XMLHttpRequest();
xhr.open("POST", "/register");
xhr.send(formData);
```
上面的代码构造了一个`formData`对象,然后使用send方法发送。它的效果与点击下面表单的submit按钮是一样的。
```html
<form id='registration' name='registration' action='/register'>
<input type='text' name='username' value='张三'>
<input type='email' name='email' value='zhangsan@example.com'>
<input type='number' name='birthDate' value='1940'>
<input type='submit' onclick='return sendForm(this.form);'>
</form>
```
FormData也可以将现有表单构造生成。
```javascript
var formElement = document.querySelector("form");
var request = new XMLHttpRequest();
request.open("POST", "submitform.php");
request.send(new FormData(formElement));
```
FormData对象还可以对现有表单添加数据,这为我们操作表单提供了极大的灵活性。
```javascript
function sendForm(form) {
var formData = new FormData(form);
formData.append('csrf', 'e69a18d7db1286040586e6da1950128c');
var xhr = new XMLHttpRequest();
xhr.open('POST', form.action, true);
xhr.onload = function(e) {
// ...
};
xhr.send(formData);
return false;
}
var form = document.querySelector('#registration');
sendForm(form);
```
FormData对象也能用来模拟File控件,进行文件上传。
```javascript
function uploadFiles(url, files) {
var formData = new FormData();
for (var i = 0, file; file = files[i]; ++i) {
formData.append(file.name, file); // 可加入第三个参数,表示文件名
}
var xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.onload = function(e) { ... };
xhr.send(formData); // multipart/form-data
}
document.querySelector('input[type="file"]').addEventListener('change', function(e) {
uploadFiles('/server', this.files);
}, false);
```
FormData也可以加入JavaScript生成的文件。
```javascript
// 添加JavaScript生成的文件
var content = '<a id="a"><b id="b">hey!</b></a>';
var blob = new Blob([content], { type: "text/xml"});
formData.append("webmasterfile", blob);
```
### setRequestHeader()
`setRequestHeader`方法用于设置HTTP头信息。该方法必须在`open()`之后、`send()`之前调用。如果该方法多次调用,设定同一个字段,则每一次调用的值会被合并成一个单一的值发送。
```javascript
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.setRequestHeader('Content-Length', JSON.stringify(data).length);
xhr.send(JSON.stringify(data));
```
上面代码首先设置头信息`Content-Type`,表示发送JSON格式的数据;然后设置`Content-Length`,表示数据长度;最后发送JSON数据。
### overrideMimeType()
该方法用来指定服务器返回数据的MIME类型。该方法必须在`send()`之前调用。
传统上,如果希望从服务器取回二进制数据,就要使用这个方法,人为将数据类型伪装成文本数据。
```javascript
var xhr = new XMLHttpRequest();
xhr.open('GET', '/path/to/image.png', true);
// 强制将MIME改为文本类型
xhr.overrideMimeType('text/plain; charset=x-user-defined');
xhr.onreadystatechange = function(e) {
if (this.readyState == 4 && this.status == 200) {
var binStr = this.responseText;
for (var i = 0, len = binStr.length; i < len; ++i) {
var c = binStr.charCodeAt(i);
var byte = c & 0xff; // 去除高位字节,留下低位字节
}
}
};
xhr.send();
```
上面代码中,因为传回来的是二进制数据,首先用`xhr.overrideMimeType`方法强制改变它的MIME类型,伪装成文本数据。字符集必需指定为“x-user-defined”,如果是其他字符集,浏览器内部会强制转码,将其保存成UTF-16的形式。字符集“x-user-defined”其实也会发生转码,浏览器会在每个字节前面再加上一个字节(0xF700-0xF7ff),因此后面要对每个字符进行一次与运算(&),将高位的8个位去除,只留下低位的8个位,由此逐一读出原文件二进制数据的每个字节。
这种方法很麻烦,在XMLHttpRequest版本升级以后,一般采用指定`responseType`的方法。
```javascript
var xhr = new XMLHttpRequest();
xhr.onload = function(e) {
var arraybuffer = xhr.response;
// ...
}
xhr.open("GET", url);
xhr.responseType = "arraybuffer";
xhr.send();
```
## XMLHttpRequest实例的事件
### readyStateChange事件
`readyState`属性的值发生改变,就会触发readyStateChange事件。
我们可以通过`onReadyStateChange`属性,指定这个事件的回调函数,对不同状态进行不同处理。尤其是当状态变为4的时候,表示通信成功,这时回调函数就可以处理服务器传送回来的数据。
### progress事件
上传文件时,XMLHTTPRequest对象的upload属性有一个progress,会不断返回上传的进度。
假定网页上有一个progress元素。
```http
<progress min="0" max="100" value="0">0% complete</progress>
```
文件上传时,对upload属性指定progress事件回调函数,即可获得上传的进度。
```javascript
function upload(blobOrFile) {
var xhr = new XMLHttpRequest();
xhr.open('POST', '/server', true);
xhr.onload = function(e) { ... };
// Listen to the upload progress.
var progressBar = document.querySelector('progress');
xhr.upload.onprogress = function(e) {
if (e.lengthComputable) {
progressBar.value = (e.loaded / e.total) * 100;
progressBar.textContent = progressBar.value; // Fallback for unsupported browsers.
}
};
xhr.send(blobOrFile);
}
upload(new Blob(['hello world'], {type: 'text/plain'}));
```
### load事件、error事件、abort事件
load事件表示服务器传来的数据接收完毕,error事件表示请求出错,abort事件表示请求被中断。
```javascript
var xhr = new XMLHttpRequest();
xhr.addEventListener("progress", updateProgress);
xhr.addEventListener("load", transferComplete);
xhr.addEventListener("error", transferFailed);
xhr.addEventListener("abort", transferCanceled);
xhr.open();
function updateProgress (oEvent) {
if (oEvent.lengthComputable) {
var percentComplete = oEvent.loaded / oEvent.total;
// ...
} else {
// 回应的总数据量未知,导致无法计算百分比
}
}
function transferComplete(evt) {
console.log("The transfer is complete.");
}
function transferFailed(evt) {
console.log("An error occurred while transferring the file.");
}
function transferCanceled(evt) {
console.log("The transfer has been canceled by the user.");
}
```
### loadend事件
`abort`、`load`和`error`这三个事件,会伴随一个`loadend`事件,表示请求结束,但不知道其是否成功。
```javascript
req.addEventListener("loadend", loadEnd);
function loadEnd(e) {
alert("请求结束(不知道是否成功)");
}
```
## 文件上传
HTML网页的`<form>`元素能够以四种格式,向服务器发送数据。
- 使用`POST`方法,将`enctype`属性设为`application/x-www-form-urlencoded`,这是默认方法。
```html
<form action="register.php" method="post" onsubmit="AJAXSubmit(this); return false;">
</form>
```
- 使用`POST`方法,将`enctype`属性设为`text/plain`。
```html
<form action="register.php" method="post" enctype="text/plain" onsubmit="AJAXSubmit(this); return false;">
</form>
```
- 使用`POST`方法,将`enctype`属性设为`multipart/form-data`。
```html
<form action="register.php" method="post" enctype="multipart/form-data" onsubmit="AJAXSubmit(this); return false;">
</form>
```
- 使用`GET`方法,`enctype`属性将被忽略。
```html
<form action="register.php" method="get" onsubmit="AJAXSubmit(this); return false;">
</form>
```
某个表单有两个字段,分别是`foo`和`baz`,其中`foo`字段的值等于`bar`,`baz`字段的值一个分为两行的字符串。上面四种方法,都可以将这个表单发送到服务器。
第一种方法是默认方法,POST发送,Encoding type为application/x-www-form-urlencoded。
```http
Content-Type: application/x-www-form-urlencoded
foo=bar&baz=The+first+line.%0D%0AThe+second+line.%0D%0A
```
第二种方法是POST发送,Encoding type为text/plain。
```javascript
Content-Type: text/plain
foo=bar
baz=The first line.
The second line.
```
第三种方法是POST发送,Encoding type为multipart/form-data。
```http
Content-Type: multipart/form-data; boundary=---------------------------314911788813839
-----------------------------314911788813839
Content-Disposition: form-data; name="foo"
bar
-----------------------------314911788813839
Content-Disposition: form-data; name="baz"
The first line.
The second line.
-----------------------------314911788813839--
```
第四种方法是GET请求。
```http
?foo=bar&baz=The%20first%20line.%0AThe%20second%20line.
```
通常,我们使用file控件实现文件上传。
```html
<form id="file-form" action="handler.php" method="POST">
<input type="file" id="file-select" name="photos[]" multiple/>
<button type="submit" id="upload-button">上传</button>
</form>
```
上面HTML代码中,file控件的multiple属性,指定可以一次选择多个文件;如果没有这个属性,则一次只能选择一个文件。
file对象的files属性,返回一个FileList对象,包含了用户选中的文件。
```javascript
var fileSelect = document.getElementById('file-select');
var files = fileSelect.files;
```
然后,新建一个FormData对象的实例,用来模拟发送到服务器的表单数据,把选中的文件添加到这个对象上面。
```javascript
var formData = new FormData();
for (var i = 0; i < files.length; i++) {
var file = files[i];
if (!file.type.match('image.*')) {
continue;
}
formData.append('photos[]', file, file.name);
}
```
上面代码中的FormData对象的append方法,除了可以添加文件,还可以添加二进制对象(Blob)或者字符串。
```javascript
// Files
formData.append(name, file, filename);
// Blobs
formData.append(name, blob, filename);
// Strings
formData.append(name, value);
```
append方法的第一个参数是表单的控件名,第二个参数是实际的值,第三个参数是可选的,通常是文件名。
最后,使用Ajax方法向服务器上传文件。
```javascript
var xhr = new XMLHttpRequest();
xhr.open('POST', 'handler.php', true);
xhr.onload = function () {
if (xhr.status !== 200) {
alert('An error occurred!');
}
};
xhr.send(formData);
```
目前,各大浏览器(包括IE 10)都支持Ajax上传文件。
除了使用FormData接口上传,也可以直接使用File API上传。
```javascript
var file = document.getElementById('test-input').files[0];
var xhr = new XMLHttpRequest();
xhr.open('POST', 'myserver/uploads');
xhr.setRequestHeader('Content-Type', file.type);
xhr.send(file);
```
可以看到,上面这种写法比FormData的写法,要简单很多。
## Fetch API
### 基本用法
Ajax操作所用的XMLHttpRequest对象,已经有十多年的历史,它的API设计并不是很好,输入、输出、状态都在同一个接口管理,容易写出非常混乱的代码。Fetch API是一种新规范,用来取代XMLHttpRequest对象。它主要有两个特点,一是简化接口,将API分散在几个不同的对象上,二是返回Promise对象,避免了嵌套的回调函数。
检查浏览器是否部署了这个API的代码如下。
```javascript
if (fetch in window){
// 支持
} else {
// 不支持
}
```
下面是一个Fetch API的简单例子。
```javascript
var URL = 'http://some/path';
fetch(URL).then(function(response) {
return response.json();
}).then(function(json) {
someOperator(json);
});
```
上面代码向服务器请求JSON文件,获取后再做进一步处理。
下面比较XMLHttpRequest写法与Fetch写法的不同。
```javascript
function reqListener() {
var data = JSON.parse(this.responseText);
console.log(data);
}
function reqError(err) {
console.log('Fetch Error :-S', err);
}
var oReq = new XMLHttpRequest();
oReq.onload = reqListener;
oReq.onerror = reqError;
oReq.open('get', './api/some.json', true);
oReq.send();
```
同样的操作用Fetch实现如下。
```javascript
fetch('./api/some.json')
.then(function(response) {
if (response.status !== 200) {
console.log('请求失败,状态码:' + response.status);
return;
}
response.json().then(function(data) {
console.log(data);
});
}).catch(function(err) {
console.log('出错:', err);
});
```
上面代码中,因为HTTP请求返回的response对象是一个Stream对象,所以需要使用`response.json`方法转为JSON格式,不过这个方法返回的是一个Promise对象。
### fetch()
fetch方法的第一个参数可以是URL字符串,也可以是后文要讲到的Request对象实例。Fetch方法返回一个Promise对象,并将一个response对象传给回调函数。
response对象还有一个ok属性,如果返回的状态码在200到299之间(即请求成功),这个属性为true,否则为false。因此,上面的代码可以写成下面这样。
```javascript
fetch("./api/some.json").then(function(response) {
if (response.ok) {
response.json().then(function(data) {
console.log(data);
});
} else {
console.log("请求失败,状态码为", response.status);
}
}, function(err) {
console.log("出错:", err);
});
```
response对象除了json方法,还包含了HTTP回应的元数据。
```javascript
fetch('users.json').then(function(response) {
console.log(response.headers.get('Content-Type'));
console.log(response.headers.get('Date'));
console.log(response.status);
console.log(response.statusText);
console.log(response.type);
console.log(response.url);
});
```
上面代码中,response对象有很多属性,其中的`response.type`属性比较特别,表示HTTP回应的类型,它有以下三个值。
- basic:正常的同域请求
- cors:CORS机制下的跨域请求
- opaque:非CORS机制下的跨域请求,这时无法读取返回的数据,也无法判断是否请求成功
如果需要在CORS机制下发出跨域请求,需要指明状态。
```javascript
fetch('http://some-site.com/cors-enabled/some.json', {mode: 'cors'})
.then(function(response) {
return response.text();
})
.then(function(text) {
console.log('Request successful', text);
})
.catch(function(error) {
log('Request failed', error)
});
```
除了指定模式,fetch方法的第二个参数还可以用来配置其他值,比如指定cookie连同HTTP请求一起发出。
```javascript
fetch(url, {
credentials: 'include'
})
```
发出POST请求的写法如下。
```javascript
fetch("http://www.example.org/submit.php", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: "firstName=Nikhil&favColor=blue&password=easytoguess"
}).then(function(res) {
if (res.ok) {
console.log("Perfect! Your settings are saved.");
} else if (res.status == 401) {
console.log("Oops! You are not authorized.");
}
}, function(e) {
console.log("Error submitting form!");
});
```
目前,还有一些XMLHttpRequest对象可以做到,但是Fetch API还没做到的地方,比如中途中断HTTP请求,以及获取HTTP请求的进度。这些不足与Fetch返回的是Promise对象有关。
### Headers
Fetch API引入三个新的对象(也是构造函数):Headers, Request 和 Response。其中,Headers对象用来构造/读取HTTP数据包的头信息。
```javascript
var content = "Hello World";
var reqHeaders = new Headers();
reqHeaders.append("Content-Type", "text/plain");
reqHeaders.append("Content-Length", content.length.toString());
reqHeaders.append("X-Custom-Header", "ProcessThisImmediately");
```
Headers对象的实例,除了使用append方法添加属性,也可以直接通过构造函数一次性生成。
```javascript
reqHeaders = new Headers({
"Content-Type": "text/plain",
"Content-Length": content.length.toString(),
"X-Custom-Header": "ProcessThisImmediately",
});
```
Headers对象实例还提供了一些工具方法。
```javascript
reqHeaders.has("Content-Type") // true
reqHeaders.has("Set-Cookie") // false
reqHeaders.set("Content-Type", "text/html")
reqHeaders.append("X-Custom-Header", "AnotherValue")
reqHeaders.get("Content-Length") // 11
reqHeaders.getAll("X-Custom-Header") // ["ProcessThisImmediately", "AnotherValue"]
reqHeaders.delete("X-Custom-Header")
reqHeaders.getAll("X-Custom-Header") // []
```
生成Header实例以后,可以将它作为第二个参数,传入Request方法。
```javascript
var headers = new Headers();
headers.append('Accept', 'application/json');
var request = new Request(URL, {headers: headers});
fetch(request).then(function(response) {
console.log(response.headers);
});
```
同样地,Headers实例可以用来构造Response方法。
```javascript
var headers = new Headers({
'Content-Type': 'application/json',
'Cache-Control': 'max-age=3600'
});
var response = new Response(
JSON.stringify({photos: {photo: []}}),
{'status': 200, headers: headers}
);
response.json().then(function(json) {
insertPhotos(json);
});
```
上面代码中,构造了一个HTTP回应。目前,浏览器构造HTTP回应没有太大用处,但是随着Service Worker的部署,不久浏览器就可以向Service Worker发出HTTP回应。
### Request对象
Request对象用来构造HTTP请求。
```javascript
var req = new Request("/index.html");
req.method // "GET"
req.url // "http://example.com/index.html"
```
Request对象的第二个参数,表示配置对象。
```javascript
var uploadReq = new Request("/uploadImage", {
method: "POST",
headers: {
"Content-Type": "image/png",
},
body: "image data"
});
```
上面代码指定Request对象使用POST方法发出,并指定HTTP头信息和信息体。
下面是另一个例子。
```javascript
var req = new Request(URL, {method: 'GET', cache: 'reload'});
fetch(req).then(function(response) {
return response.json();
}).then(function(json) {
someOperator(json);
});
```
上面代码中,指定请求方法为GET,并且要求浏览器不得缓存response。
Request对象实例有两个属性是只读的,不能手动设置。一个是referrer属性,表示请求的来源,由浏览器设置,有可能是空字符串。另一个是context属性,表示请求发出的上下文,如果是image,表示是从img标签发出,如果是worker,表示是从worker脚本发出,如果是fetch,表示是从fetch函数发出的。
Request对象实例的mode属性,用来设置是否跨域,合法的值有以下三种:same-origin、no-cors(默认值)、cors。当设置为same-origin时,只能向同域的URL发出请求,否则会报错。
```javascript
var arbitraryUrl = document.getElementById("url-input").value;
fetch(arbitraryUrl, { mode: "same-origin" }).then(function(res) {
console.log("Response succeeded?", res.ok);
}, function(e) {
console.log("Please enter a same-origin URL!");
});
```
上面代码中,如果用户输入的URL不是同域的,将会报错,否则就会发出请求。
如果mode属性为no-cors,就与默认的浏览器行为没有不同,类似script标签加载外部脚本文件、img标签加载外部图片。如果mode属性为cors,就可以向部署了CORS机制的服务器,发出跨域请求。
```javascript
var u = new URLSearchParams();
u.append('method', 'flickr.interestingness.getList');
u.append('api_key', '<insert api key here>');
u.append('format', 'json');
u.append('nojsoncallback', '1');
var apiCall = fetch('https://api.flickr.com/services/rest?' + u);
apiCall.then(function(response) {
return response.json().then(function(json) {
// photo is a list of photos.
return json.photos.photo;
});
}).then(function(photos) {
photos.forEach(function(photo) {
console.log(photo.title);
});
});
```
上面代码是向Flickr API发出图片请求的例子。
Request对象的一个很有用的功能,是在其他Request实例的基础上,生成新的Request实例。
```javascript
var postReq = new Request(req, {method: 'POST'});
```
### Response
fetch方法返回Response对象实例,它有以下属性。
- status:整数值,表示状态码(比如200)
- statusText:字符串,表示状态信息,默认是“OK”
- ok:布尔值,表示状态码是否在200-299的范围内
- headers:Headers对象,表示HTTP回应的头信息
- url:字符串,表示HTTP请求的网址
- type:字符串,合法的值有五个basic、cors、default、error、opaque。basic表示正常的同域请求;cors表示CORS机制的跨域请求;error表示网络出错,无法取得信息,status属性为0,headers属性为空,并且导致fetch函数返回Promise对象被拒绝;opaque表示非CORS机制的跨域请求,受到严格限制。
Response对象还有两个静态方法。
- Response.error() 返回一个type属性为error的Response对象实例
- Response.redirect(url, status) 返回的Response对象实例会重定向到另一个URL
### body属性
Request对象和Response对象都有body属性,表示请求的内容。body属性可能是以下的数据类型。
- ArrayBuffer
- ArrayBufferView (Uint8Array等)
- Blob/File
- string
- URLSearchParams
- FormData
```javascript
var form = new FormData(document.getElementById('login-form'));
fetch("/login", {
method: "POST",
body: form
})
```
上面代码中,Request对象的body属性为表单数据。
Request对象和Response对象都提供以下方法,用来读取body。
- arrayBuffer()
- blob()
- json()
- text()
- formData()
注意,上面这些方法都只能使用一次,第二次使用就会报错,也就是说,body属性只能读取一次。Request对象和Response对象都有bodyUsed属性,返回一个布尔值,表示body是否被读取过。
```javascript
var res = new Response("one time use");
console.log(res.bodyUsed); // false
res.text().then(function(v) {
console.log(res.bodyUsed); // true
});
console.log(res.bodyUsed); // true
res.text().catch(function(e) {
console.log("Tried to read already consumed Response");
});
```
上面代码中,第二次通过text方法读取Response对象实例的body时,就会报错。
这是因为body属性是一个stream对象,数据只能单向传送一次。这样的设计是为了允许JavaScript处理视频、音频这样的大型文件。
如果希望多次使用body属性,可以使用Response对象和Request对象的clone方法。它必须在body还没有读取前调用,返回一个前的body,也就是说,需要使用几次body,就要调用几次clone方法。
```javascript
addEventListener('fetch', function(evt) {
var sheep = new Response("Dolly");
console.log(sheep.bodyUsed); // false
var clone = sheep.clone();
console.log(clone.bodyUsed); // false
clone.text();
console.log(sheep.bodyUsed); // false
console.log(clone.bodyUsed); // true
evt.respondWith(cache.add(sheep.clone()).then(function(e) {
return sheep;
});
});
```
<h2 id="6.9">CORS通信</h2>
CORS是一个W3C标准,全称是“跨域资源共享”(Cross-origin resource sharing)。
它允许浏览器向跨源服务器,发出[`XMLHttpRequest`](http://www.ruanyifeng.com/blog/2012/09/xmlhttprequest_level_2.html)请求,从而克服了AJAX只能[同源](http://www.ruanyifeng.com/blog/2016/04/same-origin-policy.html)使用的限制。
本文详细介绍CORS的内部机制。
## 简介
CORS需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE浏览器不能低于IE10。
整个CORS通信过程,都是浏览器自动完成,不需要用户参与。对于开发者来说,CORS通信与同源的AJAX通信没有差别,代码完全一样。浏览器一旦发现AJAX请求跨源,就会自动添加一些附加的头信息,有时还会多出一次附加的请求,但用户不会有感觉。
因此,实现CORS通信的关键是服务器。只要服务器实现了CORS接口,就可以跨源通信。
## 两种请求
浏览器将CORS请求分成两类:简单请求(simple request)和非简单请求(not-so-simple request)。
只要同时满足以下两大条件,就属于简单请求。
(1)请求方法是以下三种方法之一。
> - HEAD
> - GET
> - POST
(2)HTTP的头信息不超出以下几种字段。
> - Accept
> - Accept-Language
> - Content-Language
> - Last-Event-ID
> - Content-Type:只限于三个值`application/x-www-form-urlencoded`、`multipart/form-data`、`text/plain`
凡是不同时满足上面两个条件,就属于非简单请求。
浏览器对这两种请求的处理,是不一样的。
## 简单请求
### 基本流程
对于简单请求,浏览器直接发出CORS请求。具体来说,就是在头信息之中,增加一个`Origin`字段。
下面是一个例子,浏览器发现这次跨源AJAX请求是简单请求,就自动在头信息之中,添加一个`Origin`字段。
```http
GET /cors HTTP/1.1
Origin: http://api.bob.com
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...
```
上面的头信息中,`Origin`字段用来说明,本次请求来自哪个源(协议 + 域名 + 端口)。服务器根据这个值,决定是否同意这次请求。
如果`Origin`指定的源,不在许可范围内,服务器会返回一个正常的HTTP回应。浏览器发现,这个回应的头信息没有包含`Access-Control-Allow-Origin`字段(详见下文),就知道出错了,从而抛出一个错误,被`XMLHttpRequest`的`onerror`回调函数捕获。注意,这种错误无法通过状态码识别,因为HTTP回应的状态码有可能是200。
如果`Origin`指定的域名在许可范围内,服务器返回的响应,会多出几个头信息字段。
```http
Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: FooBar
Content-Type: text/html; charset=utf-8
```
上面的头信息之中,有三个与CORS请求相关的字段,都以`Access-Control-`开头。
**(1)`Access-Control-Allow-Origin`**
该字段是必须的。它的值要么是请求时`Origin`字段的值,要么是一个`*`,表示接受任意域名的请求。
**(2)`Access-Control-Allow-Credentials`**
该字段可选。它的值是一个布尔值,表示是否允许发送Cookie。默认情况下,Cookie不包括在CORS请求之中。设为`true`,即表示服务器明确许可,Cookie可以包含在请求中,一起发给服务器。这个值也只能设为`true`,如果服务器不要浏览器发送Cookie,删除该字段即可。
**(3)`Access-Control-Expose-Headers`**
该字段可选。CORS请求时,`XMLHttpRequest`对象的`getResponseHeader()`方法只能拿到6个基本字段:`Cache-Control`、`Content-Language`、`Content-Type`、`Expires`、`Last-Modified`、`Pragma`。如果想拿到其他字段,就必须在`Access-Control-Expose-Headers`里面指定。上面的例子指定,`getResponseHeader('FooBar')`可以返回`FooBar`字段的值。
### withCredentials 属性
上面说到,CORS请求默认不包含Cookie信息(以及HTTP认证信息等)。如果需要包含Cookie信息,一方面要服务器同意,指定`Access-Control-Allow-Credentials`字段。
```http
Access-Control-Allow-Credentials: true
```
另一方面,开发者必须在AJAX请求中打开`withCredentials`属性。
```javascript
var xhr = new XMLHttpRequest();
xhr.withCredentials = true;
```
否则,即使服务器同意发送Cookie,浏览器也不会发送。或者,服务器要求设置Cookie,浏览器也不会处理。
但是,如果省略`withCredentials`设置,有的浏览器还是会一起发送Cookie。这时,可以显式关闭`withCredentials`。
```javascript
xhr.withCredentials = false;
```
需要注意的是,如果要发送Cookie,`Access-Control-Allow-Origin`就不能设为星号,必须指定明确的、与请求网页一致的域名。同时,Cookie依然遵循同源政策,只有用服务器域名设置的Cookie才会上传,其他域名的Cookie并不会上传,且(跨源)原网页代码中的`document.cookie`也无法读取服务器域名下的Cookie。
## 非简单请求
### 预检请求
非简单请求是那种对服务器有特殊要求的请求,比如请求方法是`PUT`或`DELETE`,或者`Content-Type`字段的类型是`application/json`。
非简单请求的CORS请求,会在正式通信之前,增加一次HTTP查询请求,称为”预检“请求(preflight)。
浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些HTTP动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的`XMLHttpRequest`请求,否则就报错。
下面是一段浏览器的JavaScript脚本。
```javascript
var url = 'http://api.alice.com/cors';
var xhr = new XMLHttpRequest();
xhr.open('PUT', url, true);
xhr.setRequestHeader('X-Custom-Header', 'value');
xhr.send();
```
上面代码中,HTTP请求的方法是`PUT`,并且发送一个自定义头信息`X-Custom-Header`。
浏览器发现,这是一个非简单请求,就自动发出一个”预检“请求,要求服务器确认可以这样请求。下面是这个“预检”请求的HTTP头信息。
```http
OPTIONS /cors HTTP/1.1
Origin: http://api.bob.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...
```
“预检”请求用的请求方法是`OPTIONS`,表示这个请求是用来询问的。头信息里面,关键字段是`Origin`,表示请求来自哪个源。
除了`Origin`字段,“预检”请求的头信息包括两个特殊字段。
**(1)`Access-Control-Request-Method`**
该字段是必须的,用来列出浏览器的CORS请求会用到哪些HTTP方法,上例是`PUT`。
**(2)`Access-Control-Request-Headers`**
该字段是一个逗号分隔的字符串,指定浏览器CORS请求会额外发送的头信息字段,上例是`X-Custom-Header`。
### 预检请求的回应
服务器收到“预检”请求以后,检查了`Origin`、`Access-Control-Request-Method`和`Access-Control-Request-Headers`字段以后,确认允许跨源请求,就可以做出回应。
```http
HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain
```
上面的HTTP回应中,关键的是`Access-Control-Allow-Origin`字段,表示`http://api.bob.com`可以请求数据。该字段也可以设为星号,表示同意任意跨源请求。
```http
Access-Control-Allow-Origin: *
```
如果服务器否定了”预检“请求,会返回一个正常的HTTP回应,但是没有任何CORS相关的头信息字段。这时,浏览器就会认定,服务器不同意预检请求,因此触发一个错误,被`XMLHttpRequest`对象的`onerror`回调函数捕获。控制台会打印出如下的报错信息。
```bash
XMLHttpRequest cannot load http://api.alice.com.
Origin http://api.bob.com is not allowed by Access-Control-Allow-Origin.
```
服务器回应的其他CORS相关字段如下。
```http
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Access-Control-Allow-Headers: true
Access-Control-Max-Age: 1728000
```
**(1)`Access-Control-Allow-Methods`**
该字段必需,它的值是逗号分隔的一个字符串,表明服务器支持的所有跨域请求的方法。注意,返回的是所有支持的方法,而不单是浏览器请求的那个方法。这是为了避免多次“预检”请求。
**(2)`Access-Control-Allow-Headers`**
如果浏览器请求包括`Access-Control-Request-Headers`字段,则`Access-Control-Allow-Headers`字段是必需的。它也是一个逗号分隔的字符串,表明服务器支持的所有头信息字段,不限于浏览器在”预检“中请求的字段。
**(3)`Access-Control-Allow-Credentials`**
该字段与简单请求时的含义相同。
**(4)`Access-Control-Max-Age`**
该字段可选,用来指定本次预检请求的有效期,单位为秒。上面结果中,有效期是20天(1728000秒),即允许缓存该条回应1728000秒(即20天),在此期间,不用发出另一条预检请求。
### 浏览器的正常请求和回应
一旦服务器通过了“预检”请求,以后每次浏览器正常的CORS请求,就都跟简单请求一样,会有一个`Origin`头信息字段。服务器的回应,也都会有一个`Access-Control-Allow-Origin`头信息字段。
下面是“预检”请求之后,浏览器的正常CORS请求。
```http
PUT /cors HTTP/1.1
Origin: http://api.bob.com
Host: api.alice.com
X-Custom-Header: value
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...
```
上面头信息的`Origin`字段是浏览器自动添加的。
下面是服务器正常的回应。
```http
Access-Control-Allow-Origin: http://api.bob.com
Content-Type: text/html; charset=utf-8
```
上面头信息中,`Access-Control-Allow-Origin`字段是每次回应都必定包含的。
## 与JSONP的比较
CORS与JSONP的使用目的相同,但是比JSONP更强大。
JSONP只支持`GET`请求,CORS支持所有类型的HTTP请求。JSONP的优势在于支持老式浏览器,以及可以向不支持CORS的网站请求数据。
<h2 id="6.10">IndexedDB:浏览器端数据库</h2>
## 概述
随着浏览器的处理能力不断增强,越来越多的网站开始考虑,将大量数据储存在客户端,这样可以减少用户等待从服务器获取数据的时间。
现有的浏览器端数据储存方案,都不适合储存大量数据:cookie不超过4KB,且每次请求都会发送回服务器端;Window.name属性缺乏安全性,且没有统一的标准;localStorage在2.5MB到10MB之间(各家浏览器不同)。所以,需要一种新的解决方案,这就是IndexedDB诞生的背景。
通俗地说,IndexedDB就是浏览器端数据库,可以被网页脚本程序创建和操作。它允许储存大量数据,提供查找接口,还能建立索引。这些都是localStorage所不具备的。就数据库类型而言,IndexedDB不属于关系型数据库(不支持SQL查询语句),更接近NoSQL数据库。
IndexedDB具有以下特点。
**(1)键值对储存。** IndexedDB内部采用对象仓库(object store)存放数据。所有类型的数据都可以直接存入,包括JavaScript对象。在对象仓库中,数据以“键值对”的形式保存,每一个数据都有对应的键名,键名是独一无二的,不能有重复,否则会抛出一个错误。
**(2)异步。** IndexedDB操作时不会锁死浏览器,用户依然可以进行其他操作,这与localStorage形成对比,后者的操作是同步的。异步设计是为了防止大量数据的读写,拖慢网页的表现。
**(3)支持事务。** IndexedDB支持事务(transaction),这意味着一系列操作步骤之中,只要有一步失败,整个事务就都取消,数据库回到事务发生之前的状态,不存在只改写一部分数据的情况。
**(4)同域限制** IndexedDB也受到同域限制,每一个数据库对应创建该数据库的域名。来自不同域名的网页,只能访问自身域名下的数据库,而不能访问其他域名下的数据库。
**(5)储存空间大** IndexedDB的储存空间比localStorage大得多,一般来说不少于250MB。IE的储存上限是250MB,Chrome和Opera是剩余空间的某个百分比,Firefox则没有上限。
**(6)支持二进制储存。** IndexedDB不仅可以储存字符串,还可以储存二进制数据。
目前,Chrome 27+、Firefox 21+、Opera 15+和IE 10+支持这个API,但是Safari完全不支持。
下面的代码用来检查浏览器是否支持这个API。
```javascript
if("indexedDB" in window) {
// 支持
} else {
// 不支持
}
```
## indexedDB.open方法
浏览器原生提供indexedDB对象,作为开发者的操作接口。indexedDB.open方法用于打开数据库。
```javascript
var openRequest = indexedDB.open("test",1);
```
open方法的第一个参数是数据库名称,格式为字符串,不可省略;第二个参数是数据库版本,是一个大于0的正整数(0将报错)。上面代码表示,打开一个名为test、版本为1的数据库。如果该数据库不存在,则会新建该数据库。如果省略第二个参数,则会自动创建版本为1的该数据库。
打开数据库的结果是,有可能触发4种事件。
- **success**:打开成功。
- **error**:打开失败。
- **upgradeneeded**:第一次打开该数据库,或者数据库版本发生变化。
- **blocked**:上一次的数据库连接还未关闭。
第一次打开数据库时,会先触发upgradeneeded事件,然后触发success事件。
根据不同的需要,对上面4种事件设立回调函数。
```javascript
var openRequest = indexedDB.open("test",1);
var db;
openRequest.onupgradeneeded = function(e) {
console.log("Upgrading...");
}
openRequest.onsuccess = function(e) {
console.log("Success!");
db = e.target.result;
}
openRequest.onerror = function(e) {
console.log("Error");
console.dir(e);
}
```
上面代码有两个地方需要注意。首先,open方法返回的是一个对象(IDBOpenDBRequest),回调函数定义在这个对象上面。其次,回调函数接受一个事件对象event作为参数,它的target.result属性就指向打开的IndexedDB数据库。
## indexedDB实例对象的方法
获得数据库实例以后,就可以用实例对象的方法操作数据库。
### createObjectStore方法
createObjectStore方法用于创建存放数据的“对象仓库”(object store),类似于传统关系型数据库的表格。
```javascript
db.createObjectStore("firstOS");
```
上面代码创建了一个名为firstOS的对象仓库,如果该对象仓库已经存在,就会抛出一个错误。为了避免出错,需要用到下文的objectStoreNames属性,检查已有哪些对象仓库。
createObjectStore方法还可以接受第二个对象参数,用来设置“对象仓库”的属性。
```javascript
db.createObjectStore("test", { keyPath: "email" });
db.createObjectStore("test2", { autoIncrement: true });
```
上面代码中的keyPath属性表示,所存入对象的email属性用作每条记录的键名(由于键名不能重复,所以存入之前必须保证数据的email属性值都是不一样的),默认值为null;autoIncrement属性表示,是否使用自动递增的整数作为键名(第一个数据为1,第二个数据为2,以此类推),默认为false。一般来说,keyPath和autoIncrement属性只要使用一个就够了,如果两个同时使用,表示键名为递增的整数,且对象不得缺少指定属性。
### objectStoreNames属性
objectStoreNames属性返回一个DOMStringList对象,里面包含了当前数据库所有“对象仓库”的名称。可以使用DOMStringList对象的contains方法,检查数据库是否包含某个“对象仓库”。
```javascript
if(!db.objectStoreNames.contains("firstOS")) {
db.createObjectStore("firstOS");
}
```
上面代码先判断某个“对象仓库”是否存在,如果不存在就创建该对象仓库。
### transaction方法
transaction方法用于创建一个数据库事务。向数据库添加数据之前,必须先创建数据库事务。
```javascript
var t = db.transaction(["firstOS"],"readwrite");
```
transaction方法接受两个参数:第一个参数是一个数组,里面是所涉及的对象仓库,通常是只有一个;第二个参数是一个表示操作类型的字符串。目前,操作类型只有两种:readonly(只读)和readwrite(读写)。添加数据使用readwrite,读取数据使用readonly。
transaction方法返回一个事务对象,该对象的objectStore方法用于获取指定的对象仓库。
```javascript
var t = db.transaction(["firstOS"],"readwrite");
var store = t.objectStore("firstOS");
```
transaction方法有三个事件,可以用来定义回调函数。
- **abort**:事务中断。
- **complete**:事务完成。
- **error**:事务出错。
```javascript
var transaction = db.transaction(["note"], "readonly");
transaction.oncomplete = function(event) {
// some code
};
```
事务对象有以下方法,用于操作数据。
**(1)添加数据:add方法**
获取对象仓库以后,就可以用add方法往里面添加数据了。
```javascript
var store = t.objectStore("firstOS");
var o = {p: 123};
var request = store.add(o,1);
```
add方法的第一个参数是所要添加的数据,第二个参数是这条数据对应的键名(key),上面代码将对象o的键名设为1。如果在创建数据仓库时,对键名做了设置,这里也可以不指定键名。
add方法是异步的,有自己的success和error事件,可以对这两个事件指定回调函数。
```javascript
var request = store.add(o,1);
request.onerror = function(e) {
console.log("Error",e.target.error.name);
// error handler
}
request.onsuccess = function(e) {
console.log("数据添加成功!");
}
```
**(2)读取数据:get方法**
读取数据使用get方法,它的参数是数据的键名。
```javascript
var t = db.transaction(["test"], "readonly");
var store = t.objectStore("test");
var ob = store.get(x);
```
get方法也是异步的,会触发自己的success和error事件,可以对它们指定回调函数。
```javascript
var ob = store.get(x);
ob.onsuccess = function(e) {
// ...
}
```
从创建事务到读取数据,所有操作方法也可以写成下面这样链式形式。
```javascript
db.transaction(["test"], "readonly")
.objectStore("test")
.get(X)
.onsuccess = function(e){}
```
**(3)更新记录:put方法**
put方法的用法与add方法相近。
```javascript
var o = { p:456 };
var request = store.put(o, 1);
```
**(4)删除记录:delete方法**
删除记录使用delete方法。
```javascript
var t = db.transaction(["people"], "readwrite");
var request = t.objectStore("people").delete(thisId);
```
delete方法的参数是数据的键名。另外,delete也是一个异步操作,可以为它指定回调函数。
**(5)遍历数据:openCursor方法**
如果想要遍历数据,就要openCursor方法,它在当前对象仓库里面建立一个读取光标(cursor)。
```javascript
var t = db.transaction(["test"], "readonly");
var store = t.objectStore("test");
var cursor = store.openCursor();
```
openCursor方法也是异步的,有自己的success和error事件,可以对它们指定回调函数。
```javascript
cursor.onsuccess = function(e) {
var res = e.target.result;
if(res) {
console.log("Key", res.key);
console.dir("Data", res.value);
res.continue();
}
}
```
回调函数接受一个事件对象作为参数,该对象的target.result属性指向当前数据对象。当前数据对象的key和value分别返回键名和键值(即实际存入的数据)。continue方法将光标移到下一个数据对象,如果当前数据对象已经是最后一个数据了,则光标指向null。
openCursor方法还可以接受第二个参数,表示遍历方向,默认值为next,其他可能的值为prev、nextunique和prevunique。后两个值表示如果遇到重复值,会自动跳过。
### createIndex方法
createIndex方法用于创建索引。
假定对象仓库中的数据对象都是下面person类型的。
```javascript
var person = {
name:name,
email:email,
created:new Date()
}
```
可以指定这个数据对象的某个属性来建立索引。
```javascript
var store = db.createObjectStore("people", { autoIncrement:true });
store.createIndex("name","name", {unique:false});
store.createIndex("email","email", {unique:true});
```
createIndex方法接受三个参数,第一个是索引名称,第二个是建立索引的属性名,第三个是参数对象,用来设置索引特性。unique表示索引所在的属性是否有唯一值,上面代码表示name属性不是唯一值,email属性是唯一值。
### index方法
有了索引以后,就可以针对索引所在的属性读取数据。index方法用于从对象仓库返回指定的索引。
```javascript
var t = db.transaction(["people"],"readonly");
var store = t.objectStore("people");
var index = store.index("name");
var request = index.get(name);
```
上面代码打开对象仓库以后,先用index方法指定索引在name属性上面,然后用get方法读取某个name属性所在的数据。如果没有指定索引的那一行代码,get方法只能按照键名读取数据,而不能按照name属性读取数据。需要注意的是,这时get方法有可能取回多个数据对象,因为name属性没有唯一值。
另外,get是异步方法,读取成功以后,只能在success事件的回调函数中处理数据。
## IDBKeyRange对象
索引的有用之处,还在于可以指定读取数据的范围。这需要用到浏览器原生的IDBKeyRange对象。
IDBKeyRange对象的作用是生成一个表示范围的Range对象。生成方法有四种:
- **lowerBound方法**:指定范围的下限。
- **upperBound方法**:指定范围的上限。
- **bound方法**:指定范围的上下限。
- **only方法**:指定范围中只有一个值。
下面是一些代码实例:
```javascript
// All keys ≤ x
var r1 = IDBKeyRange.upperBound(x);
// All keys < x
var r2 = IDBKeyRange.upperBound(x, true);
// All keys ≥ y
var r3 = IDBKeyRange.lowerBound(y);
// All keys > y
var r4 = IDBKeyRange.lowerBound(y, true);
// All keys ≥ x && ≤ y
var r5 = IDBKeyRange.bound(x, y);
// All keys > x &&< y
var r6 = IDBKeyRange.bound(x, y, true, true);
// All keys > x && ≤ y
var r7 = IDBKeyRange.bound(x, y, true, false);
// All keys ≥ x &&< y
var r8 = IDBKeyRange.bound(x, y, false, true);
// The key = z
var r9 = IDBKeyRange.only(z);
```
前三个方法(lowerBound、upperBound和bound)默认包括端点值,可以传入一个布尔值,修改这个属性。
生成Range对象以后,将它作为参数输入openCursor方法,就可以在所设定的范围内读取数据。
```javascript
var t = db.transaction(["people"],"readonly");
var store = t.objectStore("people");
var index = store.index("name");
var range = IDBKeyRange.bound('B', 'D');
index.openCursor(range).onsuccess = function(e) {
var cursor = e.target.result;
if(cursor) {
console.log(cursor.key + ":");
for(var field in cursor.value) {
console.log(cursor.value[field]);
}
cursor.continue();
}
}
```
<h2 id="6.11">Web Notifications API</h2>
## 概述
Notification API是浏览器的通知接口,用于在用户的桌面(而不是网页上)显示通知信息,桌面电脑和手机都适用,比如通知用户收到了一封Email。具体的实现形式由浏览器自行部署,对于手机来说,一般显示在顶部的通知栏。
如果网页代码调用这个API,浏览器会询问用户是否接受。只有在用户同意的情况下,通知信息才会显示。
下面的代码用于检查浏览器是否支持这个API。
```javascript
if (window.Notification) {
// 支持
} else {
// 不支持
}
```
目前,Chrome和Firefox在桌面端部署了这个API,Firefox和Blackberry在手机端部署了这个API。
```javascript
if(window.Notification && Notification.permission !== "denied") {
Notification.requestPermission(function(status) {
var n = new Notification('通知标题', { body: '这里是通知内容!' });
});
}
```
上面代码检查当前浏览器是否支持Notification对象,并且当前用户准许使用该对象,然后调用Notification.requestPermission方法,向用户弹出一条通知。
## Notification对象的属性和方法
### Notification.permission
Notification.permission属性,用于读取用户给予的权限,它是一个只读属性,它有三种状态。
- default:用户还没有做出任何许可,因此不会弹出通知。
- granted:用户明确同意接收通知。
- denied:用户明确拒绝接收通知。
### Notification.requestPermission()
Notification.requestPermission方法用于让用户做出选择,到底是否接收通知。它的参数是一个回调函数,该函数可以接收用户授权状态作为参数。
```javascript
Notification.requestPermission(function (status) {
if (status === "granted") {
var n = new Notification("Hi!");
} else {
alert("Hi!");
}
});
```
上面代码表示,如果用户拒绝接收通知,可以用alert方法代替。
## Notification实例对象
### Notification构造函数
Notification对象作为构造函数使用时,用来生成一条通知。
```javascript
var notification = new Notification(title, options);
```
Notification构造函数的title属性是必须的,用来指定通知的标题,格式为字符串。options属性是可选的,格式为一个对象,用来设定各种设置。该对象的属性如下:
- dir:文字方向,可能的值为auto、ltr(从左到右)和rtl(从右到左),一般是继承浏览器的设置。
- lang:使用的语种,比如en-US、zh-CN。
- body:通知内容,格式为字符串,用来进一步说明通知的目的。。
- tag:通知的ID,格式为字符串。一组相同tag的通知,不会同时显示,只会在用户关闭前一个通知后,在原位置显示。
- icon:图表的URL,用来显示在通知上。
上面这些属性,都是可读写的。
下面是一个生成Notification实例对象的例子。
```javascript
var notification = new Notification('收到新邮件', {
body: '您总共有3封未读邮件。'
});
notification.title // "收到新邮件"
notification.body // "您总共有3封未读邮件。"
```
### 实例对象的事件
Notification实例会触发以下事件。
- show:通知显示给用户时触发。
- click:用户点击通知时触发。
- close:用户关闭通知时触发。
- error:通知出错时触发(大多数发生在通知无法正确显示时)。
这些事件有对应的onshow、onclick、onclose、onerror方法,用来指定相应的回调函数。addEventListener方法也可以用来为这些事件指定回调函数。
```javascript
notification.onshow = function() {
console.log('Notification shown');
};
```
### close方法
Notification实例的close方法用于关闭通知。
```javascript
var n = new Notification("Hi!");
// 手动关闭
n.close();
// 自动关闭
n.onshow = function () {
setTimeout(n.close.bind(n), 5000);
}
```
上面代码说明,并不能从通知的close事件,判断它是否为用户手动关闭。
<h2 id="6.12">Performance API</h2>
Performance API用于精确度量、控制、增强浏览器的性能表现。这个API为测量网站性能,提供以前没有办法做到的精度。
比如,为了得到脚本运行的准确耗时,需要一个高精度时间戳。传统的做法是使用Date对象的getTime方法。
```javascript
var start = new Date().getTime();
// do something here
var now = new Date().getTime();
var latency = now - start;
console.log("任务运行时间:" + latency);
```
上面这种做法有两个不足之处。首先,getTime方法(以及Date对象的其他方法)都只能精确到毫秒级别(一秒的千分之一),想要得到更小的时间差别就无能为力了;其次,这种写法只能获取代码运行过程中的时间进度,无法知道一些后台事件的时间进度,比如浏览器用了多少时间从服务器加载网页。
为了解决这两个不足之处,ECMAScript 5引入“高精度时间戳”这个API,部署在performance对象上。它的精度可以达到1毫秒的千分之一(1秒的百万分之一),这对于衡量的程序的细微差别,提高程序运行速度很有好处,而且还可以获取后台事件的时间进度。
目前,所有主要浏览器都已经支持performance对象,包括Chrome 20+、Firefox 15+、IE 10+、Opera 15+。
## performance.timing对象
performance对象的timing属性指向一个对象,它包含了各种与浏览器性能有关的时间数据,提供浏览器处理网页各个阶段的耗时。比如,performance.timing.navigationStart就是浏览器处理当前网页的启动时间。
```javascript
Date.now() - performance.timing.navigationStart
// 13260687
```
上面代码表示距离浏览器开始处理当前网页,已经过了13260687毫秒。
下面是另一个例子。
```javascript
var t = performance.timing;
var pageloadtime = t.loadEventStart - t.navigationStart;
var dns = t.domainLookupEnd - t.domainLookupStart;
var tcp = t.connectEnd - t.connectStart;
var ttfb = t.responseStart - t.navigationStart;
```
上面代码依次得到页面加载的耗时、域名解析的耗时、TCP连接的耗时、读取页面第一个字节之前的耗时。
performance.timing对象包含以下属性(全部为只读):
- **navigationStart**:当前浏览器窗口的前一个网页关闭,发生unload事件时的Unix毫秒时间戳。如果没有前一个网页,则等于fetchStart属性。
- **unloadEventStart**:如果前一个网页与当前网页属于同一个域名,则返回前一个网页的unload事件发生时的Unix毫秒时间戳。如果没有前一个网页,或者之前的网页跳转不是在同一个域名内,则返回值为0。
- **unloadEventEnd**:如果前一个网页与当前网页属于同一个域名,则返回前一个网页unload事件的回调函数结束时的Unix毫秒时间戳。如果没有前一个网页,或者之前的网页跳转不是在同一个域名内,则返回值为0。
- **redirectStart**:返回第一个HTTP跳转开始时的Unix毫秒时间戳。如果没有跳转,或者不是同一个域名内部的跳转,则返回值为0。
- **redirectEnd**:返回最后一个HTTP跳转结束时(即跳转回应的最后一个字节接受完成时)的Unix毫秒时间戳。如果没有跳转,或者不是同一个域名内部的跳转,则返回值为0。
- **fetchStart**:返回浏览器准备使用HTTP请求读取文档时的Unix毫秒时间戳。该事件在网页查询本地缓存之前发生。
- **domainLookupStart**:返回域名查询开始时的Unix毫秒时间戳。如果使用持久连接,或者信息是从本地缓存获取的,则返回值等同于fetchStart属性的值。
- **domainLookupEnd**:返回域名查询结束时的Unix毫秒时间戳。如果使用持久连接,或者信息是从本地缓存获取的,则返回值等同于fetchStart属性的值。
- **connectStart**:返回HTTP请求开始向服务器发送时的Unix毫秒时间戳。如果使用持久连接(persistent connection),则返回值等同于fetchStart属性的值。
- **connectEnd**:返回浏览器与服务器之间的连接建立时的Unix毫秒时间戳。如果建立的是持久连接,则返回值等同于fetchStart属性的值。连接建立指的是所有握手和认证过程全部结束。
- **secureConnectionStart**:返回浏览器与服务器开始安全链接的握手时的Unix毫秒时间戳。如果当前网页不要求安全连接,则返回0。
- **requestStart**:返回浏览器向服务器发出HTTP请求时(或开始读取本地缓存时)的Unix毫秒时间戳。
- **responseStart**:返回浏览器从服务器收到(或从本地缓存读取)第一个字节时的Unix毫秒时间戳。
- **responseEnd**:返回浏览器从服务器收到(或从本地缓存读取)最后一个字节时(如果在此之前HTTP连接已经关闭,则返回关闭时)的Unix毫秒时间戳。
- **domLoading**:返回当前网页DOM结构开始解析时(即Document.readyState属性变为“loading”、相应的readystatechange事件触发时)的Unix毫秒时间戳。
- **domInteractive**:返回当前网页DOM结构结束解析、开始加载内嵌资源时(即Document.readyState属性变为“interactive”、相应的readystatechange事件触发时)的Unix毫秒时间戳。
- **domContentLoadedEventStart**:返回当前网页DOMContentLoaded事件发生时(即DOM结构解析完毕、所有脚本开始运行时)的Unix毫秒时间戳。
- **domContentLoadedEventEnd**:返回当前网页所有需要执行的脚本执行完成时的Unix毫秒时间戳。
- **domComplete**:返回当前网页DOM结构生成时(即Document.readyState属性变为“complete”,以及相应的readystatechange事件发生时)的Unix毫秒时间戳。
- **loadEventStart**:返回当前网页load事件的回调函数开始时的Unix毫秒时间戳。如果该事件还没有发生,返回0。
- **loadEventEnd**:返回当前网页load事件的回调函数运行结束时的Unix毫秒时间戳。如果该事件还没有发生,返回0。
根据上面这些属性,可以计算出网页加载各个阶段的耗时。比如,网页加载整个过程的耗时的计算方法如下:
```javascript
var t = performance.timing;
var pageLoadTime = t.loadEventEnd - t.navigationStart;
```
## performance.now()
performance.now方法返回当前网页自从performance.timing.navigationStart到当前时间之间的微秒数(毫秒的千分之一)。也就是说,它的精度可以达到100万分之一秒。
```javascript
performance.now()
// 23493457.476999998
Date.now() - (performance.timing.navigationStart + performance.now())
// -0.64306640625
```
上面代码表示,performance.timing.navigationStart加上performance.now(),近似等于Date.now(),也就是说,Date.now()可以替代performance.now()。但是,前者返回的是毫秒,后者返回的是微秒,所以后者的精度比前者高1000倍。
通过两次调用performance.now方法,可以得到间隔的准确时间,用来衡量某种操作的耗时。
```javascript
var start = performance.now();
doTasks();
var end = performance.now();
console.log('耗时:' + (end - start) + '微秒。');
```
## performance.mark()
mark方法用于为相应的视点做标记。
```javascript
window.performance.mark('mark_fully_loaded');
```
clearMarks方法用于清除标记,如果不加参数,就表示清除所有标记。
```javascript
window.peformance.clearMarks('mark_fully_loaded');
window.performance.clearMarks();
```
## performance.getEntries()
浏览器获取网页时,会对网页中每一个对象(脚本文件、样式表、图片文件等等)发出一个HTTP请求。performance.getEntries方法以数组形式,返回这些请求的时间统计信息,有多少个请求,返回数组就会有多少个成员。
由于该方法与浏览器处理网页的过程相关,所以只能在浏览器中使用。
```javascript
window.performance.getEntries()[0]
// PerformanceResourceTiming {
// responseEnd: 4121.6200000017125,
// responseStart: 4120.0690000005125,
// requestStart: 3315.355000002455,
// ...
// }
```
上面代码返回第一个HTTP请求(即网页的HTML源码)的时间统计信息。该信息以一个高精度时间戳的对象形式返回,每个属性的单位是微秒(microsecond),即百万分之一秒。
## performance.navigation对象
除了时间信息,performance还可以提供一些用户行为信息,主要都存放在performance.navigation对象上面。
它有两个属性:
**(1)performance.navigation.type**
该属性返回一个整数值,表示网页的加载来源,可能有以下4种情况:
- **0**:网页通过点击链接、地址栏输入、表单提交、脚本操作等方式加载,相当于常数performance.navigation.TYPE_NAVIGATENEXT。
- **1**:网页通过“重新加载”按钮或者location.reload()方法加载,相当于常数performance.navigation.TYPE_RELOAD。
- **2**:网页通过“前进”或“后退”按钮加载,相当于常数performance.navigation.TYPE_BACK_FORWARD。
- **255**:任何其他来源的加载,相当于常数performance.navigation.TYPE_UNDEFINED。
**(2)performance.navigation.redirectCount**
该属性表示当前网页经过了多少次重定向跳转。
<h2 id="6.13">移动设备API</h2>
为了更好地为移动设备服务,HTML 5推出了一系列针对移动设备的API。
## Viewport
Viewport指的是网页的显示区域,也就是不借助滚动条的情况下,用户可以看到的部分网页大小,中文译为“视口”。正常情况下,viewport和浏览器的显示窗口是一样大小的。但是,在移动设备上,两者可能不是一样大小。
比如,手机浏览器的窗口宽度可能是640像素,这时viewport宽度就是640像素,但是网页宽度有950像素,正常情况下,浏览器会提供横向滚动条,让用户查看窗口容纳不下的310个像素。另一种方法则是,将viewport设成950像素,也就是说,浏览器的显示宽度还是640像素,但是网页的显示区域达到950像素,整个网页缩小了,在浏览器中可以看清楚全貌。这样一来,手机浏览器就可以看到网页在桌面浏览器上的显示效果。
viewport缩放规则,需要在HTML网页的head部分指定。
```html
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"/>
</head>
```
上面代码指定,viewport的缩放规则是,缩放到当前设备的屏幕宽度(device-width),初始缩放比例(initial-scale)为1倍,禁止用户缩放(user-scalable)。
viewport 全部属性如下。
- width: viewport宽度
- height: viewport高度
- initial-scale: 初始缩放比例
- maximum-scale: 最大缩放比例
- minimum-scale: 最小缩放比例
- user-scalable: 是否允许用户缩放
其他的例子如下。
```html
<meta name = "viewport" content = "width = 320,
initial-scale = 2.3, user-scalable = no">
```
## Geolocation API
Geolocation接口用于获取用户的地理位置。它使用的方法基于GPS或者其他机制(比如IP地址、Wifi热点、手机基站等)。
下面的方法,可以检查浏览器是否支持这个接口。
```javascript
if(navigator.geolocation) {
// 支持
} else {
// 不支持
}
```
这个API的支持情况非常好,所有浏览器都支持(包括IE 9+),所以上面的代码不是很必要。
### getCurrentPosition方法
getCurrentPosition方法,用来获取用户的地理位置。使用它需要得到用户的授权,浏览器会跳出一个对话框,询问用户是否许可当前页面获取他的地理位置。必须考虑两种情况的回调函数:一种是同意授权,另一种是拒绝授权。如果用户拒绝授权,会抛出一个错误。
```javascript
navigator.geolocation.getCurrentPosition(geoSuccess,geoError);
```
上面代码指定了处理当前地理位置的两个回调函数。
**(1)同意授权**
如果用户同意授权,就会调用geoSuccess。
```javascript
function geoSuccess(event) {
console.log(event.coords.latitude + ', ' + event.coords.longitude);
}
```
geoSuccess的参数是一个event对象。event有两个属性:timestamp和coords。timestamp属性是一个时间戳,返回获得位置信息的具体时间。coords属性指向一个对象,包含了用户的位置信息,主要是以下几个值:
- **coords.latitude**:纬度
- **coords.longitude**:经度
- **coords.accuracy**:精度
- **coords.altitude**:海拔
- **coords.altitudeAccuracy**:海拔精度(单位:米)
- **coords.heading**:以360度表示的方向
- **coords.speed**:每秒的速度(单位:米)
大多数桌面浏览器不提供上面列表的后四个值。
**(2)拒绝授权**
如果用户拒绝授权,就会调用getCurrentPosition方法指定的第二个回调函数geoError。
```javascript
function geoError(event) {
console.log("Error code " + event.code + ". " + event.message);
}
```
geoError的参数也是一个event对象。event.code属性表示错误类型,有四个值:
- **0**:未知错误,浏览器没有提示出错的原因,相当于常量event.UNKNOWN_ERROR。
- **1**:用户拒绝授权,相当于常量event.PERMISSION_DENIED。
- **2**:没有得到位置,GPS或其他定位机制无法定位,相当于常量event.POSITION_UNAVAILABLE。
- **3**:超时,GPS没有在指定时间内返回结果,相当于常量event.TIMEOUT。
**(3)设置定位行为**
getCurrentPosition方法还可以接受一个对象作为第三个参数,用来设置定位行为。
```javascript
var option = {
enableHighAccuracy : true,
timeout : Infinity,
maximumAge : 0
};
navigator.geolocation.getCurrentPosition(geoSuccess, geoError, option);
```
这个参数对象有三个成员:
- **enableHighAccuracy**:如果设为true,就要求客户端提供更精确的位置信息,这会导致更长的定位时间和更大的耗电,默认设为false。
- **Timeout**:等待客户端做出回应的最大毫秒数,默认值为Infinity(无限)。
- **maximumAge**:客户端可以使用缓存数据的最大毫秒数。如果设为0,客户端不读取缓存;如果设为infinity,客户端只读取缓存。
### watchPosition方法和clearWatch方法
watchPosition方法可以用来监听用户位置的持续改变,使用方法与getCurrentPosition方法一样。
```javascript
var watchID = navigator.geolocation.watchPosition(geoSuccess,geoError, option);
```
一旦用户位置发生变化,就会调用回调函数geoSuccess。这个回调函数的事件对象,也包含timestamp和coords属性。
watchPosition和getCurrentPosition方法的不同之处在于,前者返回一个表示符,后者什么都不返回。watchPosition方法返回的标识符,用于供clearWatch方法取消监听。
```javascript
navigator.geolocation.clearWatch(watchID);
```
## Vibration API
Vibration接口用于在浏览器中发出命令,使得设备振动。显然,这个API主要针对手机,适用场合是向用户发出提示或警告,游戏中尤其会大量使用。由于振动操作很耗电,在低电量时最好取消该操作。
使用下面的代码检查该接口是否可用。目前,只有Chrome和Firefox的Android平台最新版本支持它。
```javascript
navigator.vibrate = navigator.vibrate
|| navigator.webkitVibrate
|| navigator.mozVibrate
|| navigator.msVibrate;
if (navigator.vibrate) {
// 支持
}
```
vibrate方法可以使得设备振动,它的参数就是振动持续的毫秒数。
```javascript
navigator.vibrate(1000);
```
上面的代码使得设备振动1秒钟。
vibrate方法还可以接受一个数组作为参数,表示振动的模式。偶数位置的数组成员表示振动的毫秒数,奇数位置的数组成员表示等待的毫秒数。
```javascript
navigator.vibrate([500, 300, 100]);
```
上面代码表示,设备先振动500毫秒,然后等待300毫秒,再接着振动100毫秒。
vibrate是一个非阻塞式的操作,即手机振动的同时,JavaScript代码继续向下运行。要停止振动,只有将0毫秒或者一个空数组传入vibrate方法。
```javascript
navigator.vibrate(0);
navigator.vibrate([]);
```
如果要让振动一直持续,可以使用setInterval不断调用vibrate。
```javascript
var vibrateInterval;
function startVibrate(duration) {
navigator.vibrate(duration);
}
function stopVibrate() {
if(vibrateInterval) clearInterval(vibrateInterval);
navigator.vibrate(0);
}
function startPeristentVibrate(duration, interval) {
vibrateInterval = setInterval(function() {
startVibrate(duration);
}, interval);
}
```
## Luminosity API
Luminosity API用于屏幕亮度调节,当移动设备的亮度传感器感知外部亮度发生显著变化时,会触发devicelight事件。目前,只有Firefox部署了这个API。
```javascript
window.addEventListener('devicelight', function(event) {
console.log(event.value + 'lux');
});
```
上面代码表示,devicelight事件的回调函数,接受一个事件对象作为参数。该对象的value属性就是亮度的流明值。
这个API的一种应用是,如果亮度变强,网页可以显示黑底白字,如果亮度变弱,网页可以显示白底黑字。
```javascript
window.addEventListener('devicelight', function(e) {
var lux = e.value;
if(lux < 50) {
document.body.className = 'dim';
}
if(lux >= 50 && lux <= 1000) {
document.body.className = 'normal';
}
if(lux > 1000) {
document.body.className = 'bright';
}
});
```
CSS下一个版本的Media Query可以单独设置亮度,一旦浏览器支持,就可以用来取代Luminosity API。
```css
@media (light-level: dim) {
/* 暗光环境 */
}
@media (light-level: normal) {
/* 正常光环境 */
}
@media (light-level: washed) {
/* 明亮环境 */
}
```
## Orientation API
Orientation API用于检测手机的摆放方向(竖放或横放)。
使用下面的代码检测浏览器是否支持该API。
```javascript
if (window.DeviceOrientationEvent) {
// 支持
} else {
// 不支持
}
```
一旦设备的方向发生变化,会触发deviceorientation事件,可以对该事件指定回调函数。
```javascript
window.addEventListener("deviceorientation", callback);
```
回调函数接受一个event对象作为参数。
```javascript
function callback(event){
console.log(event.alpha);
console.log(event.beta);
console.log(event.gamma);
}
```
上面代码中,event事件对象有alpha、beta和gamma三个属性,它们分别对应手机摆放的三维倾角变化。要理解它们,就要理解手机的方向模型。当手机水平摆放时,使用三个轴标示它的空间位置:x轴代表横轴、y轴代表竖轴、z轴代表垂直轴。event对象的三个属性就对应这三根轴的旋转角度。
- alpha:表示围绕z轴的旋转,从0到360度。当设备水平摆放时,顶部指向地球的北极,alpha此时为0。
- beta:表示围绕x轴的旋转,从-180度到180度。当设备水平摆放时,beta此时为0。
- gramma:表示围绕y轴的选择,从-90到90度。当设备水平摆放时,gramma此时为0。