從理念到LRU演算法實現,起底未來React非同步開發方式

卡頌發表於2021-10-18

歡迎加入人類高質量前端框架研究群,帶飛

大家好,我卡頌。

React原始碼內部在實現不同模組時用到了多種演算法與資料機構(比如排程器使用了小頂堆)。

今天要聊的是資料快取相關的LRU演算法。內容包含四方面:

  • 介紹一個React特性
  • 這個特性和LRU演算法的關係
  • LRU演算法的原理
  • ReactLRU的實現

可以說是從入門到實現都會講到,所以內容比較多,建議點個贊收藏慢慢食用。

一切的起點:Suspense

React16.6引入了SuspenseReact.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可以將包裹在其中的子元件的中間態邏輯收斂到自己身上來處理(即Suspensefallback屬性),所以子元件不需要區分同步、非同步。

那麼,能不能將Suspense的能力從React.lazy(非同步請求元件程式碼)推廣到所有非同步操作呢?

答案是可以的。

resource的大作為

React倉庫是個monorepo,包含多個庫(比如reactreact-dom),其中有個和Suspense結合的快取庫 —— react-cache,讓我們看看他的用處。

假設我們有個請求使用者資料的方法fetchUser

const fetchUser = (id) => {
  return fetch(`xxx/user/${id}`).then(
    res => res.json()
  )
};

經由react-cachecreateResource方法包裹,他就成為一個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

背後的邏輯是:

  1. 首次呼叫userResource.read,會建立一個promise(即fetchUser的返回值)
  2. throw promise
  3. React內部catch promise後,離User元件最近的祖先Suspense元件渲染fallback
  4. promise resolve後,User元件重新render
  5. 此時再呼叫userResource.read會返回resolve的結果(即fetchUser請求的資料),使用該資料繼續render

從步驟1和步驟5可以看出,對於一個請求,userResource.read可能會呼叫2次,即:

  • 第一次傳送請求、返回promise
  • 第二次返回請求到的資料

所以userResource內部需要快取該promise的值,快取的key就是userID

const data = userResource.read(userID);

由於userIDUser元件的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,其中:

  • mapkeyresource.read(key)執行時傳入的key
  • mapvalueresource.read(key)執行後返回的promise

在我們的userResource例子中,createResource執行後會建立map

const userResource = createResource(fetchUser);

userResource.read首次執行後會在該map中設定一條userIDkeypromisevalue的資料(被稱為一個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):

此時n0n1形成環狀連結串列,first指向n1

如果再插入n2,則如下所示:

可以看到,每當加入一個新entryfirst總是指向他,暗含了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.lazyreact-cache能結合Suspense,只要發揮想象力,任何非同步流程都可以收斂到Suspense中,比如React Server Compontnt流式SSR

隨著底層React18在年底穩定,相信未來這種同步寫法的開發模式會逐漸成為主流。

不管未來React開發出多少新奇玩意兒,底層永遠是這些基礎演算法與資料結構。

真是樸素無華且枯燥......

相關文章