JS 是一門(mén)弱類(lèi)型語(yǔ)言,擁有獨(dú)特的原型鏈機(jī)制,在宿主中的擁有一套 DOM、BOM 操作接口,增加其性能控制的復(fù)雜性。JavaScript 主要應(yīng)用場(chǎng)景依然圍繞瀏覽器展開(kāi),所以,它在瀏覽器中的行為表現(xiàn)依然重要。本篇將從筆者的實(shí)踐經(jīng)驗(yàn)出發(fā),分別從加載解析、語(yǔ)法優(yōu)化、DOM 操作等各方面歸納總結(jié)優(yōu)秀的 JS 代碼性能優(yōu)化策略。與此同時(shí),關(guān)注如何編寫(xiě)更優(yōu)雅干凈的 JS 代碼。
加載解析
JS 文件的加載解析涉及到瀏覽器對(duì)于文檔的解析和渲染策略,一些不好的文檔結(jié)構(gòu),會(huì)導(dǎo)致渲染空屏、卡頓,甚至出現(xiàn)頁(yè)面機(jī)能混亂等問(wèn)題。對(duì)于 JS 來(lái)說(shuō),在加載解析階段可以從以下幾個(gè)方面作出優(yōu)化。
- 將 JS 文件放到文檔最后(</body>之前)引入
JS 代碼通過(guò) <script>
標(biāo)簽引入頁(yè)面加載,<script>
標(biāo)簽是一個(gè)霸道的主兒,當(dāng)文檔遇到它時(shí),會(huì)暫停解析,等待它執(zhí)行完畢,再繼續(xù)解析剩余的部分。在新瀏覽器中,多個(gè) <script>
標(biāo)簽的內(nèi)容彼此不會(huì)阻塞,可以并行下載,但其他資源仍然會(huì)被阻塞,所以,將 JS 文件放到文檔最后引入,仍是最有效的優(yōu)化策略。
- 合并 JS 文件
減少 HTTP 請(qǐng)求是最常見(jiàn)的性能優(yōu)化策略,引入四個(gè) 10 kb 的文件需要做四次請(qǐng)求,要比引入一個(gè) 40kb 更耗性能,所以,當(dāng)需要引入的 JS 文件過(guò)多時(shí),必要的腳本合并是很有必要的。
但需要注意的是,如果一個(gè)文件過(guò)大,其解析的用時(shí)將會(huì)很大,這樣無(wú)疑是得不償失的,所以,不要有的沒(méi)的全懟在一起。
- 使用 defer 無(wú)阻塞下載腳本
defer 是標(biāo)準(zhǔn)中為 <script>
標(biāo)簽提供的一個(gè)屬性,其會(huì)使 JS 文件的下載和文檔的渲染并行展開(kāi),同時(shí)延遲 JS 的執(zhí)行時(shí)間到文檔加載完畢,所以這個(gè)屬性十分有用。
順便提一下另一個(gè)可以應(yīng)用在 <script>
上的屬性 async,顧名思義,這個(gè)屬性的作用是,使得腳本的加載和執(zhí)行與文檔的渲染并行進(jìn)行。它與 defer 在下載腳本的時(shí)機(jī)是一致的,只不過(guò),執(zhí)行時(shí)機(jī)不同。下面這張圖很形象地表明了這兩個(gè)屬性以及不帶屬性的腳本加載執(zhí)行機(jī)制。
語(yǔ)法優(yōu)化
- 慎用全局變量
全局變量的查找作用域鏈更長(zhǎng),生命周期也更長(zhǎng),且有內(nèi)存泄漏的風(fēng)險(xiǎn),甚至?xí)a(chǎn)生不可預(yù)估的 bug 出現(xiàn)。項(xiàng)目中的第三方庫(kù)一般都會(huì)暴露一些全局變量,這和你聲明的全局變量也可能會(huì)發(fā)生沖突。所以,盡量謹(jǐn)慎地使用全局變量。
在 ES6 之后,我們使用 let/const 來(lái)聲明變量是更好的選擇。
- 使用性能更優(yōu)的遍歷操作
經(jīng)過(guò)測(cè)試,JS 中的循環(huán)操作,耗時(shí)從小到大排名為:for -> forEach/for-of -> for-in
也就是說(shuō),對(duì)于大規(guī)模的遍歷操作,優(yōu)先使用 for 循環(huán)完成,其次是 forEach 以及 for-of,這兩者處于一個(gè)數(shù)量級(jí),最慢的屬于 for-in,它之所以慢,是因?yàn)樗S糜趯?duì)象屬性的遍歷,并且會(huì)訪(fǎng)問(wèn)自身屬性以及其原型鏈的屬性(包括不可枚舉屬性)。
- 避免使用 with 和 eval
with 可以改變當(dāng)前的作用域環(huán)境,將一個(gè)對(duì)象推入作用域鏈頭部,這樣,使得作用域環(huán)境內(nèi)的局部變量的訪(fǎng)問(wèn)效率降低。
eval 將傳入的字符串當(dāng)做腳本執(zhí)行,會(huì)大幅度降低腳本執(zhí)行性能,避免使用。
- 盡量少地使用閉包
閉包提供了一些便捷性,但同時(shí)也會(huì)有一些性能影響,由于保留著原應(yīng)被回收的變量引用,增加了作用域鏈的長(zhǎng)度,影響性能。
同時(shí)它會(huì)可能會(huì)有內(nèi)存泄漏的風(fēng)險(xiǎn)。
- 不要修改引用類(lèi)型的原型方法
修改原型方法,在團(tuán)隊(duì)協(xié)作中,很可能帶來(lái)不可預(yù)估的影響,盡量避免這樣做。
- 當(dāng)判斷值很多時(shí),優(yōu)先考慮 switch 替代 if-else
當(dāng)判斷條件過(guò)多,例如超過(guò)3個(gè),就應(yīng)該考慮使用 switch 來(lái)替換 if-else。這樣,不止可以提高代碼的可讀性,降低代碼的理解成本。
對(duì)于 if 語(yǔ)句的優(yōu)化,還有一些策略:提前 return;使用三元操作符;借用 ES6 的 Map 結(jié)構(gòu)進(jìn)行優(yōu)化等,感興趣的同學(xué)可以閱讀這篇文章:https://juejin.im/post/5bdfef86e51d453bf8051bf8
- 避免在循環(huán)中創(chuàng)建函數(shù)
每次循環(huán)創(chuàng)建一個(gè)函數(shù)不是明智之舉,創(chuàng)建函數(shù)意味著內(nèi)存分配與消耗,這是無(wú)用功,應(yīng)該提前創(chuàng)建函數(shù)。
- 總是使用 === 和 !== 進(jìn)行類(lèi)型判斷
== 和 != 操作符會(huì)引起 JS 的數(shù)據(jù)類(lèi)型隱式轉(zhuǎn)換,導(dǎo)致一些不可預(yù)估的負(fù)面作用,所以,更明智的選擇是,總是去使用 === 和 !== 進(jìn)行相等判斷。
- 使用字面量新建對(duì)象
通過(guò) new 操作符新建一個(gè)對(duì)象,類(lèi)似于函數(shù)調(diào)用,同時(shí)會(huì)做一些關(guān)聯(lián)原型鏈等操作,性能會(huì)慢很多,字面量則在寫(xiě)法上更直觀(guān)友好且高效。
新建數(shù)組類(lèi)似。
- 不要省略花括號(hào)
很多同學(xué)喜歡省略條件判斷語(yǔ)句后面的花括號(hào),像下面這樣:
if (somethingIsTrue)
a = 100
doSomething()
這樣的代碼,你的目的可能是這樣的效果:
if (somethingIsTrue) {
a = 100
doSomething()
}
但其實(shí)它會(huì)按這樣執(zhí)行:
if (somethingIsTrue) {
a = 100
}
doSomething()
所以,還是老老實(shí)實(shí)地加上花括號(hào),以避免上面這樣的情況。
- 要不要加分號(hào)?
近年來(lái),加不加分號(hào)在 JS 中的討論很激烈,如果你足夠了解 JS 的解釋機(jī)制,那么你可以選擇不加分號(hào),但是如果你僅僅是為了少寫(xiě)幾個(gè)字符,我認(rèn)為還是加上分號(hào)比較好。
注:主要有以下幾個(gè)字符會(huì)引起 JS 上下文解析有誤:括號(hào),方括號(hào),正則開(kāi)頭的斜杠,加號(hào),減號(hào)。
還有一個(gè)參考標(biāo)準(zhǔn)是,這只是一個(gè)風(fēng)格問(wèn)題,應(yīng)該根據(jù)你的項(xiàng)目風(fēng)格而定,與團(tuán)隊(duì)保持一致最好。而且,成熟的 JS 的編譯器都會(huì)判斷什么地方該加分號(hào),所以說(shuō),不加分號(hào)出錯(cuò)的概率極低,如果你能夠采取更好的換行策略,不加分號(hào)是完全沒(méi)問(wèn)題的。
- 優(yōu)先使用原生方法
雖然一些諸如 lodash、jQuery 這樣的操作庫(kù)大大提升 JS 開(kāi)發(fā)者的生產(chǎn)力,但是,對(duì)于原生 JS 可以實(shí)現(xiàn)的功能,使用原生 JS 一般都會(huì)獲得更快的解析速度。
例如這個(gè)例子:
$('input').on('focus', function() {
if ($(this).val() === 'some text') { ... }
})
很明顯,這里沒(méi)有必要使用 val() 方法,我們可以使用原生方法代替:
$('input').on('focus', function() {
if (this.value === 'some text') { ... }
})
DOM 操作優(yōu)化
大量的 DOM 操作會(huì)引發(fā)頁(yè)面卡頓,極耗性能,這是因?yàn)椋跒g覽器中,ECMAScript 的解釋引擎和 DOM 的渲染引擎由兩個(gè)部分實(shí)現(xiàn),例如 Chrome 的 JS 引擎為 V8,而 DOM 則是 WebCore 實(shí)現(xiàn)。而 DOM 操作,你可以理解為跨模塊操作,將 JS 和 DOM 比作兩座島嶼,而操作 DOM,就是 JS 跨過(guò)大橋,去 DOM 島上做文章,每次操作,就要過(guò)一次橋,頻繁過(guò)橋的話(huà),會(huì)引發(fā)巨大的性能損耗(參考文末《天生就慢的DOM如何優(yōu)化?》)。
這個(gè)過(guò)橋過(guò)程,主要發(fā)生在以下的操作中:
- 訪(fǎng)問(wèn)和修改 DOM 元素
- DOM 元素的重繪(Reflow)或重排(Repaint)
這也是為什么現(xiàn)代框架都使用 virtual DOM 的原因之一。若不使用現(xiàn)代 JS 框架,DOM 操作的優(yōu)化原則是:盡量減少過(guò)橋的次數(shù),也就是盡量少地訪(fǎng)問(wèn) DOM 元素,盡量減少 DOM 結(jié)構(gòu)的重繪(Reflows)或重排(Repaints)。
常用的優(yōu)化策略有:
- 最小化 DOM 訪(fǎng)問(wèn)次數(shù)
- 合并多次 DOM 操作,一次性插入頁(yè)面
當(dāng)你需要對(duì)文檔元素進(jìn)行一系列操作時(shí),應(yīng)該是先將元素脫離文檔,多重操作完成后,再插入文檔(這一點(diǎn)經(jīng)常通過(guò) DocumentFragment
實(shí)現(xiàn))
- 使用本地變量進(jìn)行緩存頻繁訪(fǎng)問(wèn)的 DOM 元素
- 不要遍歷 HTML 元素集合,而是將它們轉(zhuǎn)為數(shù)組之后執(zhí)行
HTML 元素集合與底層的文檔元素相關(guān)聯(lián),每次操作 HTML 元素,會(huì)引發(fā)元素集合的更新)
- 使用速度更快的 API
優(yōu)先使用 querySelectorAll()
以及 querySelector()
方法獲取元素。這兩個(gè)方法返回的節(jié)點(diǎn)列表,不會(huì)對(duì)應(yīng)實(shí)時(shí)的文檔結(jié)構(gòu),也就避免了上一條提到的性能問(wèn)題。
- 引發(fā)重排的動(dòng)畫(huà)元素脫離文檔流之后再操作
動(dòng)畫(huà)操作引發(fā)的重排,很可能會(huì)影響整個(gè)文檔流,引發(fā)頁(yè)面卡頓,所以,可以將發(fā)生這類(lèi)動(dòng)畫(huà)的元素,使用定位脫離文檔流,出發(fā) BFC,動(dòng)畫(huà)完成后,回歸正常定位。
使用事件委托
試想這樣一種場(chǎng)景,一個(gè) ul 中有一大堆 li,你需要為所有的 li 元素綁定點(diǎn)擊事件,最直觀(guān)的方法是,循環(huán)為每一個(gè) li 綁定:
for (let i = 0; i < uls.length; i++) {
uls[i].onClick = function() {
// do something...
}
}
這種循環(huán)寫(xiě)法,一方面增加了內(nèi)存開(kāi)銷(xiāo),另一方面,每次點(diǎn)擊時(shí),增加了循環(huán)時(shí)間,損耗頁(yè)面性能。這種情況的解決辦法是:使用事件委托。
顧名思義,事件委托指的是,將事件的響應(yīng),委托到另外的元素上,一般指父元素或者上層元素。事件委托是利用 JS 的時(shí)間冒泡機(jī)制,子層的事件會(huì)向外層冒泡,所以,在事件發(fā)生元素的父元素以及更外層元素都可以監(jiān)聽(tīng)到事件的發(fā)生。我們可以使用 addEventListener
來(lái)簡(jiǎn)單實(shí)現(xiàn):
uls.addEventListener('click', function(e) {
if (e.target.tagName.toLowerCase() === 'li') {
// do something
}
})
事件委托的好處是,動(dòng)態(tài)添加的元素,都可以響應(yīng)到。
編寫(xiě)更優(yōu)雅的 JS 代碼
程序員的工作,很大一部分并非只考慮解釋器,而是要考慮和你合作的同事,在關(guān)注準(zhǔn)確高效的業(yè)務(wù)邏輯的同時(shí),代碼的可讀性、干凈和優(yōu)雅,是十分重要的。
所謂干凈優(yōu)雅,我的理解是,使得讀你代碼的人可以基本不依賴(lài)注釋就可以順暢地理解你的邏輯,和寫(xiě)作類(lèi)似,第一要?jiǎng)?wù)是準(zhǔn)確、簡(jiǎn)潔地傳達(dá)信息。或者說(shuō),借用網(wǎng)絡(luò)上的一個(gè)說(shuō)法,優(yōu)雅的代碼是自解釋的。如果你的代碼被后來(lái)者拿到,一頭霧水,懷疑人生,那就很有問(wèn)題。以下是一些編寫(xiě)優(yōu)雅 JS 代碼的建議:
- 使用有意義的變量名稱(chēng)
這條已經(jīng)被反復(fù)提及 N 多次,但怎么強(qiáng)調(diào)都不為過(guò),最基礎(chǔ)的部分往往是最重要的部分。好的變量名,可以大幅度提高代碼的可讀性,不需要反復(fù)通過(guò)上下文邏輯去推敲。《代碼大全》指出,好的變量名有以下的特征:
首先,它們很容易理解
好的名字應(yīng)該盡可能明確。好的名字通常表達(dá)的是“什么”(what),而不是“如何”(how)。
至于具體的操作,我的建議是,打開(kāi)你手頭的項(xiàng)目,去看看你寫(xiě)下的變量名,想想有沒(méi)有優(yōu)化的地方,或者說(shuō),你自己寫(xiě)的代碼,你能明確地知道眼前的變量表示什么嗎?如果不能,那就不是一個(gè)好名字。
- 使用肯定的判斷方法
以否定方法來(lái)做判斷條件,會(huì)讓人乍看過(guò)去很疑惑,例如 isNumNotValid
,當(dāng)其結(jié)合條件控制語(yǔ)句時(shí),會(huì)大大增加閱讀負(fù)擔(dān):
if (!isNumNotValid) { ... }
前面加上 ! 操作符后,很令人疑惑 num 到底應(yīng)該是 valid 還是 not valid,應(yīng)該改為 isNumValid
:
if (isNumValid) { ... }
- 避免冗余的代碼
你的代碼工作區(qū)就像一個(gè)營(yíng)地,你離開(kāi)的時(shí)候,不應(yīng)該丟下大量垃圾。冗余的代碼,主要指重復(fù)的代碼,以及不會(huì)被執(zhí)行到的代碼,例如寫(xiě)在 return 語(yǔ)句之后的代碼,以及一些“暫時(shí)”用到的 trick,或者測(cè)試代碼,這些代碼都會(huì)大大干擾代碼的可讀性。
所以,寫(xiě)代碼的人應(yīng)該常常讀讀自己的代碼,看看有哪些代碼時(shí)冗余的,及時(shí)地刪除它們,并且可以采用一些策略來(lái)優(yōu)化重復(fù)的代碼,例如類(lèi)的抽離,組件的抽離,模塊化,變量的緩存等等。
- 跟隨團(tuán)隊(duì)的風(fēng)格指南
大部分開(kāi)發(fā)團(tuán)隊(duì)都擁有自己的開(kāi)發(fā)指南,例如業(yè)界著名的 Google、AirBnb 等都有自己的 JavaScript 指南,每個(gè)團(tuán)隊(duì)都應(yīng)該制定適合自己的代碼風(fēng)格指南,一般包含了代碼的風(fēng)格以及一些最佳的實(shí)踐策略等,按照指南的指引,勤于進(jìn)行 code review,這樣,才能打造一個(gè)戰(zhàn)斗力超強(qiáng)的隊(duì)伍。
小結(jié)
本篇主要從代碼層面提出了一些 JavaScript 應(yīng)該注意的優(yōu)化寫(xiě)法,對(duì)于開(kāi)發(fā)者來(lái)講,我們常常是面向項(xiàng)目進(jìn)行編程,所以,這要求我們?cè)谏钊氪a的同時(shí),又要學(xué)會(huì)跳出來(lái),從工程化的層面去考慮,現(xiàn)代流行的 JS 框架,正是從整體架構(gòu)的角度來(lái)優(yōu)化整個(gè) JS 項(xiàng)目的寫(xiě)法,在學(xué)習(xí)這些框架的時(shí)候,我們更應(yīng)該去考慮 JS 底層的東西,它們到底在解決什么問(wèn)題?而這些問(wèn)題,很大一部分就是和這里所說(shuō)的性能以及最佳實(shí)踐息息相關(guān)的,這也是開(kāi)發(fā)者從一個(gè)簡(jiǎn)單的碼農(nóng)向工程師升級(jí)的關(guān)鍵所在。
參考資料
- 吹毛求疵的追求優(yōu)雅高性能JavaScript
- 天生就慢的DOM如何優(yōu)化?
- 【書(shū)】《高性能JavaScript》