歡迎加入人類高質量前端框架研究群,帶飛
大家好,我卡頌。
React
原始碼內部在實現不同模組時用到了多種演算法與資料機構(比如排程器使用了小頂堆)。
今天要聊的是資料快取相關的LRU
演算法。內容包含四方面:
- 介紹一個
React
特性 - 這個特性和
LRU
演算法的關係 LRU
演算法的原理React
中LRU
的實現
可以說是從入門到實現都會講到,所以內容比較多,建議點個贊收藏慢慢食用。
一切的起點:Suspense
在React
16.6引入了Suspense
和React.lazy
,用來分割元件程式碼。
對於如下程式碼:
import A from './A';
import B from './B';
function App() {
return (
<div>
<A/>
<B/>
</div>
)
}
經由打包工具打包後生成:
- chunk.js(包含
A、B、App
元件程式碼)
對於首屏渲染,如果B
元件不是必需的,可以將其程式碼分割出去。只需要做如下修改:
// 之前
import B from './B';
// 之後
const B = React.lazy(() => import('./B'));
經由打包工具打包後生成:
- chunk.js(包含
A、App
元件程式碼) - b.js(包含
B
元件程式碼)
這樣,B
元件程式碼會在首屏渲染時以jsonp
的形式被請求,請求返回後再渲染。
為了在B
請求返回之前顯示佔位符,需要使用Suspense
:
// 之前,省略其餘程式碼
return (
<div>
<A/>
<B/>
</div>
)
// 之後,省略其餘程式碼
return (
<div>
<A/>
<Suspense fallback={<div>loading...</div>}>
<B/>
</Suspense>
</div>
)
B
請求返回前會渲染<div>loading.。.</div>
作為佔位符。
可見,Suspense
的作用是:
在非同步內容返回前,顯示佔位符(fallback屬性),返回後顯示內容
再觀察下使用Suspense
後元件返回的JSX
結構,會發現一個很厲害的細節:
return (
<div>
<A/>
<Suspense fallback={<div>loading...</div>}>
<B/>
</Suspense>
</div>
)
從這段JSX
中完全看不出元件B
是非同步渲染的!
同步和非同步的區別在於:
- 同步:開始 -> 結果
- 非同步:開始 -> 中間態 -> 結果
Suspense
可以將包裹在其中的子元件的中間態邏輯收斂到自己身上來處理(即Suspense
的fallback
屬性),所以子元件不需要區分同步、非同步。
那麼,能不能將Suspense
的能力從React.lazy
(非同步請求元件程式碼)推廣到所有非同步操作呢?
答案是可以的。
resource的大作為
React
倉庫是個monorepo
,包含多個庫(比如react
、react-dom
),其中有個和Suspense
結合的快取庫 —— react-cache
,讓我們看看他的用處。
假設我們有個請求使用者資料的方法fetchUser
:
const fetchUser = (id) => {
return fetch(`xxx/user/${id}`).then(
res => res.json()
)
};
經由react-cache
的createResource
方法包裹,他就成為一個resource
(資源):
import {unstable_createResource as createResource} from 'react-cache';
const userResource = createResource(fetchUser);
resource
配合Suspense
就能以同步的方式編寫非同步請求資料的邏輯:
function User({ userID }) {
const data = userResource.read(userID);
return (
<div>
<p>name: {data.name}</p>
<p>age: {data.age}</p>
</div>
)
}
可以看到,userResource.read
完全是同步寫法,其內部會呼叫fetchUser
。
背後的邏輯是:
- 首次呼叫
userResource.read
,會建立一個promise
(即fetchUser
的返回值) throw promise
React
內部catch promise
後,離User
元件最近的祖先Suspense
元件渲染fallback
promise resolve
後,User
元件重新render
- 此時再呼叫
userResource.read
會返回resolve
的結果(即fetchUser
請求的資料),使用該資料繼續render
從步驟1和步驟5可以看出,對於一個請求,userResource.read
可能會呼叫2次,即:
- 第一次傳送請求、返回
promise
- 第二次返回請求到的資料
所以userResource
內部需要快取該promise
的值,快取的key
就是userID
:
const data = userResource.read(userID);
由於userID
是User
元件的props
,所以當User
元件接收不同的userID
時,userResource
內部需要快取不同userID
對應的promise
。
如果切換100個userID
,就會快取100個promise
。顯然我們需要一個快取清理演算法,否則快取佔用會越來越多,直至溢位。
react-cache
使用的快取清理演算法就是LRU
演算法。
LRU原理
LRU
(Least recently used,最近最少使用)演算法的核心思想是:
如果資料最近被訪問過,那麼將來被訪問的機率也更高
所以,越常被使用的資料權重越高。當需要清理資料時,總是清理最不常使用的資料。
react-cache中LRU的實現
react-cache
的實現包括兩部分:
- 資料的存取
- LRU演算法實現
資料的存取
每個通過createResource
建立的resource
都有一個對應map
,其中:
- 該
map
的key
為resource.read(key)
執行時傳入的key
- 該
map
的value
為resource.read(key)
執行後返回的promise
在我們的userResource
例子中,createResource
執行後會建立map
:
const userResource = createResource(fetchUser);
userResource.read
首次執行後會在該map
中設定一條userID
為key
,promise
為value
的資料(被稱為一個entry
):
const data = userResource.read(userID);
要獲取某個entry
,需要知道兩樣東西:
entry
對應的key
entry
所屬的resource
LRU演算法實現
react-cache
使用雙向環狀連結串列實現LRU
演算法,包含三個操作:插入、更新、刪除。
插入操作
首次執行userResource.read(userID)
,得到entry0
(簡稱n0
),他會和自己形成環狀連結串列:
此時first
(代表最高權重)指向n0
。
改變userID props
後,執行userResource.read(userID)
,得到entry1
(簡稱n1
):
此時n0
與n1
形成環狀連結串列,first
指向n1
。
如果再插入n2
,則如下所示:
可以看到,每當加入一個新entry
,first
總是指向他,暗含了LRU
中新的總是高權重的思想。
更新操作
每當訪問一個entry
時,由於他被使用,他的權重會被更新為最高。
對於如下n0 n1 n2
,其中n2
權重最高(first
指向他):
當再次訪問n1
時,即呼叫如下函式時:
userResource.read(n1對應userID);
n1
會被賦予最高權重:
刪除操作
當快取數量超過設定的上限時,react-cache
會清除權重較低的快取。
對於如下n0 n1 n2
,其中n2
權重最高(first
指向他):
如果快取最大限制為1(即只快取一個entry
),則會迭代清理first.previous
,直到快取數量為1。
即首先清理n0
:
接著清理n1
:
每次清理後也會將map
中對應的entry
刪掉。
完整LRU實現見react-cache LRU
總結
除了React.lazy
、react-cache
能結合Suspense
,只要發揮想象力,任何非同步流程都可以收斂到Suspense
中,比如React Server Compontnt
、流式SSR
。
隨著底層React18
在年底穩定,相信未來這種同步寫法的開發模式會逐漸成為主流。
不管未來React
開發出多少新奇玩意兒,底層永遠是這些基礎演算法與資料結構。
真是樸素無華且枯燥......