メインコンテンツへ移動

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 の由来です。そして後の 2 つの問題を解決するために、loading 表示ロジックを祖先コンポーネントに配置することを望みます。也就是Suspense の作用です

三.React.lazy

React.lazy() は条件レンダリング詳細をフレームワーク層に移し、動的に導入されたコンポーネントを普通コンポーネントとして使用することを許可し、優雅にこの侵入性を消除:

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

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

動的に導入された OtherComponent は用法上で普通コンポーネントと完全に一致し、導入方式上の差異が存在するだけです(importimport() に変更し React.lazy() で包む):

import OtherComponent from './OtherComponent';
// 改为动态加载
const OtherComponent = React.lazy(() => import('./OtherComponent'));

import()ES Moduleresolve する 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 シナリオに対応すると、この 2 つの能力:

  • 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 がキャッシュデータまたは広告でも不可以ではない)

参考資料

コメント

コメントはまだありません

コメントを書く