Web 效能優化:理解及使用 JavaScript 快取

前端小智發表於2019-03-20

clipboard.png

這是 Web 效能優化的第 5 篇,上一篇在下面看點選檢視:

  1. Web 效能優化:使用 Webpack 分離資料的正確方法
  2. Web 效能優化:圖片優化讓網站大小減少 62%
  3. Web 效能優化:快取 React 事件來提高效能
  4. Web 效能優化:21種優化CSS和加快網站速度的方法

隨著我們的應用程式的不斷增長並開始進行復雜的計算時,對速度的需求越來越高(?️),所以流程的優化變得必不可少。 當我們忽略這個問題時,我們最終的程式需要花費大量時間並在執行期間消耗大量的系統資源。

快取是一種優化技術,通過儲存開銷大的函式執行的結果,並在相同的輸入再次出現時返回已快取的結果,從而加快應用程式的速度。

如果這對你沒有多大意義,那沒關係。 本文深入解釋了為什麼需要進行快取,快取是什麼,如何實現以及何時應該使用快取。

什麼是快取

快取是一種優化技術,通過儲存開銷大的函式執行的結果,並在相同的輸入再次出現時返回已快取的結果,從而加快應用程式的速度。

在這一點上,我們很清楚,快取的目的是減少執行“昂貴的函式呼叫”所花費的時間和資源。

什麼是昂貴的函式呼叫?別搞混了,我們不是在這裡花錢。在計算機程式的上下文中,我們擁有的兩種主要資源是時間和記憶體。因此,一個昂貴的函式呼叫是指一個函式呼叫中,由於計算量大,在執行過程中大量佔用了計算機的資源和時間。

然而,就像對待金錢一樣,我們需要節約。為此,使用快取來儲存函式呼叫的結果,以便在將來的時間內快速方便地訪問。

快取只是一個臨時的資料儲存,它儲存資料,以便將來對該資料的請求能夠更快地得到處理。

因此,當一個昂貴的函式被呼叫一次時,結果被儲存在快取中,這樣,每當在應用程式中再次呼叫該函式時,結果就會從快取中非常快速地取出,而不需要重新進行任何計算。

為什麼快取很重要?

下面是一個例項,說明了快取的重要性:

想象一下,你正在公園裡讀一本封面很吸引人的新小說。每次一個人經過,他們都會被封面吸引,所以他們會問書名和作者。第一次被問到這個問題的時候,你翻開書,讀出書名和作者的名字。現在越來越多的人來這裡問同樣的問題。你是一個很好的人?,所以你回答所有問題。

你會翻開封面,把書名和作者的名字一一告訴他,還是開始憑記憶回答?哪個能節省你更多的時間?

發現其中的相似之處了嗎?使用記憶法,當函式提供輸入時,它執行所需的計算並在返回值之前將結果儲存到快取中。如果將來接收到相同的輸入,它就不必一遍又一遍地重複,它只需要從快取(記憶體)中提供答案。

快取是怎麼工作的

JavaScript 中的快取的概念主要建立在兩個概念之上,它們分別是:

  • 閉包
  • 高階函式(返回函式的函式)

閉包

閉包是函式和宣告該函式的詞法環境的組合。

不是很清楚? 我也這麼認為。

為了更好的理解,讓我們快速研究一下 JavaScript 中詞法作用域的概念,詞法作用域只是指程式設計師在編寫程式碼時指定的變數和塊的物理位置。如下程式碼:

function foo(a) {
  var b = a + 2;
  function bar(c) {
    console.log(a, b, c);
  }
  bar(b * 2);
}

foo(3); // 3, 5, 10

從這段程式碼中,我們可以確定三個作用域:

  • 全域性作用域(包含 foo 作為唯一識別符號)
  • foo 作用域,它有識別符號 abbar
  • bar 作用域,包含 c 識別符號

仔細檢視上面的程式碼,我們注意到函式 foo 可以訪問變數 a 和 b,因為它巢狀在 foo 中。注意,我們成功地儲存了函式 bar 及其執行環境。因此,我們說 barfoo 的作用域上有一個閉包。

你可以在遺傳的背景下理解這一點,即個體有機會獲得並表現出遺傳特徵,即使是在他們當前的環境之外,這個邏輯突出了閉包的另一個因素,引出了我們的第二個主要概念。

從函式返回函式

通過接受其他函式作為引數或返回其他函式的函式稱為高階函式。

閉包允許我們在封閉函式的外部呼叫內部函式,同時保持對封閉函式的詞法作用域的訪問

讓我們對前面的示例中的程式碼進行一些調整,以解釋這一點。

function foo(){
  var a = 2;

  function bar() {
    console.log(a);
  }
  return bar;
}
var baz = foo();
baz();//2

注意函式 foo 如何返回另一個函式 bar。這裡我們執行函式 foo 並將返回值賦給baz。但是在本例中,我們有一個返回函式,因此,baz 現在持有對 foo 中定義的bar 函式的引用。

最有趣的是,當我們在 foo 的詞法作用域之外執行函式 baz 時,仍然會得到 a 的值,這怎麼可能呢??

請記住,由於閉包的存在,bar 總是可以訪問 foo 中的變數(繼承的特性),即使它是在 foo 的作用域之外執行的。

案例研究:斐波那契數列

斐波那契數列是什麼?

斐波那契數列是一組數字,以1 或 0 開頭,後面跟著1,然後根據每個數字等於前兩個數字之和規則進行。如

0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, …

或者

1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, …

挑戰:編寫一個函式返回斐波那契數列中的 n 元素,其中的序列是:

[1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, …]

知道每個值都是前兩個值的和,這個問題的遞迴解是:

function fibonacci(n) {
  if (n <= 1) {
    return 1
  }
  return fibonacci(n - 1) + fibonacci(n - 2)
}

確實簡潔準確!但是,有一個問題。請注意,當 n 的值到終止遞迴之前,需要做大量的工作和時間,因為序列中存在對某些值的重複求值。

看看下面的圖表,當我們試圖計算 fib(5)時,我們注意到我們反覆地嘗試在不同分支的下標 0,1,2,3 處找到 Fibonacci 數,這就是所謂的冗餘計算,而這正是快取所要消除的。

圖片描述

function fibonacci(n, memo) {
  memo = memo || {}
  if (memo[n]) {
    return memo[n]
  }
  if (n <= 1) {
    return 1
  }

  return memo[n] = fibonacci(n-1, memo) + fibonacci(n-2, memo)
}

在上面的程式碼片段中,我們調整函式以接受一個可選引數 memo。我們使用 memo 物件作為快取來儲存斐波那契數列,並將其各自的索引作為鍵,以便在執行過程中稍後需要時檢索它們。

memo = memo || {}

在這裡,檢查是否在呼叫函式時將 memo 作為引數接收。如果有,則初始化它以供使用;如果沒有,則將其設定為空物件。

if (memo[n]) {
  return memo[n]
}

接下來,檢查當前鍵 n 是否有快取值,如果有,則返回其值。

和之前的解一樣,我們指定了 n 小於等於 1 時的終止遞迴。

最後,我們遞迴地呼叫n值較小的函式,同時將快取值(memo)傳遞給每個函式,以便在計算期間使用。這確保了在以前計算並快取值時,我們不會第二次執行如此昂貴的計算。我們只是從 memo 中取回值。

注意,我們在返回快取之前將最終結果新增到快取中。

使用 JSPerf 測試效能

可以使用些連結來效能測試。在那裡,我們執行一個測試來評估使用這兩種方法執行fibonacci(20) 所需的時間。結果如下:

圖片描述

哇! ! !這讓人很驚訝,使用快取的 fibonacci 函式是最快的。然而,這一數字相當驚人。它執行 126,762 ops/sec,這遠遠大於執行 1,751 ops/sec 的純遞迴解決方案,並且比較沒有快取的遞迴速度大約快 99%。

注:“ops/sec”表示每秒的操作次數,就是一秒鐘內預計要執行的測試次數。

現在我們已經看到了快取在函式級別上對應用程式的效能有多大的影響。這是否意味著對於應用程式中的每個昂貴函式,我們都必須建立一個修改後的變數來維護內部快取?

不,回想一下,我們通過從函式返回函式來了解到,即使在外部執行它們,它們也會導致它們繼承父函式的範圍,這使得可以將某些特徵和屬性從封閉函式傳遞到返回的函式。

使用函式的方式

在下面的程式碼片段中,我們建立了一個高階的函式 memoizer。有了這個函式,將能夠輕鬆地將快取應用到任何函式。

function memoizer(fun) {
  let cache = {}
  return function (n) {
    if (cache[n] != undefined) {
      return cache[n]
    } else {
      let result = fun(n)
      cache[n] = result
      return result
    }
  }
}

上面,我們簡單地建立一個名為 memoizer 的新函式,它接受將函式 fun 作為引數進行快取。在函式中,我們建立一個快取物件來儲存函式執行的結果,以便將來使用。

memoizer 函式中,我們返回一個新函式,根據上面討論的閉包原則,這個函式無論在哪裡執行都可以訪問 cache

在返回的函式中,我們使用 if..else 語句檢查是否已經有指定鍵(引數) n 的快取值。如果有,則取出並返回它。如果沒有,我們使用函式來計算結果,以便快取。然後,我們使用適當的鍵 n 將結果新增到快取中,以便以後可以從那裡訪問它。最後,我們返回了計算結果。

很順利!

要將 memoizer 函式應用於最初遞迴的 fibonacci 函式,我們呼叫 memoizer 函式,將 fibonacci 函式作為引數傳遞進去。

const fibonacciMemoFunction = memoizer(fibonacciRecursive)

測試 memoizer 函式

當我們將 memoizer 函式與上面的例子進行比較時,結果如下:

圖片描述

memoizer 函式以 42,982,762 ops/sec 的速度提供了最快的解決方案,比之前考慮的解決方案速度要快 100%。

關於快取,我們已經說明什麼是快取 、為什麼要有快取和如何實現快取。現在我們來看看什麼時候使用快取。

何時使用快取

當然,使用快取效率是級高的,你現在可能想要快取所有的函式,這可能會變得非常無益。以下幾種情況下,適合使用快取:

  • 對於昂貴的函式呼叫,執行復雜計算的函式。
  • 對於具有有限且高度重複輸入範圍的函式。
  • 用於具有重複輸入值的遞迴函式。
  • 對於純函式,即每次使用特定輸入呼叫時返回相同輸出的函式。

快取庫

總結

使用快取方法 ,我們可以防止函式呼叫函式來反覆計算相同的結果,現在是你把這些知識付諸實踐的時候了。

你的點贊是我持續分享好東西的動力,歡迎點贊!

一個笨笨的碼農,我的世界只能終身學習!

更多內容請關注公眾號《大遷世界》

歡迎加入前端大家庭,裡面會經常分享一些技術資源。

clipboard.png

相關文章