第十四章 HTTP請求

bjsuo發表於2012-08-01

在第11章提到過,全球資訊網的通訊是基於HTTP協議的,一個簡單的http請求可能如下所示:

GET /files/fruit.txt HTTP/1.1    
Host: eloquentjavascript.net    
User-Agent: The Imaginary Browser

這個請求向eloquentjavascript.net伺服器請求/files/fruit.txt檔案。另外,它指定了使用HTTP1.1協議——HTTP1.0目前也在使用中,跟1.1略有不同。Host 和 User-Agent兩行有如下規則:行的開頭用一個詞來標識它包含的資訊,後面緊跟一個冒號和實際資訊內容。這叫做"HTTP頭"。User-Agent頭告訴伺服器端這個請求是什麼瀏覽器(或者其他程式)產生的。通常,一起傳送的還有其他頭資訊,比如,客戶端能解析的文件型別、語言等。 當我們提交一個請求後,服務端可能會做出如下的響應:

HTTP/1.1 200 OK
Last-Modified: Mon, 23 Jul 2007 08:41:56 GMT
Content-Length: 24
Content-Type: text/plain

apples, oranges, bananas

第一行再次標識出了http協議的版本。緊接著是響應請求的狀態,在本例中狀態碼是200,意思是“OK,平安無事,正在傳送檔案”。接下來是一些其他的頭資訊:Last-Modified代表檔案的修改時間、Content-Length代表檔案的長度、Content-Type代表文件的型別(text/plain,普通文字)。在這些頭資訊的後面緊跟的是一個空白行,後面是檔案內容。 如果傳送一個get請求,則表示客戶端僅僅是想從服務端得到一個文件。除了get外,還可以使用post請求,它會通過請求傳送一些資訊讓伺服器以某種方式處理。


當單擊一個超級連結、提交一個表單,或者通過其他的方式促使瀏覽器跳轉到一個新的頁面的時候,就會發起一個HTTP請求,並且立即解除安裝舊頁面並顯示載入的新頁面。在典型的情況下,這正是你所期望的——這是傳統web工作方式,但是,一些javascript程式想與伺服器通訊,但並不重新載入頁面。比如,控制板上的載入按鈕,可以載入檔案,而無需離開此頁。
為了滿足上面的需求,首先要用javascript程式自己生成一個HTTP請求,為此現代的瀏覽器提供了一個介面。與生成新視窗相比,這個介面受到某些限制來預防一些惡意指令碼。它只能向此頁所在的網址傳送HTTP請求。 可以用一個物件來生成http請求,在大多數瀏覽器上用new XMLHttpRequest()。在一些老版本的IE瀏覽器(這些物件的發明者)上,需要用new ActiveXObject("Msxml2.XMLHTTP"),更老的IE上,需要使用new ActiveXObject("Microsoft.XMLHTTP"). ActiveXObject,可以把這個介面附加在其他各種瀏覽器後面。我們就可以寫一個瀏覽器相容的包裝程式,如下所示:
function makeHttpObject() {
  try {return new XMLHttpRequest();}
  catch (error) {}
  try {return new ActiveXObject("Msxml2.XMLHTTP");}
  catch (error) {}
  try {return new ActiveXObject("Microsoft.XMLHTTP");}
  catch (error) {}

  throw new Error("Could not create HTTP request object.");
}

show(typeof(makeHttpObject()));

這個包裝程式使用了所有的三種可能來建立物件,使用try-catch塊來檢測哪一個方法執行失敗。如果最終都沒有生成物件,則可能是因為使用了太老的瀏覽器或者瀏覽器設定的安全級別太高,這將產生一個錯誤。 那麼這個物件為什麼叫做XMLHttpRequest呢?這個名字有一點誤導人。XML是用來存取文字資料的一種方式。它也使用標籤和屬性,這點像HTML,但結構化更強也更復雜——你可以自定義XML標籤來儲存各種各樣的資料。HTTP請求物件有一些內建的方法處理XML文件,這些方法的名字含有XML字樣。當然,它也能處理其他型別的文件。根據我的經驗,他們也經常用於非XML請求。


現在我們已經建立了HTTP 物件,就可以用它發起一個像上例所示的請求。
var request = makeHttpObject();
request.open("GET", "files/fruit.txt", false);
request.send(null);
print(request.responseText);

open方法用於設定請求物件,在本例中,我們要用get方法去請求fruit.txt檔案,url是是相對路徑,它不包含“http://”和伺服器名,意思是從當前頁面域名伺服器上尋找fruit.txt檔案。關於第三個引數 false,我們稍後再討論。當呼叫open方法後,就可以使用send方法發起請求了。如果是一個post請求,引數將(作為字串)通過send方法傳送到服務端。如果是get請求,send方法引數則使用null。 當請求發出以後,responseText 屬性將被用來接收服務端響應的文件文字。getResponseHeader和getAllResponseHeaders 方法用來檢查服務端響應頭資訊,第一個看起來像一個特別的頭,而第二個方法將把所有的頭資訊做為一個字串返回。如果想得到關於文件的附加資訊,這兩個方法有時很有用。

print(request.getAllResponseHeaders());
show(request.getResponseHeader("Date"));

有的時候,你可能需要向請求中新增頭資訊,setRequestHeader 能滿足這個需求,這個方法有兩個引數:頭的名字和值。 在例子中,可以通過status 屬性得到一個值為200的狀態碼。如果發生的錯誤,則用這個值標明。比如404表示請求的檔案不存在。statusText 屬性中包含對狀態的簡明描述。

show(request.status);
show(request.statusText);

如果你想知道一個請求是否成功,則檢查status是不是200就已經足夠了。理論上,服務端有時會返回304這樣的值,它表示響應已經被瀏覽器快取並且至今未變,使用這個舊的版本即可。但服務端並不會為這個請求設定200的狀態碼,而是設定304。同樣,你如果使用非HTTP協議,比如FTP協議,狀態碼也是不可用的,因為FTP協議不使用HTTP狀態碼。


如上所示,當請求發出以後,只有當服務端響應完成後,send方法才能返回。這意味著在send方法後,我們可以立即通過responseText 屬性來取得響應值,這是很方便的。但是有些情況會有問題,如果服務端響應慢,或者檔案很大,需要耗費一些時間,當這些情況發生的時候,程式就需要等待,同時導致瀏覽器阻塞直到響應完成。在這個期間使用者不能做任何操作,甚至不能滾動頁面。如果網頁執行在區域網上,由於區域網速度快而且穩定,也許不會發生這種情況,但在巨大的不可靠的Internet上,這種情況不可避免。 如果設定open方法的第三個引數為true。這個請求將被設定成為“非同步的”,它的意思是send方法可以立即返回,請求的過程將在後臺進行。
request.open("GET", "files/fruit.xml", true);
request.send(null);
show(request.responseText);

但是稍等片刻執行

print(request.responseText);

“稍等片刻”不能使用setTimeout 去實現,因為有更好的辦法,請求物件有一個readyState 屬性,它表示當前是什麼狀態,當值變為4時,表示文件已經完成載入了。而未完成載入的狀態值比4小,為了監控這個值的變化,可以給onreadystatechange 屬性設定一個方法,每當值變化的時候,都會呼叫這個方法。

request.open("GET", "files/fruit.xml", true);
request.send(null);
request.onreadystatechange = function() {
  if (request.readyState == 4)
    show(request.responseText.length);
};


當響應是一個XML型別的文件的時候,請求物件的responseXML 屬性將被用來代表這個文件,它跟12章討論的DOM物件很像,但它沒有HTML特有的功能,比如style 和innerHTML。responseXML 返回一個文件物件,它的documentElement 屬性指向包含XML文件的標籤。
var catalog = request.responseXML.documentElement;
show(catalog.childNodes.length);

這種XML文件經常被用來同伺服器交換結構化的資訊,其形式是標籤包含標籤——這對儲存資訊非常有用,而用普通文字來表述的話會很棘手。DOM介面在解析資訊時很笨拙,而XML文件的羅嗦是路人皆知的。fruit.xml這個文件看起來有很多內容,其實它只是表述“蘋果是紅色的,橙子是橙色的,香蕉是黃色的”。


為了替代XML,javascript程式設計師想出了JSON。它使用javascript的基本符號這種極簡單的方式來表示分層資訊。一個JSON 文件就是一個檔案,它的內容是一個簡單的javascript物件或者陣列,裡面可以包含任意數量的其他物件、陣列、字串、數字、boolean值以及null物件,例如:fruit.json

request.open("GET", "files/fruit.json", true);
request.send(null);
request.onreadystatechange = function() {
  if (request.readyState == 4)
    print(request.responseText);
};

一段文字可以用eval函式轉換成普通的javascript值,在執行eval函式前可能要用括號把文字括起來,否則可能會解釋為一個物件(括在括號裡面),比如下面的程式碼塊會產生一個錯誤:

function evalJSON(json) {
  return eval("(" + json + ")");
}
var fruit = evalJSON(request.responseText);
show(fruit);

當使用eval函式執行文字的時候,你需要牢記你執行的這段文字是做什麼的,由於javascript請求只能訪問當前域名,所以你通常知道響應是什麼文字以及如何解析文字,這不再是一個問題,但在其他情況下,這是不安全的。


可以寫一個serializeJSON 函式,來實現從javascript值到json字串的轉換。對數字、布林型別的值,可以直接呼叫String方法轉換成為字串,對陣列和Object物件,可以遞迴呼叫。 識別陣列物件是有些麻煩的,它的型別是“object”,可以使用“instanceof Array”來判斷,這僅針對當前window建立的陣列有效——其他陣列物件的原型是其他window,如果使用instanceof ,將會返回false。一個簡單的辦法就是把constructor 屬性轉成字串,看它是否包含“function Array”。 當進行字串轉換的時候,需要注意轉義特殊符號,如果使用雙引號包含字串時,要轉義的符號有:\", \, \f, \b, \n, \t, \r, and \v。
function serializeJSON(value) {
  function isArray(value) {
    return /^\s*function Array/.test(String(value.constructor));
  }

  function serializeArray(value) {
    return "[" + map(serializeJSON, value).join(", ") + "]";
  }
  function serializeObject(value) {
    var properties = [];
    forEachIn(value, function(name, value) {
      properties.push(serializeString(name) + ": " +
                      serializeJSON(value));
    });
    return "{" + properties.join(", ") + "}";
  }
  function serializeString(value) {
    var special =
      {"\"": "\\\"", "\\": "\\\\", "\f": "\\f", "\b": "\\b",
       "\n": "\\n", "\t": "\\t", "\r": "\\r", "\v": "\\v"};
    var escaped = value.replace(/[\"\\\f\b\n\t\r\v]/g,
                                function(c) {return special[c];});
    return "\"" + escaped + "\"";
  }

  var type = typeof value;
  if (type == "object" && isArray(value))
    return serializeArray(value);
  else if (type == "object")
    return serializeObject(value);
  else if (type == "string")
    return serializeString(value);
  else
    return String(value);
}

print(serializeJSON(fruit));


如果經常使用js發起請求的話,不需要每次都按固定的格式重複去寫open,send,onreadystatechange 。一個簡單的封裝如下所示:
function simpleHttpRequest(url, success, failure) {
  var request = makeHttpObject();
  request.open("GET", url, true);
  request.send(null);
  request.onreadystatechange = function() {
    if (request.readyState == 4) {
      if (request.status == 200)
        success(request.responseText);
      else if (failure)
        failure(request.status, request.statusText);
    }
  };
}

simpleHttpRequest("files/fruit.txt", print);

這個函式先用給定的url去發起請求,然後把第二個引數當作一個函式,引數就是響應文字。如果有第三個引數的話,這將是請求異常的處理函式——狀態碼不是200的情況。 為了處理更復雜的請求,這個函式可能需要新增引數來表示請求的方法(GET還是POST),用一個可選的字串引數代表要傳送的資料,給請求新增頭資訊等等。如果有這麼多引數的話,你大概就會想把他們作為 arguments物件了,詳見第9章。


有些網站客戶端程式與服務端程式需要進行密集的通訊,在這樣的系統裡,一個很實用的方法是把服務端要呼叫的函式傳過去,客戶端要請求的url就是要呼叫的函式,給定的引數可以在url傳遞或者post過去。服務端呼叫這個函式,把返回結果轉換成為XML或者JSON文件返回到客戶端。如果寫一些這樣方便的輔助函式,就會把呼叫服務端做得像呼叫客戶端一樣,當然,不能立刻得到響應結果。

相關文章