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

  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 對內存的管理,包括垃圾回收,以及用一個實例演示了內存泄漏後簡單的排查過程。

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