跳到主要內容
黯羽輕揚每天積累一點點

React Suspense

免費2018-11-25#JS#React挂起#React code splitting#React Suspense内部原理#React懒加载#React动态加载组件

從程式碼拆分加個 loading 說起……

一。程式碼拆分

前端應用達到一定規模時(比如 bundle size 以 MB 為單位),勢必面臨程式碼拆分的強需求:

Code-Splitting is a feature supported by bundlers like Webpack and Browserify (via factor-bundle) which can create multiple bundles that can be dynamically loaded at runtime.

執行時再去動態載入一些程式碼塊,比如非首屏業務元件,以及日曆、地址選擇、評論等重磅元件

最方便的動態載入方式是還處於 stage3,但已經被各大打包工具(webpackrollup 等)廣泛支援的 tc39/proposal-dynamic-import

import('../components/Hello').then(Hello => {
  console.log(<Hello />);
});

相當於(setTimeout 模擬非同步載入元件):

new Promise(resolve =>
  setTimeout(() =>
    resolve({
      // 來自另一個檔案的函式式元件
      default: function render() {
        return <div>Hello</div>
      }
    }),
    3000
  )
).then(({ default: Hello }) => {
  // 拿到元件了,然後呢?
  console.log(<Hello />);
});

當然,拆出去只是前一半,拿到手的元件怎樣渲染出來 則是後一半

二。條件渲染

不依賴框架支援的話,可以通過條件渲染的方式把動態元件掛上去:

class MyComponent extends Component {
  constructor() {
    super();
    this.state = {};
    // 動態載入
    import('./OtherComponent').then(({ default: OtherComponent }) => {
      this.setState({ OtherComponent });
    });
  }

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

    return (
      <div>
        {/* 條件渲染 */}
        { OtherComponent && <OtherComponent /> }
      </div>
    );
  }
}

此時對應的使用者體驗是,首屏 OtherComponent 還沒回來,過了一會兒佈局抖了一下冒出來了,存在幾個問題:

  • 對父元件有侵入性(state.OtherComponent

  • 佈局抖動體驗不佳

框架不提供支援的話,這種侵入性似乎不可避免(總得有元件去做條件渲染,就總要添這些顯示邏輯)

抖動的話,加 loading 解決,但容易出現遍地天窗(好幾處 loading 都在轉圈)的體驗問題,所以 loading 一般不單針對某個原子元件,而是元件樹上的一塊區域整體顯示 loading(這塊區域裡可能含有本能立即顯示的元件),這種場景下,loading 需要加到祖先元件上去,並且顯示邏輯變得很麻煩(可能要等好幾個動態元件都載入完畢才隱藏)

所以,想要避免條件渲染帶來的侵入性,只有靠框架提供支援,這正是React.lazy API 的由來。而為了解決後兩個問題,我們希望把 loading 顯示邏輯放到祖先元件上去,也就是Suspense 的作用

三.React.lazy

React.lazy() 把條件渲染細節挪到了框架層,允許把動態引入的元件當普通元件用,優雅地消除了這種侵入性:

const OtherComponent = React.lazy(() => import('./OtherComponent'));

function MyComponent() {
  return (
    <div>
      <OtherComponent />
    </div>
  );
}

動態引入的 OtherComponent 在用法上與普通元件完全一致,只是存在引入方式上的差異(把 import 換成 import() 並用 React.lazy() 包起來):

import OtherComponent from './OtherComponent';
// 改為動態載入
const OtherComponent = React.lazy(() => import('./OtherComponent'));

要求 import() 必須返回一個會 resolve ES Module 的 Promise,並且這個 ES Module 裡 export default 了合法的 React 元件:

// ./OtherComponent.jsx
export default function render() {
  return <div>Other Component</div>
}

類似於:

const OtherComponent = React.lazy(() => new Promise(resolve =>
  setTimeout(() =>
    resolve(
      // 模擬 ES Module
      {
        // 模擬 export default 
        default: function render() {
          return <div>Other Component</div>
        }
      }
    ),
    3000
  )
));

P.S. React.lazy() 暫時還不支援 SSR,建議用 React Loadable

四.Suspense

React.Suspense 也是虛擬元件(類似於 Fragment,僅用作型別標識),用法如下:

const OtherComponent = React.lazy(() => import('./OtherComponent'));

function MyComponent() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <OtherComponent />
      </Suspense>
    </div>
  );
}

Suspense 子樹中只要存在還沒回來的 Lazy 元件,就走 fallback 指定的內容。這不正是可以提升到任意祖先級的 loading 嗎?

You can place the Suspense component anywhere above the lazy component. You can even wrap multiple lazy components with a single Suspense component.

Suspense 元件可以放在(元件樹中)Lazy 元件上方的任意位置,並且下方可以有多個 Lazy 元件。對應到 loading 場景,就是這兩種能力:

  • 支援 loading 提升

  • 支援 loading 聚合

4 行業務程式碼就能實現 loading 最佳實踐,相當漂亮的特性

P.S. 沒被 Suspense 包起來的 Lazy 元件會報錯:

Uncaught Error: A React component suspended while rendering, but no fallback UI was specified.

算是從框架層對使用者體驗提出了強要求

五。具體實現

function lazy<T, R>(ctor: () => Thenable<T, R>): LazyComponent<T> {
  return {
    $$typeof: REACT_LAZY_TYPE,
    _ctor: ctor,
    // 元件載入狀態
    _status: -1,
    // 載入結果,Component or Error
    _result: null,
  };
}

記下傳入的元件載入器,返回帶(載入)狀態的 Lazy 元件描述物件:

// _status 取值
export const Pending = 0;
export const Resolved = 1;
export const Rejected = 2;

初始值 -1 被摸過之後會變成 Pending,具體如下:

// beginWork()
//   mountLazyComponent()
//     readLazyComponentType()

function readLazyComponentType(lazyComponent) {
  lazyComponent._status = Pending;
  const ctor = lazyComponent._ctor;
  const thenable = ctor();
  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;
      }
    },
  );
  lazyComponent._result = thenable;
  throw thenable;
}

注意最後的 throw,沒錯,為了打斷子樹渲染,這裡直接拋錯出去,路子有些狂野:

function renderRoot(root, isYieldy) {
  do {
    try {
      workLoop(isYieldy);
    } catch (thrownValue) {
      // 處理錯誤
      throwException(root, returnFiber, sourceFiber, thrownValue, nextRenderExpirationTime);
      // 找到下一個工作單元,Lazy 父元件或兄弟元件
      nextUnitOfWork = completeUnitOfWork(sourceFiber);
      continue;
    }
  } while (true);
}

最後會被長達 230 行throwException 兜住:

function throwException() {
  if (
    value !== null &&
    typeof value === 'object' &&
    typeof value.then === 'function'
  ) {
    // This is a thenable.
    const thenable: Thenable = (value: any);

    // 接下來大致做了 4 件事
    // 1. 找出祖先所有 Suspense 元件的最早超時時間(有可能已超時)
    // 2. 找到最近的 Suspense 元件,找不到的話報那個錯
    // 3. 監聽 Pending 元件,等到不 Pending 了立即排程渲染最近的 Suspense 元件
    // Attach a listener to the promise to "ping" the root and retry.
    let onResolveOrReject = retrySuspendedRoot.bind(
      null,
      root,
      workInProgress,
      sourceFiber,
      pingTime,
    );
    if (enableSchedulerTracing) {
      onResolveOrReject = Schedule_tracing_wrap(onResolveOrReject);
    }
    thenable.then(onResolveOrReject, onResolveOrReject);
    // 4. 掛起最近的 Suspense 元件子樹,不再往下渲染
  }
}

P.S. 注意,第 3 步 thenable.then(render, render)React.lazy(() => resolvedImportPromise) 的場景並不會閃 fallback 內容,這與瀏覽器任務機制有關,具體見 macrotask 與 microtask

收集結果時)回到最近的 Suspense 元件,發現有 Pending 後代就會去渲染 fallback

function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderExpirationTime: ExpirationTime,
): Fiber | null {
  if (
    primaryChildExpirationTime !== NoWork &&
    primaryChildExpirationTime >= renderExpirationTime
  ) {
    // The primary children have pending work. Use the normal path
    // to attempt to render the primary children again.
    return updateSuspenseComponent(
      current,
      workInProgress,
      renderExpirationTime,
    );
  }
}

function updateSuspenseComponent(
  current,
  workInProgress,
  renderExpirationTime,
) {
  // 渲染 fallback
  const nextFallbackChildren = nextProps.fallback;
  const primaryChildFragment = createFiberFromFragment(
    null,
    mode,
    NoWork,
    null,
  );
  const fallbackChildFragment = createFiberFromFragment(
    nextFallbackChildren,
    mode,
    renderExpirationTime,
    null,
  );
  next = fallbackChildFragment;
  return next;
}

以上,差不多就是整個過程了(能省略的細節都略掉了)

六。意義

We've built a generic way for components to suspend rendering while they load async data, which we call suspense. You can pause any state update until the data is ready, and you can add async loading to any component deep in the tree without plumbing all the props and state through your app and hoisting the logic. On a fast network, updates appear very fluid and instantaneous without a jarring cascade of spinners that appear and disappear. On a slow network, you can intentionally design which loading states the user should see and how granular or coarse they should be, instead of showing spinners based on how the code is written. The app stays responsive throughout.

初衷是為 loading 場景提供優雅的通用解決方案,允許元件樹掛起等待(即延遲渲染)非同步資料,意義在於:

  • 符合最佳使用者體驗:

    • 避免佈局抖動(資料回來之後冒出來一塊內容),當然,這是加 loading 或 skeleton 的好處,與 Suspense 關係不很大

    • 區別對待不同網路環境(資料返回快的話压根不會出現 loading)

  • 優雅:不用再為了加子樹 loading 而提升相關狀態和邏輯,從狀態提升與元件封裝性的抑鬱中解脫了

  • 靈活:loading 元件與非同步元件(依賴非同步資料的元件)之間沒有元件層級關係上的強關聯,能夠靈活控制 loading 粒度

  • 通用:支援等待非同步資料時顯示降級元件(loading 只是一種最常見的降級策略,fallback 到快取資料甚至廣告也不是不可以)

參考資料

評論

暫無評論,快來發表你的看法吧

提交評論