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