React18 之 Suspense

發表於2024-02-23

我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式資料中臺產品。我們始終保持工匠精神,探索前端道路,為社群積累並傳播經驗價值。

本文作者:佳嵐

Suspense

Suspense 元件我們並不陌生,中文名可以理解為暫停or懸停  , 在 React16 中我們通常在路由懶載入中配合 Lazy 元件一起使用 ,當然這也是官方早起版本推薦的唯一用法。

那它暫停了什麼? 進行非同步網路請求,然後再拿到請求後的資料進行渲染是很常見的需求,但這不可避免的需要先渲染一次沒有資料的頁面,資料返回後再去重新渲染。so , 我們想要暫停的就是第一次的無資料渲染。

通常我們在沒有使用Suspense 時一般採用下面這種寫法, 透過一個isLoading狀態來顯示載入中或資料。這樣程式碼是不會有任何問題,但我們需要手動去維護一個isLoading 狀態的值。

const [data, isLoading] = fetchData("/api");
if (isLoading) {
  return <Spinner />;
}
return <MyComponent data={data} />;

當我們使用Suspense 後,使用方法會變為如下, 我們只需將進行非同步資料獲取的元件進行包裹,並將載入中元件透過fallback傳入

return (
  <Suspense fallback={<Spinner />}>
    <MyComponent />
  </Suspense>
);

那 React 是如何知道該顯示MyComponent還是Spinner的?

答案就在於MyComponent內部進行fetch遠端資料時做了一些手腳。

export const App = () => {
  return (
    <div>
      <Suspense fallback={<Spining />}>
        <MyComponent />
      </Suspense>
    </div>
  );
};

function Spining() {
  return <p>loading...</p>;
}

let data = null;

function MyComponent() {
  if (!data) {
    throw new Promise((resolve) => {
      setTimeout(() => {
        data = 'kunkun';
        resolve(true);
      }, 2000);
    });
  }
  return (
    <p>
      My Component, data is {data}
    </p>
  );
}

Suspense是根據捕獲子元件內的異常來實現決定展示哪個元件的。這有點類似於ErrorBoundary ,不過ErrorBoundary是捕獲 Error 時就展示回退元件,而Suspense 捕獲到的 Error 需要是一個Promise物件(並非必須是 Promise 型別,thenable 的都可以)。

我們知道 Promise 有三個狀態,pendingfullfilledrejected ,當我們進行遠端資料獲取時,會建立一個Promise,我們需要直接將這個Promise 作為Error進行丟擲,由 Suspense 進行捕獲,捕獲後對該thenable物件的then方法進行回撥註冊thenable.then(retry) , 而 retry 方法就會開始一個排程任務進行更新,後面會詳細講。
file

知道了大致原理,這時還需要對我們的fetcher進行一層包裹才能實際運用。

// MyComponent.tsx
const getList = wrapPromise(fetcher('http://api/getList'));

export function MyComponent() {
  const data = getList.read();

  return (
    <ul>
      {data?.map((item) => (
        <li>{item.name}</li>
      ))}
    </ul>
  );
}

function fetcher(url) {
  return new Promise((resove, reject) => {
    setTimeout(() => {
      resove([{ name: 'This is Item1' }, { name: 'This is Item2' }]);
    }, 1000);
  });
}

// Promise包裹函式,用來滿足Suspense的要求,在初始化時預設就會throw出去
function wrapPromise(promise) {
  let status = 'pending';
  let response;

  const suspend = promise.then(
    (res) => {
      status = 'success';
      response = res;
    },
    (err) => {
      status = 'error';
      response = err;
    }
  );
  const read = () => {
    switch (status) {
      case 'pending':
        throw suspend;
      default:
        return response;
    }
  };

  return { read };

從上述程式碼我們可以注意到,透過const data = getList.read() 這種同步的方式我們就能拿到資料了。 注意: 上面這種寫法並非一種正規化,目前官方也沒有給出推薦的寫法
為了與Suspense配合,則我們的請求可能會變得很不優雅 ,官方推薦是直接讓我們使用第三方框架提供的能力使用Suspense請求資料,如 useSWR
下面時useSWR的示例,簡明瞭很多,並且對於Profile元件,資料獲取的寫法可以看成是同步的了。

import { Suspense } from 'react'
import useSWR from 'swr'
 
function Profile () {
  const { data } = useSWR('/api/user', fetcher, { suspense: true })
  return <div>hello, {data.name}</div>
}
 
function App () {
  return (
    <Suspense fallback={<div>loading...</div>}>
      <Profile/>
    </Suspense>
  )
}

Suspense的另一種用法就是與懶載入lazy元件配合使用,在完成載入前展示Loading

<Suspense fallback={<GlobalLoading />}>
   {lazy(() => import('xxx/xxx.tsx'))}
</Suspense>

由此得出,透過lazy返回的元件也應該包裹一層類似如上的 Promise,我們看看 lazy 內部是如何實現的。
其中ctor就是我們傳入的() => import('xxx/xxx.tsx'), 執行lazy也只是幫我們封裝了層資料結構。ReactLazy.js

export function lazy<T>(
  ctor: () => Thenable<{default: T, ...}>,
): LazyComponent<T, Payload<T>> {
  const payload: Payload<T> = {
    // We use these fields to store the result.
    _status: Uninitialized,
    _result: ctor,
  };
  const lazyType: LazyComponent<T, Payload<T>> = {
    $$typeof: REACT_LAZY_TYPE,
    _payload: payload,
    _init: lazyInitializer,
  };
  return lazyType;
}

React 會在Reconciler過程中去實際執行,在協調的render階段beginWork中可以看到對lazy單獨處理的邏輯。 ReactFiberBeginWork.js

function mountLazyComponent(
  _current,
  workInProgress,
  elementType,
  renderLanes,
) {
  const props = workInProgress.pendingProps;
  const lazyComponent: LazyComponentType<any, any> = elementType;
  const payload = lazyComponent._payload;
  const init = lazyComponent._init;
    // 在此處初始化lazy
  let Component = init(payload);
    // 下略
}

那我們再來看看init幹了啥,也就是封裝前的lazyInitializer方法,整體跟我們之前實現的 fetch 封裝是一樣的。
ReactLazy.js

function lazyInitializer<T>(payload: Payload<T>): T {
  if (payload._status === Uninitialized) {
    const ctor = payload._result;
    // 這時候開始進行遠端模組的匯入
    const thenable = ctor();
    thenable.then(
      moduleObject => {
        if (payload._status === Pending || payload._status === Uninitialized) {
          // Transition to the next state.
          const resolved: ResolvedPayload<T> = (payload: any);
          resolved._status = Resolved;
          resolved._result = moduleObject;
        }
      },
      error => {
        if (payload._status === Pending || payload._status === Uninitialized) {
          // Transition to the next state.
          const rejected: RejectedPayload = (payload: any);
          rejected._status = Rejected;
          rejected._result = error;
        }
      },
    );
  }
  if (payload._status === Resolved) {
    const moduleObject = payload._result;
    }
    return moduleObject.default;
  } else {
    // 第一次執行肯定會先丟擲異常
    throw payload._result;
  }
}

Suspense 底層是如何實現的?

其底層細節非常之多,在開始之前,我們先回顧下 React 的大致架構

Scheduler: 用於排程任務,我們每次setState可以看成是往其中塞入一個Task,由Scheduler內部的優先順序策略進行判斷何時排程執行該Task

Reconciler: 協調器,進行 diff 演算法,構建 fiber 樹

Renderer: 渲染器,將 fiber 渲染成 dom 節點

Fiber 樹的結構, 在 reconciler 階段,採用深度優先的方式進行遍歷,往下遞即呼叫beginWork的過程,往上回溯即呼叫ComplteWork的過程
file
我們先直接進入Reconciler 中分析下Suspensefiber節點是如何被建立的
beginWork

function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
    switch (workInProgress.tag) {
        case HostText:
      return updateHostText(current, workInProgress);
    case SuspenseComponent:
      return updateSuspenseComponent(current, workInProgress, renderLanes);
        // 省略其他型別
    }
}
  • beginWork中會根據**不同的元件型別**執行不同的建立方法, 而Suspense 對應的會進入到updateSuspenseComponent

updateSuspenseComponent

function updateSuspenseComponent(current, workInProgress, renderLanes) {
  const nextProps = workInProgress.pendingProps;

  let showFallback = false;
  // 標識該Suspense是否已經捕獲過子元件的異常了
  const didSuspend = (workInProgress.flags & DidCapture) !== NoFlags;

  if (
    didSuspend
  ) {
    showFallback = true;
    workInProgress.flags &= ~DidCapture;
  } 

  // 第一次元件載入
  if (current === null) {

    const nextPrimaryChildren = nextProps.children;
    const nextFallbackChildren = nextProps.fallback;
   
    // 第一次預設不展示fallback,因為要先走到children後才會產生異常
    if (showFallback) {
      const fallbackFragment = mountSuspenseFallbackChildren(
        workInProgress,
        nextPrimaryChildren,
        nextFallbackChildren,
        renderLanes,
      );
      const primaryChildFragment: Fiber = (workInProgress.child: any);
      primaryChildFragment.memoizedState = mountSuspenseOffscreenState(
        renderLanes,
      );

      return fallbackFragment;
    } 
     else {
      return mountSuspensePrimaryChildren(
        workInProgress,
        nextPrimaryChildren,
        renderLanes,
      );
    }
  } else {
    // 如果是更新,操作差不多,此處略
  }
}
  • 第一次updateSuspenseComponent 時 ,我們會把mountSuspensePrimaryChildren 的結果作為下一個需要建立的fiber , 因為需要先去觸發異常。
  • 實際上mountSuspensePrimaryChildren  會為我們的PrimaryChildren 在包上一層OffscreenFiber
function mountSuspensePrimaryChildren(
  workInProgress,
  primaryChildren,
  renderLanes,
) {
  const mode = workInProgress.mode;
  const primaryChildProps: OffscreenProps = {
    mode: 'visible',
    children: primaryChildren,
  };
  const primaryChildFragment = mountWorkInProgressOffscreenFiber(
    primaryChildProps,
    mode,
    renderLanes,
  );
  primaryChildFragment.return = workInProgress;
  workInProgress.child = primaryChildFragment;
  return primaryChildFragment;
}

什麼是OffscreenFiber/Component  ?
透過其需要的 mode 引數值,我們可以大膽的猜測,應該是一個能控制是否顯示子元件的元件,如果hidden,則會透過 CSS 樣式隱藏子元素。
file
在這之後的 Fiber 樹結構
file
當我們向下執行到MyComponent 時,由於丟擲了錯誤,當前的reconciler階段會被暫停
讓我們再回到 Reconciler 階段的起始點可以看到有Catch語句。renderRootConcurrent

function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
 // 省略..
  do {
    try {
      workLoopConcurrent();
      break;
    } catch (thrownValue) {
      handleError(root, thrownValue);
    }
  } while (true);
 // 省略..
}

performConcurrentWorkOnRoot(root, didTimeout) {
    // 省略..
    let exitStatus = shouldTimeSlice
    ? renderRootConcurrent(root, lanes)
    : renderRootSync(root, lanes);
  // 省略..
}

我們再看看錯誤處理函式handleError中做了些什麼  handleError

function handleError(root, thrownValue): void {
    // 這時的workInProgress指向MyComponent
  let erroredWork = workInProgress;
  try {
    throwException(
      root,
      erroredWork.return,
      erroredWork,
      thrownValue,
      workInProgressRootRenderLanes,
    );
    completeUnitOfWork(erroredWork);
}

function throwException(root: FiberRoot, returnFiber: Fiber, sourceFiber: Fiber, value: mixed, rootRenderLanes: Lanes) 
{
  // 給MyComponent打上未完成標識
  sourceFiber.flags |= Incomplete;

  if (
    value !== null &&
    typeof value === 'object' &&
    typeof value.then === 'function'
  ) {
    // wakeable就是我們丟擲的Promise
    const wakeable: Wakeable = (value: any);

    // 向上找到第一個Suspense邊界
    const suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber);
    if (suspenseBoundary !== null) {
      // 打上標識
      suspenseBoundary.flags &= ~ForceClientRender;
      suspenseBoundary.flags |= ShouldCapture;
      // 註冊監聽器
            attachRetryListener(suspenseBoundary, root, wakeable, rootRenderLanes);
            return;
  }
}

主要做了三件事

  • 給丟擲錯誤的元件打上Incomplete標識
  • 如果捕獲的錯誤是 thenable 型別,則認定為是 Suspense 的子元件,向上找到最接近的一個Suspense 邊界,並打上ShouldCapture 標識
  • 執行attachRetryListener 對 Promise 錯誤監聽,當狀態改變後開啟一個排程任務重新渲染 Suspense

在錯誤處理的事情做完後,就不應該再往下遞了,開始呼叫completeUnitOfWork往上歸, 這時由於我們給 MyComponent 元件打上了Incomplete 標識,這個標識表示由於異常等原因渲染被擱置,那我們是不是就要開始往上找能夠處理這個異常的元件?

我們再看看completeUnitOfWork 幹了啥

function completeUnitOfWork(unitOfWork: Fiber): void {
 // 大致邏輯
  let completedWork = unitOfWork;
  if ((completedWork.flags & Incomplete) !== NoFlags) {
      const next = unwindWork(current, completedWork, subtreeRenderLanes);
            if (next) {
                    workInProgress = next;
                    return
            }
            // 給父節點打上Incomplete標記
            if (returnFiber !== null) {
              returnFiber.flags |= Incomplete;
              returnFiber.subtreeFlags = NoFlags;
              returnFiber.deletions = null;
            }
    }
}

可以看到最終打上Incomplete 標識的元件都會進入unwindWork流程 , 並一直將祖先節點打上Incomplete 標識,直到unwindWork 中找到一個能處理異常的邊界元件,也就ClassComponent, SuspenseComponent , 會去掉ShouldCapture標識,加上DidCapture標識

這時,對於Suspense來說需要的DidCapture已經拿到了,下面就是重新從Suspense 開始走一遍beginWork流程

再次回到 Suspense 元件, 這時由於有了DidCapture 標識,則展示fallback
對於fallback元件的fiber節點是透過mountSuspenseFallbackChildren 生成的

function mountSuspenseFallbackChildren(
  workInProgress,
  primaryChildren,
  fallbackChildren,
  renderLanes,
) {
  const primaryChildProps: OffscreenProps = {
    mode: 'hidden',
    children: primaryChildren,
  };

  let primaryChildFragment = mountWorkInProgressOffscreenFiber(
      primaryChildProps,
      mode,
      NoLanes,
    );
  let fallbackChildFragment = createFiberFromFragment(
      fallbackChildren,
      mode,
      renderLanes,
      null,
    );

  primaryChildFragment.return = workInProgress;
  fallbackChildFragment.return = workInProgress;
  primaryChildFragment.sibling = fallbackChildFragment;
  workInProgress.child = primaryChildFragment;
  return fallbackChildFragment;
}

它主要做了三件事

  • PrimaryChildOffscreen元件透過css隱藏
  • fallback元件又包了層Fragment 返回
  • fallbackChild 作為sibling連結至PrimaryChild

file
到這時渲染 fallback 的 fiber 樹已經基本構建完了,之後進入commit階段從根節點rootFiber開始深度遍歷該fiber樹 進行 render。

等待一段時間後,primary元件資料返回,我們之前在handleError中新增的監聽器attachRetryListener 被觸發,開始新的一輪任務排程。注:原始碼中排程回撥實際在 Commit 階段才新增的。

這時由於Suspense 節點已經存在,則走的是updateSuspensePrimaryChildren 中的邏輯,與之前首次載入時 monutSuspensePrimaryChildren不同的是多了刪除的操作, 在 commit 階段時則會刪除fallback 元件, 展示primary元件。updateSuspensePrimaryChildren

if (currentFallbackChildFragment !== null) {
    // Delete the fallback child fragment
    const deletions = workInProgress.deletions;
    if (deletions === null) {
      workInProgress.deletions = [currentFallbackChildFragment];
      workInProgress.flags |= ChildDeletion;
    } else {
      deletions.push(currentFallbackChildFragment);
    }
  }

至此,Suspense 的一生我們粗略的過完了,在原始碼中對 Suspense 的處理非常多,涉及到優先順序相關的本篇都略過。
Suspense 中使用了Offscreen元件來渲染子元件,這個元件的特性是能根據傳入 mode 來控制子元件樣式的顯隱,這有一個好處,就是能儲存元件的狀態,有些許類似於 Vue 的keep-alive 。其次,它擁有著最低的排程優先順序,比空閒時優先順序還要低,這也意味著當 mode 切換時,它會被任何其他排程任務插隊打斷掉。
file

useTransition

useTransition 可以讓我們在不阻塞 UI 渲染的情況下更新狀態。useTransitionstartTransition 允許將某些更新標記為低優先順序更新。預設情況下,其他更新被視為緊急更新。React 將允許更緊急的更新(例如更新文字輸入)來中斷不太緊急的更新(例如展示搜尋結果列表)。
其核心原理其實就是將startTransition 內呼叫的狀態變更方法都標識為低優先順序的lane (lane優先順序參考)去更新。

const [isPending, startTransition] = useTransition()

startTransition(() => {
    setData(xxx)
})

一個輸入框的例子

function Demo() {
  const [value, setValue] = useState();
  const [isPending, startTransition] = useTransition();

  return (
    <div>
      <h1>useTramsotopm Demo</h1>
      <input
        onChange={(e) => {
          startTransition(() => {
            setValue(e.target.value);
          });
        }}
      />
      <hr />
      {isPending ? <p>載入中。。</p> : <List value={value} />}
    </div>
  );
}

function List({ value }) {
  const items = new Array(5000).fill(1).map((_, index) => {
    return (
      <li>
        <ListItem index={index} value={value} />
      </li>
    );
  });
  return <ul>{items}</ul>;
}

function ListItem({ index, value }) {
  return (
    <div>
      <span>index: </span>
      <span>{index}</span>
      <span>value: </span>
      <span>{value}</span>
    </div>
  );
}

當我每次進行輸入時,會觸發 List 進行大量更新,但由於我使用了startTransition  對List的更新進行延後 ,所以Input輸入框不會出現明顯示卡頓現象
演示地址https://stackblitz.com/edit/stackblitz-starters-kmkcjs?file=src%2Ftransition%2FList.tsx
file

由於更新被滯後了,所以我們怎麼知道當前有沒有被更新呢?
這時候第一個返回引數isPending 就是用來告訴我們當前是否還在等待中。
但我們可以看到,input元件目前是非受控元件 ,如果改為受控元件 ,即使使用了startTransition 一樣會出現卡頓,因為 input 響應輸入事件進行狀態更新應該是要同步的。
所以這時候下面介紹的useDeferredValue 作用就來了。

useDeferredValue

useDeferredValue 可讓您推遲更新部分 UI, 它與useTransition 做的事差不多,不過useTransition 是在狀態更新層,推遲狀態更新來實現非阻塞,而useDeferredValue 則是在狀態已經更新後,先使用狀態更新前的值進行渲染,來延遲因狀態變化而導致的元件重新渲染。

它的基本用法

function Page() {
  const [value, setValue] = useState('');
  const deferredValue = useDeferredValue(setValue);
}

我們再用useDeferredValue 去實現上面輸入框的例子

function Demo() {
  const [value, setValue] = useState('');
  const deferredValue = useDeferredValue(value);

  return (
    <div>
      <h1>useDeferedValue Demo</h1>
      <input
        value={value}
        onChange={(e) => {
          setValue(e.target.value)
        }}
      />
      <hr />
      <List value={deferredValue} />
    </div>
  );
}

我們將input作為受控元件 ,對於會因輸入框值而造成大量渲染List,我們使用deferredValue

其變化過程如下

  1. 當輸入變化時,deferredValue 首先會是變化前的舊值進行重新渲染,由於值沒有變,所以 List 沒有重新渲染,也就沒有出現阻塞情況,這時,input 的值能夠實時響應到頁面上。
  2. 在這次舊值渲染完成後,deferredValue 變更為新的值,React 會在後臺開始對新值進行重新渲染,List 元件開始 rerender,且此次 rerender 會被標識為低優先順序渲染,能夠被中斷
  3. 如果此時又有輸入框輸入,則中斷此次後臺的重新渲染,重新走1,2的流程

我們可以列印下deferredValue  的值看下
初始情況輸入框為1,列印了兩次1
file

輸入2時,再次列印了兩次1,隨後列印了兩次2
file

參考

最後

歡迎關注【袋鼠雲數棧UED團隊】\~\
袋鼠雲數棧 UED 團隊持續為廣大開發者分享技術成果,相繼參與開源了歡迎 star

相關文章