阿里雲最近在做活動,低至2折,有興趣可以看看:
https://promotion.aliyun.com/...
為了保證的可讀性,本文采用意譯而非直譯。
這是 Web 性能優化的第 5 篇,上一篇在下面看點擊查看:
- Web 性能優化:使用 Webpack 分離數據的正確方法
- Web 性能優化:圖片優化讓網站大小減少 62%
- Web 性能優化:緩存 React 事件來提高性能
- 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作用域,它有標識符a、b和barbar作用域,包含c標識符
仔細查看上面的代碼,我們注意到函數 foo 可以訪問變量 a 和 b,因為它嵌套在 foo 中。注意,我們成功地存儲了函數 bar 及其運行環境。因此,我們説 bar 在 foo 的作用域上有一個閉包。
你可以在遺傳的背景下理解這一點,即個體有機會獲得並表現出遺傳特徵,即使是在他們當前的環境之外,這個邏輯突出了閉包的另一個因素,引出了我們的第二個主要概念。
從函數返回函數
通過接受其他函數作為參數或返回其他函數的函數稱為高階函數。
閉包允許我們在封閉函數的外部調用內部函數,同時保持對封閉函數的詞法作用域的訪問
讓我們對前面的示例中的代碼進行一些調整,以解釋這一點。
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%。
關於緩存,我們已經説明什麼是緩存 、為什麼要有緩存和如何實現緩存。現在我們來看看什麼時候使用緩存。
何時使用緩存
當然,使用緩存效率是級高的,你現在可能想要緩存所有的函數,這可能會變得非常無益。以下幾種情況下,適合使用緩存:
- 對於昂貴的函數調用,執行復雜計算的函數。
- 對於具有有限且高度重複輸入範圍的函數。
- 用於具有重複輸入值的遞歸函數。
- 對於純函數,即每次使用特定輸入調用時返回相同輸出的函數。
緩存庫
- Lodash
- Memoizer
- Fastmemoize
- Moize
- Reselect for Redux
總結
使用緩存方法 ,我們可以防止函數調用函數來反覆計算相同的結果,現在是你把這些知識付諸實踐的時候了。
你的點贊是我持續分享好東西的動力,歡迎點贊!
交流
乾貨系列文章彙總如下,覺得不錯點個Star,歡迎 加羣 互相學習。
https://github.com/qq44924588...
我是小智,公眾號「大遷世界」作者,對前端技術保持學習愛好者。我會經常分享自己所學所看的乾貨,在進階的路上,共勉!
關注公眾號,後台回覆福利,即可看到福利,你懂的。