­

5 行代码理解 React Suspense

  • 2019 年 12 月 16 日
  • 筆記

感谢支持ayqy个人订阅号,每周义务推送1篇(only unique one)原创精品博文,话题包括但不限于前端、Node、Android、数学(WebGL)、语文(课外书读后感)、英语(文档翻译) 如果觉得弱水三千,一瓢太少,可以去 http://blog.ayqy.net 看个痛快

一.UI 层的 try…catch

先抛出结论,Suspense 就像是 try…catch,决定 UI 是否安全

try {    // 一旦有没ready的东西  } catch {    // 立即进入catch块,走fallback  }  

那么,如何定义安全?

试想,如果一个组件的代码还没加载完,就去渲染它,显然是不安全的。所以,姑且狭义地认为组件代码已就绪的组件就是安全的,包括同步组件和已加载完的异步组件(React.lazy),例如:

// 同步组件,安全  import OtherComponent from './OtherComponent';  // 异步组件,不安全  const AnotherComponent = React.lazy(() => import('./AnotherComponent'));  // ...等到AnotherComponent代码加载完成之后  // 已加载完的异步组件,安全  AnotherComponent  

Error Boundary

有个类似的东西是Error Boundary,也是 UI 层 try…catch 的一种,其安全的定义是组件代码执行没有 JavaScript Error:

Error boundaries are React components that catch JavaScript errors anywhere in their child component tree, log those errors, and display a fallback UI instead of the component tree that crashed.

我们发现这两种定义并不冲突,事实上,Suspense 与 Error Boundary 也确实能够共存,比如通过 Error Boundary 来捕获异步组件加载错误:

If the other module fails to load (for example, due to network failure), it will trigger an error. You can handle these errors to show a nice user experience and manage recovery with Error Boundaries.

例如:

import MyErrorBoundary from './MyErrorBoundary';  const OtherComponent = React.lazy(() => import('./OtherComponent'));  const AnotherComponent = React.lazy(() => import('./AnotherComponent'));    const MyComponent = () => (    <div>      <MyErrorBoundary>        <Suspense fallback={<div>Loading...</div>}>          <section>            <OtherComponent />            <AnotherComponent />          </section>        </Suspense>      </MyErrorBoundary>    </div>  );  

二.手搓一个 Suspense

开篇的 5 行代码可能有点意思,但还不够清楚,继续填充:

function Suspense(props) {    const { children, fallback } = props;    try {      // 一旦有没ready的东西      React.Children.forEach(children, function() {        assertReady(this);      });    } catch {      // 立即进入catch块,走fallback      return fallback;    }      return children;  }  

assertReady是个断言,对于不安全的组件会抛出 Error:

import { isLazy } from "react-is";    function assertReady(element) {    // 尚未加载完成的Lazy组件不安全    if (isLazy(element) && element.type._status !== 1) {      throw new Error('Not ready yet.');    }  }  

P.S.react-is用来区分 Lazy 组件,而_status表示 Lazy 组件的加载状态,具体见React Suspense | 具体实现

试玩一下:

function App() {    return (<>      <Suspense fallback={<div>loading...</div>}>        <p>Hello, there.</p>      </Suspense>      <Suspense fallback={<div>loading...</div>}>        <LazyComponent />      </Suspense>      <Suspense fallback={<div>loading...</div>}>        <ReadyLazyComponent />      </Suspense>      <Suspense fallback={<div>loading...</div>}>        <p>Hello, there.</p>        <LazyComponent />        <ReadyLazyComponent />      </Suspense>    </>);  }  

对应界面内容为:

Hello, there.  loading...  ready lazy component.  loading...  

首次渲染结果符合预期,至于之后的更新过程(组件加载完成后把 loading 替换回实际内容),更多地属于 Lazy 组件渲染机制的范畴,与 Suspense 关系不大,这里不展开,感兴趣可参考React Suspense | 具体实现

P.S.其中,ReadyLazyComponent的构造有点小技巧:

const ReadyLazyComponent = React.lazy(() =>    // 模拟 import('path/to/SomeOtherComponent.js')    Promise.resolve({      default: () => {        return <p>ready lazy component.</p>;      }    })  );    // 把Lazy Component渲染一次,触发其加载,使其ready  const rootElement = document.getElementById("root");  // 仅用来预加载lazy组件,忽略缺少外层Suspense引发的Warning  ReactDOM.createRoot(rootElement).render(<ReadyLazyComponent />);    setTimeout(() => {    // 等上面渲染完后,ReadyLazyComponent就真正ready了  });  

因为Lazy Component 只在真正需要 render 时才加载(所谓 lazy),所以先渲染一次,之后再次使用时就 ready 了

三.类比 try…catch

如上所述,Suspense 与 try…catch 的对应关系为:

  • Suspense:对应try
  • fallback:对应catch
  • 尚未加载完成的 Lazy Component:对应Error

由于原理上的相似性,Suspense 的许多特点都可以通过类比 try…catch 来轻松理解,例如:

  • 就近 fallback:Error抛出后向上找最近的try所对应的catch
  • 存在未 ready 组件就 fallback:一大块try中,只要有一个Error就立即进入catch

所以,对于一组被 Suspense 包起来的组件,要么全都展示出来(包括可能含有的 fallback 内容),要么全都不展示(转而展示该 Suspense 的 fallback),理解到这一点对于掌握 Suspense 尤为重要

性能影响

如前面示例中的:

<Suspense fallback={<div>loading...</div>}>    <p>Hello, there.</p>    <LazyComponent />    <ReadyLazyComponent />  </Suspense>  

渲染结果为loading...,因为处理到LazyComponent时触发了 Suspense fallback,无论是已经处理完的Hello, there.,还是尚未处理到的ReadyLazyComponent都无法展示。那么,存在 3 个问题:

  • 伤及池鱼:一个尚未加载完成的 Lazy Component 就能让它前面许多本能立即显示的组件无法显示
  • 阻塞渲染:尚未加载完成的 Lazy Component 会阻断渲染流程,阻塞最近 Suspense 祖先下其后所有组件的渲染,造成串行等待

所以,像使用 try…catch 一样,滥用 Suspense 也会造成(UI 层的)性能影响,虽然技术上把整个应用都包到顶层 Suspense 里确实能为所有 Lazy Component 提供 fallback:

<Suspense fallback={<div>global loading...</div>}>    <App />  </Suspense>  

但必须清楚地意识到这样做的后果

结构特点

Suspense 与 try…catch 一样,通过提供一种固定结构来消除条件判断:

try {    // 如果出现Error  } catch {    // 则进入catch  }  

将分支逻辑固化到了语法结构中,Suspense 也类似:

<Suspense fallback={ /* 则进入fallback */ }>    { /* 如果出现未ready的Lazy组件 */ }  </Suspense>  

这样做的好处是代码中不必出现条件判断,因而不依赖局部状态,我们能够轻松调整其作用范围

<Suspense fallback={<div>loading...</div>}>    <p>Hello, there.</p>    <LazyComponent />    <ReadyLazyComponent />  </Suspense>  

改成:

<p>Hello, there.</p>  <Suspense fallback={<div>loading...</div>}>    <LazyComponent />  </Suspense>  <ReadyLazyComponent />  

前后几乎没有改动成本,甚至比调整 try…catch 边界还要容易(因为不用考虑变量作用域),这对于无伤调整 loading 的粒度、顺序很有意义

Suspense lets us change the granularity of our loading states and orchestrate their sequencing without invasive changes to our code.

四.在线 Demo

文中涉及的所以重要示例,都在 Demo 项目中(含详尽注释):

  • react-suspense-and-try…catch

五.总结

Suspense 就像是 UI 层的 try…catch,但其捕获的不是异常,而是尚未加载完成的组件

当然,Error Boundary 也是,二者各 catch 各的互不冲突

参考资料

  • Suspense for Data Fetching (Experimental)
  • Error Handling in React 16
  • React.Children.forEach

联系ayqy

如果在文章中发现了什么问题,请查看原文并留下评论,ayqy看到就会回复的(不建议直接回复公众号,看不到的啦)

特别要紧的问题,可以直接微信联系ayqywx