张啸


世界上最快乐的事,莫过于为理想而奋斗。


JS(11) 脚本化HTTP

超文本传输协议(HyperText Transfer Protocol,HTTP)规定Web浏览器如何从Web服务器获取文档和向Web服务器提交表单内容,以及Web服务器如何响应这些请求和提交。Web浏览器会处理大量HTTP。通常,HTTP并不在脚本的控制下,只是当用户单机链接、提交表单和输入URL时才发生。

术语Ajax描述了一种主要使用脚本操纵HTTP的Web应用架构。Ajax应用的主要特点是使用脚本操纵HTTP和Web服务器进行数据交换,不会导致页面重载。避免页面重载的能力使Web应用感觉更像传统的桌面应用。Web应用可以使用Ajax技术把用户的交互数据记录到服务器中,也可以开始只显示简单页面,之后按需加载额外的数据和页面组件来提升应用的启动时间。

<img>元素无法实现完整的Ajax传输协议,因为数据交换是单向的:客户端能发送数据到服务器,但服务器的响应一直是张图片导致客户端无法轻易从中提取信息。然而,<iframe>元素更加强大,为了把<iframe>作为Ajax传输协议使用,脚本首先要把发送给Web服务器的信息编码到URL中,然后设置<iframe>的src属性为该URL。服务器能创建一个包含相应内容的HTML,并把它返回给Web浏览器,并且在<iframe>中显示它。<iframe>需要对用户不可见,例如可以使用css隐藏它。脚本能通过遍历<iframe>的文档对象来读取服务端的响应。

实际上,<script>元素的src属性能设置URL并发起HTTP GET请求。使用<script>元素实现脚本操纵HTTP是非常吸引人的,因此它们可以跨域通信而不受限于同源策略。通常,使用基于<script>的Ajax传输协议时,服务器的响应采用JSON编码的数据格式,当执行脚本时,JavaScript解析器能够自动将其“解码”。由于它使用JSON数据格式,因此这种Ajax传输协议也叫做“JSONP”。

虽然在<iframe><script>传输协议上能实现Ajax技术,但通常还有更简单的方式。一段时间以来,所有浏览器都支持XMLHttpRequest对象,它定义了用脚本操纵HTTP的API。除了常用的GET请求,这个API还包含实现POST请求的能力,同时它能用文本或Document对象的形式返回服务器的响应。虽然名字叫XMLHttpRequest API,但并没有限定只能使用XML文档,它能获取任何类型的文本文档。

Comet传输协议比Ajax更精妙,但都需要客户端和服务器之间建立(必要时重新建立)连接,同时需要服务器保持连接处于打开状态,这样它才能够发送异步信息。隐藏的<iframe>能像Comet传输协议一样有用,例如,如果服务器以<iframe>中待执行的<script>元素的形式发送每条信息。实现Comet的一种更可靠跨平台方案是客户端建立一个和服务器的连接(使用Ajax传输协议),同时服务器保持这个连接打开直到它需要推送一条消息。服务器每发送一条消息就关闭这个连接,这样可以确保客户端正确接收到消息。处理该消息之后,客户端马上为后续的消息推送建立一个新连接。

实现可靠的跨平台Comet传输协议是非常有挑战性的,所以大部分使用Comet架构的Web应用开发者依赖于像Dojo这样的Web框架库中的传输协议。

在Ajax和Comet之上构建更高级的通信协议是可行的。例如,这些客户端/服务器技术可以用RPC(Remote Procedure call,远程过程调用)机制或发布/订阅事件系统的基础。


一、使用XMLHttpRequest

浏览器在XMLHttpRequest类上定义了它们的HTTP API。这个类的每个实例都表示一个独立的请求/响应对,并且这个对象的属性和方法允许指定请求细节和提取响应数据。

1
var request = new XMLHttpRequest();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 在IE5和IE6中模拟XMLHttpRequest()构造函数
if (window.XMLHttpRequest === undefined) {
window.XMLHttpRequest = function() {
try {
return new ActiveXObject('Msxml2.XMLHTTP.6.0');
}
catch (e1) {
try {
return new ActiveXObject('Msxml2.XMLHTTP.3.0');
}
catch (e2) {
throw new Error('XMLHttpRequest is not supported!');
}
}
}
}

一个HTTP请求由4部分组成:

  • HTTP请求方法或动作(verb)

  • 正在请求的URL

  • 一个可选的请求头集合,其中可能包括身份验证信息

  • 一个可选的请求主体

服务器返回的HTTP响应包含3部分:

  • 一个数字和文字组成的状态码,用来显示请求的成功和失败

  • 一个响应头集合

  • 响应主体

HTTP的基础请求/相应架构非常简单并且易于使用。但在实践中会有各种各样随之而来的复杂问题:客户端和服务器交换cookie,服务器重定向浏览器到其他服务器,缓存某些资源而剩下的不换村,某些客户端通过代理服务器发送所有的请求等。XMLHttpRequest不是协议级的HTTP API而是浏览器级的API。浏览器需要考虑cookie、重定向、缓存和代理,但代码只需要关心请求和响应。

1. 指定请求

创建XMLHttpRequest对象之后,发起HTTP请求的下一步是调用XMLHttpRequest对象的open()方法去指定这个请求的两个必须部分:方法和URL。

1
2
request.open('GET',                     // 开始一个HTTP GET请求
'data.csv'); // URL的内容

open()的第一个参数指定HTTP方法或动作。这个字符串不区分大小写,但通常大家用大写字母来匹配HTTP协议。“GET”和“POST”方法是得到广泛支持的。“GET”用于常规请求,它适用于当URL完全制定请求资源,当请求对服务器没有任何副作用以及当服务器的响应是可缓存时。“POST”方法常用于HTML表单。它在请求主体中包含额外数据(表单数据)且这些数据常存储到服务器上的数据库中(副作用)。相同URL的重复POST请求从服务器得到的响应可能不同,同时不应该缓存使用这个方法的请求。

除了“GET”和“POST”之外,XMLHttpRequest规范也允许把“DELETE”、“HEAD”、“OPTIONS”和“PUT”作为open()的第一个参数。旧浏览器并不支持所有这些方法,但至少“HEAD”得到广泛支持。

open()的第二个参数是URL,它是请求的主题。这时相对于文档的URL,这个文档包含调用open()的脚本。如果只i的那个绝对URL、协议、主机和端口通常必须匹配所在文档的对应内容:跨域的请求通常会报错。

如果有请求头的话,请求进程的下个步骤就是设置它。

1
request.setRequestHeader('Content-Type', 'text/plain');

如果对相同的头调用setRequestHeader()多次,新值不会取代之前指定的值,相反,HTTP请求将包含这个头的多个副本或这个头将指定多个值。

我们不能自己指定“Content-Length”、“Date”、“Referer”或“User-Agent”头,XMLHttpRequest将自动添加这些头而防止伪造它们。类似地,XMLHttpRequest对象自动处理cookie、连接时间、字符集和编码判断,所以我们无法向setRequestHeader()传递这些头信息:

1
2
3
4
5
6
Accept-Charset      Content-Transfer-Encoding       TE
Accept-Encoding Date Trailer
Connection Expect Transfer-Encoding
Content-Length Host Upgrade
Cookie Keep-Alive User-Agent
Cookie2 Referer Via

我们能为请求指定“Authorization”头,但通常不需要这么做。如果请求一个受密码保护的URL,把用户名和密码作为第4个和第5个参数传递给open(),则XMLHttpRequest将设置合适的头。

使用XMLHttpRequest发起HTTP请求的最后一步是指定可选的请求主体并向服务器发送它。使用send()方法像如下这样做:

1
request.send(null);

GET请求绝对没有主体,所以应该传递null或省略这个参数。POST请求通常拥有主体,同时它应该匹配使用setRequestHeader()指定的“Content-Type”头。

顺序问题

HTTP请求的各部分有指定顺序:请求方法和URL首先到达,然后是请求头,最后是请求主体。XMLHttpRequest实现通常直到调用send()方法才开始启动网络。但XMLHttpRequest API的设计似乎使每个方法都将写入网络流。这意味着调用XMLHttpRequest方法的顺序必须匹配HTTP请求的架构。例如,setRequestHeadr()方法的调用必须在调用open()之后且在调用send()之前,否则它将抛出异常。

下面使用了我们目前介绍的所有XMLHttpRequest方法。它用POST方法发送文本字符串给服务器,并忽略服务器返回的任何响应。

1
2
3
4
5
6
function postMessage(msg) {
var request = new XMLHttpRequest();
request.open('POST', '/log.php');
request.setRequestHeader('Content-Type', 'text/plain;charset=UTF-8');
request.send(msg);
}

2. 取得响应

一个完整的HTTP响应由状态码、响应头集合和响应主体组成。这些都可以通过XMLHttpRequest对象的属性和方法使用:

  • status和statusText属性以数字和文本的形式返回HTTP状态码。这些属性保存标准的HTTP值,像200和“OK”表示成功请求,404和“Not Found”表示URL不能匹配服务器上的任何资源。

  • 使用getResponseHeader()getAllResponseHeaders()能查询响应头。XMLHttpRequest会自动处理cookie:它会从getAllResponseHeaders()头返回集合中过滤掉cookie头,而如果给getResponseHeader()传递“Set-Cookie”和“Set-Cookie2”则返回null。

  • 响应主体可以从responseText属性中得到文本形式的,从responseXML属性中得到Document形式的。

XMLHttpRequest对象通常异步使用:发送请求后,send()方法立即返回,直接响应返回,前面列出的相应方法和属性才有效。为了在响应准备就绪时得到通知,必须监听XMLHttpRequest对象上的readystatechange事件。但是为了理解这个事件类型,我们必须理解readyState属性。

readyState是一个整数,它指定了HTTP请求的状态,下表列出了它可能的值。

常量 含义
UNSENT 0 open()尚未调用
OPENED 1 open()已调用
HEADERS_RECEIVED 2 接收到头信息
LOADING 3 接收到响应主体
DONE 4 响应完成

第一列的符号是XMLHttpRequest构造函数定义的常量。这些常量是XMLHttpRequest规范的一部分,但老的浏览器和IE8没有定义它们,通常看到使用硬编码值4来表示XMLHttpRequest.DONE。

理论上,每次readyState属性改变都会触发readystatechange事件。实际中,当readyState改变为0或1时可能没有触发这个事件。当调用send()时,即使readyState仍处于OPENED状态,也通常触发它。某些浏览器在LOADING状态时能触发多次事件来给出进度反馈。当readyState值改变为4或服务器的响应完成时,所有的浏览器都触发readystatechange事件。因为在响应完成之前也会触发事件,所以事件处理程序应该一直检验readyState值。

为了监听readystatechange事件,请把事件处理函数设置为XMLHttpRequest对象的onreadystatechange属性。也能用addEventListener(),但通常每个请求只需要一个处理程序,所以只设置onreadystatechange更容易。

下面定义了getText()函数来演示如何监听readystatechange事件。事件处理程序首先要确保请求完成。如果这样,它会检查响应状态码来确保请求成功。然后它查找“Content-Type”头来验证响应主体是否时期望的类型。如果3个条件都得到满足,它会把响应主体(以文本形式)发送给指定的回调函数。

1
2
3
4
5
6
7
8
9
10
11
12
function getText(url, callback) {
var request = new XMLHttpRequest();
request.open('GET', url);
request.onreadystatechange = function() {
if (request.readyState == 4 && request.status == 200) {
var type = request.getResponseHeader('Content-Type');
if (type.match(/^text/))
callback(request.responseText);
}
};
request.send(null);
}
  • 1) 同步响应

    由于其本身的性质,异步处理HTTP响应是最好的方式。然而,XMLHttpRequest也支持同步响应。如果把false作为第三个参数传给open(),那么send()方法将阻塞直到请求完成。在这种情况下,不需要使用事件处理程序:一旦send()返回,仅需要检查XMLHttpRequest对象的status和responseText属性。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    function getTextSync(url) {
    var request = new XMLHttpRequest();
    request.open("GET", url, false);
    request.send(null);

    if (request.status != 200)
    throw new Error(request.statusText);

    var type = request.getResponseHeader('Content-Type');

    if (!type.match(/^text/))
    throw new Error('Expected textual response; got: ' + type);

    return request.responseText;
    }

    同步请求是吸引人的,但应该避免使用它们。客户端JavaScript是单线程的,当send()方法阻塞时,它通常会导致整个浏览器UI冻结。如果连接的服务器响应慢,那么用户的浏览器将冻结。

  • 2) 响应解码

    在前面的示例中,我们假设服务器使用像“text/plain”、“text/html”、“text/css”这样的MIME类型发送文本响应,然后我们使用XMLHttpRequest对象的responseText属性得到它。

    还可以通过其他方式来处理服务器的响应。如果服务器发送XML或XHTML文档作为其响应,我们可以通过responseXML属性获得一个解析形式的XML文旦。这个属性的值是一个Document对象。

    如果服务器向发送诸如对象或数组这样的结构化数据作为其响应,它应该传输JSON编码的字符串数据。当接收它时,可以把responseText属性传递给JSON.parse()

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    function get(url, callback) {
    var request = new XMLHttpRequest();
    request.open('GET', url);
    request.onreadystatechange = function() {
    if (request.readyState == 4 && request.status == 200) {
    var type = request.getResponseHeader('Content-Type');
    if (type.indexOf('xml') !== -1 && request.responseXML)
    callback(request.responseXML);
    else if (type == 'application/json')
    callback(JSON.parse(request.responseText));
    else
    callback(request.responseText);
    }
    };
    request.send(null);
    }

    示例中检查该响应的“Content-Type”头且专门处理“application/json”影响。我们可能希望特殊编码的另一个响应类型是“application/javascript”或“text/javascript”。我们能使用XMLHttpRequest请求JavaScript脚本,然后使用全局eval()执行这个脚本。但是,在这种情况下不需要使用XMLHttpRequest对象,因为<script>元素本身操纵HTTP脚本的能力完全可以加载并执行脚本。且记住<script>元素能发起跨域HTTP请求,而XMLHttpRequest API则禁止。

    Web服务端通常使用二进制数据响应HTTP请求。responseText属性只能用于文本(比如图片文件),且它不能妥善处理二进制响应,即使对最终字符串使用了charCodeAt()。XHR2定义了处理二进制响应的方法,本文不详细介绍该方法。

    服务器响应的正常解码是假设服务器为这个响应发送了“Content-Type”头和正确的MIME类型。假设我们将下载XML文件,而我们计划把它当作纯文本对待。可以使用setOverrideMimeType()让XMLHttpRequest知道它不需要把文件解析成XML文档

    1
    request.overrideMimeType('text/plain; charset=utf-8');

3. 编码请求主体

HTTP POST请求包括一个请求主体,它包含客户端传递给服务器的数据。

  • 1) 表单编码的请求

    默认情况下,HTML表单通过POST方法发送给服务器,而编码后的表单数据则用做请求主体。对表单数据使用的编码方案相对简单:对每个表单元素的名字和值执行普通的URL编码(使用十六进制转义码替换特殊字符),使用等号把编码后的名字和值分开,并使用“&”符号分开名值对。一个简单表单的编码如下所示

    1
    find=pizza&zipcode=01234&radius=1km

    表单数据编码格式有一个正式的MIME类型

    1
    application/x-www-form-urlencoded

    当使用POST方法提交这种顺序的表单数据时,必须设置“Content-Type”请求头为这个值。

    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
    38
    39
    40
    41
    function encodeFormData(data) {
    if (!data)
    return '';

    var pairs = [];
    for(var name in data) {
    if (!data.hasOwnProperty(name))
    continue;

    if (typeof data[name] === 'function')
    continue;

    var value = data[name].toString();
    name = encodeURIComponent(name.replace('%20', '+'));
    value = encodeURIComponent(value.replace('%20', '+'));
    pairs.push(name + '=' + value);
    }

    return pairs.join('&');
    }

    function postData(url, data, callback) {
    var request = new XMLHttpRequest();
    request.open('OPEN', url);
    request.onreadystatechange = function() {
    if (request.readyState === 4 && callback)
    callback(request);
    };
    request.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
    request.send(encodeFormData(data));
    }

    function getData(url, data, callback) {
    var request = new XMLHttpRequest();
    request.open('GET', url + '?' + encodeFormData(data));
    request.onreadystatechange = function() {
    if (request.readyState === 4 && callback)
    callback(request);
    };
    request.send(null);
    }
  • 2) JSON编码的请求

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    function postJSON(url, data, callback) {
    var request = new XMLHttpRequest();
    request.open('POST', url);
    request.onreadystatechange = function() {
    if (request.readyState === 4 && callback)
    callback(request);
    };
    request.setRequestHeader('Content-Type', 'application/json');
    request.send(JSON.stringigy(data));
    }
  • 3) XML编码的请求

    XML有时也用于数据传输的编码。JavaScript对象的用表单编码或JSON编码版本表达的pizza查询,也能用XML文档来表示它。

    1
    2
    3
    4
    5
    <query>
    <find zipcode="01234" radius="1km">
    pizza
    </find>
    </query>

    在目前展示的所有示例中,XMLHttpRequest的send()方法的参数是一个字符串或null。实际上,这里可以传入XML Document对象。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    function postQuery(url, what, where, radius, callback) {
    var request = new XMLHttpRequest();
    request.open('POST', url);
    request.onreadystatechange = function() {
    if (request.readyState === 4 && callback)
    callback(request);
    };
    var doc = document.implementation.createDocument('', 'query', null);
    var query = doc.documentElement;
    var find = doc.createElement('find');
    query.appendChild(find);
    find.setAttribute('zipcode', where);
    find.setAttribute('radius', radius);
    find.appendChild(doc.createTextNode(what));
    request.send(doc);
    }

    当给send()方法传入XML文档时,并没有预先指定“Content-Type”头,但XMLHttpRequest对象会自动设置一个合适的头。类似地,如果给send()传入一个字符串但没有指定“Content-Type”头,那么XMLHttpRequest会添加“ext/plain;charset=utf-8”头。

  • 4) 上传文件

    HTML表单的特性之一是当用户通过<input type="file">元素选择文件时,表单将在它产生的POST请求主体中发送文件内容。HTML表单始终能上传文件,但它还不能使用XMLHttpRequest API做相同的事情。然后,XHR2 API允许通过向send()方法传入File对象来实现上传文件。

    没有File()对象构造函数,脚本仅能获得表示用户当前选择文件的File对象。在支持File对象的浏览器中,每个<input type="file">元素有一个files属性,它是File对象中的类数组对象。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    whenReady(function() {
    var elts = document.getElementsByTagName('input');
    for (var i = 0; i < elts.length; i++) {
    var input = elts[i];
    if (input.type != 'file')
    continue;
    var url = input.getAttribute('data-uploadto');
    if (!url)
    continue;

    input.addEventListener('change', function() {
    var file = this.files[0];
    if (!file)
    return;
    var xhr = new XMLHttpRequest();
    xhr.open('POST', url);
    xhr.send(file);
    }, false);
    }
    })

    文件类型是更通用的二进制大对象(Blob)类型中的一个子类型。XHR2允许向send()方法传入任何Blob对象。如果没有显式设置Content-Type头,这个Blob对象的type属性用于设置待上传的Content-Type头。如果需要上传已经产生的二进制数据,可以把数据转化为Blob并将其作为请求主体。

  • 5) multipart/form-data请求

    当HTML表单同时包含文件上传元素和其他元素时,浏览器不能使用普通的表单编码而必须使用称为“multipart/form-data”的特殊Content-Type来用POST方法提交表单。这种编码包括使用长“边界”字符串把请求主体分离成多个部分。对于文本数据,手动创建”multipart/form-data“请求主体是可能的,但很复杂。

    XHR2定义了新的FormData API,它容易实现多部分请求主体。首先,使用FormData()构造函数创建FormData对象,然后按需多次调用这个对象的append()方法把个体“部分”(可以是字符串、File或Blob对象)添加到请求中。最后,把FormData对象传递给send()方法。send()方法将对请求定义合适的边界字符串和设置“Content-Type”头。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    function postFormData(url, data, callback) {
    if (typeof FormData === 'undefined')
    throw new Error('FormData is not implemented');

    var request = new XMLHttpRequest();
    request.open('POST', url);
    request.onreadystatechange = function() {
    if (request.readyState === 4 && callback)
    callback(request);
    };
    var formdata = new FormData();
    for (var name in data) {
    if (!data.hasOwnProperty(name))
    continue;
    var value = data[name];
    if (typeof value === 'function')
    continue;

    formdata.append(name, value);
    }
    request.send(formdata);
    }

4. HTTP进度事件

在之前的示例中,使用readystatechange事件探测HTTP请求的完成。XHR2规范草案定义了更多有用的事件集,在这个新的事件模型中,XMLHttpRequest对象在请求的不同阶段触发不同类型的事件,所以它不需要再检查readyState属性。

在支持它们的浏览器中,这些新事件会像如下这样触发。当调用send()时,触发单个loadstart事件。当正在加载服务器响应时,XMLHttpRequest对象会发生progress事件,通常每隔50毫秒左右,所以可以使用这些事件给用户反馈请求的进度。如果请求快速完成,它可能从不会触发progress事件。当事件完成,会触发load事件。

一个完成的请求不一定是成功的请求,例如,load事件的处理程序应该检查XMLHttpRequest对象的status状态码来确定收到的是“200 OK”而不是“404 Not Found”的HTTP响应。

HTTP请求无法完成有3种情况,对应3种事件。如果请求超时,会触发timeout事件。如果请求中止,会触发abort事件。最后,像太多重定向这样的网络错误会阻止请求完成,但这些情况发生时会触发error事件。

对于任何具体请求,浏览器将只会触发load、abort、timeout和error事件中的一个。XHR2规范草案指出一旦这些事件中的一个发生后,浏览器应该触发loadend事件。

可以通过XMLHttpRequest对象的addEventListener()方法为这些progress事件中的每个都注册处理程序。如果每种事件只有一个事件处理程序,通常更容易的方法是只设置对应的处理程序属性,比如onprogress和onload。甚至可以使用这些属性是否存在来测试浏览器是否支持progress事件

1
2
3
if ('onprogress' in (new XMLHttpRequest())) {
// 支持progress事件
}

除了像type和timestamp这样常用的Event对象属性外,与这些progress事件相关联的事件对象还有3个有用的属性。loaded属性是目前传输的字节数值。total属性是自“Content-Length”头传输的数据的整体长度(单位是字节),如果不知道内容长度则为0。最后,如果直到内容长度则lengthComputable属性为true,否则为false。显然,total和loaded属性对progress事件处理程序相当有用

1
2
3
4
request.onprogress = function(e) {
if (e.lengthComputable)
progress.innerHTML = Math.round(100 * e.loaded / e.total) + '% Complete';
}

5. 上传进度事件

除了为监控HTTP响应的加载定义的这些有用的事件外,XHR2也给出了用于监控HTTP请求上传的事件。在实现这些特性的浏览器中,XMLHttpRequest对象将有upload属性。upload属性值是一个对象,它定义了addEventListener()方法和整个progress事件集合,比如onprogress和onload。(但upload对象没有定义onreadystatechange属性,upload仅能触发新的事件类型。)

我们能仅仅像使用常见的progress事件处理程序一样使用upload事件处理程序。对于XMLHttpRequest对象x,设置x.onprogress以监控响应的下载进度,并且设置x.upload.onprogress以监控请求的上传进度。

下面我们延时如何使用upload progress事件把上传进度反馈给用户。这个示例也演示了如何从拖放API中获得File对象和如何使用FormData API在单个XMLHttpRequest请求中上传多个文件。

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
whenReady(function() {
var elts = document.getElementsByClassName('fileDropTarget');
for(var i = 0; i < elts.length; i++) {
var target = elts[i];
var url = target.getAttribute('data-uploadto');
if (!url)
continue;
createFileUploadDropTarget(target, url);
}

function createFileUploadDropTarget(target, url) {
var uploading = false;

console.log(target, url);

target.ondragenter = function(e) {
console.log('dragenter');

if (uploading)
return;

var types = e.dataTransfer.types;
if (types &&
(types.contains && types.contains('Files')) ||
(types.indexOf && types.indexOf('Files') !== -1)) {
target.classList.add('wantdrop');
return false;
}
};

target.ondragover = function(e) {
if (!uploading)
return false;
};

target.ondragleave = function(e) {
if (!uploading)
target.classList.remove('wantdrop');
};

target.ondrop = function(e) {
if (uploading)
return false;

var files = e.dataTransfer.files;
if (files && files.length) {
uploading = true;
var message = "Uploading files:<ul>";
for (var i = 0; i < files.length; i++)
message += '<li>' + files[i].name + '</li>';
message += '</ul>';

target.innerHTML = message;
target.classList.remove('wantdrop');
target.classList.add('uploading');

var xhr = new XMLHttpRequest();
xhr.open('POST', url);
var body = new FormData();
for (var i = 0; i < file.length; i++)
body.append(i, files[i]);
xhr.upload.onprogress = function(e) {
if (e.lengthComputable) {
target.innerHTML = message + Math.round(e.loaded / e.total * 100) + '% COmplete';
}
};

xhr.upload.onload = function(e) {
uploading = false;
target.classList.remove('uploading');
target.innerHTML = 'Drop files to upload';
};

xhr.send(body);

return false;
}

target.classList.remove('wantdrop');
}
}
});

6. 中止请求和超时

可以通过调用XMLHttpRequest对象的abort()方法来取消正在进行的HTTP请求。abort()方法在所有的XMLHttpRequest版本和XHR2中可用,调用abort()方法在这个对象上触发abort事件,可以通过XMLHttpRequest对象的onabort属性来判断是否存在。

调用abort()的主要原因是完成取消或超时请求消耗的时间太长或当响应变得无关时。假设使用XMLHttpRequest为文本输入域请求自动完成推荐。如ugoyonghu在服务器的建议达到之前输入了新字符,这时等待请求不再有用,应该中止。

XHR2定义了timeout属性来指定请求自动中止后的毫秒数,也定义了timeout事件用于当超时发生时触发(不是abort事件)。可以用setTimeout()abort()方法实现自己的超时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function timedGetText(url, timeout, callback) {
var request = new XMLHttpRequest();
var timedout = false;
var timer = setTimeout(function() {
timedout = true;
request.abort();
}, timeout);
request.open('GET', url);
request.onreadystatechange = function() {
if (request.readystate !== 4)
return;
if (timedout)
return;
clearTimeout(timer);
if (request.status == 200)
callback(request.responseText);
};
request.send(null);
}

7. 跨域HTTP请求

作为同源策略的一部分,XMLHttpRequest对象通常仅可以发起和文档具有相同服务器的HTTP请求。这个限制关闭了安全漏洞,但它笨手笨脚并且也阻止了大量合适使用的跨域请求。可以在<form><iframe>元素中使用跨域URL,而浏览器显示最终的跨域文档。但因为同源策略,浏览器不允许原始脚本查找跨域文档的内容。使用XMLHttpRequest,文档内容都是通过responseText属性暴露,所以同源策略不允许XMLHttpRequest进行跨域请求。(注意<script>元素并未真正受限于同源策略:它加载并执行任何来源的脚本。)

XHR2通过在HTTP相应中选择发送合适的CORS(Cross-Origin Resource Sharing,跨域资源共享)允许跨域访问网站。作为Web程序员,使用这个功能并不需要做什么额外的工作:如果浏览器支持XMLHttpRequest的CORS且实现跨域请求的网站决定使用CORS允许跨域请求,那么同源策略将会放宽而跨域请求就会正常工作。

虽然实现CORS支持的跨域请求工作不需要做任何事情,但有些安全细节需要了解。首先,如果给XMLHttpRequest的open()方法传入用户名和密码,那么它们绝对不会通过跨域请求发送(这使分布式密码破解攻击成为可能)。除外,跨域请求通常也不会包含其他任何的用户证书:cookie和HTTP身份令牌(token)通常不会作为请求的内容部分发送且任何作为跨域响应来接收的cookie都会丢弃。如果跨域请求需要这几种凭证才能成功,那么必须在用send()发送请求前设置XMLHttpRequest的withCredentials属性为true。这样做不常见,但测试withCredentials的存在性是测试浏览器是否支持CORS的一种方法。

下面示例使用XMLHttpRequest实现HTTP HEAD请求以下载文档中<a>元素链接资源的类型、大小和时间等信息。这个HEAD请求按需发起,且由此产生的链接信息会出现在工具提示中。这个示例假设跨域链接的信息不可用,但通过支持CORS的浏览器尝试下载它。

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
whenReady(function() {
var supportsCORS = (new XMLHttpRequest()).withCredentials !== undefined;

var links = document.getElementsByTagName('a');
for(var i = 0; i < links.length; i++) {
var link = links[i];
if (!link.href)
// 跳过没有超链接的锚点
continue;
if (link.title)
// 跳过已经有工具提示的链接
continue;
if (link.host !== location.host || link.protocol !== location.protocol) {
// 如果这是一个跨域链接
link.title = '站外链接';
if (!supportsCORS)
// 如果没有CORS支持就退出
continue;
// 否则,我们能了解这个链接的更多信息
// 所以继续前进,注册事件处理程序,于是我们可以尝试
}

if (link.addEventListener)
link.addEventListener('mouseover', mouseoverHandler, false);
else
link.attachEvent('onmouseover', mouseoverHandler);
}

function mouseoverHandler(e) {
var link = e.target || e.srcElement;
var url = link.href;

var req = new XMLHttpRequest();
req.open('HEAD', url);
req.onreadystatechange = function() {
if (req.readyState !== 4)
return;
if (req.status === 200) {
var type = req.getResponseHeader('Content-Type');
var size = req.getResponseHeader('Content-Length');
var date = req.getResponseHeader('Last-Modified');

link.title = '类型:' + type + ' \n' + '大小:' + size + ' \n' + '时间:' + date;
} else {
if (!link.title)
link.title = "Couldn't fetch details:\n" + req.status + ' ' + req.statusText;
}
};
req.send(null);

if (link.removeEventListener)
link.removeEventListener('mouseover', mouseoverHandler, false);
else
link.detachEvent('onmouseover', mouseoverHandler);
}
});

二、借助script发送HTTP请求:JSONP

本文概述提到过<script>元素可以作为一种Ajax传输机制:只须设置<script>元素的src属性(假如它还没插入到document中,需要插入进去),然后浏览器就会发送一个HTTP请求以下载src属性所指向的URL。使用<script>元素进行Ajax传入的一个主要原因是,它不受同源策略的影响,因此可以使用它们从其他的服务器请求数据,第二个原因是包含JSON编码数据的响应体会自动解码执行。

脚本和安全性

为了使用<script>元素进行Ajax传入,必须允许Web页面可以执行远程服务器发送过来的任何JavaScript代码。这意味着对于不可信的服务器,不应该采取该技术。当与可信的服务器通信时,要提防攻击者可能进入服务器中,然后黑客会接管你的网页,运行他自己的代码,并显示任何他想要的内容,还表现的就像这些内容本就来自你的网站。

需要注意的是,这种方式普遍用于可信的第三方脚本,特别是在页面中嵌入广告和“组件”。作为Ajax传输使用的<script>与可信的Web服务通信,没有比这更危险的了。

这种使用<script>元素作为Ajax传入的技术称为JSONP,若HTTP请求所得到的响应数据是经过JSON编码的,则适合使用该技术。P代表“填充”或“前缀”。

假设我们已经写了一个服务,它处理GET请求并返回JSON编码的数据,同源的文档可以在代码中使用XMLHttpRequest和JSON.parse()。加入在服务器上启用了CORS,在新的浏览器上,跨域的文档也可以使用XMLHttpRequest享受到该服务。在不支持CORS的旧浏览器上,跨域文档只能通过<script>元素访问这个服务。使用JSONP,JSON响应数据是合法的JavaScript代码,当它到达时浏览器将执行它。相反,不使用JSONP,而是对JSON编码过的数据解码,结果还是数据,并没有做任何事情。

这就是JSONP中P的意义所在。当通过<script>元素调用数据时,响应内容必须用JavaScript函数名和圆括号包裹起来。而不是发送这样一段JSON数据

1
[1, 2, {"buckle": "my shoe"}]

它会发送这样一个包裹后的JSON响应:

1
2
3
handleResponse(
[1, 2, {"buckle": "my shoe"}]
)

包裹后的响应会成为<script>元素的内容,它先判断JSON编码后的数据,然后把它传递给handleResponse()函数,我们可以假设,文档会拿这些数据做一些有用的事情。

为了可行起见,我们必须通过某种方式告诉服务,它正在从一个<script>元素调用,必须返回一个JSONP响应,而不应该是普通的JSON响应。这个可以通过在URL中添加一个查询参数来实现:例如,追加“?json”。

在实践中,支持JSONP的服务不会强制指定客户端必须实现的回调函数名称,比如handleResponse。想法,它们使用查询参数的值,允许客户端指定一个函数名,然后使用函数名去填充响应。下面代码使用了一个名为jsonp的查询参数来指定回调函数的名称。许多支持JSONP的服务都能分辨出这个参数名。另一个常见的参数名称是callback,为了让使用到的服务支持类似特殊的需求,就需要在代码上做一些修改了。

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
function getJSONP(url, callback) {
var cbnum = 'cb' + getJSONP.count++;
var cbname = 'getJSONP.' + cbnum;

if (url.indexOf('?') === -1)
url += '?jsonp=' + cbname;
else
url += '&jsonp=' + cbname;

var script = document.createElement('script');

getJSONP[cbnum] = function(response) {
try {
callback(response);
}
finally {
delete getJSONP[cbnum];
script.parentNode.removeChild(script);
}
};

script.src = url;
document.body.appendChild(script);
}

getJSONP.counter = 0;

三、基于服务器端推送事件的Comet技术

在服务器端推送事件的标准草案中定义了一个EventSource对象,简化了Comet应用程序的编写可以传递一个URL给EventSource()构造函数,然后再返回的实例上监听消息事件。

1
2
3
4
5
6
var ticker = new EventSource('stockprices.php');
ticker.onmessage = function(e) {
var type = e.type;
var data = e.data;
// 现在处理事件类型和事件的字符串数据
};

与message事件关联的事件对象有一个data属性,这个属性保存服务器作为该事件的负载发送的任何字符串。如同其他类型的事件一样,该对象还有一个type属性,默认值是message,事件源可以修改这个值。onmessage事件处理程序接收从一个给定的服务器事件源发出的所有事件,如果有必要,也可以根据type属性派发一个事件。

服务器端推送事件的协议很简单。客户端(创建一个EventSource对象时会)建立一个到服务器的链接,服务器保持这个连接处于打开状态。当发生一个事件时,服务器端在连接中写入几行文本,抛给客户端的事件可能看起来是这样

1
2
3
4
event: bid              // 设置时间对象的类型
data: GOOG // 设置data属性
daa: 999 // 追加新的一行和更多的数据
// 一个空行会触发消息事件

该协议还有一些额外的细节,比如允许事件携带给定ID,然后再次连上的客户端高速服务器它受到的最后一个事件的ID,这样服务器就可以重新发送客户端错过的事件。

Comet架构的一个常见应用是聊天应用,聊天客户端可以通过XMLHttpRequest向聊天室发送新的消息,也可以通过EventSource对象订阅聊天信息。

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
<script>
// 一个使用EventSource的简易聊天客户端
window.onload = function() {
// 注意一些UI细节
var nick = prompt('Enter your nickname'); // 获取用户昵称
var input = document.getElementById('input'); // 找出input表单元素
input.focus(); // 设置键盘焦点

// 通过EventSource注册新消息的通知
var chat = new EventSource('/chat');
chat.onmessage = function(event) { // 当捕获一条消息时
var msg = event.data; // 从事件对象中取得文本数据
var node = document.createTextNode(msg); // 把它放入一个文本节点
var div = document.createElement('div'); // 创建一个div
div.appendChild(node); // 将文本节点插入div中
document.body.insertBefore(div, input); // 将div插入input之前
input.scrollIntoView(); // 保证input元素可见
};

// 使用XMLHttpRequest把用户的消息发送给服务器
input.onchange = function() { // 用户完成输入
var msg = nick + ': ' + input.value; // 组合用户名和用户输入的信息
var xhr = new XMLHttpRequest();
xhr.open('POST', '/chat'); // 发送到/chat
xhr.setRequestHeader('Content-Type', 'text/plain;charset=UTF-8');
xhr.send(msg);
input.value = ''; // 准备下次输入
}
}
</script>
<input id="input" style="width:100%">

我们通过一个服务器示例结束Comet架构的探讨。下面示例展示了一个用NodeJS编写的定制HTTP服务器。当一个客户端请求根URL为“/”时,它会把上面示例展示的聊天客户端代码发送到客户端。当客户端创建了一个指向“/chat”的GET请求时,它会用一个数组来保存响应数据流并保持连接处于打开状态。当客户端发起针对“chat”的POST请求时,它会将响应的主体部分作为一条聊天消息使用并写入数据,以“data:”作为Server-Sent Events的前缀,添加到每个已打开的响应数据流上。

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
// 定制的Server-Sent Events聊天服务器
var http = require('http');

var clientui = require('fs').readFileSync('chatclient.html');

// ServerResponse对象数组,用于接收发送的事件
var clients = [];

// 每20秒发送一条注释到客户端
// 这样它们就不会关闭连接再重连
setInterval(function() {
clients.forEach(function(client) {
client.write(':ping?n');
});
}, 20000);

// 创建一个新服务器
var server = new http.Server();

// 当服务器获取到一个新的请求,运行回调函数
server.on('request', function(request, response) {
// 解析请求的URL
var url = require('url').parse(request.url);

// 如果请求是发送到“/”,服务器就发送客户端聊天室UI
if (url.pathname === '/') {
response.writeHead(200, {"Content-Type": "text/html"});
response.write(clientui);
response.end();
return;
}
// 如果请求是发送到“/chat”之外的地址,则返回404
else if (url.pathname !== '/chat') {
response.writeHead(404);
response.end();
return;
}

// 如果请求类型是post,那么就有一个客户端发送了一条新的消息
if (request.method === 'POST') {
request.setEncoding('utf-8');
var body = '';

// 在获取到数据后,将其添加到请求主体中
request.on('data', function(chunk) {
body += chunk;
});

// 当请求完成时,发送一个空响应
// 并将消息传播到所有处于监听状态的客户端中
request.on('end', function() {
response.writeHead(200);
response.end();

// 将消息转换成文本/事件流格式
// 确保每一行的前缀都是“data:”
// 并以两个换行符结束
message = 'data: ' + body + "\r\n\r\n";
// 发送消息给所有监听的客户端
clients.forEach(function(client) {
client.write(message);
})
});
}
// Otherwise, a client is requesting a stream of messages
else {
response.writeHead(200, {
'Content-Type': 'text/event-stream'
});
response.write('data: Connected\n\n');

// 如果客户端关闭了链接
// 从活动客户端数组中删除对应的响应对象
request.connection.on('end', function() {
clients.splice(clients.indexOf(response), 1);
response.end();
});

// 记下响应对象,这样就可以向他发送未来的消息
clients.push(response);
}
});

// 启动服务器,监听8000端口
server.listen(8000);

参考文献

  1. 《JavaScript权威指南(第6版)》