英文: Understanding Memoization in JavaScript to Improve Performance
中文: 瞭解JavaScript中的Memoization以提高效能--react的應用(歡迎star)
我們渴望提高應用程式的效能,Memoization
是JavaScript
中的一種技術,通過快取結果並在下一個操作中重新使用快取來加速查詢費時的操作。
在這裡,我們將看到memoization
的用法以及它如何幫助優化應用的效能。
Memoization: 基本理念
如果我們有CPU密集型操作,我們可以通過將初始操作的結果儲存在快取中來優化使用。如果操作必然會再次執行,我們將不再麻煩再次使用我們的CPU,因為相同結果的結果儲存在某個地方,我們只是簡單地返回結果。
可以看下面的例子:
function longOp(arg) {
if( cache has operation result for arg) {
return the cache
}
else {
假設執行一個耗時30分鐘的操作
把結果存在`cache`快取裡
}
return the result
}
longOp('lp') // 因為第一次執行這個引數的操作,所以需要耗時30分鐘
// 接下來會把結果快取起來
longOp('bp') // 同樣的第一次執行bp引數的操作,也需要耗時30分鐘
// 同樣會把結果快取起來
longOp('bp') // 第二次出現了
// 會很快的把結果從快取裡取出來
longOp('lp') //也同樣出現過了
// 快速的取出結果
複製程式碼
就CPU使用而言,上面的偽函式longOp
是一種耗時的功能。上面的程式碼會把第一次的結果給快取起來,後面具有相同輸入的呼叫都會從快取中提取結果,這樣就會繞過時間和資源消耗。
下面看一個平方根的例子:
function sqrt(arg) {
return Math.sqrt(arg);
}
log(sqrt(4)) // 2
log(sqrt(9)) // 3
複製程式碼
現在我們可以使用memoize
來處理這個函式:
function sqrt(arg) {
if (!sqrt.cache) {
sqrt.cache = {}
}
if (!sqrt.cache[arg]) {
return sqrt.cache[arg] = Math.sqrt(arg)
}
return sqrt.cache[arg]
}
複製程式碼
可以看到,結果會快取在cache
的屬性裡。
Memoization:履行
在上面部分,我們為函式新增了memoization
。
現在,我們可以建立一個獨立的函式來記憶任何函式。我們將此函式稱為memoize
。
function memoize(fn) {
return function () {
var args = Array.prototype.slice.call(arguments)
fn.cache = fn.cache || {};
return fn.cache[args] ? fn.cache[args] : (fn.cache[args] = fn.apply(this,args))
}
}
複製程式碼
我們可以看到這段程式碼接收另外一個函式作為引數並返回。
要使用此函式,我們呼叫memoize
將要快取的函式作為引數傳遞。
memoizedFunction = memoize(funtionToMemoize)
memoizedFunction(args)
複製程式碼
我們現在把上面的例子加入到這個裡面:
function sqrt(arg) {
return Math.sqrt(arg);
}
const memoizedSqrt = memoize(sqrt)
複製程式碼
返回的函式memoizedSqrt
現在是sqrt
的memoized
版本。
我們來呼叫下:
//...
memoizedSqrt(4) // 2 calculated(計算)
memoizedSqrt(4) // 2 cached
memoizedSqrt(9) // 3 calculated
memoizedSqrt(9) // 3 cached
memoizedSqrt(25) // 5 calculated
memoizedSqrt(25) // 5 cached
複製程式碼
我們可以將memoize
函式新增到Function
原型中,以便我們的應用程式中定義的每個函式都繼承memoize
函式並可以呼叫它。
Function.prototype.memoize = function() {
var self = this
return function () {
var args = Array.prototype.slice.call(arguments)
self.cache = self.cache || {};
return self.cache[args] ? self.cache[args] : (self.cache[args] = self(args))
}
}
複製程式碼
我們知道JS中定義的所有函式都是從Function.prototype
繼承的。因此,新增到Function.prototype
的任何內容都可用於我們定義的所有函式。
我們現在再來試試:
function sqrt(arg) {
return Math.sqrt(arg);
}
// ...
const memoizedSqrt = sqrt.memoize()
log(memoizedSqrt(4)) // 2, calculated
log(memoizedSqrt(4)) // 2, returns result from cache
log(memoizedSqrt(9)) // 3, calculated
log(memoizedSqrt(9)) // 3, returns result from cache
log(memoizedSqrt(25)) // 5, calculated
log(memoizedSqrt(25)) // 5, returns result from cache
複製程式碼
Memoization: Speed and Benchmarking
memoization
的目標是速度,他通過記憶體來提升速度。
看下面的對比:
檔名: memo.js
:
function memoize(fn) {
return function () {
var args = Array.prototype.slice.call(arguments)
fn.cache = fn.cache || {};
return fn.cache[args] ? fn.cache[args] : (fn.cache[args] = fn.apply(this,args))
}
}
function sqrt(arg) {
return Math.sqrt(arg);
}
const memoizedSqrt = memoize(sqrt)
console.time("non-memoized call")
console.log(sqrt(4))
console.timeEnd("non-memoized call")
console.time("memoized call")
console.log(sqrt(4))
console.timeEnd("memoized call")
複製程式碼
然後node memo.js
可以發現輸出,我這裡是:
2
non-memoized call: 2.210ms
2
memoized call: 0.054ms
複製程式碼
可以發現,速度還是提升了不少。
Memoization: 該什麼時候使用
在這裡,memoization
通常會縮短執行時間並影響我們應用程式的效能。當我們知道一組輸入將產生某個輸出時,memoization
最有效。
遵循最佳實踐,應該在純函式上實現memoization
。純函式輸入什麼就返回什麼,不存在副作用。
記住這個是以空間換速度,所以最好確定你是否值得那麼做,有些場景很有必要使用。
在處理遞迴函式時,Memoization
最有效,遞迴函式用於執行諸如GUI渲染,Sprite和動畫物理等繁重操作。
Memoization: 什麼時候不要使用
不是純函式的時候(輸出不完全依賴於輸入)。
使用案例:斐波那契系列(Fibonacci)
Fibonacci
是許多複雜演算法中的一種,使用memoization
優化的作用很明顯。
1,1,2,3,5,8,13,21,34,55,89
每個數字是前面兩個數字的和。
現在我們用js
實現:
function fibonacci(num) {
if (num == 1 || num == 2) {
return 1
}
return fibonacci(num-1) + fibonacci(num-2)
}
複製程式碼
如果num超過2,則此函式是遞迴的。它以遞減方式遞迴呼叫自身。
log(fibonacci(4)) // 3
複製程式碼
讓我們根據memoized版本對執行斐波那契的有效性進行測試。
memo.js
檔案:
function memoize(fn) {
return function () {
var args = Array.prototype.slice.call(arguments)
fn.cache = fn.cache || {};
return fn.cache[args] ? fn.cache[args] : (fn.cache[args] = fn.apply(this,args))
}
}
function fibonacci(num) {
if (num == 1 || num == 2) {
return 1
}
return fibonacci(num-1) + fibonacci(num-2)
}
const memFib = memoize(fibonacci)
console.log('profiling tests for fibonacci')
console.time("non-memoized call")
console.log(memFib(6))
console.timeEnd("non-memoized call")
console.time("memoized call")
console.log(memFib(6))
console.timeEnd("memoized call")
複製程式碼
接下來呼叫:
$ node memo.js
profiling tests for fibonacci
8
non-memoized call: 1.027ms
8
memoized call: 0.046ms
複製程式碼
可以發現,很小的一個數字,時間差距就那麼大了。
上面是參考原文,下面是個人感想。
咋說呢, 第一時間想到了react
的memo
元件(注意 這裡,現版本(16.6.3
)有兩個memo
,一個是React.memo,還有一個是React.useMemo, 我們這裡說的是useMemo
),相信關注react
動態的都知道useMemo
是新出來的hooks api
,並且這個api
是作用於function
元件,官方文件寫的是這個可以優化用以優化每次渲染的耗時工作。
看文件這裡介紹的也挺明白。今天看到medium
的這篇文章,感覺和react memo
有關係,就去看了下原始碼,發現的確是和本文所述一樣。
export function useMemo<T>(
nextCreate: () => T,
inputs: Array<mixed> | void | null,
): T {
currentlyRenderingFiber = resolveCurrentlyRenderingFiber(); //返回一個變數
workInProgressHook = createWorkInProgressHook(); // 返回包含memoizedState的hook物件
const nextInputs =
inputs !== undefined && inputs !== null ? inputs : [nextCreate]; // 需要儲存下來的inputs,用作下次取用的key
const prevState = workInProgressHook.memoizedState; // 獲取之前快取的值
if (prevState !== null) {
const prevInputs = prevState[1];
// prevState不為空,並且取出上次存的`key`, 然後下面判斷(前後的`key`是不是同一個),如果是就直接返回,否則繼續向下
if (areHookInputsEqual(nextInputs, prevInputs)) {
return prevState[0];
}
}
const nextValue = nextCreate(); //執行useMemo傳入的第一個引數(函式)
workInProgressHook.memoizedState = [nextValue, nextInputs]; // 存入memoizedState以便下次對比使用
return nextValue;
}
複製程式碼
進行了快取(workInProgressHook.memoizedState
就是hook
返回的物件並且包含memoizedState
,進行對比前後的inputs
是否相同,然後再次進行操作),並且支援傳遞第二個陣列引數作為key
。
果然, useMemo
就是用的本文提到的memoization
來提高效能的。
其實從官方文件就知道這個兩個有關係了 :cry: :
Pass a “create” function and an array of inputs. useMemo will only recompute the memoized value when one of the inputs has changed. This optimization helps to avoid expensive calculations on every render.