前端效能精進之瀏覽器(五)——JavaScript

2023-03-20 09:03:20

  JavaScript 是一種通過解釋執行的高階程式語言,同時也是一門動態、弱型別的直譯指令碼語言,適合物件導向(基於原型)和函數式的程式設計風格。

  直譯語言可以直接在直譯器中執行,而與直譯語言相對應的編譯語言(例如 C++),要先將程式碼編譯為機器碼,然後才能執行。

  不過直譯語言有一個弱點,就是如果有一條不能執行,那麼下面的也不能執行了。

  JavaScript 主要執行在一個內建 JavaScript 直譯器的使用者端中(例如 Web 瀏覽器),能夠處理複雜的計算,操控檔案的內容、樣式和行為。

  能在使用者端完成的操作(例如輸入驗證、日期計算等)儘量都由 JavaScript 完成,這樣就能減少與伺服器的通訊,降低伺服器的負載。

  JavaScript 作為現今前端開發最為核心的一部分,處理的不好就很容易影響網頁效能和使用者體驗。

  因此有必要了解一些 JavaScript 的程式碼優化。本文所用的範例程式碼已上傳至 Github

一、程式碼優化

  完整的JavaScript由3部分組成,如下所列:

  • ECMAScript,定義了該語言的語法和語意。
  • DOM(Document Object Model)即檔案物件模型,處理檔案內容的程式設計介面。
  • BOM(Browser Object Model)即瀏覽器物件模型,獨立於內容與瀏覽器進行互動的介面。

  對其優化,也會圍繞這 3 部分展開。

1)相等運運算元

  相等(==)和全等(===)這兩個運運算元都用來判斷兩個運算元是否相等,但它們之間有一個最大的區別。

  就是「==」允許在比較中進行型別轉換,而「===」禁止型別轉換。

  各種型別之間進行相等比較時,執行的型別轉換是不同的,在 ECMAScript 5 中已經定義了具體的轉換規則。

  下表將規則以表格的形式展示,第一列表示左邊運算元「X」的型別,第一行表示右邊運算元「Y」的型別。

  在表格中,Number() 函數用簡寫 N() 表示,ToPrimitive() 函數用簡寫 TP() 表示。

X == Y 數位 字串 布林值 null undefined 物件
數位   N(Y) N(Y) 不等 不等 TP(Y)
字串 N(X)   N(Y) 不等 不等 TP(Y)
布林值 N(X) N(X)   N(X) N(X) N(X)
null 不等 不等 N(Y) 相等 相等 TP(Y)
undefined 不等 不等 N(Y) 相等 相等 TP(Y)
物件 TP(X) TP(X) N(Y) TP(X) TP(X)  

  當判斷物件和非物件是否相等時,會先讓物件執行 ToPrimitive 抽象操作,再進行相等判斷。

  ToPrimitive 抽象操作就是先檢查是否有 valueOf() 方法,如果有並且返回基本型別的值,就用它的返回值;如果沒有就改用 toString() 方法,再用它的返回值。

  由於相等會進行隱式的型別轉換,因此會出現很多不確定性,ESLint 的規則會建議將其替換成全等。

  順帶說一句,弱型別有著程式碼簡潔、靈活性高等優勢,但它的可讀性差、不夠嚴謹等劣勢也非常突出。

  在編寫有點規模的專案時,推薦使用強型別的 TypeScript,以免發生不可預測的錯誤。

2)位運算

  在記憶體中,數位都是按二進位制儲存的,位運算就是直接對更低層級的二進位制位進行操作。

  由於位運算不需要轉成十進位制,因此處理速度非常快。

  常見的運運算元包括按位元與(&)、按位元或(|)、按位元互斥或(^)、按位元非(~)、左移(<<)、帶符號右移(>>)等。

  位運算常用於取代純數學操作,例如對 2 取模(digit%2)判斷偶數與奇數、數位交換,如下所示。

if (digit & 1) {
  // 奇數(odd)
} else {
  // 偶數(even)
}
// 數位交換
a = a^b;
b = b^a;
a = a^b;

  位掩碼技術是使用單個數位的每一位來判斷選項是否成立。注意,每項值都是 2 的冪,如下所示。

const OPTION_A = 1, OPTION_B = 2, OPTION_C = 4, OPTION_D = 8, OPTION_E = 16;
//用按位元或運算建立一個數位來包含多個設定選項
const options = OPTION_A | OPTION_C | OPTION_D;
//接下來可以用按位元與操作來判斷給定的選項是否可用
//選項A是否在列表中
if(options & OPTION_A) {
  //...
}

  用按位元左移(<<)做乘法,用按位元右移做除法(>>),例如 digit * 2 可以替換成 digit << 2。

  位運算的應用還有很多,此處只做拋磚引玉。

  順便說一句,推薦在 JavaScript 中使用原生方法,例如數學計算就呼叫 Math 中的方法。

  當年,jQuery 為了抹平瀏覽器之間的 DOM 查詢,自研了一款 CSS 選擇器引擎(sizzle),原始碼在 2500 多行。

  而現在,瀏覽器內建了 querySelector()querySelectorAll() 選擇器方法,若使用基礎功能,完全可以替代 sizzle。

  瀏覽器和規範的不斷髮展,使得原生方法也越來越完善,在效能方面也在越做越好。

3)儲存

  在早期,網頁的資料儲存都是通過 Cookie 完成的,不過 Cookie 最初的作用是保持 HTTP 請求的狀態。

  隨著網頁互動的複雜度越來越高,它的許多缺陷也暴露了出來,例如:

  1. 每個 HTTP 請求都會帶上 Cookie 資訊,增加了 HTTP 首部的內容,如果網站存取量巨大,將會很影響頻寬。
  2. Cookie 不適合儲存一些隱私敏感資訊(例如使用者名稱、密碼等),因為 Cookie 會在網路中傳遞,很容易被劫持,劫持後可以偽造請求,執行一些危險操作(例如刪除或修改資訊)。
  3. Cookie 的大小被瀏覽器限制在 4KB 左右,只能儲存一點簡單的資訊,不能應對複雜的儲存需求,例如快取表單資訊、資料同步等。

  為了解決這些問題,HTML5 引入了 Web 儲存:本地儲存(local storage)和對談儲存(session storage)。

  它們的儲存容量,一般在 2.5M 到 10M 之間(大部分是 5M),在 Chrome DevTools 的 Application 面板可以檢視當前網頁所儲存的內容。

  它不會作為請求報文中的額外資訊傳遞給伺服器,因此比較容易實現網頁或應用的離線化。

  若儲存的資料比較大,那麼就需要 IndexedDB,這是一個嵌入在瀏覽器中的事務資料庫,但不像關係型資料庫使用固定列。

  而是一種基於 JavaScript 物件的資料庫,類似於 NoSQL。

4)虛擬 DOM

  在瀏覽器中,DOM 和 JavaScript 是兩個獨立的模組,在 JavaScript 中存取 DOM 就好比穿過要收費的跨海大橋。

  存取次數越多,過橋費越貴,最直接的優化方法就是減少過橋次數,虛擬 DOM 的優化思路與此類似。

  所謂虛擬DOM(Virtual DOM),其實就是構建在真實 DOM 之上的一層抽象。

  它先將 DOM 元素對映成記憶體中的 JavaScript 物件(即通過 React.createElement() 得到的 React 元素),形成一棵 JavaScript 物件樹。

  再用演演算法找出新舊虛擬 DOM 之間的差異,隨後只更新真實 DOM 中需要變化的節點,而不是將整棵 DOM 樹重新渲染一遍,過程參考下圖。

  

  虛擬 DOM 還有一大亮點,那就是將它與其他渲染器配合能夠整合到指定的終端。

  例如將 React 元素對映成對應的原生控制元件,既可以用 react-dom 在 Web 端渲染,還可以使用 react-native 在手機端渲染。

5)Service Worker

  Service Worker 是瀏覽器和伺服器之間的代理伺服器,可攔截網站所有請求,根據自定義條件採取適當的動作,例如讀取響應快取、將請求轉發給伺服器、更新快取的資源等。

  

  Service Worker 執行在主執行緒之外,提供更細粒度的快取管理,雖然無法存取 DOM,但增加了離線快取的能力。

  當前,正在使用 Service Worker 技術的網站有 google微博等。

  每個 Service Worker 都有一個獨立於 Web 頁面的生命週期,如下圖所示,其中 Cache API 是指 CacheStorage,可對快取進行增刪改查。

  

  在主執行緒中註冊 Service Worker 後,觸發 install 事件,安裝 Service Worker 並且解析和執行 Service Worker 檔案(常以 sw.js 命名)。

  當 install 事件回撥成功時,觸發 activate 事件,開始啟用 Service Worker,然後監聽指定作用域中頁面的資源請求,監聽邏輯記錄在 fetch 事件中。

  接下來用一個例子來演示 Service Worker 的使用,首先在 load 事件中註冊 Service Worker,如下所示。

  因為註冊的指令碼是執行在主執行緒中的,為了避免影響首屏渲染,遂將其移動到 load 事件中。

window.addEventListener("load", () => {
  // 註冊一個 sw.js,通知瀏覽器為該頁面分配一塊記憶體,然後就會進入安裝階段
  navigator.serviceWorker
    .register("/sw.js")
    .then((registration) => {
      console.log("service worker 註冊成功");
    })
    .catch((err) => {
      console.log("servcie worker 註冊失敗");
    });
});

  sw.js 是一個 Service Worker 檔案,將其放置在根目錄中,這樣就能監控整個專案的頁面。若放置在其他位置,則需要設定 scope 引數,如下所示。

navigator.serviceWorker.register("/assets/js/sw.js", { scope: '/' })

  但是在存取 sw.js 時會報錯(如下所示),需要給它增加 Service-Worker-Allowed 首部,然後才能在整個域中工作,預設只能在其所在的目錄和子目錄中工作。

The path of the provided scope ('/') is not under the max scope allowed ('/assets/js/'). 
Adjust the scope, move the Service Worker script, or use the Service-Worker-Allowed HTTP header to allow the scope

  在 sw.js 中註冊了 install 和 fetch 事件(如下所示),都是比較精簡的程式碼,caches 就是 CacheStorage,提供了 open()、match()、addAll() 等方法。

  在 then() 方法中,當存在 response 引數時,就直接將其作為響應返回,它其實就是一個 Response 範例。

// 安裝
self.addEventListener("install", e => {
  e.waitUntil(
    caches.open("resource").then(cache => {
      cache.addAll(["/assets/js/demo.js"]).then(() => {
        console.log("資源都已獲取並快取");
      }).catch(error => {
        console.log('快取失敗:', error);
      });
    })
  );
});
// 攔截
self.addEventListener("fetch", e => {
  e.respondWith(
    caches.match(e.request).then(response => {
      // 響應快取
      if (response) {
        console.log("fetch cache");
        return response;
      }
      return fetch(e.request);
    })
  );
});

  執行網頁後,在 Chrome DevTools 的 Application 面板中的 Service Workers 選單中,就能看到註冊成功的 Service Worker,如下圖所示。

  

  在 Cache Storage 選單中,就能看到新增的快取資源,如下圖所示。

  

  關閉頁面,再次開啟,檢視 demo.js 的網路請求,size 那列顯示的就不是檔案尺寸,而是 Service Worker,如下圖所示。

  worker.html 沒有進行快取,所以將請求轉發給伺服器。

  

  2022 年 HTTP Archive 預估網站中 Service Worker 的使用率在桌面端和行動端分別有 1.63% 和 1.81%,從資料中可知,使用率並不高。

  雖然 Service Worker 的相容性除了 IE 之外,主流的瀏覽器都已相容,但是在實際使用中,還是要慎重。

  首先得做到引入 Service Worker 後帶來某些方面的效能提升,但不能讓另一些方面的效能降低,需要考慮成本和收益。

  其次網頁的執行不能依賴 Service Worker,它的作用只是錦上添花,而不是業務必須的。

  對於不能執行 Service Worker 的瀏覽器,也要確保網頁的呈現和互動都是正常的。

二、函數優化

  函數(function)就是一段可重複使用的程式碼塊,用於完成特定的功能,能被執行任意多次。

  它是一個 Function 型別的物件,擁有自己的屬性和方法。

  JavaScript 是一門函數語言程式設計語言,它的函數既是語法也是值,能作為引數傳遞給一個函數,也能作為一個函數的結果返回。

  與函數相關的優化有許多,本文選取其中的 3 種進行講解。

1)記憶函數

  記憶函數是指能夠快取先前計算結果的函數,避免重複執行不必要的複雜計算,是一種用空間換時間的程式設計技巧。

  具體的實施可以有多種寫法,例如建立一個快取物件,每次將計算條件作為物件的屬性名,計算結果作為物件的屬性值。

  下面的程式碼用於判斷某個數是否是質數,在每次計算完成後,就將計算結果快取到函數的自有屬性 digits 內。

  質數又叫素數,是指一個大於1的自然數,除了1和它本身外,不能被其它自然數整除的數。

function prime(number) {
  if (!prime.digits) {
    prime.digits = {};     //快取物件
  }
  if (prime.digits[number] !== undefined) {
    return prime.digits[number];
  }
  var isPrime = false;
  for (var i = 2; i < number; i++) {
    if (number % i == 0) {
      isPrime = false;
      break;
    }
  }
  if (i == number) {
    isPrime = true;
  }
  return (prime.digits[number] = isPrime);
}
prime(87);
prime(17);
console.log(prime.digits[87]);     //false
console.log(prime.digits[17]);     //true

2)惰性模式

  惰性模式用於減少每次程式碼執行時的重複性分支判斷,通過對物件重定義來遮蔽原物件中的分支判斷。

  惰性模式按觸發時機可分為兩種,第一種是在檔案載入後立即執行物件方法來重定義。

  在早期為了統一 IE 和其他瀏覽器之間註冊事件的語法,通常會設計一個相容性函數,下面範例採用的是第一種惰性模式。

var A = {};
A.on = (function (dom, type, fn) {
  if (dom.addEventListener) {
    return function (dom, type, fn) {
      dom.addEventListener(type, fn, false);
    };
  } else if (dom.attachEvent) {
    return function (dom, type, fn) {
      dom.attachEvent("on" + type, fn);
    };
  } else {
    return function (dom, type, fn) {
      dom["on" + type] = fn;
    };
  }
})(document);

  第二種是當第一次使用方法物件時來重定義,同樣以註冊事件為例,採用第二種惰性模式,如下所示。

A.on = function (dom, type, fn) {
  if (dom.addEventListener) {
    A.on = function (dom, type, fn) {
      dom.addEventListener(type, fn, false);
    };
  } else if (dom.attachEvent) {
    A.on = function (dom, type, fn) {
      dom.attachEvent("on" + type, fn);
    };
  } else {
    A.on = function (dom, type, fn) {
      dom["on" + type] = fn;
    };
  }
  //執行重定義on方法
  A.on(dom, type, fn);
};

3)節流和防抖

  節流(throttle)是指預先設定一個執行週期,當呼叫動作的時刻大於等於執行週期則執行該動作,然後進入下一個新週期,範例如下。

function throttle(fn, wait) {
  let start = 0;
  return () => {
    const now = +new Date();
    if (now - start > wait) {
      fn();
      start = now;
    }
  };
}

  適用於 mousemove、resize 和 scroll 事件。之前做過一個內部系統的表格,希望在左右捲動時能將第一列固定在最左邊。

  為了讓操作能更流暢,在 scroll 事件中使用了節流技術,如下圖所示。

  

  值得一提的是,在未來會有一個 scrollend 事件,專門監聽捲動的結束,待到瀏覽器支援,就可以不用再大費周章的節流了。

  防抖(debounce)是指當呼叫動作 n 毫秒後,才會執行該動作,若在這 n 毫秒內又呼叫此動作則將重新計算執行時間,範例如下。

function debounce(fn, wait) {
  let start = null;
  return () => {
    clearTimeout(start);
    start = setTimeout(fn, wait);
  };
}

  適用於文字輸入的 keydown 和 keyup 兩個事件,常應用於文字方塊自動補全。

  節流與防抖最大的不同的地方就是在計算最後執行時間的方式上,著名的開源工具庫 underscore 中有內建了兩個方法。

三、記憶體優化

  JavaScript 並沒有提供像 C 語言那樣底層的記憶體管理函數,例如 malloc() 和 free()。

  而是在建立變數(物件,字串等)時自動分配記憶體,並且在不使用它們時自動釋放,釋放過程稱為垃圾回收。

  雖然垃圾回收器很智慧,但是若處理不當,還是有可能發生記憶體漏失的。

1)垃圾回收器

  Node.js 是一個基於 V8 引擎的 JavaScript 執行時環境,而 Node.js 中的垃圾回收器(GC)其實就是 V8 的垃圾回收器。

  這麼多年來,V8 的垃圾回收器(Garbage Collector,簡寫GC)從一個全停頓(Stop-The-World),慢慢演變成了一個更加並行,並行和增量的垃圾回收器。

  本節內容參考了 V8 團隊分享的文章:Trash talk: the Orinoco garbage collector

  在垃圾回收中有一個重要術語:代際假說(The Generational Hypothesis),這個假說不僅僅適用於 JavaScript,同樣適用於大多數的動態語言,Java、Python 等。

  代際假說表明很多物件在記憶體中存在的時間很短,即從垃圾回收的角度來看,很多物件在分配記憶體空間後,很快就變得不可存取。

  在 V8 中,會將堆分為兩塊不同的區域:新生代(Young Generation)和老生代(Old Generation)。

  新生代中存放的是生存時間短的物件,大小在 1~ 8M之間;老生代中存放的生存時間久的物件。

  對於這兩塊區域,V8 會使用兩個不同的垃圾回收器:

  • 副垃圾回收器(Scavenger)主要負責新生代的垃圾回收。如果經過垃圾回收後,物件還存活的話,就會從新生代移動到老生代。
  • 主垃圾回收器(Full Mark-Compact)主要負責老生代的垃圾回收。

  無論哪種垃圾回收器,都會有一套共同的工作流程,定期去做些任務:

  1. 標記活動物件和非活動物件,前者是還在使用的物件,後者是可以進行垃圾回收的物件。
  2. 回收或者重用被非活動物件佔據的記憶體,就是在標記完成後,統一清理那些被標記為可回收的物件。
  3. 整理記憶體碎片(不連續的記憶體空間),這一步是可選的,因為有的垃圾回收器不會產生記憶體碎片。

  V8 為新生代採用 Scavenge 演演算法,會將記憶體空間劃分成兩個區域:物件區域(From-Space)和空閒區域(To-Space)。

  副垃圾回收器在清理新生代時:

  • 會先將所有的活動物件移動(evacuate)到連續的一塊空閒記憶體中(這樣能避免記憶體碎片)。
  • 然後將兩塊記憶體空間互換,即把 To-Space 變成 From-Space。
  • 接著為了新生代的記憶體空間不被耗盡,對於兩次垃圾回收後還活動的物件,會把它們移動到老生代,而不是 To-Space。
  • 最後是更新參照已移動的原始物件的指標。上述幾步都是交錯進行,而不是在不同階段執行。

  主垃圾回收器負責老生代的清理,而在老生代中,除了新生代中晉升的物件之外,還有一些大的物件也會被分配到此處。

  主垃圾回收器採用了 Mark-Sweep(標記清除)和 Mark-Compact(標記整理)兩種演演算法,其中涉及三個階段:標記(marking),清除(sweeping)和整理(compacting)。

  1. 在標記階段,會從一組根元素開始,遞迴遍歷這組根元素。其中根元素包括執行堆疊和全域性物件,瀏覽器環境下的全域性物件是 window,Node.js 環境下是 global。
  2. 在清除階段,會將非活動物件佔用的記憶體空間新增到一個叫空閒列表的資料結構中。
  3. 在整理階段,會讓所有活動的物件都向一端移動,然後直接清理掉那一端邊界以外的記憶體。

2)記憶體漏失

  記憶體漏失(memory leak)是電腦科學中的一種資源洩漏,主因是程式的記憶體管理失當,因而失去對一段已分配記憶體的控制。

  程式繼續佔用已不再使用的記憶體空間,或是記憶體所儲存物件無法透過執行程式碼而存取,令記憶體資源空耗,簡單地說就是記憶體無法被垃圾回收。

  下面會羅列幾種記憶體漏失的場景:

  第一種是全域性變數,它不會被自動回收,而是會常駐在記憶體中,因為它總能被垃圾回收器存取到。

  第二種是閉包(closure),當一個函數能夠存取和操作另一個函數作用域中的變數時,就會構成一個閉包,即使另一個函數已經執行結束,但其變數仍然會被儲存在記憶體中。

  如果參照閉包的函數是一個全域性變數或某個可以從根元素追溯到的物件,那麼就不會被回收,以後不再使用的話,就會造成記憶體漏失。

  第三種是事件監聽,如果對某個目標重複註冊同一個事件,並且沒有移除,那麼就會造成記憶體漏失。

  第四種是快取,當快取中的物件屬性越來越多時,長期存活的概率就越大,垃圾回收器也不會清理,部分不需要的物件就會造成記憶體漏失。

  在實際開發中,曾遇到過第三種記憶體漏失,如下圖所示,記憶體一直在升。

  

  要分析記憶體漏失,首先需要下載堆快照(*.heapsnapshot檔案),然後在 Chrome DevTools 的 Memory 面板中載入,可以看到下圖內容。

  

  在將堆快照做縝密的分析後發現,請求的 ma.gif 地址中的變數不會釋放,其內容如下圖所示。

  

  仔細檢視程式碼後,發現在為外部的 queue 物件反覆註冊一個 error 事件,如下所示。

import queue from "../util/queue";
router.get("/ma.gif", async (ctx) => {
  queue.on('error', function( err ) {
    logger.trace('handleMonitor queue error', err);
  });
});

  將這段程式碼去除後,記憶體就恢復了平穩,沒有出現暴增的情況,如下圖所示。

  

總結

  本文首先分析了相等和全等兩個運運算元的差異,然後再介紹了幾種位運算的巧妙用法。

  再介紹了目前主流的幾種 Web 儲存,以及虛擬 DOM 解決的問題,並且講解了 Service Worker 管理快取的過程。

  在第二節中主要分析了三種函數優化,分別是記憶函數、惰性模式、節流和防抖。

  其中節流和防抖在實際專案中有著廣泛的應用,很多知名的庫也都內建這兩個函數。

  最後講解了 V8 對記憶體的管理,包括垃圾回收,以及用一個範例演示了記憶體漏失後簡單的排查過程。