請求中的“開源節流”—MXHR的實現細節和應用


頁面中最常見的三種資源是:JS文件,CSS文件,圖片文件。為了減少HTTP請求數量,通常在部署一個應用的時候,都會用工具把一堆的JS文件合並再壓縮,就像一塊兒海綿一樣,把里面的水分擰去;CSS文件通常都是合並(壓縮),CSS的壓縮只是去除注釋,空格以及換行符。那么圖片文件呢?

如果一個頁面的用戶訪問量很大,而且這個頁面中有100個圖片,那么,就會有100次的HTTP請求(除去圖片信息)之外的消耗,MXHR似乎可以解決這個問題:

MXHR技術,整體的流程就是,把這100個圖片在后端使用base64編碼,然后把它拼成一個長字符串,通過一次HTTP請求,傳送回客戶端,然后通過JS來把這個長字符串分割,並解析成瀏覽器可以識別的圖片形式。當然用MXHR也可以用來傳送JS或者CSS文件,但是現在通常用更簡潔的合並壓縮來部署,這里先不考慮JS和CSS文件的MXHR應用。

關於MXHR原始的介紹和應用在這里,但是貌似原始的測試小例子有些問題,修改后的在這里,我們來詳細的學習下這個例子,目的:搞懂MXHR的實現細節。

分布

先來看下mxhr_test.php文件,為了簡便起見,把原先的英文注釋翻譯了一遍,幫助理解:

<?php
	/**
	 * Functions for combining payloads into a single stream that the
	 * JS will unpack on the client-side, to reduce the number of HTTP requests.
	 * 這里的payloads可以理解為一個流(stream)中的單元,包含信息和控制符,mxhr_stream函數將每一個
	 * payload(復數加s)合並成一個“流”,在客戶端,Javascript將會解析這些payload,進而減少HTTP請求的數量。
	 * Takes an array of payloads and combines them into a single stream, which is then
	 * sent to the browser.
	 * 此函數以payload為元素的數組作為參數,並把它們合並成一個單獨的“流”,這個“流”將會發送回瀏覽器。
	 * Each item in the input array should contain the following keys:
	 * 參數數組的每一個單元,應該包含如下keys:
	 * data         - the image or text data. image data should be base64 encoded.
	 * data - 圖片或者文本的data,圖片的data是經過base64編碼的。
	 * content_type - the mime type of the data
	 * xontent_type - data 的 mime 類型
	 */
	function mxhr_stream($payloads) {
		
		$stream = array();
		
		$version = 1;
		//使用特殊的符號來作為分隔符和邊界符(它們都屬於控制符)
		$sep = chr(1); // control-char SOH/ASCII 1
		$newline = chr(3); // control-char ETX/ASCII 3
		
		foreach ($payloads as $payload) {
			$stream[] = $payload['content_type'] . $sep . (isset($payload['id']) ? $payload['id'] : '') . $sep . $payload['data'];
		}
		echo $version . $newline . implode($newline, $stream) . $newline;
		/*
		此例中$stream中的一個元素的展現:image/png0iVBORw0KGgoAAAANSUhEUgAAABwAAAAWCAMAAADkSAzAAAAAQlBMVEWZmZmenp7m5ubKysq/v7+ysrLy8vLHx8fb29uvr6/v7++pqan39/empqbT09Pq6urX19dtkDObvkpLbSmLsDX///8MOm2bAAAAFnRSTlP///////////////////////////8AAdLA5AAAAIdJREFUKM910NkSwyAIBVDM0iVdUrnk/381wdLUBXlgRo7D6KXNLUA7+RYjeogoIvAxmSp1zcW/tZhZg7nVWFiFpX0RcC0h7IjIhSmCmXXQ2IEQVo22MrMT04XK0lrpmD3IN/uKuGYhQDz7JQTPzvjg2EbL8Bmn+REAOiqE132eruP7NqyX5w49di+cmF4NJgAAAABJRU5ErkJggg==
		*/
	}
	
	// Package image data into a payload(將一個圖片的data打包成一個payload)
	
	function mxhr_assemble_image_payload($image_data, $id=null, $mime='image/jpeg') {
		return array(
			'data' => base64_encode($image_data),
			'content_type' => $mime,
			'id' => $id
		);
	}
	
	// Package html text into a payload(將一個html文件打包成一個payload,這個例子中沒有用到)

	function mxhr_assemble_html_payload($html_data, $id=null) {
		return array(
			'data' => $html_data,
			'content_type' => 'text/html',
			'id' => $id
		);
 	}

	// Package javascript text into a payload(將一個javascript文件打包成一個payload,這個例子中沒有用到)

	function mxhr_assemble_javascript_payload($js_data, $id=null) {
		return array(
			'data' => $js_data,
			'content_type' => 'text/javascript',
			'id' => $id
		);
 	}

	// Send the multipart stream(發送“流”)

	if ($_GET['send_stream']) {
		//設置重復次數
		$repetitions = 300;
		$payloads = array();

		// JS files(可以略去)

		$js_data = 'var a = "JS execution worked"; console.log(a, ';

		for ($n = 0; $n < $repetitions; $n++) {
			//$payloads[] = mxhr_assemble_javascript_payload($js_data . $n . ', $n);');
		}

		// HTML files(可以略去)

		$html_data = '<!DOCTYPE HTML><html><head><title>Sample HTML Page</title></head><body></body></html>';

		for ($n = 0; $n < $repetitions; $n++) {
			//$payloads[] = mxhr_assemble_html_payload($html_data, $n);
		}

		// Images(這里使用的是測試圖片)

		$image = 'icon_check.png';
		$image_fh = fopen($image, 'r');
		//將此圖片read進$image_data變量
		$image_data = fread($image_fh, filesize($image));
		fclose($image_fh);

		for ($n = 0; $n < $repetitions; $n++) {
			//生成特定的payload數組
			$payloads[] = mxhr_assemble_image_payload($image_data, $n, 'image/png');
		}

		// Send off the multipart stream(發送)
		mxhr_stream($payloads);
		exit;
	}

?>

在這個測試里面,設置了300次的重復次數,這個php作為后端的支持文件,將用它來揭示mxhr加載300個測試圖片和用普通模式的加載300個圖片的區別,以及耗時多少的比較。

小提示:從后端php傳回的數據的總體結構是:

[version][boundary][payload][boundary][payload][boundary][payload]........[payload][boundary]

通過php文件可以知道,這里的[version]等於1;[boundary]則為 \u0001 ,對於客戶端來說 \u0001 的length等於1;[payload]則作為我們的重點要提取的內容。

而一個[payload]的結構是:

[mimetype][sep][id][sep][data]

[sep]即為各個字段之間的分隔符:\u0003,[data]則為我們重點要提取的內容。

接下來是重頭戲,看下mxhr.js文件的實現細節,同樣的,相關說明均在注釋之中:

(function() {
	
	// ================================================================================
	// MXHR
	// --------------------------------------------------------------------------------
	// F.mxhr is a porting of DUI.Stream (git://github.com/digg/stream.git).
	//
	// We ripped out the jQuery specific code, and replaced it with normal for() loops. 
	// Also worked around some of the brittleness in the string manipulations, and 
	// refactored some of the rest of the code.
	// 
	// Images don't work on IE yet, since we haven't found a way to get the base64
	// encoded image data into an actual image (RFC 822 looks promising, and terrifying:
	// http://www.hedgerwow.com/360/dhtml/base64-image/demo.php)
	//
	// Another possible approach uses "mhtml:", 
	// http://www.stevesouders.com/blog/2009/10/05/aptimize-realtime-spriting-and-more/ 
	//
	// --------------------------------------------------------------------------------
	// GLOSSARY
	// packet:  the amount of data sent in one ping interval
	// payload: an entire piece of content, contained between control char boundaries
	// stream:  the data sent between opening and closing an XHR. depending on how you 
	//          implement MHXR, that could be a while.
	// 這里使用到的術語:
	// packet: 一次請求的數據包大小
	// payload: 可以把它看成是整個stream中的一個單元,包含着控制符,邊界符,以及數據data
	// stream: 一次http請求,注意:between opening and closing an XHR
	// ================================================================================

	F = window.F || {};
	F.mxhr = {
		
		// --------------------------------------------------------------------------------
		// Variables that must be global within this object.
		// --------------------------------------------------------------------------------

		getLatestPacketInterval: null,
		lastLength: 0,
		listeners: {},//我們可以通過這個來設置監聽器
		//與php中的chr(3)和chr(1)相對應
		boundary: "\u0003", 		// IE jumps over empty entries if we use the regex version instead of the string.
		fieldDelimiter: "\u0001",

		//這里需要注意,在IE中初始化xmlhttp的時候,老版本的IE(6,7)不支持readyState == 3的情況(在本文的最后還會有說明)
		_msxml_progid: [
			'MSXML2.XMLHTTP.6.0',
			'MSXML3.XMLHTTP',
			'Microsoft.XMLHTTP', // Doesn't support readyState == 3 header requests.
			'MSXML2.XMLHTTP.3.0', // Doesn't support readyState == 3 header requests.
		],

		// --------------------------------------------------------------------------------
		// load()
		// --------------------------------------------------------------------------------
		// Instantiate the XHR object and request data from url.
		// 實例化XHR對象,請求數據
		// --------------------------------------------------------------------------------

		load: function(url) {
			this.req = this.createXhrObject();
			if (this.req) {
				this.req.open('GET', url, true);

				var that = this;
				this.req.onreadystatechange = function() {
					that.readyStateHandler();
				}

				this.req.send(null);
			}
		},

		// --------------------------------------------------------------------------------
		// createXhrObject()
		// --------------------------------------------------------------------------------
		// Try different XHR objects until one works. Pulled from YUI Connection 2.6.0.
		// --------------------------------------------------------------------------------
		
		createXhrObject: function() {
			var req;
			try {
				req = new XMLHttpRequest();
			}
			catch(e) {
				for (var i = 0, len = this._msxml_progid.length; i < len; ++i) {
					try {
						req = new ActiveXObject(this._msxml_progid[i]);
						break;
					}
					catch(e2) {  }
				}
			}
			finally {
				return req;
			}
		},		
    
		// --------------------------------------------------------------------------------
		// readyStateHandler()
		// --------------------------------------------------------------------------------
		// Start polling on state 3; stop polling and fire off oncomplete event on state 4.
		// 這個是一個重要的函數,處理返回狀態等,在readyState為3時開始不斷地輪詢,直到為4,會暫停輪詢,並且激活oncomplete事件
		// --------------------------------------------------------------------------------

		readyStateHandler: function() {

			if (this.req.readyState === 3 && this.getLatestPacketInterval === null) {
					
				// Start polling.(開始輪詢)

				var that = this;					
				this.getLatestPacketInterval = window.setInterval(function() { that.getLatestPacket(); }, 15);
			}

			if (this.req.readyState == 4) {

				// Stop polling.

				clearInterval(this.getLatestPacketInterval);

				// Get the last packet.

				this.getLatestPacket();

				// Fire the oncomplete event.
				// 激活oncomplete函數
				if (this.listeners.complete && this.listeners.complete.length) {
					var that = this;
					for (var n = 0, len = this.listeners.complete.length; n < len; n++) {
						this.listeners.complete[n].apply(that);
					}
				}
			}
		},
		
		// --------------------------------------------------------------------------------
		// getLatestPacket()
		// --------------------------------------------------------------------------------
		// Get all of the responseText downloaded since the last time this was executed.
		// 此函數得到調用此函數之時的所有響應(responseText)
		// --------------------------------------------------------------------------------		
    
		getLatestPacket: function() {
			//獲取響應字符串的總長度
			var length = this.req.responseText.length;
			//獲取此次調用之時,服務器的增量響應
			var packet = this.req.responseText.substring(this.lastLength, length);

			this.processPacket(packet);
			this.lastLength = length;
		},
   
		// --------------------------------------------------------------------------------
		// processPacket()
		// --------------------------------------------------------------------------------
		// Keep track of incoming chunks of text; pass them on to processPayload() once
		// we have a complete payload.
		// 一個packet里面不一定就會有一個整數倍的payload(在這里,一個payload才是一個可以解析的單元)
		// 這個函數會不斷地跟蹤響應數據,如果獲取到了一個完整的payload,那么就會將這個payload交予processPayload
		// 函數處理
		// --------------------------------------------------------------------------------
 
		processPacket: function(packet) {

			if (packet.length < 1) return;

			// Find the beginning and the end of the payload. (找到一個payload的開始和結尾)
			// boundary 作為每個payload的分割符(一個payload的邊界線)chr(3)
			// 一個整體的響應的結構可以看成:
			// [version][boundary][payload][boundary][payload][boundary][payload]........[payload][boundary]
			// 參照上面的結構,有助於理解下面的邏輯
			var startPos = packet.indexOf(this.boundary),
			    endPos = -1;

			if (startPos > -1) {
				if (this.currentStream) {

					// If there's an open stream, that's an end marker.

					endPos = startPos;
					startPos = -1;
				} 
				else {
					endPos = packet.indexOf(this.boundary, startPos + this.boundary.length);
				}
			}

			// Using the position markers, process the payload.

			if (!this.currentStream) {

				// Start a new stream.

				this.currentStream = '';

				if (startPos > -1) {

					if (endPos > -1) {

						// Use the end marker to grab the entire payload in one swoop
						// 當確認了一個payload的開始和結束位置的時候,就把它截取出來

						var payload = packet.substring(startPos, endPos);
						this.currentStream += payload;

						// Remove the payload from this chunk

						packet = packet.slice(endPos);

						this.processPayload();

						// Start over on the remainder of this packet

						try {
							this.processPacket(packet);
						}
						catch(e) {  } 
						// This catches the "Maximum call stack size reached" error in Safari (which has a 
						// really low call stack limit, either 100 or 500 depending on the version).
						//這里主要說明,在老版本的Safari下,可能會引起一個調用棧大小限制的錯誤(這里使用遞歸算法),根據不同的版本而情況各異
					} 
					else {
						// Grab from the start of the start marker to the end of the chunk.

						this.currentStream += packet.substr(startPos);

						// Leave this.currentStream set and wait for another packet.
					}
				} 
			} 
			else {
				// There is an open stream.

				if (endPos > -1) {

					// Use the end marker to grab the rest of the payload.

					var chunk = packet.substring(0, endPos);
					this.currentStream += chunk;

					// Remove the rest of the payload from this chunk.
					packet = packet.slice(endPos);

					this.processPayload();

					//Start over on the remainder of this packet.

					this.processPacket(packet);
				} 
				else {
					// Put this whole packet into this.currentStream.

					this.currentStream += packet;

					// Wait for another packet...
				}
			}
		},

		// --------------------------------------------------------------------------------
		// processPayload()
		// --------------------------------------------------------------------------------
		// Extract the mime-type and pass the payload on to its listeners.
		// 提取出一個payload的mime-type,並且把待處理的payload交予它的監聽器
		// --------------------------------------------------------------------------------
    
		processPayload: function() {

			// Get rid of the boundary.
			
			this.currentStream = this.currentStream.replace(this.boundary, '');

			// Perform some string acrobatics to separate the mime-type and id from the payload.
			// This could be customized to allow other pieces of data to be passed in as well,
			// such as image height & width.
			// 把圖片的相關信息從一個payload中提取出來,除去測試中的數據,還可以自定義一些其他的圖片信息,作為
			// payload的字段,字段之間使用chr(1)來分割('\u0001')

			var pieces = this.currentStream.split(this.fieldDelimiter);
			var mime = pieces[0]
			var payloadId = pieces[1];
			//payload即為圖片的data
			var payload = pieces[2];

			// Fire the listeners for this mime-type.(開始執行這個mime type下的監聽函數)

			var that = this;
			if (typeof this.listeners[mime] != 'undefined') {
				for (var n = 0, len = this.listeners[mime].length; n < len; n++) {
					this.listeners[mime][n].call(that, payload, payloadId);
				}
			}
			//刪除此次的currentStream
			delete this.currentStream;
		},
		
		// --------------------------------------------------------------------------------
		// listen()
		// --------------------------------------------------------------------------------
		// Registers mime-type listeners. Will probably rip this out and use YUI custom
		// events at some point. For now, it's good enough.
		// 使用listen函數來主次mime type監聽器
		// --------------------------------------------------------------------------------		
    
		listen: function(mime, callback) {
			if (typeof this.listeners[mime] == 'undefined') {
				this.listeners[mime] = [];
			}

			if (typeof callback === 'function') {
				this.listeners[mime].push(callback);
			}
		}
	};

})();

簡單起見,只把index.html的主要測試代碼展示出來,如下:

	<div id="bd">
    	<!-- 作為mxhr輸出的展示區 -->
		<div id="mxhr-output">
			<div id="mxhr-timing"></div>
		</div>

		<!-- 作為normal輸出的展示區 -->
		<div id="normal-output">
			<div id="normal-timing"></div>
		</div>

		<script src="mxhr.js"></script>
		<script>
			// --------------------------------------
			// Test code
			// --------------------------------------

			var totalImages = 0;

			F.mxhr.listen('image/png', function(payload, payloadId) {
				var img = document.createElement('img');
				img.src = 'data:image/png;base64,' + payload;
				document.getElementById('mxhr-output').appendChild(img);

				totalImages++;
			});

/*			F.mxhr.listen('text/html', function(payload, payloadId) {
				console.log('Found text/html payload:', payload, payloadId);
			});

			F.mxhr.listen('text/javascript', function(payload, payloadId) {
				eval(payload);
			});*/

			F.mxhr.listen('complete', function() {

				var time = (new Date).getTime() - streamStart;
				document.getElementById('mxhr-timing').innerHTML = '<p>' + totalImages + ' images in a multipart stream took: <strong>' + time + 'ms</strong> (' + (Math.round(100 * (time / totalImages)) / 100) + 'ms per image)</p>';
		
				var normalStart = (new Date).getTime();
				var img;
				for (var i = 0, last = 300; i < last; i++) {
					img = document.createElement('img');
					img.src = 'icon_check.png?nocache=' + (new Date).getTime() * Math.random();
					img.width = 28;
					img.height = 22;
					document.getElementById('normal-output').appendChild(img);

					var count = 0;
					img.onload = function() {
						count++;
						if (count === last) {
							var time = (new Date).getTime() - normalStart;
							document.getElementById('normal-timing').innerHTML = '<p>' + last + ' normal, uncached images took: <strong>' + time + 'ms</strong> (' + (Math.round(100 * (time / count)) / 100) + 'ms per image)</p>';
						}
					};
				}
			});

			var streamStart = (new Date).getTime();
			F.mxhr.load('mxhr_test.php?send_stream=1');
		</script>
	</div>

測試結果:

IE8:

300 images in a multipart stream took: 178ms (0.59ms per image)

300 normal, uncached images took: 3066ms (10.22ms per image)

IE9:

300 images in a multipart stream took: 78ms (0.26ms per image)

300 normal, uncached images took: 5822ms (19.41ms per image)

Firefox 9.0.1:

300 images in a multipart stream took: 129ms (0.43ms per image)

300 normal, uncached images took: 10278ms (34.26ms per image)

Chrome 16:

300 images in a multipart stream took: 499ms (1.66ms per image)

300 normal, uncached images took: 2593ms (8.64ms per image)

Safari 5.1.2:

300 images in a multipart stream took: 50ms (0.17ms per image)

300 normal, uncached images took: 2504ms (8.35ms per image)

Opera 11.60:

300 images in a multipart stream took: 75ms (0.25ms per image)

300 normal, uncached images took: 1060ms (3.53ms per image)

測試數據不一定很准確,只能顯示一定程度上的差別。

要是對mxhr感興趣,可以猛擊這里跳至官網:Multipart XHR,也可以直接下載,然后在本地測試(需要php環境的支持)。

Mxhr的卻減少了HTTP請求的數量,但是也有瀏覽器自身的限制,由於IE6,7中的xmlhttp請求不支持readyState為3的情況,而且不支持圖片的:

img.src = 'data:image/png;base64,' + imageData;

形式解析,所以只能另尋他法,但是總體上來說,mxhr還是能夠提高網頁的整體性能的,實現請求中的“開源節流”。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM