ThinkChat🤖让你学习和工作更高效,注册即送10W Token,即刻开启你的AI之旅 广告
<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.&#37;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。