React Suspense

一.代码拆分

前端应用达到一定规模时(比如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.

初衷是为logading场景提供优雅的通用解决方案,允许组件树挂起等待(即延迟渲染)异步数据,意义在于:

  • 符合最佳用户体验:

    • 避免布局抖动(数据回来之后冒出来一块内容),当然,这是加loading或skeleton的好处,与Suspense关系不很大

    • 区别对待不同网络环境(数据返回快的话压根不会出现loading)

  • 优雅:不用再为了加子树loading而提升相关状态和逻辑,从状态提升与组件封装性的抑郁中解脱了

  • 灵活:loading组件与异步组件(依赖异步数据的组件)之间没有组件层级关系上的强关联,能够灵活控制loading粒度

  • 通用:支持等待异步数据时显示降级组件(loading只是一种最常见的降级策略,fallback到缓存数据甚至广告也不是不可以)

参考资料

React Suspense》上有3条评论

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code