JS性能優化策略

JS 是一門弱類型語言,擁有獨特的原型鏈機制,在宿主中的擁有一套 DOM、BOM 操作接口,增加其性能控制的複雜性。JavaScript 主要應用場景依然圍繞瀏覽器展開,所以,它在瀏覽器中的行爲表現依然重要。本篇將從筆者的實踐經驗出發,分別從加載解析、語法優化、DOM 操作等各方面歸納總結優秀的 JS 代碼性能優化策略。與此同時,關注如何編寫更優雅乾淨的 JS 代碼。

加載解析

JS 文件的加載解析涉及到瀏覽器對於文檔的解析和渲染策略,一些不好的文檔結構,會導致渲染空屏、卡頓,甚至出現頁面機能混亂等問題。對於 JS 來說,在加載解析階段可以從以下幾個方面作出優化。

  1. 將 JS 文件放到文檔最後(</body>之前)引入

JS 代碼通過 <script> 標籤引入頁面加載,<script> 標籤是一個霸道的主兒,當文檔遇到它時,會暫停解析,等待它執行完畢,再繼續解析剩餘的部分。在新瀏覽器中,多個 <script> 標籤的內容彼此不會阻塞,可以並行下載,但其他資源仍然會被阻塞,所以,將 JS 文件放到文檔最後引入,仍是最有效的優化策略。

  1. 合併 JS 文件

減少 HTTP 請求是最常見的性能優化策略,引入四個 10 kb 的文件需要做四次請求,要比引入一個 40kb 更耗性能,所以,當需要引入的 JS 文件過多時,必要的腳本合併是很有必要的。

但需要注意的是,如果一個文件過大,其解析的用時將會很大,這樣無疑是得不償失的,所以,不要有的沒的全懟在一起。

  1. 使用 defer 無阻塞下載腳本

defer 是標準中爲 <script> 標籤提供的一個屬性,其會使 JS 文件的下載和文檔的渲染並行展開,同時延遲 JS 的執行時間到文檔加載完畢,所以這個屬性十分有用。

順便提一下另一個可以應用在 <script> 上的屬性 async,顧名思義,這個屬性的作用是,使得腳本的加載和執行與文檔的渲染並行進行。它與 defer 在下載腳本的時機是一致的,只不過,執行時機不同。下面這張圖很形象地表明瞭這兩個屬性以及不帶屬性的腳本加載執行機制。

語法優化

  1. 慎用全局變量

全局變量的查找作用域鏈更長,生命週期也更長,且有內存泄漏的風險,甚至會產生不可預估的 bug 出現。項目中的第三方庫一般都會暴露一些全局變量,這和你聲明的全局變量也可能會發生衝突。所以,儘量謹慎地使用全局變量。

在 ES6 之後,我們使用 let/const 來聲明變量是更好的選擇。

  1. 使用性能更優的遍歷操作

經過測試,JS 中的循環操作,耗時從小到大排名爲:for -> forEach/for-of -> for-in

也就是說,對於大規模的遍歷操作,優先使用 for 循環完成,其次是 forEach 以及 for-of,這兩者處於一個數量級,最慢的屬於 for-in,它之所以慢,是因爲它常用於對象屬性的遍歷,並且會訪問自身屬性以及其原型鏈的屬性(包括不可枚舉屬性)。

  1. 避免使用 with 和 eval

with 可以改變當前的作用域環境,將一個對象推入作用域鏈頭部,這樣,使得作用域環境內的局部變量的訪問效率降低。

eval 將傳入的字符串當做腳本執行,會大幅度降低腳本執行性能,避免使用。

  1. 儘量少地使用閉包

閉包提供了一些便捷性,但同時也會有一些性能影響,由於保留着原應被回收的變量引用,增加了作用域鏈的長度,影響性能。

同時它會可能會有內存泄漏的風險。

  1. 不要修改引用類型的原型方法

修改原型方法,在團隊協作中,很可能帶來不可預估的影響,儘量避免這樣做。

  1. 當判斷值很多時,優先考慮 switch 替代 if-else

當判斷條件過多,例如超過3個,就應該考慮使用 switch 來替換 if-else。這樣,不止可以提高代碼的可讀性,降低代碼的理解成本。

對於 if 語句的優化,還有一些策略:提前 return;使用三元操作符;借用 ES6 的 Map 結構進行優化等,感興趣的同學可以閱讀這篇文章:https://juejin.im/post/5bdfef86e51d453bf8051bf8

  1. 避免在循環中創建函數

每次循環創建一個函數不是明智之舉,創建函數意味着內存分配與消耗,這是無用功,應該提前創建函數。

  1. 總是使用 === 和 !== 進行類型判斷

== 和 != 操作符會引起 JS 的數據類型隱式轉換,導致一些不可預估的負面作用,所以,更明智的選擇是,總是去使用 === 和 !== 進行相等判斷。

  1. 使用字面量新建對象

通過 new 操作符新建一個對象,類似於函數調用,同時會做一些關聯原型鏈等操作,性能會慢很多,字面量則在寫法上更直觀友好且高效。

新建數組類似。

  1. 不要省略花括號

很多同學喜歡省略條件判斷語句後面的花括號,像下面這樣:

if (somethingIsTrue)
  a = 100
  doSomething()

這樣的代碼,你的目的可能是這樣的效果:

if (somethingIsTrue) {
  a = 100
  doSomething()
}

但其實它會按這樣執行:

if (somethingIsTrue) {
  a = 100
}
doSomething()

所以,還是老老實實地加上花括號,以避免上面這樣的情況。

  1. 要不要加分號?

近年來,加不加分號在 JS 中的討論很激烈,如果你足夠了解 JS 的解釋機制,那麼你可以選擇不加分號,但是如果你僅僅是爲了少寫幾個字符,我認爲還是加上分號比較好。

注:主要有以下幾個字符會引起 JS 上下文解析有誤:括號,方括號,正則開頭的斜槓,加號,減號。

還有一個參考標準是,這只是一個風格問題,應該根據你的項目風格而定,與團隊保持一致最好。而且,成熟的 JS 的編譯器都會判斷什麼地方該加分號,所以說,不加分號出錯的概率極低,如果你能夠採取更好的換行策略,不加分號是完全沒問題的。

  1. 優先使用原生方法

雖然一些諸如 lodash、jQuery 這樣的操作庫大大提升 JS 開發者的生產力,但是,對於原生 JS 可以實現的功能,使用原生 JS 一般都會獲得更快的解析速度。

例如這個例子:

$('input').on('focus', function() {
  if ($(this).val() === 'some text') { ... }
})

很明顯,這裏沒有必要使用 val() 方法,我們可以使用原生方法代替:

$('input').on('focus', function() {
  if (this.value === 'some text') { ... }
})

DOM 操作優化

大量的 DOM 操作會引發頁面卡頓,極耗性能,這是因爲,在瀏覽器中,ECMAScript 的解釋引擎和 DOM 的渲染引擎由兩個部分實現,例如 Chrome 的 JS 引擎爲 V8,而 DOM 則是 WebCore 實現。而 DOM 操作,你可以理解爲跨模塊操作,將 JS 和 DOM 比作兩座島嶼,而操作 DOM,就是 JS 跨過大橋,去 DOM 島上做文章,每次操作,就要過一次橋,頻繁過橋的話,會引發巨大的性能損耗(參考文末《天生就慢的DOM如何優化?》)。

這個過橋過程,主要發生在以下的操作中:

  • 訪問和修改 DOM 元素
  • DOM 元素的重繪(Reflow)或重排(Repaint)

這也是爲什麼現代框架都使用 virtual DOM 的原因之一。若不使用現代 JS 框架,DOM 操作的優化原則是:儘量減少過橋的次數,也就是儘量少地訪問 DOM 元素,儘量減少 DOM 結構的重繪(Reflows)或重排(Repaints)

常用的優化策略有:

  1. 最小化 DOM 訪問次數
  2. 合併多次 DOM 操作,一次性插入頁面

當你需要對文檔元素進行一系列操作時,應該是先將元素脫離文檔,多重操作完成後,再插入文檔(這一點經常通過 DocumentFragment 實現)

  1. 使用本地變量進行緩存頻繁訪問的 DOM 元素
  2. 不要遍歷 HTML 元素集合,而是將它們轉爲數組之後執行

HTML 元素集合與底層的文檔元素相關聯,每次操作 HTML 元素,會引發元素集合的更新)

  1. 使用速度更快的 API

優先使用 querySelectorAll() 以及 querySelector() 方法獲取元素。這兩個方法返回的節點列表,不會對應實時的文檔結構,也就避免了上一條提到的性能問題。

  1. 引發重排的動畫元素脫離文檔流之後再操作

動畫操作引發的重排,很可能會影響整個文檔流,引發頁面卡頓,所以,可以將發生這類動畫的元素,使用定位脫離文檔流,出發 BFC,動畫完成後,迴歸正常定位。

使用事件委託

試想這樣一種場景,一個 ul 中有一大堆 li,你需要爲所有的 li 元素綁定點擊事件,最直觀的方法是,循環爲每一個 li 綁定:

for (let i = 0; i < uls.length; i++) {
  uls[i].onClick = function() {
    // do something...
  }
}

這種循環寫法,一方面增加了內存開銷,另一方面,每次點擊時,增加了循環時間,損耗頁面性能。這種情況的解決辦法是:使用事件委託。

顧名思義,事件委託指的是,將事件的響應,委託到另外的元素上,一般指父元素或者上層元素。事件委託是利用 JS 的時間冒泡機制,子層的事件會向外層冒泡,所以,在事件發生元素的父元素以及更外層元素都可以監聽到事件的發生。我們可以使用 addEventListener 來簡單實現:

uls.addEventListener('click', function(e) {
  if (e.target.tagName.toLowerCase() === 'li') {
    // do something
  }
})

事件委託的好處是,動態添加的元素,都可以響應到。

編寫更優雅的 JS 代碼

程序員的工作,很大一部分並非只考慮解釋器,而是要考慮和你合作的同事,在關注準確高效的業務邏輯的同時,代碼的可讀性、乾淨和優雅,是十分重要的。

所謂乾淨優雅,我的理解是,使得讀你代碼的人可以基本不依賴註釋就可以順暢地理解你的邏輯,和寫作類似,第一要務是準確、簡潔地傳達信息。或者說,借用網絡上的一個說法,優雅的代碼是自解釋的。如果你的代碼被後來者拿到,一頭霧水,懷疑人生,那就很有問題。以下是一些編寫優雅 JS 代碼的建議:

  1. 使用有意義的變量名稱

這條已經被反覆提及 N 多次,但怎麼強調都不爲過,最基礎的部分往往是最重要的部分。好的變量名,可以大幅度提高代碼的可讀性,不需要反覆通過上下文邏輯去推敲。《代碼大全》指出,好的變量名有以下的特徵:

首先,它們很容易理解
好的名字應該儘可能明確。好的名字通常表達的是“什麼”(what),而不是“如何”(how)。

至於具體的操作,我的建議是,打開你手頭的項目,去看看你寫下的變量名,想想有沒有優化的地方,或者說,你自己寫的代碼,你能明確地知道眼前的變量表示什麼嗎?如果不能,那就不是一個好名字。

  1. 使用肯定的判斷方法

以否定方法來做判斷條件,會讓人乍看過去很疑惑,例如 isNumNotValid,當其結合條件控制語句時,會大大增加閱讀負擔:

if (!isNumNotValid) { ... }

前面加上 ! 操作符後,很令人疑惑 num 到底應該是 valid 還是 not valid,應該改爲 isNumValid

if (isNumValid) { ... }
  1. 避免冗餘的代碼

你的代碼工作區就像一個營地,你離開的時候,不應該丟下大量垃圾。冗餘的代碼,主要指重複的代碼,以及不會被執行到的代碼,例如寫在 return 語句之後的代碼,以及一些“暫時”用到的 trick,或者測試代碼,這些代碼都會大大幹擾代碼的可讀性。

所以,寫代碼的人應該常常讀讀自己的代碼,看看有哪些代碼時冗餘的,及時地刪除它們,並且可以採用一些策略來優化重複的代碼,例如類的抽離,組件的抽離,模塊化,變量的緩存等等。

  1. 跟隨團隊的風格指南

大部分開發團隊都擁有自己的開發指南,例如業界著名的 Google、AirBnb 等都有自己的 JavaScript 指南,每個團隊都應該制定適合自己的代碼風格指南,一般包含了代碼的風格以及一些最佳的實踐策略等,按照指南的指引,勤於進行 code review,這樣,才能打造一個戰鬥力超強的隊伍。

小結

本篇主要從代碼層面提出了一些 JavaScript 應該注意的優化寫法,對於開發者來講,我們常常是面向項目進行編程,所以,這要求我們在深入代碼的同時,又要學會跳出來,從工程化的層面去考慮,現代流行的 JS 框架,正是從整體架構的角度來優化整個 JS 項目的寫法,在學習這些框架的時候,我們更應該去考慮 JS 底層的東西,它們到底在解決什麼問題?而這些問題,很大一部分就是和這裏所說的性能以及最佳實踐息息相關的,這也是開發者從一個簡單的碼農向工程師升級的關鍵所在。

參考資料

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