basket.js 源碼分析


basket.js 源碼分析

一、前言

basket.js 可以用來加載js腳本並且保存到 LocalStorage 上,使我們可以更加精准地控制緩存,即使是在 http 緩存過期之后也可以使用。因此可以使我們防止不必要的重新請求 js 腳本,提升網站加載速度。

可以到 basket.js 的 Github 上查看更多的相關信息。

由於之前在工作中使用過 basket.js ,好奇它的實現原理,因此就有了這篇分析 basket.js 源碼的文章。

二、簡單的使用說明

basket.js 的使用非常簡單,只要引入相應的js腳本,然后使用 basket 的 require 方法加載就可以了,例子:

<!DOCTYPE html>
<html>
<head>
	<title>basket.js demo</title>
	<script src="basket.full.min.js"></script>
</head>
<body>
	<script>
		basket.require({url: 'helloworld.js'});
	</script>
</body>
</html>

第一次加載,由於helloworld.js 只有一行代碼alert('hello world');, 所以運行該demo時就會彈出 "hello world"。並且對應的 js 會被保存到 LocalStorage:

此時對應的資源加載情況:

刷新一次頁面,再查看一次資源的加載情況:

可以看到已經沒有再發送 helloworld.js 相關的請求,因為 LocalStorage 上已經有對應的緩存了,直接從本地獲取即可。

三、實現流程

流程圖

細節說明

處理參數

參數處理就是根據已有的參數初始化未指定的參數。例如 require 方法支持 once 參數用來表示是否只執行一次對應 JS,execute 參數標示是否加載完該 JS 之后立刻執行。所以參數處理這一步驟就會根據是否執行過該 JS 和 once 參數是否為 ture 來設置execute參數。

獲取緩存

調用 localStorage.getItem方法獲取緩存。存入的 key 值為 basket- 加上 JS 文件的 URL。以上面加載 helloworld.js 為例,key 值為:basket-helloworld.js獲取的緩存為一個緩存對象,里面包含 JS 代碼和相關的一些信息,例如:

{
"url": "helloworld.js?basket-unique=123",
"unique": "123",
"execute": true,
"key": "helloworld.js",
"data": "alert('hello world');",
"originalType": "application/javascript",
"type": "application/javascript",
"skipCache": false,
"stamp": 1459606005108,
"expire": 1477606005108
}

其中 data 屬性對應的值就是 JS 代碼。

判斷緩存是否有效

判斷比較簡單,根據緩存對象里面的版本號 unique 和過期時間 expire 等來判斷。這和瀏覽器使用 Expire 和 Etag 頭部來判斷 HTTP 緩存是否有效相似。最大的不同就是緩存完全由 JS 控制!這也就是 basket.js 最大的作用。讓我們更好的控制緩存。默認的過期時間為5000小時,也就是208.33天。

判斷代碼:

/**
 * 判斷ls上的緩存對象是否過期
 * @param   {object}   source 從ls里取出的緩存對象
 * @param   {object}   obj    傳入的參數對象
 * @returns {Boolean} 		  過期返回true,否則返回false
 */
var isCacheValid = function(source, obj) {
	return !source || // 沒有緩存數據返回true
		source.expire - +new Date() < 0  || // 超過過期時間返回true
		obj.unique !== source.unique || // 版本號不同的返回true
		(basket.isValidItem && !basket.isValidItem(source, obj)); // 自定義驗證函數不成功的返回true
};

Ajax獲取JS

普通的利用 XMLHttpRequest 請求。

緩存到LocalStorage

調用localStorage.setItem方法保存緩存對象。一般來說,只要這一行代碼就能完成本步驟。但是LocalStorage保存的數據是有大小限制的!我利用 chrome 做了一個小測試,保存500KB左右的東西就會令到 Resources 面板變卡,2M 幾乎可以令到 Resources 基本卡死,到了 5M 就會超出限制,瀏覽器拋出異常:

DOMException: Failed to execute 'setItem' on 'Storage': Setting the value of 'basket-http://file.com/ykq/wap/v3Templates/timeout/timeout/large.js' exceeded the quota

因此需要使用 try catch 對localStorage.setItem方法進行異常捕獲。當沒容量不足時就需要根據保存時間逐一刪除 LocalStorage 的緩存對象。

相關代碼:

/**
 * 把緩存對象保存到localStorage中
 * @param   {string} 	key      	ls的key值
 * @param   {object} 	storeObj 	ls的value值,緩存對象,記錄着對應script的對象、有url、execute、key、data等屬性
 * @returns {boolean}  				成功返回true
 */
var addLocalStorage = function( key, storeObj ) {
	// localStorage對大小是有限制的,所以要進行try catch
	// 500KB左右的東西保存起來就會令到Resources變卡
	// 2M左右就可以令到Resources卡死,操作不了
	// 5M就到了Chrome的極限
	// 超過之后會拋出如下異常:
	// DOMException: Failed to execute 'setItem' on 'Storage': Setting the value of 'basket-http://file.com/ykq/wap/v3Templates/timeout/timeout/large.js' exceeded the quota
	try {
		localStorage.setItem( storagePrefix + key, JSON.stringify( storeObj ) );
		return true;
	} catch( e ) {
		// localstorage容量不夠,根據保存的時間刪除已緩存到ls里的js代碼
		if ( e.name.toUpperCase().indexOf('QUOTA') >= 0 ) {
			var item;
			var tempScripts = [];

			// 先把所有的緩存對象來出來,放到 tempScripts里
			for ( item in localStorage ) {
				if ( item.indexOf( storagePrefix ) === 0 ) {
					tempScripts.push( JSON.parse( localStorage[ item ] ) );
				}
			}

			// 如果有緩存對象
			if ( tempScripts.length ) {
				// 按緩存時間升序排列數組
				tempScripts.sort(function( a, b ) {
					return a.stamp - b.stamp;
				});

				// 刪除緩存時間最早的js
				basket.remove( tempScripts[ 0 ].key );

				// 刪除后在再添加,利用遞歸完成
				return addLocalStorage( key, storeObj );

			} else {
				// no files to remove. Larger than available quota
				// 已經沒有可以刪除的緩存對象了,證明這個將要緩存的目標太大了。返回undefined。
				return;
			}

		} else {
			// some other error
			// 其他的錯誤,例如JSON的解析錯誤
			return;
		}
	}

};

生成script標簽注入到頁面

生成 script 標簽,append 到 document.head:

var injectScript = function( obj ) {
	var script = document.createElement('script');

	script.defer = true;
	// Have to use .text, since we support IE8,
	// which won't allow appending to a script
	script.text = obj.data;
	head.appendChild( script );
};

四、異步編程

basket.js 是一個典型的需要大量異步編程的庫,所以稍有不慎,代碼將會高度耦合,臃腫難看。。。

所以 basket.js 引入遵從 Promises/A+ 標准的異步編程庫 RSVP.js 來這個問題。

(遵從 Promises/A+ 標准的還有 ES6 原生的 Promise 對象,jQuery 的$.Deferred 方法等)

所以 basket.js 中涉及異步編程的方法都會返回一個 Promise 對象。很好地解決了異步編程問題。例如 basket.require 方法就是返回一個promise 對象,因此需要按順序加載 JS 的時候可以這樣子寫:

basket.require({
    url: 'helloworld.js'
}).then(function() {
    basket.require({
        url: 'helloworld2.js'
    })
});

為了使代碼更好看,basket.js 添加了一個方法 basket.thenRequire,現在代碼就可以寫成這樣:

basket.require({
    url: 'helloworld.js'
}).thenRequire({
    url: 'helloworld2.js'
});

五、吐槽

其實 basket.js 算是一種黑科技,使用起來有比較多的東西要注意。例如我們無法正常使用 chrome 的 Sources 面板斷點調試,解決方法為手動在代碼里面添加debugger設置斷點。還有就是由於強制刷新頁面也不能清除 localStorage 上的緩存,所以每次修改代碼時我們都需要手動清除 localStorage,比較麻煩。當然調試時可以在 JS 文件的頭部添加localStorage.clear()解決這個問題。

還有就是 basket.js 已經好久沒有更新了,畢竟黑科技,總會被時代淘汰。而且 api 文檔也不齊全,例如上面的 thenRequire 方法是我查看源代碼時才發現的,官方文檔里面根本沒有。

最后,雖然 basket.js 應該不會在維護了,但是閱讀其源碼還是能有很多收獲,推薦大家花點時間閱讀一下。

六、源碼完整注釋

/*!
* basket.js
* v0.5.2 - 2015-02-07
* http://addyosmani.github.com/basket.js
* (c) Addy Osmani;  License
* Created by: Addy Osmani, Sindre Sorhus, Andrée Hansson, Mat Scales
* Contributors: Ironsjp, Mathias Bynens, Rick Waldron, Felipe Morais
* Uses rsvp.js, https://github.com/tildeio/rsvp.js
*/(function( window, document ) {
	'use strict';

	var head = document.head || document.getElementsByTagName('head')[0];
	var storagePrefix = 'basket-'; // 保存localStorage時的前綴
	var defaultExpiration = 5000; // 默認過期時間為5000小時
	var inBasket = []; // 保存已經執行過的js的url。輔助設置參數的execute選項。

	/**
	 * 把緩存對象保存到localStorage中
	 * @param   {string} 	key      	ls的key值
	 * @param   {object} 	storeObj 	ls的value值,緩存對象,記錄着對應script的對象、有url、execute、key、data等屬性
	 * @returns {boolean}  				成功返回true
	 */
	var addLocalStorage = function( key, storeObj ) {
		// localStorage對大小是有限制的,所以要進行try catch
		// 500KB左右的東西保存起來就會令到Resources變卡
		// 2M左右就可以令到Resources卡死,操作不了
		// 5M就到了Chrome的極限
		// 超過之后會拋出如下異常:
		// DOMException: Failed to execute 'setItem' on 'Storage': Setting the value of 'basket-http://file.com/ykq/wap/v3Templates/timeout/timeout/large.js' exceeded the quota
		try {
			localStorage.setItem( storagePrefix + key, JSON.stringify( storeObj ) );
			return true;
		} catch( e ) {
			// localstorage容量不夠,根據保存的時間刪除已緩存到ls里的js代碼
			if ( e.name.toUpperCase().indexOf('QUOTA') >= 0 ) {
				var item;
				var tempScripts = [];

				// 先把所有的緩存對象來出來,放到 tempScripts里
				for ( item in localStorage ) {
					if ( item.indexOf( storagePrefix ) === 0 ) {
						tempScripts.push( JSON.parse( localStorage[ item ] ) );
					}
				}

				// 如果有緩存對象
				if ( tempScripts.length ) {
					// 按緩存時間升序排列數組
					tempScripts.sort(function( a, b ) {
						return a.stamp - b.stamp;
					});

					// 刪除緩存時間最早的js
					basket.remove( tempScripts[ 0 ].key );

					// 刪除后在再添加,利用遞歸完成
					return addLocalStorage( key, storeObj );

				} else {
					// no files to remove. Larger than available quota
					// 已經沒有可以刪除的緩存對象了,證明這個將要緩存的目標太大了。返回undefined。
					return;
				}

			} else {
				// some other error
				// 其他的錯誤,例如JSON的解析錯誤
				return;
			}
		}

	};

	/**
	 * 利用ajax獲取相應url的內容
	 * @param   {string} 	url 請求地址
	 * @returns {object} 		返回promise對象,解決時的參數為對象:{content:'', type: ''}
	 */
	var getUrl = function( url ) {
		var promise = new RSVP.Promise( function( resolve, reject ){

			var xhr = new XMLHttpRequest();
			xhr.open( 'GET', url );

			xhr.onreadystatechange = function() {
				if ( xhr.readyState === 4 ) {
					if ( ( xhr.status === 200 ) ||
							( ( xhr.status === 0 ) && xhr.responseText ) ) {
						resolve( {
							content: xhr.responseText,
							type: xhr.getResponseHeader('content-type')
						} );
					} else {
						reject( new Error( xhr.statusText ) );
					}
				}
			};

			// By default XHRs never timeout, and even Chrome doesn't implement the
			// spec for xhr.timeout. So we do it ourselves.
			// 自定義超時設置
			setTimeout( function () {
				if( xhr.readyState < 4 ) {
					xhr.abort();
				}
			}, basket.timeout );

			xhr.send();
		});

		return promise;
	};

	/**
	 * 獲取js,保存緩存對象到ls
	 * @param   {object}   obj basket.require的參數對象(之前的處理過程中添加相應的屬性)
	 * @returns {object}  	   promise對象
	 */
	var saveUrl = function( obj ) {
		return getUrl( obj.url ).then( function( result ) {
			var storeObj = wrapStoreData( obj, result );

			if (!obj.skipCache) {
				addLocalStorage( obj.key , storeObj );
			}

			return storeObj;
		});
	};

	/**
	 * 進一步添加對象obj屬性
	 * @param   {object}   obj  basket.require的參數(之前的處理過程中添加相應的屬性)
	 * @param   {object}   data 包含content和type屬性的對象,content就是js的內容
	 * @returns {object} 		經過包裝后的obj
	 */
	var wrapStoreData = function( obj, data ) {
		var now = +new Date();
		obj.data = data.content;
		obj.originalType = data.type;
		obj.type = obj.type || data.type;
		obj.skipCache = obj.skipCache || false;
		obj.stamp = now;
		obj.expire = now + ( ( obj.expire || defaultExpiration ) * 60 * 60 * 1000 );

		return obj;
	};

	/**
	 * 判斷ls上的緩存對象是否過期
	 * @param   {object}   source 從ls里取出的緩存對象
	 * @param   {object}   obj    傳入的參數對象
	 * @returns {Boolean} 		  過期返回true,否則返回false
	 */
	var isCacheValid = function(source, obj) {
		return !source || // 沒有緩存數據返回true
			source.expire - +new Date() < 0  || // 超過過期時間返回true
			obj.unique !== source.unique || // 版本號不同的返回true
			(basket.isValidItem && !basket.isValidItem(source, obj)); // 自定義驗證函數不成功的返回true
	};

	/**
	 * 判斷緩存是否還生效,獲取js,保存到ls
	 * @param   {object}   obj basket.require參數對象
	 * @returns {object} 	   返回promise對象
	 */
	var handleStackObject = function( obj ) {
		var source, promise, shouldFetch;

		if ( !obj.url ) {
			return;
		}

		obj.key =  ( obj.key || obj.url );

		source = basket.get( obj.key );

		obj.execute = obj.execute !== false;

		shouldFetch = isCacheValid(source, obj); // 判斷緩存是否還有效

        // 如果shouldFetch為true,請求數據,保存到ls(live選項意義不明,文檔也沒有說,這里當它一只是undefined)
		if( obj.live || shouldFetch ) {
			if ( obj.unique ) {
				// set parameter to prevent browser cache
				obj.url += ( ( obj.url.indexOf('?') > 0 ) ? '&' : '?' ) + 'basket-unique=' + obj.unique;
			}
			promise = saveUrl( obj ); // 請求對應js,緩存到ls里

			if( obj.live && !shouldFetch ) {
				promise = promise
					.then( function( result ) {
						// If we succeed, just return the value
						// RSVP doesn't have a .fail convenience method
						return result;
					}, function() {
						return source;
					});
			}
		} else {
        // 緩存可用。
			source.type = obj.type || source.originalType;
			source.execute = obj.execute;
			promise = new RSVP.Promise( function( resolve ){
				// 下面的setTimeout用來解決結合requirejs使用時的加載問題。
                // setTimeout(function(){
                	debugger;
                    resolve( source );
                // },0);
			});
		}

		return promise;
	};

	/**
	 * 把script插入到head中
	 * @param {object} obj 緩存對象
	 */
	var injectScript = function( obj ) {
		var script = document.createElement('script');

		script.defer = true;
		// Have to use .text, since we support IE8,
		// which won't allow appending to a script
		script.text = obj.data;
		head.appendChild( script );
	};

    // 保存着特定類型的執行函數,默認行為是把script注入到頁面
	var handlers = {
		'default': injectScript
	};

	/**
	 * 執行緩存對象對應回調函數,把script插入到head中
	 * @param   {object}   obj 緩存對象
	 * @returns {undefined}    不需要返回結果
	 */
	var execute = function( obj ) {
        // 執行類型特定的回調函數
		if( obj.type && handlers[ obj.type ] ) {
			return handlers[ obj.type ]( obj );
		}

        // 否則執行默認的注入script行為
		return handlers['default']( obj ); // 'default' is a reserved word
	};

	/**
	 * 批量執行緩存對象動作
	 * @param   {Array} resources  緩存對象數組
	 * @returns {Array}            返回參數resources
	 */
	var performActions = function( resources ) {
		return resources.map( function( obj ) {
			if( obj.execute ) {
				execute( obj );
			}

			return obj;
		} );
	};

	/**
	 * 處理請求對象,不包括執行對應的動作
	 * @param   {object}   會把basket.require的參數傳過來,也就是多個對象
	 * @returns {object}   promise對象
	 */
	var fetch = function() {
		var i, l, promises = [];

		for ( i = 0, l = arguments.length; i < l; i++ ) {
			promises.push( handleStackObject( arguments[ i ] ) );
		}
		return RSVP.all( promises );
	};

	/**
	 * 包裝promise的then方法實現鏈式調用
	 * @returns {Object} 添加了thenRequire方法的promise實例
	 */
	var thenRequire = function() {
		var resources = fetch.apply( null, arguments );
		var promise = this.then( function() {
			return resources;
		}).then( performActions );
		promise.thenRequire = thenRequire;
		return promise;
	};

	window.basket = {
		require: function() { // 參數為多個請求相關的對象,對象的屬性:url、key、expire、execute、unique、once和skipCache等
			// 處理execute參數
			for ( var a = 0, l = arguments.length; a < l; a++ ) {
				arguments[a].execute = arguments[a].execute !== false; // execute 默認選項為ture
				
                // 如果有只執行一次的選項once,並之前已經加載過這個js,那么設置execute選項為false
				if ( arguments[a].once && inBasket.indexOf(arguments[a].url) >= 0 ) {
					arguments[a].execute = false;
				// 需要執行的請求的url保存到inBasket,
				} else if ( arguments[a].execute !== false && inBasket.indexOf(arguments[a].url) < 0 ) {  
					inBasket.push(arguments[a].url);
				}
			}

			var promise = fetch.apply( null, arguments ).then( performActions );

			promise.thenRequire = thenRequire;
			return promise;
		},

		remove: function( key ) {
			localStorage.removeItem( storagePrefix + key );
			return this;
		},

		// 根據key值獲取對應ls的value
		get: function( key ) {

			var item = localStorage.getItem( storagePrefix + key );
			try	{
				return JSON.parse( item || 'false' );
			} catch( e ) {
				return false;
			}
		},

		// 批量清除緩存對象,傳入true只清除過期對象
		clear: function( expired ) {
			var item, key;
			var now = +new Date();

			for ( item in localStorage ) {
				key = item.split( storagePrefix )[ 1 ];
				if ( key && ( !expired || this.get( key ).expire <= now ) ) {
					this.remove( key );
				}
			}

			return this;
		},

		isValidItem: null, // 可以自己擴展一個isValidItem函數,來自定義判斷緩存是否過期。

		timeout: 5000, // ajax 默認的請求timeout為5s

        // 添加特定類型的執行函數
		addHandler: function( types, handler ) {
			if( !Array.isArray( types ) ) {
				types = [ types ];
			}
			types.forEach( function( type ) {
				handlers[ type ] = handler;
			});
		},

		removeHandler: function( types ) {
			basket.addHandler( types, undefined );
		}
	};

	// delete expired keys
	// basket.js 加載時會刪除過期的緩存
	basket.clear( true );

})( this, document );


免責聲明!

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



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