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,但已经被各大打包工具(webpack、rollup等)广泛支持的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到缓存数据甚至广告也不是不可以)

参考资料

  • Code-Splitting
  • facebook/react v16.6.3

本文分享自微信公众号 - 前端向后(backward-fe),作者:黯羽轻扬

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2018-11-25

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • React Hooks简介

    组件间逻辑复用一直是个问题,Render Props、Higher-Order Components等常用套路模式都是为了分离横切关注点(Cross-cutti...

    ayqy贾杰
  • React 16 Roadmap

    其中,Concurrent Mode(之前叫Async Rendering)无疑是最值得期待的东西,或将引领变革(合作式调度机制可能泛化成为浏览器能力)

    ayqy贾杰
  • low-code 大旗之下,我正在做的低代码平台该何去何从?

    这样的大类不一定适合所有业务,可根据业务重要性(核心、重要、边缘)、差异程度(所用的技术体系、面向的用户群体)等进行具体划分,必要的话,还可以细分出各个子维度。...

    ayqy贾杰
  • 30 道 Vue 面试题,内含详细讲解(中)

    比如有父组件 Parent 和子组件 Child,如果父组件监听到子组件挂载 mounted 就做一些逻辑处理,可以通过以下写法实现:

    咻咻ing
  • wx小程序的自定义组件

    我的博客即将搬运同步至腾讯云+社区,邀请大家一同入驻:https://cloud.tencent.com/developer/support-plan?invi...

    河湾欢儿
  • 5、Java Swing布局管理器(FlowLayout、BorderLayout、CardLayout、BoxLayout、GirdBagLayout 和 GirdLayout)

    5、Java-Swing常用布局管理器       应用布局管理器都属于相对布局,各组件位置可随界面大小而相应改变,不变的只是其相对位置,布局管理器比较难以控制...

    YGingko
  • 微信小程序 自定义组件样式

    组件对应 wxss 文件的样式,只对组件wxml内的节点生效。编写组件样式时,需要注意以下几点:

    天天_哥
  • Web Components是不是Web的未来

    今天 ,Web 组件已经从本质上改变了HTML。初次接触时,它看起来像一个全新的技术。Web组件最初的目的是使开发人员拥有扩展浏览器标签的能力,可以自由的进行定...

    葡萄城控件
  • Vue常用性能优化

    不要将所有的数据都放到data中,data中的数据都会增加getter和setter,并且会收集watcher,这样还占内存,不需要响应式的数据我们可以直接定义...

    WindrunnerMax
  • 微信小程序自定义组件详解

    从小程序基础库版本 1.6.3 开始,小程序支持简洁的组件化编程。所有自定义组件相关特性都需要基础库版本 1.6.3 或更高。

    前端开发博客

扫码关注云+社区

领取腾讯云代金券