今天看到一本好书,名称《JavaScript权威指南》,作者:David Flanagan,由淘宝前端团队译,HTTP章节是有选择性的看的第一章节,觉得内容讲的很丰富,很精彩也很深入,有时间决定细细品味,这章节主要讲解HTTP请求。
前言
- 超文本传输协议HTTP规定Web浏览器如何从Web服务器获取文档和向Web服务器提交表单内容,以及Web服务器如何响应这些请求和提交。
- 通常HTTP并不在脚本的控制下,只是当用户单击链接,提交表单和输入URL时才发生,但是JavaScript操纵HTTP是可行的
- 加载新页面:window对象的location属性或调用表单对象的submit方法
- 不重新加载页面:Ajax(客户端拉)+Comet(服务端推)
实现方式
Ajax实现
<img>
:当脚本设置src属性为url时,浏览器发起的HTTP GET请求就会从这个URL下载图片。img元素无法实现完整的Ajax传输协议,因为数据交换是单向的:客户端能发送数据到服务器,但是服务器的响应一直是图片导致客户端无法轻易从中提取信息。<iframe>
:指定src为url,服务器创建一个包含响应内容的HTML文档并把它返回给Web浏览器,并且通过iframe中显示他。受限于同源策略问题。<script>
:src属性能设置url并发起HTTP GET请求,吸引人之处在于可以跨域通信而不受限于同源策略,通常使用<script>
的Ajax传输协议时,服务器的响应采用json数据格式,当执行脚本时,JavaScript解析器能自动将其解码,因此也叫做JSONP。- XMLHttpRequest对象:定义了用脚本操纵HTTP的API,除了GET方式,还实现了POST请求的能力,同时他能用文本或Document对象的形式返回服务器的响应。
- 名称虽然叫做XMLHttpRequest,但是能获取任何类型的文本文档,并不限定只能使用XML文档,其对象的responseXML属性。看起来像说明XML是脚本操纵HTTP的重要组成部分,实际上并不是,仅仅只是XML流行时的遗迹。Ajax技能能和XML文档一起工作,但是XML只是一种选择,实际上很少使用。
Comet实现
- 实现Comet的一种更可靠的跨平台方案是客户端建立一个和服务器的连接,同时服务器保持这个连接打开直到他需要推送一条消息。服务器每发送一条消息就关闭这个连接,这样就能确保客户端正确接收到消息,处理完该消息之后,客户端马上为后续的消息推送建立一个新的连接。
- Dojo,HTML5(EventSource)
后续
在Ajax和Comet之上构建更高级的通信协议是可行的,这些客户端/服务端技术可以用做RPC(Remote Procedure Call远程过程调用)机制或发布订阅事件系统的基础。
XMLHttpRequest
备注:XHR2=2级XMLHttpRequest
实例化XMLHttpRequest对象
var request=new XMLHttpRequest();
你能重用已经存在的XMLHttpRequest请求,但注意这将会终止之前通过该对象挂起的任何请求。
在IE旧版本中不存在XMLHttpRequest对象,而是提供了另外一种方式,兼容性设置如下.
//在IE5和IE6中模拟XMLHttpRequest对象
if(window.XMLHttpRequest === undefined){
window.XMLHttpRequest=function(){
try{
//如果可用,使用ActiveX对象的最新版本
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部分组成:
- 请求方法
- URL
- 可选的请求头集合,其中可能包括身份验证信息
- 可选的请求主体
HTTP响应由3部分组成:
- 数字和文字组成的状态码,用来显示请求的成功和失败
- 响应头集合
- 响应主题
指定请求
创建对象之后就是调用对象的open()方法指定这个请求的两个部分:方法和URL。
- 方法:GET,POST,DELETE,HEAD,OPTIONS,PUT,PATCH
- OPTIONS请求:旨在发送一种“探测”请求以确定针对某个目标地址的请求必须具有怎样的约束(比如应该采用怎样的HTTP方法以及自定义的请求报头),然后根据其约束发送真正的请求。比如针对“跨域资源”的预检(Preflight)请求采用的HTTP方法就是OPTIONS
- HEAD请求:询问链接的详细信息
- PATCH请求:PATCH方法是新引入的,是对PUT方法的补充,用来对已知资源进行局部更新(resetful规范)
- PUT请求:PUT虽然也是更新资源,但要求前端提供的一定是一个完整的资源对象
- URL:请求的主题
设置请求头和发送请求
- 设置请求头
- POST请求需要
Content-Type
头指定请求主题的MIME类型 - 对相同的头设置多次,新值不会取代之前指定的值,相反,HTTP请求将包含这个头的多个副本或这个头将指定多个值
- 不能自己指定
Content-Length
,Date
,Referer
或User-Agent
头等,因为XMLHttpRequest对象会自动添加这些头而防止伪造他们
- POST请求需要
- 如果一个受密码保护的URL,把用户名和密码作为第4个和第5个参数传递给open(),则XMLHttpRequest将设置合适的头
- 指定可选的请求主体并向服务器发送它
request.send(null); // GET请求绝对没有主体,所以应该传递null或省略参数 // post请求通常拥有主体,同时应该配合使用setRequestHeader()指定`Content-Type`头
取得响应
一个完整的HTTP响应由状态、响应头集合和响应主主体组成。
- status和statusText属性以数字和文本的形式返回HTTP状态码
- getResponseHeader(),getAllResponseHeaders()
- 响应主体:responseText属性得到文本形式,从responseXML属性得到Document形式的
XMLHttpRequest对象通常是异步使用的,send()之后就立即返回,直到响应返回,为了在响应准备就绪时得到通知,则必须监听XMLHttpRequest对象的readystatechange事件。为了理解这个事件的类型,则必须理解readyState属性。
readyState是一个整数,指定了HTTP请求的状态,具体枚举如下:
- UNSEND 0 open尚未调用
- OPENED 1 open已经调用
- HEADERS_REVEIVED 2 接收到头消息
- LOADING 3 接收到响应主体
- DONE 4 响应完成
理论上每次readyState属性改变都会触发readystatechange事件。实际上由于兼容性问题,浏览器表现不一致,只有当readyState值变成4或服务器响应完成时,所有浏览器都会触发readystatechange事件,因为在响应完成之前也会触发事件,因此事件处理程序应该一直检验readyState的值。
为了监听事件,可以直接处理函数设置为XMLHttpRequest对象的onreadystatechange属性,当然也可以使用addEventListener,但是每个请求通常只有一个处理程序,因此不建议使用多播的方式。
事件处理函数步骤:
- XMLHttpRequest的stateReady和status值
- 查找
Content-Type
值,验证响应主体是否是期望的类型var type=request.getResponseHeader('Content-Type'); if(type.match(/^text/)){ }
- 获取响应主体,responseText和responseXML
同步响应
由于其本身的性质,异步处理HTTP响应是最好的方式,然而XMLHttpRequest也支持同步响应,如果把false作为第三个参数传递给open()方法,那么send()方法会一直阻塞知道请求完成,这种情况下就不需要请求处理函数了,一旦send()返回,检查status和responseText的属性就好啦。
响应解码
服务器发送的MIME类型是多种多样的,如果浏览器想发送XML文档呢,如果浏览器想发送诸如对象和数组这样结构化的数据呢?下面的代码就是一个简单的情景分析:
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);
}
备注:加载JavaScript脚本
- 如果你需要特殊编码的另一个响应类型是
application/javascript
或text/javascript
,这样可以使用XMLHttpRequest请求JavaScript脚本,然后使用全局eval加载它。 - 书上说这种情况下一般不需要使用XMLHttpRequest对象,因为script标签本身操作HTTP脚本的能力完成可以实现加载并执行脚本。
- 虽然这么说,但我总觉得中方法也有使用场景。
服务器的正常解码是指服务器为这个响应发送Content-Type
头和正确的MIME类型,如果服务器发送的XML文档但是没有设置合适的MIME类型,或者服务器设置了错误的Content-Type
值,都会导致服务器响应的内容错误。XHR2定了overrideMineType()方法,如果相对于服务端你更了解资源的MIME类型,那么在调用send()之前把类型传递给overrideMineType()方法,这将使XMLHttpReques对象忽略Content-Type
头而是用指定的类型。
request.overrideMineType('text/plain;chatset=utf-8')
编码请求主体
POST请求包括一个请求主体,包含客户端传递给服务器的数据。主要有表单编码,JSON编码,XML编码,还有上传文件等特殊请求。
表单编码请求
提交表单时,编码后的表单数据作为请求主体,编码格式如:name=sky&id=1,同时表单数据编码有一个正式的MIME类型:application/x-www-form-urlencoder
,当时用POST方式提交这种顺序的表达数据时,必须设置Content-Type
为这个值。
实际上很多时候我们不直接使用表单,在Ajax应用中,我希望直接发送给服务器一个JavaScript对象,因此我们可以构造一个编码对象的函数。
function encodeFormData(data){
if(!data)return "";
var pairs=[];
for(var name in data){
if(!data.hasOwnProperty(name)) contine;// 跳过继承方法
if(typeof data[name]==='funcytion') continue;// 跳过方法
var value=data[name].toString();
// 把编码为 %20 的空格替换为 +,因为由于历史原因,表单提交的 x-www-form-urlencoded 格式要求空格编码为 +
name = encodeURIComponent(name.replace(/%20/g,'+'));
value = encodeURIComponent(value.replace(/%20/g,'+'));
pairs.push(name+'='+value);
}
return pairs.join('&');
}
表单数据同样可以通过GET请求来提交,如果表单提交的目的是为了执行只读查询,GET比POST更加合适,GET方法从来没有主体,所以需要发送给服务器的表单编码数据要作为URL(后跟一个问号)的查询部分。
JSON编码请求
在POST请求主体中使用表单编码是常见惯例,但是任何情况下它都不是HTTP协议的必需品。如今Web交换格式的JSON得到普及,可以直接使用JSON.stringify()
编码请求主体。
XML编码请求
目前为止XMLHttpRequest的send()方法的参数是null或字符串,其实也可以传入XML Document对象。
如果没有预先指定Content-Type
的值,但send方法传人XML文档时,XMLHttpRequest对自动设置一个合适的头,如果传入字符串没有指定的话,会自动添加text/plain;charset=UTF-8
头。
上传文件
用户通过file类型的input元素选择文件时,表单将在他产生的post请求中发送文件内容,HTML表单始终能上传文件,但在XHR2之前是不能使用XMLHttpRequest对象上传文件的,然后XHR2允许通过向send方法传入File对象来实现文件上传。
没有File()对象构造函数, 脚本仅能获得表示用户当前选择文件的File对象。每个file类型的inputu元素有一个files
属性,他是File对象中的类数组对象,在拖放API中允许通过拖放事件的dataTransfer.files
属性访问用户拖放到元素上的文件。
当HTML表单同时包含上传元素和其他元素时,浏览器不能使用普通的表单编码而必须使用称为multipart/form-data
的特殊Content-Type
来用POST方式提交表单,这种编码包括使用长边界
字符串把请求主体分离成多个部分。对于文本数据,手动创建multipart/form-data
的请求主体是可能的,但是很复杂。
因此XHR2定义了新的FormData对象,然后按需多次调用这个对象的append(k,v)方法把个体部分
(可以是字符串、File或Blob对象)添加到请求中。最后把FormData对象传递给send方法。此时send方法将对请求定义合适的边界字符串和设置Content-Type
头。
HTTP进度事件
XHR2定义了更多有用的事件集,触发顺序如下:
- send()时,触发loadstart事件
- 正在加载服务器响应时,触发progress事件,通常每个
50ms
左右,可以使用这些进度给用户反馈请求的进度 - 请求快速完成,可能不触发progress事件
- 请求完成,触发load事件
- 完成的请求不一定是成功的请求,依旧应该检查XMLHttpRequest的status状态码
请求无法完成有3种情况,对应三种事件:
- 请求超时,触发timeout事件
- 请求终止,触发abort事件
- 请求错误,触发error事件
任何具体请求,浏览器将只会触发load,abort,timeout和error事件。
判断是否支持progress事件,直接判断对象是否有此属性就可以了。
if('onprogress' in (new XMLHttpRequest())){
}
progress事件除了有像type和timestamp这样常用的Event对象属性外,还有三个有用的属性,loaded属性是目前传输的字节数值,total属性是自己Content-Length
头传输的数据的整体长度,单位字节,如果不知道内容长度则为0,如果知道内容长度则lengthComputable属性为true,否则为false。
上传进度事件
除了监控HTTP响应的事件外,XHR2也给出了用于监控HTTP请求上传的事件,在实现这些特性的浏览器中,XMLHttpRequest对象将有upload属性。upload属性是一个对象,定义了addEventListener()方法和整个progress事件集合,比如onprogress和onload。但upload对象没有定义onreadystatechange属性,upload仅能触发新的事件类型。
你可以像使用常见的progress事件处理程序一样使用upload事件处理程序,对于XMLHttpRequest对象,设置onprogress监控响应的下载进度,设置upload.onprogress以监控请求上传的进度。
终止请求和超时
使用调用XMLHttpRequest对象的abort()方法来取消正在进行的http请求,abort()方法在所有的XMLHttpRequest和XHR2中可用,调用此方法将触发abort事件。
调用abort()的主要原因是完成取消或超时请求消耗的时间太长或响应变得无关时,最典型的应用场景使用XMLHttpRequest为文本输入域请求自动完成推荐。如果用户在服务器的建议达到之前输入了新字符串,这时等待请求不在有用,应该终止。
XHR2定义了timeout属性来指定请求自动终止的毫秒数,也定义了timeout事件用户当超时发生时触发,使用前先判断是否有timeout和ontimeout属性,在不支持的情况下可以用setTimeout()和abort()方法实现自己的超时。
function timedGetText(url,timeout,callback){
var request=new XMLHttpRequest();
var timedout=false;// 是否超时
var timer=setTimeout(function(){
timedout=true;
request.abort();
});
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);
}
跨域HTTP请求
作为同源策略的一部分,XMLHttpRequest对象仅可以发起和文档具有相同服务器的HTTP请求,因为使用XMLHttpRequest,文档内容都是通过responseText属性暴露,所以同源策略不允许XMLHttpRequest进行跨域请求。
XHR2通过在HTTP响应中选择发送合适的CORS(Cross-Origin Resource Sharing,跨域资源共享)允许跨域访问网站。
虽然实现CORS支持的跨域请求工作不需要做任何事情,但有一些安全细节需要了解。
- 如果XMLHttpRequest的open方法传入用户名和密码,那么他们绝对不会通过跨域请求发送。
- 跨域请求通常也不会包含其他任何的用户证书:
cookie和HTTP身份令牌通常不会作为请求的内容部分发送且任何作为跨域响应来接受的Cookie都会丢弃
。 - 如果跨域请求需要这几种凭证才能成功,那么必须在send发送前设置XMLHttpRequest的withCredentials属性为true,但这样做不常见,但测试withCredentials的存在性是测试浏览器是否支持CORS的一种方法。
DEMO,head方式的作用,询问链接的详细信息
function handle(){
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 type=req.getResponseHeader('Last-Modified');
...
}
}
}
JSONP
<script>
元素可以作为一种Ajax传输机制:只须设置<script>
元素的src属性,需要插入到document文档中,然后浏览器就会发送一个HTTP请求下载src属性指向的URL,使用<script>
元素的主要原因是:
- 不受同源策略的影响
- 包含JSON编码数据的响应体会自动解码,就是因为会自动解码的缘故,可以理解成自动eval执行它。
当通过<script>
元素调用数据时,响应内容必须用JavaScript函数名和圆括号包裹起来。类似如下:
handleResponse([1,2,{user:123}])
服务端书写:
- 约定获取回调函数名词的键名
- 得到回调函数名称
- 获取数据,并将响应内容用第二步得到函数名和圆括号包裹起来,为了避免出现为错误,可以非空判断,比如回调函数名词为test,返回
test&&test({this is a json})
为了可行起见,我们必须通过某种方式告诉服务,他正在从一个<script>
元素调用,必须返回一个JSONP响应,而不应该是普通的JSON响应。这个可以通过在URL中添加一个查询的参数来实现。
在实践中,支持JSONP的服务不会强制指定客户端必须实现的回调函数名称,而不是使用查询参数的值,允许客户端自己指定一个函数名,然后使用函数名去填充响应。例子如下:
function getJSONP(url,callback){
// 为本次请求创建唯一函数名
var cbnum='cb'+JSONP.counter++;
var cbname='getJSONP.'+cbnum;//作为JSONP属性
if(url.indexOf('?') === -1){
url+='?jsonp='+cbname;
}else{
url+='&json='+cbname;
}
var script=document.createElement('script');
getJSONP[cbname]=function(response){
try{
callback(response)
}finally{
delete getJSONP[cbname];
script.parentNode.removeChild(script);
}
}
script.scr=url;
document.body.appendChild(script);
}
getJSONP.count = 0;
Comet技术
在服务端推送事件的标准草案中定义了一个EventResource对象,简化了Comet应用程序的编写。
- 传递URL给EventResource对象
- 返回的实例上监听消息事件
var ticker=new EventResource(''); ticker.onmessage=function(e){ // 事件类型,默认message,事件源可以修改这个值,onmeessage可以接收服务器事件源发出的所有事件,如果有必要可以从type属性派发一个事件 var type=e.type; // 服务器传送数据 var data=e.data; }
服务器推送事件协议分析:
- 创建EventResource对象时建立链接
- 服务器保持这个连接处于打开状态
- 发生一个事件时,服务端在连接中写入几行文本
Comet架构常见的应用就是聊天应用,客户端通过XMLHttpRequest向聊天室发送消息,也可以通过EventResource订阅聊天消息。
var chat=new EventResource('/chat');
chat.onmessage=function(event){
var msg=event.data;
var node=document.createTextNode(msg);
var div=document.createElement('div');
div.appendChild(node);
document.body.insertBefore(div,input);
input.scrollIntoView();// 保证input元素可见
}
input.onchange=function(){
var msg=input.value;
var xhr=new XMLHttpRequest();
xhr.open('POST','/chat');
xhr.setRequestHeader('Content-Type','text/plain;charset=UTF-8');
xhr.send(msg);
input.value='';
}
对于不支持EventResource对象的浏览器怎么办呢?可以使用XMLHttpRequest对象模拟EventResource。
if(window.EventSource === undefined){
window.EventSource=function(url){
var xhr;
var evtsrc=this;
var charsReceived=0;// 用来判断数据那部分是新的
var type=null;
var data="";// 存放消息数据
var eventName='message';
var lastEventId='';// 用于和服务器再次同步
var retrydelay=1000;// 多个请求连接之间设置延迟
var abort=false;
xhr=new XMLHttpRequest();
xhr.onreadystatechange=function(){
switch(xhr.readyState){
case 3:processDate();break;// 数据块到达时读取数据
case 4:reconnect();break;// 请求完成关闭时重连
}
}
connect();
function reconnect(){
if(abort) return;// 终止连接后不进行重连操作
if(xhr.status>=300) return;// 在报错之后不进行重连操作
setTimeout(connect,retrydalay);
}
function connect(){
charReceived=0;// 新的连接重置
type=null;
xhr.open('GET',url);
// 不要读取缓存中的文件,要求向WEB服务器重新请求
xhr.sendRequestHeader('Cache-Control','no-cache');
if(lastEventId) xhr.setRequestHeader('Last-Event-ID',lastEventId);
xhr.send(null);
}
// 当数据到达时,处理并触发事件
function processData(){
if(!type){
type=xhr.getResponseHeader('Content-type');
// 如果不是事件流类型,终止
if(type != 'text/event-stream'){
abort=true;
xhr.abort();
return;
}
}
// 记录收到的数据,并获得响应中为处理的数据
var chunk = xhr.responseText.substing(charsReceived);
charsReceived=xhr.responseText.length;
// 大块的文本数据分成多行并遍历他们
var lines=chunk.replace(/(\r\n|\r|\n)$/,'').split(\r\n|\r|\n);
for(var i=0;i<lines.length;i++){
var line=lines[i],pos=line.indexOf(':'),name,value;
if(pos == 0) continue;//忽略注释
if(pos>0){
name=line.substring(0,pos);
value=line.substring(pos+1);
if(value.charAt(0) == " ")value=value.substring(1);
}
else name=line;
switch(name){
case "event":eventName=value;break;
case "data":data+=value+"\n";break;
case "id":lastEventId=value;break;
case "retry":retrydalay=parseInt(value) || 1000;break;
default:break;
}
if(line === ""){
// 一个空行意味着发送事件
if(evt.src.onmessage && data!==""){
if(data.chatAt(data.length-1)=='\n')
data=data.substring(0,data.length-1);
evtsrc.onmessage({
type:eventName,
data:data,
origin:url
});
}
data="";
continue;
}
}
}
}
}
服务端代码:
var http=require('http');
var fs=require('fs');
var clientUi=fs.readFileSync('chatclient.html');
var emulation=fs.readFileSync('EventSourceEmulation.js');
var clients=[];
// 每20秒发送一条注释到客户端,这样就不会关闭连接在重连
setInterval(function(){
client.forEach(function(client){
client.write(':ping?n');
})
})
var server = new http.Server();
server.on('request',function(request,response){
var url=require('url').parse(request.url);
if(url.pathname==='/'){
response.writeHead(200,{'Content-Type':'text/html'});
response.write('<script>'+emulation+'</script>');
response.write(clientUi);
response.end();
return;
}else if(url.pathname !=='/chat'){
response.writeHead(404);
response.end();
return;
}
// 客户端发送了新消息
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();
// 1.将消息转换为文本事件流格式
// 2.确保每一行的前缀都是data:
// 3.并以两个换行符结束
message='data: '+body.replace('\n','\ndata: ')+"\r\n\r\n";
clients.forEach(function(client){
client.write(message);
})
})
}else{
// 客户端正在请求一组消息
response.writeHead(200,{'Content-Type':'text/event-stream'});
response.write('data:Connected\n\n');
request.connection.on('end',funcyion(){
clients.splice(client.indexOf(response),1);
response.end();
});
// 记下响应对象,用来给他发送消息
clients.push(response);
}
})