【Web性能】Javascript 代碼性能優化條目31條

腳本

1 腳本數量

每個<script>標籤初始下載時都會阻塞頁面渲染,減少頁面包含的<script>標籤數量有助於改善這一情況。同時,不僅是針對外鏈腳本,內嵌腳本的數量同樣也要限制。瀏覽器在解析HTML頁面的過程中每遇到一個script標籤,都會因執行腳本而導致一定的延時,因此最小化延遲時間將會明顯改善頁面的總體性能。

考慮到HTTP請求會帶來額外的性能開銷,因此下載單個100KB的文件將比下載4個25KB的文件更快。所以,減少頁面中外鏈腳本文件的數量將會改善性能。

無阻塞腳本

減少JS文件大小並限制HTTP請求數僅僅是創建響應迅速的Web應用的第一步。儘管下載單個較大的JS文件只會產生一次HTTP請求,但這麼做會鎖死瀏覽器一大段時間。因此,避免這種情況,你需要向頁面中逐步加載JS文件。

無阻塞腳本的好處在於頁面加載完成後纔會加載JS代碼。即,在window.load事件觸發後纔會下載腳本。

2 延遲腳本

HTML4中引入一個script標籤擴展屬性:defer。該屬性指明元素所含的腳本不會修改DOM,代碼能安全地延遲執行。
同時,HTML5中引入async屬性,用於異步加載腳本。asyncdefer的相同點是採用並行下載,在下載過程中不會產生阻塞。區別在於執行時機,async是加載完成後自動執行,defer需要等待頁面完成後執行。

3 動態加載腳本

如下:

let script = document.createElement("script");
script.src = "1.js";
document.head.appendChild(script);

這個新建的script元素加載了1.js文件。文件在該元素被添加到頁面時開始下載。這種方式的重點在於:無論何時啓動下載,文件的下載和執行過程不會阻塞頁面其他進程。甚至,你可以將代碼插入到<head>區域而不會影響頁面其他部分。因爲,一般而言,把新建的<script>標籤添加到<head>標籤裏比添加到<body>裏保險,尤其是在頁面加載過程中執行代碼時更是如此。當<body>中的內容沒有全部加載完成,IE可能會拋出一個“操作已終止”的錯誤信息。

使用動態腳本加載文件,返回的代碼通常會立即執行。但是,當代碼只包含供頁面其他腳本調用的接口時,就會出問題。在這種情況下,你必須跟蹤並確保腳本下載完成且準備就緒:

<script>元素接收完成時會觸發一個load事件。你可以通過監聽該事件來獲得腳本加載完成時的狀態:

let script = document.createElement("script");
script.onload = function() {
	console.log("script loaded!");
}
script.src = "1.js";
document.head.appendChild(script);

而IE支持另一種實現方式,它會觸發一個readystatechange事件,script元素提供一個readyState屬性,它的值在外鏈文件的下載過程的不同階段會發生變化,共5種取值:

  • “uninitialized”:初始狀態
  • “loading”:開始下載
  • “loaded”: 下載完成(關注)
  • “interactive”: 所有數據已準備就緒(關注)

實際中,最有用的兩個狀態是"loaded"和"complate"。IE下,readyState的值並不一致,有事到達loaded狀態不會到達complate;有時甚至不經過loaded就到達complate。所以,最保障的方式是對兩種狀態同時檢查,只要有一個觸發,就移除readystatechange事件處理器。下面,我們把這個過程放在一個函數中處理:

	function loadScript(url,callback) {
		let script = document.createElement("script");
		if(script.readyState) {
			script.onreadystatechange = function() {//IE
				if(script.readyState == "loaded" || script.readyState == "complate") {
					script.onreadystatechange = null;//移除`readystatechange`事件處理器
					callback();
				}else {//其他瀏覽器
					script.onload = function() {
						callback();
					}
				}
				script.src = url;
				document.head.appendChild(script);
			}

		}
	}

如果需要的話,你可以動態加載儘可能多的JS文件到頁面。但是:要考慮清楚文件的加載順序。在所有主流瀏覽器中,只有Firefox和Opera能保證腳本會按照你指定的順序執行,其他瀏覽器將會按照從服務器返回的順序下載和執行代碼。

對於多個文件,更好的做法還是把它們合併爲一個文件。

4 XHR腳本注入

XHR腳本注入是另一種無阻塞腳本加載方法。

	let xhr = new XMLHttpRequest();
	xhr.open("get","1.js",true);
	xhr.onreadystatechange = function() {
		if(xhr.readyState === 4) {
			if(xhr.status => 200 && xhr.status < 300 || xhr.status === 304) {
				let script = document.createElement("script");
				script.text = xhr.responseText;
				document.head.appendChild(script);
			}
		}
	}
  • 優點:你可以下載JS代碼但不立即執行。由於代碼是在script標籤之外返回的,因此它下載後不會自動執行,這使得你可以把腳本的執行 推遲到你準備好的時候。

  • 優點:在主流瀏覽器中能工作,不存在兼容性問題

  • 缺點:xhr不支持跨域。

大型Web應用通常不會採用XHR腳本注入方式。


作用域

Javascript中有4中基本的數據存取位置:

  • 字面量:只代表本身,不存儲在特定位置。JS中的字面量有:字符串、數字、布爾值、對象、數組、函數、正則表達式、null、undefined.

  • 本地變量:使用關鍵詞let/const/var 定義的數據存儲單元

  • 數組元素:存儲在js數組對象內部,以數字作爲索引;

  • 對象成員:存儲在JS對象內部,以字符串作爲索引;

每一種數據存儲的位置都有不同的讀寫消耗。從一個字面量和一個局部變量中存取數據的性能差異是微不足道的。訪問數組元素和對象成員的代價則高一些,很大程度上取決於瀏覽器。

如果在乎運行速度,儘量使用字面量和局部變量,減少數組項和對象成員的使用。

作用域對JS有許多影響,從確定哪些變量可以被函數訪問,到確定this的賦值。要理解性能和作用域的關係,首先要正確理解作用域的工作原理。

5 作用域鏈和標識符解析

每一個javascript函數是Function對象的一個實例。其中它有一個內部屬性:[[Scope]],包含了一個函數被創建的作用域中對象的集合。這個集合被稱爲函數的 作用域鏈。它決定哪些數據能被函數訪問。函數作用域中每個對象被稱爲一個可變對象,每個可變對象都以“鍵值對”的形式存在。當一個函數創建後,它的作用域鏈被創建爲此函數的作用域中可訪問的數據對象所填充

例如:

function add(a,b) {
	let sum = a + b;
	return sum;
}

當 函數add()創建時,它的作用域鏈中插入了一個對象變量,這個全局對象代表着所有在全局範圍內定義的變量。該全局對象包含如:window、navigator、document等,如圖:

在這裏插入圖片描述
Fig1. 函數add的作用域鏈

假設,我們調用add函數let total = add(3,4),現在看看add函數在執行時的作用域鏈:

在這裏插入圖片描述
Fig2. 函數add執行期的作用域鏈

執行此函數會創建一個執行環境(執行上下文execution context)內部對象一個執行環境定義了一個函數執行時的環境。函數每次執行時對應的執行環境都是獨一無二的所以多次調用同一個函數就會導致創建多個執行環境。
當函數執行完畢,執行環境就被銷燬

每個執行環境,都有自己的作用域鏈,用於解析標識符。當執行環境被創建時,它的作用域鏈初始化爲當前運行函數的[[Scope]]屬性中的對象。這些值按照它們出現在函數中的順序,被複制到執行環境的作用域鏈中。

這個過程一旦完成,一個被稱爲活動對象的新對象就爲執行環境創建好了。

活動對象:作爲函數運行時的變量對象,包含了所有局部變量,命名參數,參數集合以及this。然後此對象被推入作用域鏈的最前端。但執行環境被銷燬,活動對象也銷燬。

在函數執行過程中,每遇到一個變量,都會經歷一次標識符解析過程,用以決定從哪裏獲取或存儲數據。該過程搜索執行環境的作用域鏈,查找同名的標識符。搜索過程從作用域鏈頭部開始,即當前運行函數的活動對象

如果找到,就使用這個標識符對應的變量
如果沒有找到,繼續搜索作用域鏈中的下一個對象。搜索過程會持續進行,直到找到標識符。若無法搜索到,那麼標識符將被視爲未定義(undefined)
在函數執行過程中,每個標識符都要經歷這樣的搜索過程。正是這個搜索過程影響了性能

注意:如果名字相同的兩個變量存在於作用域鏈的不同部分,那麼標識符就是遍歷作用域鏈時最先找到的那個,即第一個變量遮蔽了第二個

6 標識符解析性能

在執行環境的作用域鏈中,一個標識符所在的位置越深,它的讀寫速度也就越慢。因此,函數中讀寫局部變量總是最快的。而讀寫全局變量通常是最慢的

記住:全局變量總是存在於執行環境作用域鏈的最末端,因此它是最遠的

一個好的經驗法則是:如果某個跨作用域的值在函數中被引用一次以上,那麼就把它存儲到局部變量。

例如:

let links = document.getElementByTagName("a"),
	bd = document.body;
====>
let doc = document, 
	 links = doc.getElementByTagName("a"),
	 bd = doc.body;
//訪問全局變量的次數從2次,減少到1次
  • document是全局對象,搜索該變量的過程必須遍歷整個作用域鏈,直到最後在全局變量對象中找到。所以:我們將全局變量的引用存儲在一個局部變量中,然後使用這個局部變量代替全局變量。

7 改變作用域鏈

一個執行環境的作用域是不會改變的。但是,有2個語句可以在執行時臨時改變作用域鏈:

  • with():給對象的所有屬性創建了一個變量,它包含了參數指定的對象的所有屬性。這個對象被推入作用域鏈首位,這意味着函數的所有局部變量現在處於第二個作用域鏈對象中,因此訪問的代價更高!因此,要避免使用with()
  • try-catch:catch中也有同樣的效果。當try代碼中發生錯誤,執行過程會自動跳到catch子句,然後把異常對象推入一個變量對象並置於作用域鏈首位。一旦catch子句執行完畢,作用域鏈就會返回到之前的狀態。
try {
	//發生錯誤,進入catch
}catch(ex) {
	console.log(ex.message); //作用域鏈在此處改變,ex.message 該代碼訪問了局部變量
}

但是try-catch是個有用的語句,它不應該用來解決javascript錯誤。一種推薦的做法是將錯誤委託給一個函數來處理:

try {
	//發生錯誤,進入catch
}catch(ex) {
	handleError(ex);//委託給錯誤處理函數,由於只有一條語句,且沒有局部變量的訪問,作用域鏈的臨時改變就不會影響代碼性能
}

withtry-catch或是eval()都被認爲是動態作用域,動態作用域只存在於代碼執行過程中,因此無法通過靜態分析檢測出來。

8 閉包與內存

閉包,允許函數訪問局部作用域之外的數據。由於閉包的[[Scope]]屬性包含了與執行環境作用域鏈相同的對象的引用,因此會產生副作用。通常。函數的活動對象會隨着執行環境一同銷燬。但引入閉包時,由於引用仍然存在於閉包的[[Scope]]屬性中,因此激活對象無法被銷燬,因此腳本中的閉包與非閉包函數相比,需要更多內存開銷。在IE中,由於使用非原生javascript對象來實現DOM對象,因此閉包會導致內存泄漏

9 原型

javascript中的對象是基於原型的。原型是其他對象的基礎,它定義並實現了一個新創建的對象所必須包含的成員列表

對象通過一個內部屬性,綁定到它的原型。一旦你創建一個內置對象(如Object、Array)的實例,他們就會自動擁有一個Object實例作爲原型。

因此,對象可以有兩種成員類型:實例成員原型成員實例成員直接存在於對象實例中,原型成員則從對象原型繼承而來

你可以使用hasOwnProperty()方法來判斷對象是否包含特定的實例成員
要確定對象是否包含特定的屬性,可以使用in操作符。

let book = {
	title: 'book1',
	author: 'zzz'
}
console.log(book.hasOwnProperty('title')); //true
console.log(book.hasOwnProperty('toString')); //false
console.log("title" in book); //true
console.log("toString" in book); //true

  • 使用in操作符,會既搜索實例也會搜索原型toString在原型對象中,因此true

10 原型鏈

對象的原型決定了實例的類型。默認下,所有對象都是Object的實例,並繼承了所有基礎方法,如toString()

function Book(title,author) {
	this.title = title;
	this.author = author;
}
Bool.prototype.sayTitle = function() {
	console.log(this.title);
}
let book1 = new Book("book1","zzz");
let book2 = new Book("book2","xxx");
console.log(book1 instanceof Book); //true
console.log(book1 instanceof Object); //true
book1.sayTitle(); // "book1"
console.log(book1.toString());//[boject Object]
  • 使用構造函數Book 創建一個新的Book實例。實例Bookl1的原型(__proto__)是Book.prototype,而Book.prototype原型是Object。實例book1和book2共享同一個原型鏈。

11 嵌套成員

  • 對象成員嵌套的越深,讀取速度就會越慢。執行location.href總是比window.location.href快。
  • 大部分瀏覽器,通過點表示法(object.name)操作和通過括號表示法(object["name"])操作並沒有明顯區別。只有在Safari中,點符號始終更快,但這不意味不要用括號表示法。

12 緩存對象成員值

所有類似的性能問題都與對象成員有關。因此應該儘可能避免使用它們。應該注意:只在必要時使用對象成員。例如:在同一函數中沒有必要多次讀取同一個對象成員。將要多次讀取的對象成員屬性,存儲在一個局部變量中即可。但是,不推薦將對象的成員方法保存在局部變量中,這會導致this綁定到window,而this值的改變會使得javascript引擎無法正確解析它的成員對象。

DOM

瀏覽器通常會將DOM和Javascript操作分開獨立實現。例如:在Chrome中使用Webkit的WebCore庫來渲染頁面,使用V8引擎實現Javascript。然而,實際中我們常常需要使用Javascript操作DOM,因此兩個相互獨立的功能通過接口相互連接時,會產生消耗。

訪問DOM的次數越多,消耗就越多。因此,推薦的做法就是:
儘可能減少javascript操作DOM的次數。

13 修改元素

不僅修改DOM代價昂貴,修改元素更爲昂貴。因爲它會導致瀏覽器重新計算頁面的幾何變化。當然,最壞的情況是在循環中訪問或修改元素尤其是對HTML元素集合循環操作

例如:

function innerHTMLLoop() {
	for(let count=0;count<100000;count++) {
		document.getElementById('sum').innerHTML += 'a'
	}
}

上述代碼每次循環迭代時,id=sum的元素都會被訪問兩次:

  • 一次讀取innerHTML屬性值;
  • 一次是重寫它。

換一種方式:將局部變量存儲修改的內容,在循環結束後 一次性寫入。

function innerHTMLLoop() {
	let content = '';
;	for(let count=0;count<100000;count++) {
		content += 'a'
	}
	document.getElemenyById('sum').innerHTML += content;
}

通常的經驗是:減少訪問DOM的次數,把運算儘量瀏覽javascript中處理。
從下圖可以看到:還是有區別的哈哈。
但是,對於已優化的瀏覽器,性能上差別並不大:![在這裏插入圖片描述](https://img-blog.csdnimg.cn/20190516162530839.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0ltYWdpbmVDb2Rl,size_16,color_FFFFFF,t_70)

除此之外,在一個對性能有苛刻要求的操作中更新大段HTML,推薦使用innerHTML。因爲它在絕大部分瀏覽器中運行更快。另,也建議使用 數組來合併大量字符串,讓innerHTML的效率更高。

14 HTML集合

HTML集合是包含了DOM節點引用的類數組對象。例如,下列方法返回的是一個集合:

  • document.getElementsByName()
  • document.getElementsByClassName()
  • document.getElementsByTagName()

下列屬性同樣返回HTML集合:

  • document.images : 頁面中所有img元素
  • document.links:頁面中所有a元素
  • document.forms:所有表單元素
  • document.forms[0].elements : 頁面中第一個表單的所有字段

它們不是真正的數組,而是類數組列表(因爲沒有push()或slice()等方法)。但提供了一個類似數組中的length屬性。並且還能以數字索引的方式訪問列表中的元素。

HTML集合以一種”假定實時態“的形式實時存在,這意味着當底層文檔對象更新時,它也會自動更新。

HTML集合一直與文檔保持連接,每次你需要最新的信息時,都會重複執行查詢的過程,哪怕只是獲取集合裏的元素個數。所以,這是低效的源頭。

在循環的條件中讀取數組的length屬性是不推薦的做法。讀取一個集合的length比讀取普通數組的length要慢的多。因爲每次都要重新查詢。

  • 處理辦法:將集合拷貝到數組中
//集合拷貝至數組中函數
function toArray(coll) {
	for(let i=0,a=[],len=coll.length;i<len;i++) {
		a[i] = coll[i];
	}
	return a;
}
let coll = document.getElementsByTagName('div');//獲取div元素集合
let arr = toArray(coll);//將集合拷貝到數組中

在循環中讀取:

//較慢
function loopCollection() {
	for(let count=0;count<coll.length;count++) {
		//todo
	}
}
function loopCopiedArray() {
	for(let count=0;count<arr.length;count++) {
		//todo
	}
}

每次迭代過程中,讀取元素集合的length屬性會引發集合進行 更新。這在所有瀏覽器中都有明顯的性能問題 。我們再次進行優化:將集合的長度緩存到一個局部變量中,然後再循環的條件退出語句中使用該變量:

function loopCacheLengthCollection() {
	let coll = document.getElementsByTagName('div'),
	coll = coll.length;
	for(let count=0;count<len;count++) {
		//todo
	}
}

但是,將集合拷貝到數組中,會帶來額外的開銷。而且會多遍歷一次集合,因此應當評估在特定條件下使用數組拷貝是否有幫助。

15 遍歷DOM

通常你需要某一個DOM元素開始,操作周圍元素,或者遞歸查找所有子節點。你可以使用childNodes得到元素集合,或者用nextSibling獲取每個相鄰元素。
childNodes返回的是一個元素集合,在循環中我們應該緩存length屬性避免在每次迭代中更新。

大部分現代瀏覽器提供的API只返回元素節點。如果可用的話推薦使用這些API,效率會更高。例如:

屬性名 被代替的屬性
children childNodes
childElementCount childNodes.length
firstElementChild firstChild
lastElementChild lastChild
nextElementSibling nextSibling
previousElementSibling previousSibling
使用children替代childNodes會更快,因爲集合項更少。
HTML源碼中的空白實際上是文本節點,而且它並不包含在children集合中。
children都比 childNodes要快,儘管不會快太多。

另外,querySelectorAll()使用CSS選擇器作爲參數並返回一個NodeList,包含着匹配節點的類數組對象。這個方法不會返回HTML集合,因此返回的節點不會對應實時的文檔結構。

如果需要處理大量組合查詢,該方法會更高效。同時,querySelector()可以獲取第一個匹配的節點。

16 重繪和重排

瀏覽器下載完頁面中的所有組件——HTML標記、javascript、css、圖片之後會解析並生成兩個內部數據結構:

  • DOM樹:表示頁面結構
  • 渲染樹:表示DOM節點如何顯示

DOM樹中的每一個需要顯示的節點在渲染樹中至少存在一個對應的節點(隱藏的DOM元素在渲染樹中沒有對應的節點)。渲染樹中的節點被稱爲”幀 frames“或”盒 boxes“,可理解成頁面元素爲一個具有內邊距padding、外邊距margin、邊框border和位置position的盒子。一旦DOM和渲染樹構建完成,瀏覽器就開始繪製/顯示頁面元素。

  • 重排reflow:當DOM的變化影響了元素的幾何屬性(寬和高)——比如改變邊框寬度或給段落增加文字,導致行數增加——瀏覽器需要重新計算元素的幾何屬性,同樣其他元素的幾何屬性和位置也會因此受到影響。瀏覽器會使渲染樹中受到影響的部分失效,並重新構造渲染樹

  • 重繪repaint完成重排後,瀏覽器會重新繪製受影響的部分到屏幕中,該過程爲”重繪repaint“。

當然,並不是所有的DOM變化都會影響幾何屬性。例如:改變一個元素的背景色並不會影響它的寬和高。這種情況,只會發生一次重繪,不需要重排,因爲元素的佈局沒有改變。

因此,重排會導致重繪。重繪不一定會導致重排。

  • 重排發生的時機:
  1. 添加或 刪除可見的DOM元素
  2. 元素位置該百年
  3. 元素尺寸改變(如:margin、padding、border-width、width、height等屬性改變)
  4. 內容改變,如文本改變或圖片被另一個不同尺寸的圖片替代
  5. 頁面渲染器初始化
  6. 瀏覽器窗口尺寸改變

有些改變會觸發整個頁面的重排,例如:滾動條出現時。

  • 渲染樹變化的排隊與刷新
    大多數瀏覽器通過隊列化修改並批量執行來優化重排過程。獲取佈局信息的操作也會導致隊列刷新,如下面的屬性和方法:

  • offsetTop,offsetLeft,offsetWidth,offsetHeight

  • scrollTop,scrollLeft,scrollWidth,scrollHeight

  • clientTop,clientLeft,clientWidth,clientHeight

  • getComputedStyle()

以上屬性和方法需要返回最新的佈局信息,因此瀏覽器不得不執行渲染隊列中“待處理變化”並觸發重排以返回正確的值。

所以,在修改樣式的過程中,最好避免使用上面列出的屬性或方法。它們都會刷新渲染隊列,即使你是在獲取最近未發生改變的或者與最近改變無關的佈局信息。

一個更有效的方法是不要在佈局信息改變時進行查詢。

  • 最小化重排和重繪

重排和重繪的代價昂貴,減少此類操作的發生能提升性能。爲了減少發生次數,應該合併多次對DOM和樣式的修改,然後一次性處理

  1. 批量修改樣式

例如:下面這段代碼,糟糕的情況下會發生三次重排。

let el = document.getElementById('mydiv');
el.style.borderLeft = '1px';
el.style.borderRight = '2px';
el.style.padding = '5px';

一個能達到同樣效果且效率更高的方式是:合併所有的改變然後一次處理,這樣只會修改DOM一次。這裏我們使用cssText處理:

let el = document.getElementById('mydiv');
el.style.cssText = 'borderLeft : 1px;border-right:2px;padding:5px;';

上述代碼修改cssText屬性並覆蓋了 已存在的樣式信息,因此如果想保留現有樣式,可以把它附加在cssText字符串後面。

el.style.cssText += ';border-left:1px;';

另一個辦法:修改CSS的class名稱,這使得改變CSS的class名稱的方法更清晰,如:

el.className = 'active';

  1. 批量修改DOM

可以通過下列步驟減少重繪和重排的次數:

1.使元素脫離文檔流(重排)
2. 對其應用多重改變
3. 把元素待會文檔中(重排)

該過程裏觸發兩次重排,即1和3。如果你忽略這兩個步驟,那麼在第二步產生的任何修改都會觸發一次重排。

有3種基本方法可以使DOM脫離文檔:

  • 隱藏元素,應用修改,重新顯示
let ul =  document.getElementById('lists');
ul.style.display = 'none';//臨時隱藏
...
ul.style.display = 'block';//然後再恢復
  • 推薦使用文檔片段(document fragment)在當前DOM之外構建一個子樹,再把它拷貝迴文檔。這項技術所產生的DOM遍歷和重排次數最少。
//文檔片段是一個輕量級的document對象,它能更新和移動節點。
//文檔片段一個便利的語法特性是:
//當你附加一個片段到節點時,實際上被添加的是該片段的子節點,而不是片段本身。

//html
<ul id="ul">
</ul>
//js
var element  = document.getElementById('ul'); // assuming ul exists
var fragment = document.createDocumentFragment();
var browsers = ['Firefox', 'Chrome', 'Opera', 
    'Safari', 'Internet Explorer'];

browsers.forEach(function(browser) {
    var li = document.createElement('li');
    li.textContent = browser;
    fragment.appendChild(li);//添加到文檔片段中
});

element.appendChild(fragment);//把文檔片段添加到ul容器中

  • 原始元素拷貝到一個脫離文檔的節點中修改副本,完成後再替換原始元素
let old = document.getElementById('list');
let clone = old.cloneNode(true);;//返回調用該方法的節點的一個副本.
...對副本進行操作...
old.parentNode.replaceChild(clone,old);//使用新節點代替舊的節點

17 緩存佈局信息

當你查詢佈局信息時,比如獲取偏移量(offsets)、滾動位置(scroll )或計算出的樣式值時,瀏覽器爲了返回最新值,會刷新隊列並應用所有變更。

最好的做法是:

  • 儘量減少佈局信息的獲取次數,獲取後把它賦值給局部變量,然後再操作局部變量

18 讓元素脫離動畫流

重排隻影響渲染樹中的一小部分。但也可能影響很大部分,甚至整個渲染樹,瀏覽器所需要重排的次數越少,應用程序的響應速度越快。因此當頁面頂部的一個動畫推移頁面整個餘下的部分時,會導致一次代價昂貴的大規模重排

避免大規模重排:

  1. 使用絕對定位(position:absolute)定位頁面上的動畫元素,將其脫離文檔流
  2. 讓元素動起來。當它擴大時,會臨時覆蓋部分頁面。但這只是頁面一個小區域的重繪過程,不會產生重排並重繪頁面的大部分內容
  3. 當動畫結束時恢復定位,從而只會下移一次文檔的其他元素。

19 事件委託

事件綁定佔用了處理時間,同時瀏覽器要跟蹤每個事件處理器,也會佔用更多內存。

事件委託:事件逐層冒泡並能被父級元素捕獲。使用事件代理,只需要給外層元素綁定一個處理器,就可以處理在其子元素上出發的所有事件。

每個事件都要經歷3個階段:

  • 捕獲
  • 到達目標
  • 冒泡

IE不支持捕獲,但對於委託而言,冒泡已經足夠。

document.getElementById(menu').onclick = function(e) {
	//瀏覽器target,判斷事件源
	e = e || window.event;
	let target = e.target || e.srcElement;
	let pageid,hrefparts;
	//非鏈接點擊則退出
	if(target.nodeName !=='A') {
		return;
	}
	//從鏈接中找出頁面ID
	hrefparts = target.href.split('/');
	pageid = hrefparts[hrefparts.length-1];
	pageid = pageid.replace('.html','');
	//更新頁面
	...
	//阻止瀏覽器默認行爲並取消冒泡
	if(typeof e.preventDefault === 'function') {
		e.preventDefault();//阻止瀏覽器默認行爲
		e.stopPropagation();//阻止冒泡
	}else {
		e.returnValue = false;
		e.cancelBubble = true;//取消冒泡
	}
}

控制流程

代碼數量少並不意味着運行速度快,代碼數量多也不意味着運行速度一定慢。代碼的組織結構和解決問題的思路是影響代碼性能的主要因素。

循環

  • 標準for循環for(var i=0;i<10;i++){//循環祖逖}var語句會創建一個函數級的變量,而不是循環級。由於javascript只有函數級作用域,因此在for循環中 定義一個新變量相當於在循環體外定義一個新變量

  • while:前測循環

  • do-while:後測循環

  • for-in:它可以枚舉任何對象的屬性名for(var prop in object){}

20 循環性能

不斷引發循環性能爭論的源頭是循環類型的選擇。javascript四種循環中,只有for-in循環比其他幾種要慢。

由於每次迭代操作會同時搜索實例或原型屬性,for-in循環的每次迭代都會產生更多開銷,所以比其他循環類型慢。因此,避免使用for-in遍歷對象或數組成員。

優化循環性能有兩個可選因素:

  • 每次迭代處理的事務
  • 迭代的次數
  1. 減少迭代處理的事務:例如,查找成員屬性時,可以把值存儲到一個局部變量,然後再控制語句中使用這個變量。其次,你可以通過顛倒數組的順序來提高循環性能。數組項的順序與所要執行的任務無關,因此從最後一項開始向前處理是個備選方案。例如:
var  i=0,len = obj.length;
while(i<len){
...
}
//倒序
var  j = obj.length;
while(j--) {
...
}
迭代次數從兩次(1要比較總數;2要判斷是否爲true)減少到1次(判斷是否爲true)
  1. 減少迭代次數:廣爲人知的一種限制循環迭代次數的模式被稱爲“達夫設備”。它是一種循環體展開技術,使得一次迭代中實際上執行了多次迭代的操作。如:
var a = [0, 1, 2, 3, 4];
var sum = 0;
for(var i = 0; i < 5; i++)
  sum += a[i];
console.log(sum);

我們將循環體展開來寫:

var a = [0, 1, 2, 3, 4];
var sum = 0;
sum += a[0];
sum += a[1];
sum += a[2];
sum += a[3];
sum += a[4];
console.log(sum);

因爲少作了多次的for循環,很顯然這段代碼比前者效率略高,而且隨着數組長度的增加,少作的for循環將在時間上體現更多的優勢。

達夫設備這種思想或者說是策略,原來是運用在C語言上的,Jeff Greenberg將它從C語言移植到了JavaScript上,我們可以來看看他寫的模板代碼:

var iterations = Math.floor(items.length / 8),
  startAt = items.length % 8,
  i = 0;
 
  do {
    switch(startAt) {
      case 0: process(items[i++]);
      case 7: process(items[i++]);
      case 6: process(items[i++]);
      case 5: process(items[i++]);
      case 4: process(items[i++]);
      case 3: process(items[i++]);
      case 2: process(items[i++]);
      case 1: process(items[i++]);
    }
    startAt = 0;
  } while(--iterations);

注意看switch/case語句,因爲沒有寫break,所以除了第一次外,之後的每次迭代實際上會運行8次!Duff’s Device背後的基本理念是:每次循環中最多可調用8次process()。循環的迭代次數爲總數除以8。由於不是所有數字都能被8整除,變量startAt用來存放餘數,便是第一次循環中應調用多少次process()。

此算法一個稍快的版本取消了switch語句,將餘數處理和主循環分開:

var i = items.length % 8;
while(i) {
  process(items[i--]);
}
 
i = Math.floor(items.length / 8);
 
while(i) {
  process(items[i--]);
  process(items[i--]);
  process(items[i--]);
  process(items[i--]);
  process(items[i--]);
  process(items[i--]);
  process(items[i--]);
  process(items[i--]);
}

儘管這種方式用兩次循環代替了之前的一次循環,但它移除了循環體中的switch語句,速度比原始循環更快。

目前,老版本的瀏覽器運用達夫設備優化性能能得到大幅度的提升,而新版的瀏覽器引擎肯定對循環迭代語句進行了更強的優化,所以達夫設備能實現的優化效果日趨減弱甚至於沒有。

21 基於函數的迭代

  • Array.forEach(function(value,index,array){}):該方法由於每個數組項要調用外部方法,所以會帶來開銷。

在所有情況下,基於循環的迭代比基於函數的迭代要快8倍,因此在運行速度嚴格的情況下,基於函數的迭代不是合適的選擇。

條件語句

使用if-else還是switch,常基於測試條件的數量來判斷:條件數量越大,越傾向於使用switch。因爲大多數語言對switch採用分支表索引進行優化。

那麼,如果是if-else,那要怎麼優化呢:

  • 確保最可能出現的條件放在首位。即,語句中應該總是按照最大概率到最小概率的順序排列
  • if-else組織成一系列嵌套的if-else語句。使用單個龐大的if-else語句通常會導致運行緩慢。如:
if(value<6) {
	if(value<3) {//嵌套
		
	}else if() {}
}

使用二分法把值域分成一系列的區間,然後逐步縮小範圍。這個方法非常適用於有多個值域需要測試的時候。

另一種方法:查找表,在JS中可以使用數組和普通對象來構建查找表。通過查找表訪問數據比用if-else或switch快很多。特別是在條件語句數量很大的時候。

如:

switch(value) {
	case 0: 
		return result0;
	case 1:
		return result1;
	...
	case 9:
		return result9;
	default:
		return result10;
}

使用查找表:

let results = [result0,result1,result2,...,result10];
...
return result[value];

當使用查找表時,這個過程就變成了數組項查詢或對象成員查詢。查找表的一個主要優點是:不用書寫任何條件判斷語句,即便候選值數量增加時,也幾乎不會產生額外性能開銷。

當單個鍵和單個值之間存在邏輯映射時,查找表的優勢就能體現出來。switch語句更適合與每個鍵都有對應的獨特的操作或一系列操作的場景。

22 調用棧

除了IE中調用棧的大小與系統內存相關,其他瀏覽器都有固定數量的調用棧限制。常常調用棧發送錯誤時會報stack相關的錯誤,如Maximum call stack size exceeded

最常見的導致棧溢出的原因是不正確的終止條件。如果終止條件沒問題,那麼可能是算法中包含了太多層的遞歸,那麼我們可以用迭代、Memoization等來代替遞歸。

  • Memoization():它是一種避免重複工作的方法。它緩存前一個計算結果供後續計算使用,避免重複工作。

memoize函數:接收2個參數,一個是需要增加緩存功能的函數,一個是可選的緩存對象。如果你需要預設一些值,就給緩存對象傳入一個預設的緩存對象,否則會創建一個新的緩存對象。然後創建一個封裝了原始函數(fundamental)的外殼函數(shell),以確保只有當一個結果值之前從未被計算過時會產生新的計算。

function memoize(fundamental,cache) {
	cache = cache || {};
	let shell = function(arg) {
		if(!cache.hasOwnProperty(arg)){
			cache[arg] = fundamental(arg);
		}
		return cache[arg];
	};
	return shell;
}

調用:

//緩存該階乘函數
let memfactorial = memoize(factorial,{"0",1,“1”,1});
//調用新函數
let fact6 = memfactorial(6);
let fact5 = memfactorial(5);
let fact4 = memfactorial(4);

字符串操作優化

23 字符串連接

+、+=是字符串連接最簡單的方法。例如:

str += "one" + "two";此代碼會經歷4個步驟:

  1. 內存中創建一個臨時字符串
  2. 連接後的字符串"onetwo"被賦值給該臨時字符串
  3. 臨時字符串與str當前的值連接
  4. 結果賦值給str。

實際上,我們可以用一個語句提升性能:

`str = str+“one”+“two”;

賦值表達式由str開始作爲基礎,每次給它附加一個字符串,由左向右依次連接,因此避免了使用臨時字符串。如果改變連接順序(如:str = "one"+str+"two";),優化向失效。這與瀏覽器合併字符串時分配內存的方法有關。除IE外,其他瀏覽器會嘗試爲表達式左側的字符串分配更多的內存,然後簡單地將第二個字符串拷貝到它的末尾。如果在一個循環中,基礎字符串位於最左端的位置,就可以避免重複拷貝一個逐漸變大的基礎基礎字符串。

基礎字符串:理解爲連接時排在前面的字符串。如:str+one意味着拷貝one並附加在str之後,而one+str則意味着拷貝str並附加在one之後。str如果很大,拷貝過程的性能損耗就很高。

24 String.prototype.concat

字符串原生方法concat能接收任意數量的參數,並將每一個參數附加到所調用的字符串上。例如:

str = str.concat(s1); //附加一個字符串
str = str.concat(s1,s2,s3);//附加3個字符串
str = String.prototype.concat.apply(str,array); //如果傳遞一個數組,可以附加數組中所有字符串

但是,大多數情況下,String.prototype.concat++=稍慢。

定時器

瀏覽器的UI線程的工作,基於一個簡單的隊列系統。任務會被保存到隊列中直到進程空閒。一旦空閒,隊列中的下一個任務就被重新提取出來並運行。這些任務要麼是運行Javascript代碼,要麼是執行UI更新,包括重繪和重排。也會這個進程中最有趣的部分在於每一次輸入(如:用戶事件)可能會導致一個或多個任務被添加到隊列。

隊列在空閒狀態下是理想的,因爲用戶所有的交互都會立刻觸發UI更新。如果用戶試圖在任務運行期間與頁面交互,不僅沒有即時的UI更新,甚至可能新的UI更新任務都不會被創建並加入隊列。事實上,大多數瀏覽器在Javascript運行時會停止把新任務加入UI線程的隊列中,也就是說Javascript任務必須儘快結束,避免對用戶體驗造成不良影響。

瀏覽器限制Javascript的運行時間的方式通常有2種:

  • 調用棧大小限制(上節提到過)
  • 長時間運行腳本限制:瀏覽器會記錄一個腳本的運行時間,並在達到一定限度時終止它。不同瀏覽器檢測腳本運行時間的方法略有不同。IE默認是限制500萬條語句;Firefox默認限制10秒;Safari默認限制爲5秒;Chrome沒有單獨的長運行腳本限制,其依賴其通過的奔潰檢測系統來處理該問題;Opera沒有該限制,它會讓Javascript執行直到結束。

早在1968年,Robert在他的論文《Response time in man-computer conversational transactions》中提到:“單個Javascript操作花費的總時間不應該超過100毫秒”。這個論證至今仍被重申。如果界面在100毫秒內響應用戶輸入,用戶會認爲自己在“直接操縱界面中的對象”。超過100毫秒意味着用戶會感到自己與界面失去聯繫。 所以,我們應該限制所有Javascript任務在100毫秒內或更短時間內完成。

25 使用定時器讓出時間片段

最理想的方法是讓出UI線程的控制權,使得UI可以更新。讓出控制權意味着停止執行Javascript,使UI線程有機會更新,然後再繼續執行Javascript。定時器與UI線程的交互方式有助於把運行耗時較長的腳本拆分爲較短的片段。定時器會告訴Javascript引擎先等待一定時間,然後添加一個Javascript任務到UI隊列。例如:

function greeting() {
	alert("hello");
}
setTimeout(greeting,300);

這段代碼將在300毫秒後,向UI隊列插入一個執行greeting()函數的Javascript任務。在這個時間之前所有其他UI更新和Javascript任務都會執行。setTimeout()的第二個參數表示任務何時被添加到UI隊列,而不是一定會在這段時間後執行。這個任務會等待隊列中其他所有任務執行完畢纔會執行。所以,定時器代碼只有在創建它的函數執行完成後,纔有可能執行。也因此,定時器不可用於測量實際時間。
如果定時器之前的任務在300毫秒後沒有執行完成,那麼該定時器會在這些任務完成後立即執行。

無論發生何種情況,創建一個定時器會造成UI線程暫停,如同它從一個任務切換到下一個任務。因此,定時器代碼會重置所有相關瀏覽器限制,包括長時間運行腳本定時器。此外,調用棧也在定時器的代碼中重置爲0。

定時器中,setInterval()setTimeout()最主要的區別:如果UI隊列中已經存在由同一個setInterval()創建的任務,那麼後續任務不會被添加到UI隊列中。

26 使用定時器處理數組

是否可用定時器處理數組有2個決定性因素:

  • 處理過程是否必須同步
  • 數據是否必須按順序處理

如果這兩個問題的答案都是“否”,那麼代碼將適用於定時器分解任務。一種基本的異步代碼模式如下:

function processArray(items,process,callback) {
	let todo = items.concat(); //克隆原數組
	setTimeout(function(){
	let start =  +new Date();//+號能將Date對象轉換爲數字
		//取得數組的下個元素並進行處理
		do {
			process(todo.shift());//返回數組第一個元素,並將其從數組中刪除
		}while(todo.length>0 && (+new Date() - start < 50));//使用時間檢測機讓定時器能處理多個數組元素
		//如果todo中還有需要處理的元素,創建另一個定時器
		if(todo.length>0) {
			setTimeout(arguments.callee,25);//arguments.callee指向當前正在運行的匿名函數
		}else {
		//不再有條目需要處理
			callback(items);
		}
	},25);//25毫秒,是對於普遍情況。因爲再小的延時,對於UI更新來說不夠用
}

調用:

let items = [21,23,45,67,86,34,54];
function outputValue(value) {
	console.log(value);
}
processArray(items,outputValue,function(){
	console.log("Done!");
});

27 使用定時器分割任務

如果一個函數運行時間過長,那麼可以檢查一下是否可以把它拆分爲一系列能在較短時間內完成的子函數,把每個獨立的函數放在定時器中調用:

function multistep(steps,args,callback) {
	let tasks = steps.concat(); //克隆數組
	setTimeout(function(){
		//執行下一個任務
		let task = tasks.shift();
		task.apply(null,args||[]);
		//檢查是否還有其他任務
		if(tasks.length>0) {
			setTimeout(arguments.callee,25);
		}else {
			callback();
		}
	},25);
}

調用:

function todoList(id) {
	let tasks = [openDocument,writeText,closeDocument,updateUI];
	multistep(tasks,[id],function(){
		console.log("tasks completed!");
	})
}

儘管定時器能讓Javascript代碼性能提升,但是過度使用也會對性能造成負面影響最好的方法是:同一時間只有一個定時器存在,只有當這個定時器結束時纔會新建一個。這樣的方式不會導致性能問題。
因爲只有一個UI線程,當多個重複的定時器同時創建時,所有的定時器會爭奪運行時間。通常,我們會把多個定時器的間隔時間設爲1秒及以上,這種情況下定時器延遲遠遠超過UI線程產生瓶頸的值,可安全地使用。

數據傳輸

通常,有5中常用的向服務器請求數據的方式:

  1. XHR:只獲取數據,應該使用GET方式。經GET請求的數據會被緩存起來,如果需要多次請求統一數據的話,有助於提升性能。只有當請求的URL加上參數的長度>2048個字符,才應該使用POST方式獲取數據。
  2. 動態腳本注入:克服XHR不能跨域請求數據的方式。
let scriptElement = document.createElement('script');
scriptElement.src = 'http://xxxx.js';
document.head.appendChild(scriptElement);

但是,該方式是有限的。參數傳遞的方式只能是GET。你不能設置請求的超時處理,且必須等待所有數據返回纔可以訪問它們。
3. iframes
4. Comet
5. Multipart XHR:允許客戶端只用一個HTTP請求就可以從服務器項客戶端傳送多個資源。服務端會將多個資源(如HTML、CSS、Javascript代碼、圖片等)打包成一個有雙方約定的字符串分割的長字符串併發送到客戶端。然後用Javascript代碼處理這個長字符串,並根據他的mime-tyoe類型和傳入的其他“頭信息”解析出每個資源

例如:一個用來獲取多張圖片的請求發送到服務器

let req = new XMLHttpRequest();
req.open('GET','requestImages.php',true);//服務器代碼需要讀取圖片,並轉化爲base64編碼的字符串,然後將每張圖片的字符串合併成一個長字符串,輸出給前端
req.onreadystatechange = function() {
	if(req.readyState === 4 ) {
		splitImages(req.responseText);
	}
}
req.send(null);
function splitImages(imageString) {
	let imageData = imageString.split("\u0001");
	let imageElement;
	for(let i=0,len=imageData.length;i<len;i++) {
		imageElement = document.createElement('img');
		imageElement.src = 'data:image/jpeg;base64,'+imageData[i];
		document.getElementById('container').appendChild(imageElement);
	}
}

MXHR有一個缺點:這種方式獲得的資源不能被瀏覽器緩存。因此,適合用在頁面中無需緩存的資源,以及頁面中對多個文件已經進行打包後的文件。

28 發送數據

當數據只需要發送到服務器是,有兩種廣泛使用的技術:XHR和信標(beacons)。

  • XHR:
function xhrPost(url,name,callback){
	let req = new XMLHttpRequest();
	req.onerror = function() {
		setTimeout(function(){
			xhrPost(url,params,callback);//失敗時,重試
		},1000);
	};
	req.onreadystatechange = function() {
		if(req.readyState === 4) {
			if(callback && typeof callback === 'function') {
				callback();
			}
		}
	};
	req.open('POST',url,true);
	req.setRequestHeader('Content-Type','application/x-www-form-urlencoded');
	req.setRqeuestHeader('Content-Length',params.length);
	req.send(params.join('&'));
}

當使用XHR發送數據到服務器,GET方式會更快。因爲,對於少量數據而言,一個GET請求往服務器只發送一個數據包。一個POST請求,至少發送兩個數據包。一個裝載頭信息,另一個裝載POST正文。 POST更適合發送大量數據到服務器,因爲它不關心額外佈局包的數量。另一個原因:IE對URL長度有限制,不可能使用過長的GET請求。

  • 信標(Beacons):類似動態腳本注入。例如,圖片信標:使用Javascript創建一個新的image對象,把src屬性設置爲服務器上腳本的URL。該URL包含了我們要通過GET傳回的鍵值對數據。當然:需要注意,我們沒有創建img元素,並把它插入到DOM中。
let url = '/status_track.php';
let params = [
	'step=2',
	'time=1234323423'
];
let beacon = new Image();
beacon.src = url + '?' + params.join('&');
beacon.onload = function() {
	if(this.width === 1) {
		//success
	}else if(this.width ===2){
		//failed,重試並創建另一個信標
	}
};
beacon.onerror = function() {
	//error,重試並創建另一個信標
}

服務器會接收數據並保存下來。它無須向客戶端發送任何反饋信息,因此沒有圖片會實際顯示出來。這是給服務器回傳信息最有效的方式。它的性能消耗很小,而且服務端的錯誤完全不會影響到客戶端。

這種方式有個缺點:客戶端能接收到的響應類型是有限的。如果需要服務器返回大量數據給客戶端,那麼請使用XHR。

29 JSON

當使用XHR時,JSON數據會被當成字符串返回。該字符串緊接着被eval()轉換成原生對象。然而,在使用動態腳本注入時,JSON數據被當成另一個Javascript文件並作爲原生代碼執行。爲實現這一點,這些數據必須封裝在一個回調函數裏。這就是“JSON填充(JSON with padding)”或"JSONP"。

JSONP因爲回調包裝的原因,略微增大了文件尺寸,但與其解析性能的提升相比這點增加微不足道。因爲在JSONP中,數據被轉換成原生Javascript,因此解析速度跟原生js代碼一樣快。

有一種情景需要避免使用JSONP:因爲JSONP是可執行的Javascript,它可能會被任何人調用並使用動態腳本注入技術插入到任何網站。而另一方面,JSON在eval前是無效的Javascript,使用XHR時它只是被當作字符串獲取。所以不要把任何敏感數據編碼在JSONP中。

30 自定義格式數據

理想情況下,數據的格式應該只包含必要的結構。例如:

1:alice:Alice Smith:[email protected];
2.bob:Bob Jones:[email protected];

上面代碼中,使用一個字符分隔自定義格式的用戶列表。

對於前端,只需要使用split()對字符串進行拆分即可:

function parseCustomFormat(responseText) {
	let users = [];
	let usersEncoded = responseText.split(';');
	let userArray;
	for(let i=0,len=usersEncoded.length;i<len;i++) {
		userArray = usersEncoded[i].split(':');//採用;作爲分隔符。
		users[i] = {
			id: userArray[0],
			username: userArray[1],
			realname: userArray[2],
			email: userArray[3]
		}
	}
	return users;
}

當你創建自定義格式是,最重要的決定之一是採用哪種分隔符。理想下,它應該是一個單字符,而且不應該存在於你的數據中。

31 緩存數據

最快的請求就是沒有請求。有2中主要的方法可以避免發生不必要的請求。

  • 在服務端:設置HTTP頭信息以確保你的響應會被瀏覽器緩存
  • 在客戶端:把獲取到的信息存儲到本地,避免再次的請求。
  1. 設置HTTP頭信息:
    Expires頭信息會告訴瀏覽器應該緩存多久。它的值是一個日期,過期後,對該URL的任何請求都不再從緩存中獲取,而是會重新訪問服務器。PHP中這樣設置:
$lifetime = 7*24*60*60;//7天,單位:秒
header('Expires':. gmdate('D, d M Y H:i:s',time()+$lifetime) . 'GMT');
  1. 緩存數據到本地:將服務器的響應文本保存到一個對象中,以URL爲鍵值作爲索引。
let localCache = {};
function xhrRequest (url,callback) {
	//檢查此URL的本地緩存
	if(localCache[url]){
		callback.success(localCache[url]);
		return;
	}
	//此URL對應的緩存沒有找到,則發送請求
	let req = createXhrObject();
	req.onerror = function() {
		callback.error();
	};
	req.onreadystatechange = function() {
		if(req.readyState === 4) {
			if(req.responseText === '' || req.status === '404') {
				callback.error();
				return;
			}
			//存儲響應文本到本地緩存
			localCache[url] = req.responseText;
			callback.success(req.responseText);
		}
	};
	req.open("GET",url,true);
	req.send(null);
}

設置Expires頭信息是更好的方案,因爲它實現簡單,並且能夠跨頁面和跨會話(session)。本地緩存有一個問題:你每次請求都會使用緩存數據,但用戶執行了某些動作可能導致一個或多個已經緩存的響應失效。所以:這種情況需要刪除那些響應:delete localCache['url/xxx/xxx']

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章