0%

JavaScript学习笔记(21)

《JavaScript高级程序设计》第二十一章,Ajax与Comet。


Ajax指的是Asynchronous JavaScript+XML,这一技术是2005年提出的,能够向服务器请求额外的数据而无须卸载页面。Ajax的核心是XMLHttpRequest对象(简称XHR),它提供了流畅的接口。能够以异步方式从服务器取得更多信息,意味着用户单击后无须刷新页面也能取得新数据。

实际上这种技术在提出之前就已经存在了,只不过之前叫做远程脚本(remote scripting),早在1998年就有人采用不同的手段实现了这种浏览器与服务器的通信。以前是用Java applet或Flash等中间层向服务器发送请求,而XHR则将浏览器原生的通信能力提供给开发者。

Comet是Ajax的进一步扩展,让服务器几乎能够时实地向客户端推送数据。

* 虽然Ajax的命名里有XML,但Ajax通信与数据格式无关,不一定非得用XML。

Ajax

Ajax的核心是XMLHttpRequest对象,下面就介绍它。

最早引入XHR的是IE5,它内部实现是基于MSXML库中的ActiveX对象,有多种版本;IE7+和其它浏览器则支持原生的XHR对象。因此如果要考虑IE7之前的话要用new ActiveXObject(version)兼容一下,这里我就忽略一下,直接用下面这一行创建XHR对象。

1
const xhr = new XMLHttpRequest();

基本用法

XHR对象有几个方法,按操作顺序如下。

准备

open()方法用来启动请求,接收3个参数:请求类型、请求的URL、表示是否异步发送的布尔值。

1
xhr.open("get", "example.php", false);

上例会启动对“和当前执行代码页面同目录下”的example.phpGET请求(当然也可以用绝对路径,但还是有同源限制)。需要注意的是,调用open()并不会真正发送请求,而只是启动一个请求以备发送。

发送

send()方法用来发送请求,接收1个参数:要作为请求主体发送的数据(如果不发送数据则用null)。

1
xhr.send(null);

接收

由于这次请求是同步的,JavaScript代码会等到服务器响应之后再继续执行。在收到响应后,响应数据会自动填充到XHR对象里,作为它的属性,有下面这些。

  • responseText:作为响应主体被返回的文本
  • responseXML:当响应的内容类型为"text/xml""application/xml"时,此属性为包含响应数据的XML DOM文档;否则为null
  • status:响应的HTTP状态
  • statusText:HTTP状态的说明
1
2
3
4
5
6
7
8
9
10
// false表示发送同步请求
xhr.open("get", "example.txt", false);
xhr.send(null);

// 200成功,304用缓存
if((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
alert(xhr.responseText);
} else {
alert(`Request was unsuccessful: ${xhr.status}`);
}

上面的同步请求固然可行,但大多数情况下我们还是要发送异步请求,才能让JavaScript继续执行而不必等待响应。此时可以检测XHR对象的readyState属性和readystatechange事件,来查看请求/响应过程所处的阶段,并做出回应。它有几种取值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if(xhr.readystate == 4) {
if((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
alert(xhr.responseText);
} else {
alert(`Request was unsuccessful: ${xhr.status}`);
}
}
};

// true表示发送异步请求
xhr.open("get", "example.txt", true);
xhr.send(null);

取消

在接收到响应之前还可以调用abort()来取消异步请求。调用此方法后,XHR对象会停止触发事件,也不允许访问任何与响应有关的对象属性。还要注意是的,此时还应该对XHR对象进行解引用操作。

1
xhr.abort();

HTTP头部

XHR对象提供操作请求头或响应头的方法,包括发送时的设置、接收时的提取。默认情况下,包含这么些header字段:AcceptAccept-CharsetAccept-EncodingAccept-LanguageConnectionCookieHostRefererUser-Agent

使用setRequestHeader()可以设置自定义的请求头部信息,它接收2个参数:头部字段名称、头部字段值。要注意的是,需要在open()send()前调用此方法才有用。

1
2
3
xhr.open("get", "example.txt", true);
xhr.setRequestHeader("MyHeader", "MyValue");
xhr.send(null);

使用getResponseHeader()并传入头部字段名称,可以获取相应的响应头部信息。使用getAllResponseHeaders()则可以获取包含所有头部信息的字符串。

1
2
const myHeader = xhr.getResponseHeader("MyHeader");
const allHeaders = xhr.getAllResponseHeaders();

提交表单:FormData

表单功能在现代Web应用中使用非常频繁,XMLHttpRequest 2级为此定义了FormData类型,为序列化表单及创建与表单格式相同的数据(用于通过XHR传输)提供了便利。

1
2
3
4
5
6
7
8
9
10
11
12
/* 创建FormData */

// 既可以手动填入键值对
const data = new FormData();
data.append("name", "Nicholas");

// 也可以自动填入表单元素
const data = new FormData(document.forms[0]);

/* 使用XHR传输 */
xhr.open("post", "postexample.php", true);
xhr.send(data);

使用FormData的好处在于,不必手动给XHR设置请求头部,它会自动识别传入的FormData实例并配置适当的头部信息。

超时设定:timeout

XHR对象有个timeout属性,表示请求在等待响应多少毫秒之后就终止。设置数值后,如果在规定的时间内浏览器还没有接收到响应,就会触发timeout事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if(xhr.readystate == 4) {
// 放在try里是因为,超时终止请求后再访问status属性会导致错误
try {
if((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
alert(xhr.responseText);
} else {
alert(`Request was unsuccessful: ${xhr.status}`);
}
} catch(ex) {
// 假设由ontimeout事件处理程序处理
}
}
};

// true表示发送异步请求
xhr.open("get", "timeout.php", true);
// 将超时设置为1s
xhr.timeout = 1000;
xhr.ontimeout = function() {
alert("Request did not return in a second");
}
xhr.send(null);

重写MIME类型:overrideMimeType

overrideMimeType()用于重写XHR响应的MIME类型。因为返回响应的MIME类型决定了XHR对象如何处理它,但是存在一些返回类型错误的问题(比如说服务器返回的MIME类型是text/plain,而数据中实际包含的是XML,根据MIME类型,即使数据是XMLresponseXML依然是null),因此提供这种重写响应MIME的方法。

1
2
3
4
const xhr = new XMLHttpRequest();
xhr.open("get", "text.php", true);
xhr.overrideMimeType("text/xml");
xhr.send(null);

这里强迫XHR将响应当作XML而非纯文本来处理,要注意的是此方法必须在send()之前调用才能保证重写。

Comet

Comet指的是更高级的Ajax技术,也被称作服务器推送。Ajax是从页面向服务器请求数据的技术,Comet是服务器向页面推送数据的技术。有两种实现Comet的方式:长轮询和流。

轮循

传统的轮循是短轮循,即浏览器定时向服务器发送请求,看看有没有更新的数据,时间线如下:

长轮询则颠倒了一下,即页面发起一个到服务器的请求,服务器一直保持连接打开,直到有数据可发送。发送完数据后,浏览器关闭连接的同时又发起一个到服务器的新请求,时间线如下:

二者的区别是,短轮循下服务器立即发送响应,长轮询下服务器等待有数据后发送响应。轮循的优点在于所有浏览器都支持,因为使用XHR和setTimeout()就能实现。

“流”这个名称就很明显了,与轮循的区别在于,流只用一个HTTP连接。过程是,浏览器向服务器发送一个请求,服务器保持连接打开,然后周期性地向浏览器发送数据。一般的实现是,将内容打印到输出缓存,将其一次性发送出去,再刷新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
function createStreamingClient(url, progress, finished) {
// 这里先创建了一个xhr,也就是这个长连接
const xhr = new XMLHttpRequest();
// 用这个变量作为游标,从所有的响应内容中取出最新收到的那一部分
let recieved = 0;

xhr.open("get", url, true);
xhr.onreadystatechange = function() {
let result;
// readyState会周期性地变成3,此值表示已接收到部分响应数据
if(xhr.readyState == 3) {
// 从responseText提取
result = xhr.responseText.substring(recieved);
// 移动游标
recieved += result.length;
// 当接收到数据,调用传入的`progress`函数处理
progress(result);
// readyState值为4时,表示已接收到全部响应数据
} else if(xhr.readyState == 4) {
// 当关闭连接,调用传入的`finished`函数
finished(xhr.responseText);
}
};
xhr.send(null);

return xhr;
}

const client = createStreamingClient(
"streaming.php",
function(data) {
alert(`Recieved: ${data}`);
},
function(data) {
alert("Done");
}
);

SSE

背景

上面的例子比较简单,也能在大多数浏览器上运行,但管理Comet的连接是很容易出错的。为了简化,又为Comet创建了两个新的接口。

SSE即Server-Sent Events,服务器发送事件。SSE API用于创建到服务器的单向连接(那为什么叫服务器发送事件,而不叫浏览器发送事件?因为浏览器只是发起请求和创建连接,实际上发送数据的是服务器呀!)。服务器可以通过这个连接发送任意数量的数据,但仅限于MIME为text/event-stream的数据;它支持短轮循、长轮询和HTTP流;且能在断开连接的时候自动确定何时重连,比较方便。

使用EventSource

要预定新的事件流,首先要创建一个EventSource对象,并传入一个接口点。这里的事件流,创建于浏览器,表示的是来自“源”(服务器)的“事件”们。所以这个接口点也就是想要连的服务器端URL。要注意的是,这个传入的URL有同源限制。

1
const source = new EventSource("myevents.php");

EventSource对象有几个属性和方法:

  • readyState0表示正连接到服务器、1表示打开了连接、2表示关闭了连接
  • close()EventSource默认保持与服务器的连接,连接断开时候还会重新连接,因此强制断开连接并且不再重新连接需要调用此方法

EventSource对象有几个事件,它们的event里都有个data属性,保存着返回数据的字符串形式:

  • open:在建立连接时触发
  • message:在从服务器接收到新数据时触发
  • error:在无法建立连接时触发
1
2
3
4
source.onmessage = function(event) {
let data = event.data;
alert(data);
};

数据格式

要注意的有如下几点:

  • 响应的最简单情况是每个数据项都带有前缀data
  • 只有在数据行后面有空行时,才会触发message事件
  • 如果发送多行,那么接收到的数据内会有换行符
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- 假如服务器发送了3次数据如下 -->
data: foo

data: bar

data: foo
data: bar

<!-- 那么message事件的event.data依次如下 -->
"foo"

"bar"

"foo\nbar"

还可以通过id:前缀给特定的事件指定关联ID,这个ID位于data:前后均可。设置了ID后,EventSource对象会跟踪上一次触发的事件。当连接断开时,会向服务器发送带有Last-Event-ID这个HTTP头部的请求,以便服务器知道下一次该触发哪个事件。这有助于浏览器按顺序收到数据段。

1
2
data: foo
id: 1

Web Sockets

背景

Web Sockets的目标是在一个单独的持久连接上提供全双工、双向通信。

它的整个连接过程是:在浏览器中创建了Web Socket后,会有一个HTTP请求发送到服务器以创建连接;服务器收到这个请求后发回响应;浏览器收到响应后,建立的连接会从HTTP协议升级到Web Socket协议(所以URL模式为ws://wss://,对应http://https://);再相互交换信息。

使用自定义协议的优点是,数据量小,不用HTTP那样字节级的开销,适合移动应用;使用自定义协议的缺点是,制定协议的时间比指定JavaScript API的时间还长,因为不断有人发现这个协议存在一致性和安全性问题(不过这貌似也不算缺点…只能说制定过程比较曲折)。

使用WebSocket

创建WebSocket对象时要传入要连接的URL,且必须是绝对URL。还有就是,它不受同源策略的限制。

1
const socket = new WebSocket("ws://www.example.com/server.php");

WebSocket对象有几个属性(类似展开了的readyState,但它没有readystatechange事件),几个方法:

  • WebSocket.OPENING(0):正在建立连接
  • WebSocket.OPEN(1):已经建立连接
  • WebSocket.CLOSING(2):正在关闭连接
  • WebSocket.CLOSE(3):已经关闭连接
  • send():只能发送纯文本(格式化数据的话,就得序列化一下了)
  • close():关闭连接

WebSocket对象有几个事件如下,要注意的是WebSocket不支持DOM2级事件侦听器,得用DOM0级的。

  • open:在建立连接时触发
  • message:在从服务器接收到新数据时触发,event.data为接收的数据的字符串形式
  • error:在发生错误时触发
  • close:在连接关闭时触发,event有3个额外属性(wasClean为连接是否已明确地关闭的布尔值、code为服务器返回的数值状态码、reason为服务器发回的消息)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const socket = new WebSocket("ws://www.example.com/server.php");
const message = {
time: new Data(),
text: "Hello World"
};
socket.onopen = function() {
alert("Connection established");
};
socket.onmessage = function(event) {
const data = event.data;
alert(data);
};
socket.onerror = function() {
alert("Connection error");
};
socket.onclose = function(event) {
alert(`wasClean: ${event.wasClean}
code: ${event.code}
reason: ${event.reason}
`);
};
socket.send(JSON.stringify(message));

* SSE vs Web Sockets

二者的选用需要考虑两个因素。一是是否能够建立和维护Web Sockets服务器。如果不能就只能用SSE,否则才考虑选择的问题。二是是否需要双向通信。如果只需要读取数据(如获取比赛成绩),那么SSE比较容易实现;如果必须双向通信(如聊天室),那么Web Sockets更好。不过在无法选用Web Sockets的情况下,组合XHR和SSE也是可以实现双向通信的。

进度事件

Progress Events定义了与客户端服务器通信有关的事件。这些事件最早其实只针对XHR操作,后来也被其它API借鉴了。有6个进度事件:

  • loadstart:接收到响应数据的第一个字节时触发
  • progress:接收响应期间持续触发
  • error:请求发生错误时触发
  • abort:调用abort()而终止连接时触发
  • load:接收到完整的响应数据时触发
  • loadend:通信完成或触发errorabortload事件后触发

每个请求都从loadstart开始,然后一个或多个progress,然后errorabortload,最后loadend。大部分事件都很直观,下面说一下其中两个事件中需要注意的地方。

load

load事件的引入主要是为了简化异步交互模型,用来替代readystatechange事件。响应接收完毕后触发load事件,这样就不用去检测readyState属性了。这个事件对象eventtarget就是XHR对象。

1
2
3
4
5
6
7
8
9
10
11
const xhr = new XMLHttpRequest();
// 只要浏览器收到服务器的响应,不管状态如何,都会触发load事件,因此要检查status
xhr.onload = function() {
if((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
alert(xhr.responseText);
} else {
alert(`Request was unsuccessful: ${xhr.status}`);
}
};
xhr.open("get", "altevents.php", true);
xhr.send(null);

progress

这个事件就会在接收数据的时候持续触发啦。这个事件对象eventtarget也是XHR对象,但包含3个额外属性:lengthComputable(表示进度信息是否可用的布尔值)、position(已接收的字节数)、totalSize(根据Content-Length响应头部确定的预期字节数)。这些信息可以用来创建进度条。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const xhr = new XMLHttpRequest();
xhr.onload = function() {
if((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
alert(xhr.responseText);
} else {
alert(`Request was unsuccessful: ${xhr.status}`);
}
};
xhr.onprogress = function(event) {
const divStatus = document.getElementById("status");
if(event.lengthComputable) {
divStatus.innerHTML = `Recieved ${event.position} of ${event.totalSize} bytes`;
}
}

xhr.open("get", "altevents.php", true);
xhr.send(null);

跨域资源共享

通过XHR实现Ajax通信的主要限制在于,XHR对象有同源限制(相同域、端口、协议),这样可以预防某些恶意行为,不过同时也会带来阻碍。

CORS

简单请求

为了解决这个问题,W3C提出了CORS(Cross-Origin Resource Sharing)。它的基本思想在于,使用自定义的HTTP头部让浏览器与服务器进行沟通,从而决定请求或响应是应该成功还是应该失败。具体实现则是,发方附加Origin头部,收方附加Access-Control-Allow-Origin头部,内容均含协议、域名、端口(或后者可以使用"*")。如果匹配上,则允许访问;否则驳回请求。

1
2
3
4
5
<!-- 发方 -->
Origin: http://www.nczonline.net

<!-- 收方 -->
Access-Control-Allow-Origin: http://www.nczonline.net
IE的实现

微软在IE8引入XDR(XDomainRequest),它与XHR类似,但能实现安全可靠的跨域通信,它和XHR的区别在于:

  • cookie不会随请求发送,也不会随响应返回
  • 只能设置请求头中的Content-Type字段
  • 不能访问响应头,意味着没法确定响应的状态码,只能通过某些事件的触发来判断是否成功了。响应有效则触发load事件,响应失败则触发error事件,但也没额外的信息了
  • 只支持GETPOST
  • open()方法只接收2个参数(请求的类型和URL),意味着所有XDR请求都是异步的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const xdr = new XDomainRequest();
xdr.onload = function() {
alert(xdr.responseText);
};
xdr.onerror = function() {
alert("An error occured");
};
xdr.timeout = 1000;
xdr.ontimeout = function() {
alert("Request took too long");
};

// GET
xdr.open("get", "http://www.somewhere-else.com/page/");
xdr.send(null);

// POST
xdr.open("post", "http://www.somewhere-else.com/page/");
xdr.contentType = "application/x-www-form-urlencoded";
xdr.send("name1=value1&name2=value2");
其它浏览器的实现

其它浏览器都通过XMLHttpRequest对象实现了对CORS的原生支持。也就是说什么都不用做都行,只不过有一点限制:

  • 不能使用setRequestHeader()设置自定义头部
  • 不能发送和接收cookie
  • 调用getAllResponseHeaders()总会返回空字符串

预检请求

CORS通过Preflighted Requests的透明服务器验证机制,支持开发人员使用自定义头部、GET或POST之外的方法,以及不同类型的主题内容(重点:主要是为了跨域发送这些特殊请求,而让请求和响应都附加一些头部,且在第一次多发送一个OPTIONS请求)。

发送方的额外请求头为:

  • Origin:与简单请求相同
  • Access-Control-Request-Method:请求自身使用的方法
  • Access-Control-Request-Headers:可选的自定义头部,多个则用逗号分隔

服务器返回的响应则用下面这些头部与之进行沟通:

  • Access-Control-Allow-Origin:与简单请求相同
  • Access-Control-Allow-Methods:允许的方法,多个则用逗号分隔
  • Access-Control-Allow-Headers:允许的头部,多个则以逗号分隔
  • Access-Control-Max-Age:应该将Preflight请求缓存的时间(以秒表示)
1
2
3
4
5
6
7
8
9
10
<!-- 预检请求头 -->
Origin: http://www.nczonline.net
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Custom-Header

<!-- 响应头 -->
Access-Control-Allow-Origin: http://www.nczonline.net
Access-Control-Allow-Methods: POST, GET
Access-Control-Allow-Headers: Custom-Header
Access-Control-Max-Age: 1728000

预检请求结束后,会按照响应中的时间被缓存起来,也就是说第一次发请求之前会多发一个预检请求,之后只要在缓存时间内,再发送相同请求的话就不需要额外发送预检请求了。

带凭据的请求

默认的跨域请求不提供凭据(cookie、HTTP认证、客户端SSL证明等),通过将withCredentials设置为true,可以指定某个请求应该发送凭据;如果服务器接受带凭据的请求则用Access-Control-Allow-Credentials: true来响应。

图像Ping

加载图像不用考虑跨域,因此可以借用。

1
2
3
4
5
const img = new Image();
img.onload = img.onerror = function() {
alert("Done");
};
img.src = "http://www.example.com/test?name=Nicholas";

这里创建了一个Image实例,通过指定onloadonerror来获取响应通知,请求从设置src的那一刻开始。这种方法常用于跟踪用户点击页面或动态广告曝光次数,有几个主要的缺点:一是只能发送GET请求;二是无法访问服务器的响应文本。因此只能用于单向通信。

JSONP

加载脚本不用考虑跨域,因此可以借用。

这里的JSONP即JSON with padding(填充式JSON/参数式JSON),叫这个名字主要是因为它是包含着JSON数据的一个回调函数。那很显然它的组成就是:回调函数、数据,像下面这样。

1
callback({ "name": "Nicholas" });

举个通过JSONP进行跨域发送请求的例子:

1
2
3
4
5
6
7
function handleResponse(resp) {
alert(`You're at IP address ${resp.ip}, which is in ${resp.city}, ${resp.region}`);
}

const script = document.createElement("script");
script.src = "http://freegeoip.net/json/?callback=handleResponse";
document.body.insertBefore(script, document.body.firstChild);

这里通过查询字符串来指定JSONP服务的回调参数,这里就是handleResponse()方法。当设置完src并加载完资源后,就会立即执行该callback。

JSONP和图像Ping相比,优点在于能够直接访问响应文本,支持浏览器与服务器之间的双向通信。不过它仍然有不足:一是它从其它域加载代码执行,有安全隐患;二是不容易确定JSONP请求是否失败了,因为<script>元素的onerror虽然在HTML5中新增了,但目前还没得到任何浏览器支持。

安全

XHR访问URL的过程中,服务器最好要验证发送请求者是否有权访问相应的资源。否则就会有CSRF(Cross-Site Request Forgery,跨站点请求伪造),也就是未被授权的系统伪装自己去访问某个资源,让服务器认为它是合法的。

下面的做法是有效的:

  • 要求以SSL连接来访问资源
  • 要求请求要附带经过相应算法计算得到的验证码

下面的做法是无效的:

  • 要求发送POST而不是GET——很容易改变
  • 检查来源URL以确定是否可行——来源记录很容易伪造
  • 基于cookie信息进行验证——很容易伪造