《高效能javascript》閱讀摘要

任乃千發表於2019-02-16

最近在閱讀這本Nicholas C.Zakas(javascript高階程式設計作者)寫的最佳實踐、效能優化類的書。記錄下主要知識。


載入和執行

指令碼位置

放在<head>中的javascript檔案會阻塞頁面渲染:一般來說瀏覽器中有多種執行緒:UI渲染執行緒、javascript引擎執行緒、瀏覽器事件觸發執行緒、HTTP請求執行緒等。多執行緒之間會共享執行資源,瀏覽器的js會操作dom,影響渲染,所以js引擎執行緒和UI渲染執行緒是互斥的,導致執行js時會阻塞頁面的渲染。
最佳實踐:所有的script標籤應儘可能的放在body標籤的底部,以儘量減少對整個頁面下載的影響。

組織指令碼

每個<script>標籤初始下載時都會阻塞頁面渲染,所以應減少頁面包含的<script>標籤數量。內嵌指令碼放在引用外鏈樣式表的<link>標籤之後會導致頁面阻塞去等待樣式表的下載,建議不要把內嵌指令碼緊跟在<link>標籤之後。外鏈javascript的HTTP請求還會帶來額外的效能開銷,減少指令碼檔案的數量將會改善效能。

無阻塞的指令碼

無阻塞指令碼的意義在於在頁面載入完成後才載入javascript程式碼。(window物件的load事件觸發後)

延遲的指令碼

帶有defer屬性的<script>標籤可以放置在文件的任何位置。對應的javascript檔案將在頁面解析到<script>標籤時開始下載,但並不會執行,直到DOM載入完成(onload事件被觸發前)。當一個帶有defer屬性的javascript檔案下載時,它不會阻塞瀏覽器的其他程式,可以與其他資源並行下載。執行的順序是script、defer、load。

動態指令碼元素

使用javascript動態建立HTML中script元素,例如一些懶載入庫。
優點:動態指令碼載入憑藉著它在跨瀏覽器相容性和易用的有時,成為最通用的無阻塞載入解決方式。

XHR指令碼注入

建立XHR對線個,用它下載javascript檔案,通過動態建立script元素將程式碼注入頁面中

var xhr = new XMLHttpRequest();
xhr.open("get","file.js",true);
xhr.onreadystatechange = function() {
  if(xht.readyState === 4) {
    if(xhr.status >= 200 && xhr.status < 300 || xhr.status === 304) {
      var script = document.createElement("script");
      script.type = "text/javascript";
      script.text = xhr.responseText;
      document.body.appendChild(script); 
    }
  }
};
xhr.send(null);

優點:可以下載javascript但不立即執行,在所有主流瀏覽器中都可以正常工作。
缺點:javascript檔案必須與所請求的頁面處於相同的域,意味著不能檔案不能從CDN下載。


資料存取

儲存的位置

資料儲存的位置會很大程度上影響讀取速度。

  • 字面量:字面量只代表自身,不儲存在特定的位置。包括:字串、數字、布林值、物件、陣列、函式、正規表示式、null、undefined。(個人理解:物件的指標本身是字面量)
  • 本地變數:var定義的資料儲存單元。
  • 陣列元素:儲存在javascript陣列內部,以數字為引。
  • 物件成員:儲存在javascript物件內部,以字串作為索引。

大多數情況下從一個字面量和一個區域性變數中存取資料的差距是微不足道的。訪問資料元素和物件成員的代價則高一點。如果在乎執行速度,儘量使用字面量和區域性變數,減少陣列和物件成員的使用。

管理作用域

作用域鏈

每個javascript函式都表示為一個物件,更確切的說是Function物件的一個例項。它也有僅供javascript引擎儲存的內部屬性,其中一個內部屬性是[[Scope]],包含了一個被建立的作用域中物件的集合即作用域鏈。作用域鏈決定哪些資料能被函式訪問。作用域中的每個物件被稱為一個可變物件。
當一個函式被建立後,作用域鏈會被建立函式的作用域中可訪問的資料物件所填充。執行函式時會建立一個稱為執行上下文的內部物件。執行上下文定義了函式執行時的環境。每次函式執行時對應的執行環境都是獨一無二的,多次呼叫同一個函式也會建立多個執行上下文,當函式執行完畢,執行上下文就會被銷燬。每個執行上下文都有自己的作用域鏈,用於解析識別符號。當執行上下文被建立時,它的作用域鏈初始化為當前執行函式的[[Scope]]屬性中的物件。這些值按照它們出現在函式中的順序,被複制到執行環境的作用域鏈中。這個過程一旦完成,一個被稱為活動物件的新物件就為執行上下文建立好了。
活動物件作為函式執行時的變數物件,包含了所有區域性物件,命名函式,引數集合以及this。然後此物件被推入作用域鏈的最前端。當執行環境被銷燬時,活動物件也隨之銷燬。執行過程中,每遇到一個變數,都會經歷一次識別符號解析過程以決定從哪裡獲取或儲存資料。該過程搜尋執行環境的作用域鏈,查詢同名的識別符號。搜尋過程從作用域鏈頭部開始,也就是當前執行函式的活動物件。如果找到,就使用這個識別符號對應的變數,如果沒找到,繼續搜尋作用域鏈的下一個物件知道找到,若無法搜尋到匹配的物件,則識別符號被當作未定義的。這個搜尋過程影響了效能。

識別符號解析的效能

一個識別符號所在的位置越深,讀寫速度就越慢,全域性變數總是存在於執行環境作用域的最末端,因此它是最深的。
最佳實踐:如果某個跨作用域的值在函式中被引用一次以上,那麼就把它儲存到區域性變數中。

改變作用域鏈

一般來說一個執行上下文的作用域鏈是不會改變的。但是,with語句和try-catch語句的catch子語句可以改變作用域鏈。
with語句用來給物件的所有屬性建立一個變數,可以避免多次書寫。但是存在效能問題:程式碼執行到with語句時,執行環境的作用域鏈臨時被改變了,建立了一個新的(包含了with物件所有屬性)物件被建立了,之前所有的區域性變數現在處於第二個作用域鏈物件中,提高了訪問的代價。建議放棄使用with語句。
try-catch語句中的catch子句也可以改變作用域鏈,當try程式碼塊中發生錯誤,執行過程會自動跳轉到catch子句,把異常物件推入一個變數物件並置於作用域的首位,區域性變數處於第二個作用域鏈物件中。簡化程式碼可以使catch子句對效能的影響降低。
最佳實踐:將錯誤委託給一個函式來處理。

動態作用域

無論with語句還是try-catch語句的子句catch子句、eval()語句,都被認為是動態作用域。經過優化的javascript引擎,嘗試通過分析程式碼來確定哪些變數是可以在特定的時候被訪問,避開了傳統的作用域鏈,取代以識別符號索引的方式快速查詢。當涉及動態作用域時,這種優化方式就失效了。
最佳實踐:只在確實有必要時使用動態作用域。

閉包、作用域和記憶體

由於閉包的[[Scope]]屬性包含了與執行上下文作用域鏈相同的物件的引用,因此會產生副作用。通常來說,函式的活動物件會隨著執行環境一同銷燬。但引入閉包時,由於引用仍然存在閉包的[[Scope]]屬性中,因此啟用物件無法被銷燬,導致更多的記憶體開銷。

最需要關注的效能點:閉包頻繁訪問跨作用域的識別符號,每次訪問都會帶來效能損失。

最佳實踐:將常用的跨作用域變數儲存在區域性變數中,然後直接訪問區域性變數。

物件成員

無論是通過建立自定義物件還是使用內建物件都會導致頻繁的訪問物件成員。

原型

javascript中的物件是基於原型的。解析物件成員的過程與解析變數十分相似,會從物件的例項開始,如果例項中沒有,會一直沿著原型鏈向上搜尋,直到找到或者到原型鏈的盡頭。物件在原型鏈中位置越深,找到它也就越慢。搜尋例項成員比從字面量或區域性變數中讀取資料代價更高,再加上遍歷原型鏈帶來的開銷,這讓效能問題更為嚴重。

巢狀成員

物件成員可能包含其他成員,每次遇到點操作符”.”會導致javascript引擎搜尋所有物件成員。

快取物件成員值

由於所有類似的效能問題都與物件成員有關,因此應該儘可能避免使用他們,只在必要時使用物件成員,例如,在同一個函式中沒有必要多次讀取同一個物件屬性(儲存到區域性變數中),除非它的值變了。這種方法不推薦用於物件的方法,因為將物件方法儲存在區域性變數中會導致this繫結到window,導致javascript引擎無法正確的解析它的物件成員,進而導致程式出錯。


DOM程式設計

瀏覽器中的DOM

文件物件模型(DOM)是一個獨立於語言的,用於操作XML和HTML文件的程式介面API。DOM是個與語言無關的API,在瀏覽器中的介面是用javascript實現的。客戶端指令碼程式設計大多數時候是在和底層文件打交道,DOM就成為現在javascript編碼中的重要組成部分。瀏覽器把DOM和javascript單獨實現,使用不同的引擎。

天生就慢

DOM和javascript就像兩個島嶼通過收費橋樑連線,每次通過都要繳納“過橋費”。
推薦的做法是儘可能減少過橋的次數,努力待在ECMAScript島上。

DOM訪問與修改

訪問DOM元素是有代價的——前面的提到的“過橋費”。修改元素則更為昂貴,因為它會導致瀏覽器重新計算頁面的幾何變化(重排)。最壞的情況是在迴圈中訪問或修改元素,尤其是對HTML元素集合迴圈操作。
在迴圈訪問頁面元素的內容時,最佳實踐是用區域性變數儲存修改中的內容,在迴圈結束後一次性寫入。
通用的經驗法則是:減少訪問DOM的次數,把運算儘量留在ECMAScript中處理。

節點克隆

大多數瀏覽器中使用節點克隆都比建立新元素要更有效率。

選擇API

使用css選擇器也是一種定位節點的便利途徑,瀏覽器提供了一個名為querySelectorAll()的原生DOM方法。這種方法比使用javascript和DOM來遍歷查詢元素快很多。使用另一個便利方法——querySelector()來獲取第一個匹配的節點。

重繪與重排

瀏覽器下載完頁面中的所有元件——HTML標記、javascript、CSS、圖片——之後會解析並生成兩個內部的資料結構:DOM樹(表示頁面結構)、渲染樹(表示DOM節點如何顯示)。當DOM的變化影響了元素的幾何屬性,瀏覽器會使渲染樹中受到影響的部分失效,並重構,這個過程成為重排,完成後,會重新繪製受影響的部分到螢幕,該過程叫重繪。並不是所有的DOM變化都會影響幾何屬性,這時只發生重繪。重繪和重排會導致web應用程式的UI反應遲鈍,應該儘量避免。

重排何時發生

當頁面佈局的幾何屬性改變時就需要重排:

  1. 新增或刪除可見的DOM元素
  2. 元素位置改變
  3. 元素尺寸改變(包括:外邊據、內邊距、邊框厚度、寬度、高度等屬性改變)
  4. 內容改變,例如:文字改變或圖片被另一個不同尺寸的圖片代替
  5. 頁面渲染器初始化
  6. 瀏覽器視窗尺寸改變
渲染樹變化的排隊與重新整理

由於每次重排都會產生計算消耗,大多數瀏覽器通過佇列化修改並批量執行來優化重排過程。但是有些操作會導致強制重新整理佇列並要求任務立刻執行:

  1. offsetTop,offsetLeft,offsetWidth,offsetHeight
  2. scrollTop,scrollLeft,scrollWidth,scrollHeight
  3. clientTop,clientLeft,clientWidth,clientHeight
  4. getComputedStyle()

以上屬性和方法需要返回最新的佈局資訊,因此瀏覽器不得不執行渲染佇列中的修改變化並觸發重排以返回正確的值。
最佳實踐:儘量將修改語句放在一起,查詢語句放在一起。

最小化重繪和重排

為了減少發生次數,應該合併多次DOM的樣式的修改,然後一次處理掉。

批量修改DOM

當你需要對DOM元素進行一系列操作時,可以通過以下步驟來減少重繪和重排的次數:

  1. 使元素脫離文件
  2. 對其應用多重改變
  3. 把元素帶回文件流

該過程會觸發兩次重排——第一步和第三步,如果忽略這兩步,在第二步所產生的任何修改都會觸發一次重排。

      有三種基本的方法可以使DOM脫離文件:

  1. 隱藏元素,應用修改,重新顯示
  2. 使用文件片段,在當前DOM之外構建一個子樹,再把它拷貝迴文件
  3. 將原始元素拷貝到一個脫離文件的節點中,修改副本,完成後再替換原始元素
      

推薦使用文件片段,因為它們所產生的DOM遍歷和重排次數最少。

快取快取佈局資訊

當你查詢佈局資訊時,瀏覽器為了返回最新值,會重新整理佇列並應用所有變更。
最佳實踐:儘量減少佈局資訊的獲取次數,獲取後把它賦值給區域性變數,然後操作區域性變數。

讓元素脫離動畫流

用展開、摺疊的方式來顯示和隱藏部分頁面是一種常見的互動模式。通常包括展開區域的幾何動畫,並將頁面其他部分推向下方。一般來說,重排隻影響渲染樹中的一小部分,但也可能影響很大的部分,甚至整個渲染樹。瀏覽器所需要重排的次數越少,應用程式的響應速度就越快。當一個動畫改變整個頁面的餘下部分時,會導致大規模重排。節點越多情況越差。避免大規模的重排:

  1. 使用絕對定位頁面上的動畫元素,將其脫離文件流。
  2. 應用動畫
  3. 當動畫結束時回恢復定位,從而只會下移一次文件的其他元素。

這樣只造成了頁面的一個小區域的重繪,不會產生重排並重繪頁面的大部分內容。

:hover

如果有大量元素使用了:hover,那麼會降低響應速度。此問題在IE8中更為明顯。

事件委託

當頁面中存在大量元素,並且每一個都要一次或多次繫結事件處理器時,這種情況可能會影響效能,每繫結一個事件處理器都是有代價的,它要麼加重了頁面負擔(更多的程式碼、標籤),要麼增加了執行期的執行時間。需要訪問和修改的DOM元素越多,應用程式就越慢,特別是事件繫結通常發生在onload時,此時對每一個富互動應用的網頁來說都是一個擁堵的時刻。事件繫結佔用了處理事件,而且瀏覽器要跟蹤每個事件處理器,這也會佔用更多的記憶體。這些事件處理器中的絕大部分都可能不會被觸發。
事件委託原理:事件逐層冒泡並能被父級元素捕獲。使用事件代理,只需要給外層元素繫結一個處理器,就可以處理在其子元素上觸發的所有事件。
根據DOM標準,每個事件都要經歷三個階段:

  1. 捕獲
  2. 到達目標
  3. 冒泡

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

<body>
     <div>     
          <ul id="menu">
               <li>
                    <a href="menu1.html">menu #1</a>
               </li>
               <li>
                    <a href="menu1.html">menu #2</a>
               </li>
          </ul>
     </div>
</body>

在以上的程式碼中,當使用者點選連結“menu #1”,點選事件首先從a標籤元素收到,然後向DOM樹上層冒泡,被li標籤接收然後是ul標籤然後是div標籤,一直到達document的頂層甚至window。
委託例項:阻止預設行為(開啟連結),只需要給所有連結的外層UL”menu”元素新增一個點選監聽器,它會捕獲並分析點選是否來自連結。

document.getElementById(`menu`).onclick = function(e) {
          //瀏覽器target
          e=e||window.event;
          var target = e.target||e.srcElement;

          var pageid,hrefparts;
          
          //只關心hrefs,非連結點選則退出,注意此處是大寫
          if (target.nodeName !== `A`) {
         return;
          }

          //從連結中找出頁面ID
          hrefparts = target.href.split(`/`);
          pageid = hrefparts[hrefparts.length-1];
          pageid = pageid.replace(`.html`,``);

          //更新頁面
          ajaxRequest(`xhr.php?page=`+id,updatePageContents);

          //瀏覽器阻止預設行為並取消冒泡
          if (type of e.preventDefault === `function`) {
               e.preventDefault();
               e.stopPropagation();
          } else {
               e.returnValue=false;
               e.cancelBubble=true;
          }
};

跨瀏覽器相容部分:

  1. 訪問事件物件,並判斷事件源
  2. 取消文件樹中的冒泡(可選)
  3. 阻止預設動作(可選)

演算法和流程控制

迴圈

迴圈的型別

ECMA-262標準第三版定義了javascript的基本語法和行為,其中共有四種迴圈。

  1. 第一種是標準的for迴圈。它由四部分組成:初始化、前測條件、後執行體、迴圈體。
           for (var i=0;i<10;i++){
                //do something
           }

for迴圈是javascript最常用的迴圈結構,直觀的程式碼封裝風格被開發者喜愛。

  2. while迴圈。while迴圈是最簡單的前測迴圈,由一個前測條件和一個迴圈體構成。

  3. do-while迴圈是javascript唯一一種後測迴圈,由一個迴圈體和一個後測條件組成,至少會執行一次。
  4. for-in迴圈。可以列舉任何物件的屬性名。
迴圈的效能

javascript提供的四種迴圈型別中,只有for-in迴圈比其他幾種明顯要慢。因為每次迭代操作會同時搜尋例項或原型屬性,for-in迴圈的每次迭代都會產生更多開銷。速度只有其他型別迴圈的七分之一。除非你明確需要迭代一個屬性數量未知的物件,否則應該避免使用for-in迴圈。如果你需要遍歷一個數量有限的已知屬性列表,使用其他迴圈型別會更快,比如陣列。
除for-in外,其他迴圈型別的效能都差不多,型別的選擇應該基於需求而不是效能。

提高迴圈的效能
  1. 減少每次迭代處理的事務
  2. 減少迭代的次數
減少迭代的工作量

減少物件成員及陣列項的查詢次數。

      在不影響的結果的情況下,可以使用倒序來略微提升效能。因為控制條件只要簡單的與零比較。控制條件與true比較時,任何非零數會自動轉換為true,而零值等同於false,實際上從兩次比較(迭代數少於總數麼?是否為true?)減少到一次比較(它是true麼)。當迴圈複雜度為O(n)時,減少每次迭代的工作量是最有效的方法。當複雜度大於O(n)時,建議著重減少迭代次數。
減少迭代次數

Duff`s Device是一個迴圈體展開技術,使得一次迭代中實際上執行了多次迭代的操作。一個典型的實現如下:

//credit:Jeff Greenberg
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);

Duff`s Device背後的基本理念是:每次迴圈中最多可以呼叫8此process()。迴圈的迭代次數除以8。由於不是所有數字都能被8整除,變數startAt用來存放餘數,表示第一次迴圈中應該呼叫多少次process()。
此演算法稍快的版本取消了switch語句,並將餘數處理和主迴圈分開

//credit:Jeff Greenberg
var i = items.length % 8;
while(i){
        process(item[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語句,速度比原始迴圈更快。
如果迴圈迭代的次數小於1000,可能它與常規迴圈結構相比只有微不足道的效能提升。如果迭代數超過1000,那麼執行效率將明顯提升。例如在500000此迭代中,其執行時間比常規迴圈減少70%

基於函式的迭代

ECMA-262第四版加入的陣列方法:forEach()方法。此方法遍歷一個陣列的所有成員,並在每個成員上執行一個函式。要執行的函式作為引數傳給forEach(),並在呼叫時接受三個引數,分別是當前的值、索引以及陣列本身。儘管基於函式的迭代提供了一個更為便利的迭代方法,但它仍比基於迴圈的迭代要慢一些。對每個陣列項呼叫外部方法所帶來的開銷是速度慢的主要原因。

條件語句

if-else對比switch

條件數數量越大,越傾向於使用switch,主要是因為易讀性。事實證明,大多數情況下switch比if-else執行得要快,但只有條件數量很大時才快得明顯。

優化if-else

最小化到達正確分支前所需要判斷的條件數量。最簡單的優化方法是確保最可能出線的條件放在首位。if-else中的條件語句應該總是按照從最大概率到最小概率的順序排列,以確保執行速度最快。假設均勻分部,可使用二分法的思想,重寫為一系列巢狀的if-else語句。

查詢表

有些時候優化條件語句的最佳方案是避免使用if-else和switch。可以使用陣列和普通物件來構建查詢表,通過查詢表訪問資料比用if-else或switch快很多。當單個鍵值存在邏輯對映時,構建查詢表的優勢就能體現出來。(比如把按照順序的鍵值對映放到陣列裡)

遞迴

使用遞迴可以把複雜的演算法變的簡單。潛在問題是終止條件不明確或缺少終止條件會導致函式長時間執行,並使得使用者介面處於假死狀態和瀏覽器的呼叫棧大小限制。

呼叫棧限制

javascript引擎支援的遞迴數量與javascript呼叫棧大小直接相關。

遞迴模式

當你遇到呼叫棧大小限制時,第一步應該檢查程式碼中的遞迴例項。有兩種遞迴模式,第一種是呼叫自身,很容易定位錯誤。第二種是互相呼叫,很難定位。

迭代

任何遞迴能實現的演算法同樣可以使用迭代來實現。使用優化後的迴圈代替長時間執行的遞迴函式可以提升效能,因為執行一個迴圈比反覆呼叫一個函式的開銷要少的多。
歸併排序演算法是最常見的用遞迴實現的演算法:

function merge(left, right) {
    var result = [];

    while (left.length > 0 && right.length > 0){
        if (left[0] < right[0]) {
            result.push(left.shift());
        } else {
            result.push(right.shift());    
        }
    }

    return result.concat(left).concat(right);
}

function mergeSort(items){
    if (items.length == 1){
        return items;
    }

    var middle = Math.floor(items.length / 2),
        left = items.slice(0, middle),
        right = items.slice(middle);
        return merge(mergeSort(left),mergeSort(right));
}

使用迭代實現歸併演算法:

//使用和上面相同的merge函式

function mergeSort(items){
    if (items.length == 1){
        return items;
    }

    var work = [];
    for (var i=0, len=items.length;i < len; i++){
        work.push([items[i]]);
    }
    work.push([]);

    for (var lim=len; lim>1; lim = (lim+1)/2){
        for (var j=0,k=0; k < lim; j++, k+=2){
            work[j] = merge(work[k],work[k+1]);
        }
        work[j] = [];
    }

    return work[0];
}

儘管迭代版本的歸併排序演算法比遞迴實現得要慢一些,但它不會像遞迴版本那樣受到呼叫棧限制的影響。把遞迴演算法改用迭代實現是避免棧溢位錯誤的方法之一

Memoization

Memoization是一種避免重複工作的方法,它快取前一個計算結果供後續計算使用,避免了重複工作。
使用Memoization技術來重寫階乘函式:

function memfactorial(n){
    if(!memfactorial.cache){
        memfactorial.cache={
            "0":1,
            "1":1
        };
    }

    if(!memfactorial.cache.hasOwnProperty(n)){
        memfactorial.cache[n] = n * memfactorial (n-1);
    }

    return memfactorial.cache[n];
}

字串和正規表示式

字串連結

+和+=

不應在等號右邊進行和被賦值的量無關的字串拼接運算,這樣會創造臨時字串。
例如:

str += "one" + "two";

會經歷四個步驟:

  1. 在記憶體中建立一個臨時字串
  2. 連線後的字串“onetwo”被賦值給該臨時字串
  3. 臨時字串與str當前的值連線
  4. 結果賦值給str

使用這種方式來代替:

str = str + "one" + "two";
//等價於 str = ((str + "one") + "two")

賦值表示式由str開始作為基礎,每次給它附加一個字串,由做到右一次連線,因此避免了使用臨時字串。

陣列項合併

Array.prototype.join方法將陣列的所有元素合併成一個字串,它接受一個字串引數作為分隔符插入每個元素的中間。大多數瀏覽器中,陣列項合併比其他字串連線的方法更慢。

String.prototype.concat

字串的原生方法concat能接收任意數量的引數,並將每一個引數附加到所呼叫的字串上。這是最靈活的字串合併方法。多數情況下,使用concat比使用簡單的+和+=稍慢。

正規表示式優化

部分匹配比完全不匹配所用的時間要長。

正規表示式工作原理
  1.  第一步編譯

瀏覽器會驗證正規表示式,然後把它轉換為一個原生程式碼程式,用於執行匹配工 作。如果把正則物件賦值給一個變數,可以避免重複這一步。

  2.  第二步設定起始位置
  3.  第三步匹配每個正規表示式字元
  4.  第四步匹配成功或失敗
回溯

當正則比到達時匹配目標字串時,從左到右逐個測試表示式的組成部分,看是否能找到匹配項。在遇到量詞和分支時,需要決策下一步如何處理。如果遇到量詞,正規表示式需決定何時嘗試匹配更多字元;如果遇到分支,那麼必須從可選項中選擇一個嘗試匹配。每當正規表示式做類似的決定時,如果有必要的話,都會記錄其他選擇,以備返回時使用。如果當前選項匹配成功,正規表示式繼續掃描表示式,如果其他部分也匹配成功,尼瑪匹配結束。但是如果當前選項找不到匹配值,或後面的部分匹配失敗,那麼正規表示式會回溯到最後一個決策點,然後在剩餘的選項中選擇一個。這個過程會一直進行,知道找到匹配項,或者正規表示式中量詞和分支選項的所有排列組合都嘗試失敗,那麼它將放棄匹配從而移動到字串的下一個字元,再重複此過程。

重複和回溯

貪婪匹配是段尾一個個回溯接下來的匹配內容,惰性正好相反;

回撥失控

最佳實踐:如果你的正規表示式包含了多個捕獲組,那麼你需要使用適當的反向引用次數。

巢狀量詞與回溯失控

所謂的巢狀量詞需要格外的關注且小心使用,以確保不會引發潛在的回溯失控。巢狀兩次是指兩次出線在一個自身被重複量詞修飾的組中。確保正規表示式的兩個部分不能對字串的相同部分進行匹配

更多提高正規表示式效率的方法
1.  關於如何讓正則匹配更快失敗

正規表示式慢的原因通常是匹配失敗的過程慢。

2.  正規表示式以簡單、必需的字元開始

一個正規表示式的起始標記應當儘可能快速的測試並排除明顯不匹配的位置。儘量以一個錨、特定字串、字元類和單詞邊界開始,儘量避免以分組或選擇字元開頭,避免頂層分支。

3.  使用量詞模式,使它們後面的字元互斥

當字元與字元相鄰或子表示式能夠重疊匹配時,正規表示式嘗試拆解文字的路徑數量將增加。

4.  減少分支數量,縮小分支範圍

分支使用豎線|可能要求在字串的每一個位置上測試所有的分支選項。你通常可以通過使用字符集和選項元件來減少對分支的需求,或將分支在正規表示式上的位置推後。

5.  使用非捕獲組

捕獲組消耗時間和記憶體來記錄反向引用,並使它保持最新。如果你不需要一個反向引用,可以使用非捕獲組來避免這些開銷。

6.  只捕獲感興趣的文字以減少後處理

如果需要引用匹配的一部分,應該才去一切手段捕獲那些片段,再使用反向引用來處理。

7.  暴露必需的字元

嘗試讓正規表示式引擎更容易判斷哪些字元是必需的。

8.  使用合適的量詞
9.  把正規表示式賦值給變數並重用它們

避免在迴圈體中重複編譯正規表示式。

10.  將複雜的正規表示式拆分為簡單的片段

何時不使用正規表示式

當只是搜尋字面字串,尤其是事先知道字串的哪一部分將要被查詢時。正規表示式無法直接跳到字串末尾而不考慮沿途的字元。


快速響應的使用者介面

瀏覽器UI執行緒

用於執行Javascript和更新使用者介面的程式通常被稱為“瀏覽器UI執行緒”。UI執行緒的工作基於一個見到那的佇列系統,任務會被儲存到佇列中直到執行緒空閒。

瀏覽器限制

瀏覽器限制了javascript的執行時間。此類限制分為兩種:呼叫棧的大小限制和長時間執行指令碼限制。

多久算太久

單個Javascript操作話費的總時間不應該超過100毫秒。
最佳實踐:限制所有的Javascript任務在100毫秒或更短的時間內完成。

使用定時器讓出時間片段

當Javascript不能在100毫秒或更短的時間內完成。最理想的方法是讓出UI執行緒的控制權,使得UI可以更新。

定時器基礎

在Javascript中可以使用setTimeout()和setInterval()建立定時器,它們接收相同的引數:要執行的函式和執行前的等待時間。定時器與UI執行緒的互動:定時器會告訴Javascript引擎先等待一定時間,然後新增一個Javascript任務到UI佇列。定時器程式碼只有在建立它的函式執行完之後,才有可能執行。無論發生何種情況,建立一個定時器會造成UI執行緒暫停,如同它從一個任務切換到下一個任務。因此,定時器程式碼會重置所有相關的瀏覽器限制,包括 長時間執行指令碼定時器。此外,呼叫棧也會在定時器中重置為0。setTimeout()和setInterval()幾近相同,如果 UI佇列中已經存在由同一個setInterval()建立的任務,那麼後續任務不會被新增到UI佇列中。如果setTimeout()中的函式需要消耗比定時器延時更長的執行時間,那麼定時器程式碼中的延時幾乎是不可見的。

定時器的精度

Javascript定時器延遲通常不太準確,相差大約為幾毫秒,無法用來精確計算時間。而且還存在最小值的限制。

使用定時器處理陣列

是否可以用定時器取代迴圈的兩個決定性因素:處理過程是否必須同步;資料是否必須按照順序處理;如果兩個答案都是否,那麼程式碼適用於定時器分解任務。

var todo = items.concat();
// 克隆原陣列

setTimeout(function(){

    // 取得陣列的下一個元素並進行處理
    process(todo.shift());

    // 如果還有需要處理的元素,建立另一個定時器
    if(todo.length > 0){
        setTimeout(arguments.callee, 25);
    } else {
        callback(items);
    }

}, 25);

每個定時器的真實延時在很程度上取決於具體情況。普遍來講,最好使用至少25毫秒,因為再小的延時,對大多數UI更新來說不夠用。

記錄程式碼執行使勁啊

通過定時器建立Date物件並比較它們的值來記錄程式碼執行事件。加號可以將Date物件轉換成數字,那麼在後續的運算中就無須轉換了。避免把任務分解成過於零碎的碎片,因為定時器之間有最小間隔,會導致出線空閒事件。

定時器與效能

當多個重複的定時器同時建立往往會出線效能問題。因為只有一個UI執行緒,而所有的定時器都在爭奪執行時間。那些間隔在1秒或1秒以上的低頻率的重複定時器幾乎不會影響Web應用的響應速度。這種情況下定時器延遲遠遠超過UI執行緒產生瓶頸的值,可以安全的重複使用。當過個定時器使用較高的頻率(100到200毫秒之間)時,會明顯影響效能。在web應用中限制高頻率重複定時器的數量,作為代替方案,使用一個獨立的重複定時器每次執行多個操作。

Web Worker

引入了一個介面,能使程式碼執行並且不佔用瀏覽器UI執行緒的時間。

Worker

沒有繫結UI執行緒,每個Web Worker都有自己的全域性環境,其功能只是Javascript特性的一個子集。執行環境由如下部分組成:一個navigator物件,值包括四個屬性:appName、appVersion、userAgent和platform。
一個location物件(與window.location相同,不過所有屬性都是隻讀的。)。
一個self物件,指向全域性worker物件。
一個importScipt()方法,用來載入Worker所用到的外部javascript檔案。
所有的ECMAScript物件
XMLHttpRequest構造器
setTimeout()方法和setInterval()方法
一個close()方法,它能立刻停止Worker執行
由於Web Worker有著不同的全域性執行環境,因此你無法從javascript程式碼中建立它。需要建立一個完全獨立的javascript檔案,其中包含了需要在Worker中執行的程式碼。要建立網頁人工執行緒,你必須傳入這個javascript檔案的URL;

與Worker通訊

通過事件介面進行通訊。網頁程式碼可以通過postMessage()方法給Worker傳遞資料,它接受一個引數,即需要傳遞給Worker的資料。此外,Worker還有一個用來接收資訊的onmessage事件處理器。Worker可通過它自己的postMessage()方法把資訊回傳給頁面。訊息系統是網頁和Worker通訊的唯一途徑。只有特定型別的資料可以使用postMessage()傳遞。你可以傳遞原始值(字串、數字、布林值、null和undefined),也可以傳遞Object和Array的例項,其他型別就不允許了。有效資料會被序列化,傳入或傳出Worker,然後反序列化。雖然看上去物件可以直接傳入,但物件例項完全是相同資料的獨立表述。

載入外部檔案

Worker 通過importScript()方法載入外部javascript檔案,該方法接收一個或多個javascript檔案URL作為引數。importScript()的呼叫過程是阻塞式的,知道所有所有檔案載入並執行完成之後,指令碼才會繼續執行。由於Worker在UI執行緒之外執行,所以這種阻塞並不會影響UI響應。
Web Worker適合用於那些處理純資料,或者與瀏覽器UI無關的長時間執行指令碼。儘管它看上去用處不大,但Web應用中通常有一些資料處理功能將收益於Worker而不是定時器。
可能的用處:

  1. 編碼/解碼大字串
  2. 複雜數學運算
  3. 大陣列排序
  4. 任何超過100毫秒的處理過程,都應當考慮Worker方案是不是比基於定時器的方案更為合適。

Ajax

Ajax是高效能javascript的基礎。它可以通過延遲下載體積較大的資原始檔來使得頁面載入速度更快。它通過非同步的方式在客戶端和服務端之間傳輸資料,避免同時傳輸大量資料。

資料傳輸

請求資料

有五種常用技術用於想伺服器請求資料:

  1. XMLHttpRequest
  2. Dynamic script tag insertion(指令碼動態注入)
  3. iframes
  4. Comet
  5. Multipart XHR

現代高效能Javascript中使用的三種技術是:XHR、動態指令碼注入和Multipart XHR

XMLHttpRequest

XMLHttpRequest是目前最常用的技術,它允許非同步傳送和接收資料。由於XHR提供了高階的控制,所以瀏覽器對其增加了一些限制。你不能使用XHR從外域請求資料。對於那些不會改變伺服器狀態,只會獲取資料(冪等行為)的請求,應該使用GET。經GET請求的資料會被快取起來,如果需要多次請求統一資料的話,它會有助於提升效能。只有當請求的URL加上引數的長度接近或超過2048個字元時,才應該用POST獲取資料。因為IE限制URL長度,過長將導致請求的URL被截斷。

動態指令碼注入

這種技術客服了XHR的最大限制:它能跨域請求資料。這是一個Hack,你不需要例項化一個專用物件,而可以使用javascript建立一個新的指令碼標籤,並設定它的src屬性為不同域的URL。與XHR相比,動態指令碼注入提供的控制是有限的。只能使用GET方法而不是POST方法。不能設定請求的超時處理或重試;不能訪問請求的頭部資訊,不能把整個響應資訊作為字串來處理。因為響應訊息作為指令碼標籤的原始碼,它必須是可執行的javascript程式碼。你不能使用純XML、純JSOn或其他任何格式的資料,無論哪種格式,都必須封裝在一個回撥函式中。這項技術的速度卻非常快。響應訊息是作為javascript執行,而不是作為字串需要進一步處理。正因如此,它有潛力成為客戶端獲取並解析資料最快的方法。

Multipart XHR

允許客戶端只用一個HTTP請求就可以從服務端向客戶端傳送多個字元。它通過在服務端將字元打包成一個由雙方約定的字串分割的長字串併傳送到客戶端。然後用javascript程式碼處理這個長字串,並根據它的mime-type型別和傳入的其他“頭資訊”解析出每個資源。缺點:資源不能被瀏覽器快取。
能顯著提高效能的場景:
頁面包含了大量其他地方用不到的資源,尤其是圖片;
網站已經在每個頁面中使用了一個獨立打包的Javascript或CSS檔案以減少http請求;

傳送資料

XMLHttpRequest
當使用XHR傳送資料到伺服器時,GET方式會更快。這是因為,對少量資料而言一個GET請求只傳送一個資料包。而一個POST請求至少要發兩個資料包,一個裝載頭資訊,另一個裝載POST正文。POST更適合傳送大量資料到伺服器,因為它不關心額外資料包的數量,另一個原因是URL長度有限制,它不可能使用過長的GET請求。

Beacons

類似動態指令碼注入。使用Javascript建立一個新的Image物件,並把src屬性設定為伺服器上指令碼的URL。該URL包含了我們要通過GET傳回的鍵值對資料。伺服器會接受資料並儲存下來,無須向客服端傳送任何回饋資訊,因此沒有圖片會實際顯示出來。這是回傳資訊最有效的方式。效能消耗更小,而且伺服器端的錯誤不影響客戶端。缺點:無法傳送POST資料,而URL的長度有最大值,所以可以傳送的資料的長度被限制的相當小。

資料格式

考慮資料格式時唯一需要比較的標準就是速度

XML

當Ajax最先開始流行時,它選擇了XML作為資料格式。優勢:極佳的通用性、格式嚴格,且易於驗證。缺點:冗長,依賴大量結構、有效資料的比例很低、語法模糊,如果有其他格式可選不要使用它。

JSON

是一種使用Javascript物件和陣列直接量編寫的輕量級且易於解析的資料格式。

JSON-P

事實上,JSON可以被本地執行會導致幾個重要的效能影響。當使用XHR時,JSON資料被當成字串返回。在使用動態指令碼注入時,JSON資料要被當成另一個Javascript檔案並作為原生程式碼執行,為實現這一點必須封裝在一個回撥函式中。JSON-P因為回撥包裝的原因略微增大了檔案尺寸,但效能提升巨大。由於資料是當作原生的Javascript,因此解析速度跟原生Javascript一樣快。最快的JSON格式是使用陣列形式的JSON-P。不要把敏感資料編碼在JSON-P中,因為無法確認它是否保持著私有呼叫狀態。

HTML

通常你請求的資料需要被轉換成HTML以顯示到頁面上。Javascript可以較快地把一個較大的資料結構轉換成簡單的HTML,但在伺服器處理會快很多。一種可考慮的技術是在伺服器上構建好整個HTML再傳回客戶端,Javascript可以很方便地通過innerHTML屬性把它插入頁面相應的位置。取點:臃腫的資料格式、比XML更繁雜。在資料本身的最外層,可以巢狀HTML標籤,每個都帶有id、class和其他屬性。HTML格式可能比實際資料佔用更多空間。應當在客戶端的瓶頸是CPU而不是頻寬時才使用此技術。

自定義格式

理想的資料格式應該只包含必要的結構,以便你可以分解出每個獨立的欄位。最重要的決定就是採用哪種分隔符,它應當是一個單字元,而且不應該存在你的資料中。

Ajax效能指南

快取資料

在服務端,設定HTTP頭資訊以確保你的響應會被瀏覽器快取。
在客戶端,把獲取到的資訊儲存到本地,從而避免再次請求。

設定HTTP頭資訊

如果希望ajax能被瀏覽器快取,那麼你必須使用GET方式傳送請求並且需要在響應中傳送正確的HTTP頭資訊。Expires頭資訊會告訴瀏覽器應該快取多久。它的值是一個日期。

本地資料儲存

直接把從伺服器接收到的資料儲存起來。可以把響應文字儲存到一個物件中,以URL為鍵值作為索引。

Ajax類庫的侷限性

所有的Javascript類庫都允許你訪問一個Ajax物件,它遮蔽了瀏覽器之間的差異,給你一個統一的介面。為了統一介面的功能,類庫簡化介面,使得你不能訪問XMLHttpRequest的完整功能。


程式設計實踐

避免雙重求值

Javascript允許你在程式中提取一個包含程式碼的字串,然後動態執行它。有四種標準方法可以實現:eval()、Function()建構函式、setTimeout()和setInterval()。首先會以正常的方式求值,然後在執行的過程中對包含於字串的程式碼發起另一個求值運算。每次使用這些方法都要建立一個新的直譯器/編譯器例項,導致消耗時間大大增加。
大多數時候沒有必要使用eval()和Function(),因此最好避免使用它們。定時器則建議傳入函式而不是字串作為第一個引數。

使用Object/Array直接量

Javascript中建立物件和陣列的方法有多種,但使用物件和陣列直接量是最快的方式。

避免重複工作

別做無關緊要的工作,別重複做已經完成的工作。

延遲載入

第一次被呼叫時,會先檢查並決定使用哪種方法去繫結或取消繫結事件處理器。然後原始函式被包含正確操作的新函式覆蓋。最後一步呼叫新的函式,並傳入原始引數。隨後每次呼叫都不會再做檢測,因為檢測程式碼已經被新的函式覆蓋。呼叫延遲載入函式時,第一次總會消耗較長的費時間,因為它必須執行檢測接著再呼叫另一個函式完成任務。但隨後呼叫相同的函式會更快,因為不需要再執行檢測邏輯。當一個函式在頁面中不會立刻呼叫時,延遲載入是最好的選擇。

條件預載入

它會在指令碼載入期間提前檢測,而不會等到函式被呼叫。檢測的操作依然只有一次,知識它在過程中來的更早。條件預載入確保所有函式呼叫消耗的時間相同。其代價是需要在指令碼載入時就檢測,而不是載入後。預載入適用於一個函式馬上就要被用到,並且在整個頁面的生命週期中頻繁出現的場合。

使用快的部分

執行速度慢的部分實際上是程式碼,引擎通常是處理過程中最快的部分。

位操作

使用位運算代替純數學操作:對2的取模運算可以被&1代替,速度提高很多。位掩碼:處理同時存在多個布林選項時的情形,思路是使用單個數字的每一位來判定是否選項成立,從而有效得把數字轉換為布林值標記組成的陣列。

原生方法

原生方法更快,因為寫程式碼前就存在瀏覽器中了,並且都是用底層語言比如c++編寫的。經驗不足的Javascript開發者經常犯的錯誤就是在程式碼中進行復雜的數學運算,而沒有使用內建的Math物件中那些效能更好的版本。另一個例子是選擇器API,它允許使用CSS選擇器來查詢DOM節點。原生的querySelector()和querySelectorAll()方法完成任務平均所需時間是基於Javascript的CSS查詢的10%。


構建並部署高效能Javascript應用

合併多個Javascript檔案,網站提速指南中第一條也是最重要的一條規則,就是減少http請求數。

預處理Javascript檔案

預處理你的Javascript原始檔並不會讓應用變的更快,但它允許你做些其他的事情,例如有條件地插入測試程式碼,來衡量你的應用程式的效能。

Javascript壓縮

指的是把Javascript檔案中所有與執行無關的部分進行剝離的過程。剝離的內容包括註釋和不必要的空白字元。該過程通常可以將檔案大小減半,促使檔案更快被下載,並鼓勵程式設計師編寫更好的更詳細的註釋。

構建時處理對比執行時處理

普遍規則是隻要能在構建時完成的工作,就不要留到執行時去做。

Javascript的http壓縮

當Web瀏覽器請求一個資源時,它通常會傳送一個Accept-Encoding HTTP頭來告訴Web伺服器它支援哪種編碼轉換型別。這個資訊主要用來壓縮文件以更快的下載,從而改善使用者體驗。Accept-Encoding可用的值包括:gzip、compress、deflate和identity。gzip是目前最流行的編碼方式。它通常能減少70%的下載量,成為提升Web應用效能的首選武器。記住Gzip壓縮主要適用於文字,包括Javascript檔案。

快取Javascript檔案

快取HTTP元件能極大提高網站回訪使用者的體驗。Web伺服器通過Expires HTTP響應頭來告訴客戶端一個字元應當快取多長事件。它的值是一個遵循RFC1123標準的絕對時間戳。

處理快取問題

適當的快取控制能提升使用者體驗,但它有一個缺點:當應用升級時,你需要確保使用者下載到最新的靜態內容。這個問題可以通過把改動過的靜態資源重新命名來解決。

使用內容分發網路(CDN)

內容分發網路是在網際網路上按地理位置設定分部計算機網路,它負責傳遞內容給終端使用者。使用CDN的主要原因是增強Web應用的可靠性、可擴充套件性,更重要的是提升效能。事實上,通過向地理位置最近的使用者輸入內容,CDN能極大減少網路延時。

相關文章