深度理解 React Suspense

牧云云發表於2019-03-04

深度理解 React Suspense

本文介紹與 Suspense 在三種情景下使用方法,並結合原始碼進行相應解析。歡迎關注個人部落格

Code Spliting

在 16.6 版本之前,code-spliting 通常是由第三方庫來完成的,比如 react-loadble(核心思路為: 高階元件 + webpack dynamic import), 在 16.6 版本中提供了 Suspenselazy 這兩個鉤子, 因此在之後的版本中便可以使用其來實現 Code Spliting

目前階段, 服務端渲染中的 code-spliting 還是得使用 react-loadable, 可查閱 React.lazy, 暫時先不探討原因。

Code SplitingReact 中的使用方法是在 Suspense 元件中使用 <LazyComponent> 元件:

import { Suspense, lazy } from 'react'

const DemoA = lazy(() => import('./demo/a'))
const DemoB = lazy(() => import('./demo/b'))

<Suspense>
  <NavLink to="/demoA">DemoA</NavLink>
  <NavLink to="/demoB">DemoB</NavLink>

  <Router>
    <DemoA path="/demoA" />
    <DemoB path="/demoB" />
  </Router>
</Suspense>
複製程式碼

原始碼中 lazy 將傳入的引數封裝成一個 LazyComponent

function lazy(ctor) {
  return {
    $$typeof: REACT_LAZY_TYPE, // 相關型別
    _ctor: ctor,
    _status: -1,   // dynamic import 的狀態
    _result: null, // 存放載入檔案的資源
  };
}
複製程式碼

觀察 readLazyComponentType 後可以發現 dynamic import 本身類似 Promise 的執行機制, 也具有 PendingResolvedRejected 三種狀態, 這就比較好理解為什麼 LazyComponent 元件需要放在 Suspense 中執行了(Suspense 中提供了相關的捕獲機制, 下文會進行模擬實現`), 相關原始碼如下:

function readLazyComponentType(lazyComponent) {
  const status = lazyComponent._status;
  const result = lazyComponent._result;
  switch (status) {
    case Resolved: { // Resolve 時,呈現相應資源
      const Component = result;
      return Component;
    }
    case Rejected: { // Rejected 時,throw 相應 error
      const error = result;
      throw error;
    }
    case Pending: {  // Pending 時, throw 相應 thenable
      const thenable = result;
      throw thenable;
    }
    default: { // 第一次執行走這裡
      lazyComponent._status = Pending;
      const ctor = lazyComponent._ctor;
      const thenable = ctor(); // 可以看到和 Promise 類似的機制
      thenable.then(
        moduleObject => {
          if (lazyComponent._status === Pending) {
            const defaultExport = moduleObject.default;
            lazyComponent._status = Resolved;
            lazyComponent._result = defaultExport;
          }
        },
        error => {
          if (lazyComponent._status === Pending) {
            lazyComponent._status = Rejected;
            lazyComponent._result = error;
          }
        },
      );
      // Handle synchronous thenables.
      switch (lazyComponent._status) {
        case Resolved:
          return lazyComponent._result;
        case Rejected:
          throw lazyComponent._result;
      }
      lazyComponent._result = thenable;
      throw thenable;
    }
  }
}
複製程式碼

Async Data Fetching

為了解決獲取的資料在不同時刻進行展現的問題(在 suspenseDemo 中有相應演示), Suspense 給出瞭解決方案。

下面放兩段程式碼,可以從中直觀地感受在 Suspense 中使用 Async Data Fetching 帶來的便利。

  • 一般進行資料獲取的程式碼如下:
export default class Demo extends Component {
  state = {
    data: null,
  };

  componentDidMount() {
    fetchAPI(`/api/demo/${this.props.id}`).then((data) => {
      this.setState({ data });
    });
  }

  render() {
    const { data } = this.state;

    if (data == null) {
      return <Spinner />;
    }

    const { name } = data;

    return (
      <div>{name}</div>
    );
  }
}
複製程式碼
  • Suspense 中進行資料獲取的程式碼如下:
const resource = unstable_createResource((id) => {
  return fetchAPI(`/api/demo`)
})

function Demo {
  const data = resource.read(this.props.id)

  const { name } = data;

  return (
    <div>{name}</div>
  );
}
複製程式碼

可以看到在 Suspense 中進行資料獲取的程式碼量相比正常的進行資料獲取的程式碼少了將近一半!少了哪些地方呢?

  • 減少了 loading 狀態的維護(在最外層的 Suspense 中統一維護子元件的 loading)
  • 減少了不必要的生命週期的書寫

總結: 如何在 Suspense 中使用 Data Fetching

當前 Suspense 的使用分為三個部分:

第一步: 用 Suspens 元件包裹子元件

import { Suspense } from 'react'

<Suspense fallback={<Loading />}>
  <ChildComponent>
</Suspense>
複製程式碼

第二步: 在子元件中使用 unstable_createResource:

import { unstable_createResource } from 'react-cache'

const resource = unstable_createResource((id) => {
  return fetch(`/demo/${id}`)
})
複製程式碼

第三步: 在 Component 中使用第一步建立的 resource:

const data = resource.read('demo')
複製程式碼

相關思路解讀

來看下原始碼中 unstable_createResource 的部分會比較清晰:

export function unstable_createResource(fetch, maybeHashInput) {
  const resource = {
    read(input) {
      ...
      const result = accessResult(resource, fetch, input, key);
      switch (result.status) {
        case Pending: {
          const suspender = result.value;
          throw suspender;
        }
        case Resolved: {
          const value = result.value;
          return value;
        }
        case Rejected: {
          const error = result.value;
          throw error;
        }
        default:
          // Should be unreachable
          return (undefined: any);
      }
    },
  };
  return resource;
}
複製程式碼

結合該部分原始碼, 進行如下推測:

  1. 第一次請求沒有快取, 子元件 throw 一個 thenable 物件, Suspense 元件內的 componentDidCatch 捕獲之, 此時展示 Loading 元件;
  2. Promise 態的物件變為完成態後, 頁面重新整理此時 resource.read() 獲取到相應完成態的值;
  3. 之後如果相同引數的請求, 則走 LRU 快取演算法, 跳過 Loading 元件返回結果(快取演算法見後記);

官方作者是說法如下:

深度理解 React Suspense

所以說法大致相同, 下面實現一個簡單版的 Suspense:

class Suspense extends React.Component {
  state = {
    promise: null
  }

  componentDidCatch(e) {
    if (e instanceof Promise) {
      this.setState({
        promise: e
      }, () => {
        e.then(() => {
          this.setState({
            promise: null
          })
        })
      })
    }
  }

  render() {
    const { fallback, children } = this.props
    const { promise } = this.state
    return <>
      { promise ? fallback : children }
    </>
  }
}
複製程式碼

進行如下呼叫

<Suspense fallback={<div>loading...</div>}>
  <PromiseThrower />
</Suspense>

let cache = "";
let returnData = cache;
const fetch = () =>
  new Promise(resolve => {
    setTimeout(() => {
      resolve("資料載入完畢");
    }, 2000);
  });

class PromiseThrower extends React.Component {
  getData = () => {
    const getData = fetch();

    getData.then(data => {
      returnData = data;
    });
    if (returnData === cache) {
      throw getData;
    }
    return returnData;
  };

  render() {
    return <>{this.getData()}</>;
  }
}
複製程式碼

深度理解 React Suspense

效果除錯可以點選這裡, 在 16.6 版本之後, componentDidCatch 只能捕獲 commit phase 的異常。所以在 16.6 版本之後實現的 <PromiseThrower> 又有一些差異(即將 throw thenable 移到 componentDidMount 中進行)。

ConcurrentMode + Suspense

當網速足夠快, 資料立馬就獲取到了,此時頁面存在的 Loading 按鈕就顯得有些多餘了。(在 suspenseDemo 中有相應演示), SuspenseConcurrent Mode 下給出了相應的解決方案, 其提供了 maxDuration 引數。用法如下:

<Suspense maxDuration={500} fallback={<Loading />}>
  ...
</Suspense>
複製程式碼

該 Demo 的效果為當獲取資料的時間大於(是否包含等於還沒確認) 500 毫秒, 顯示自定義的 <Loading /> 元件, 當獲取資料的時間小於 500 毫秒, 略過 <Loading> 元件直接展示使用者的資料。相關原始碼

需要注意的是 maxDuration 屬性只有在 Concurrent Mode 下才生效, 可參考原始碼中的註釋。在 Sync 模式下, maxDuration 始終為 0。

後記: 快取演算法

  • LRU 演算法: Least Recently Used 最近最少使用演算法(根據時間);
  • LFU 演算法: Least Frequently Used 最近最少使用演算法(根據次數);

漫畫:什麼是 LRU 演算法

若資料的長度限定是 3, 訪問順序為 set(2,2),set(1,1),get(2),get(1),get(2),set(3,3),set(4,4), 則根據 LRU 演算法刪除的是 (1, 1), 根據 LFU 演算法刪除的是 (3, 3)

react-cache 採用的是 LRU 演算法。

相關資料

相關文章