React 之 Suspense

ops/2025/2/12 8:33:23/

Suspense

Suspense 组件我们并不陌生,中文名可以理解为暂停or悬停  , 在 React16 中我们通常在路由懒加载中配合 Lazy 组件一起使用 ,当然这也是官方早起版本推荐的唯一用法。

那它暂停了什么? 进行异步网络请求,然后再拿到请求后的数据进行渲染是很常见的需求,但这不可避免的需要先渲染一次没有数据的页面,数据返回后再去重新渲染。so , 我们想要暂停的就是第一次的无数据渲染。

通常我们在没有使用Suspense 时一般采用下面这种写法, 通过一个isLoading状态来显示加载中或数据。这样代码是不会有任何问题,但我们需要手动去维护一个isLoading 状态的值。

const [data, isLoading] = fetchData("/api");
if (isLoading) {return <Spinner />;
}
return <MyComponent data={data} />;

当我们使用Suspense 后,使用方法会变为如下, 我们只需将进行异步数据获取的组件进行包裹,并将加载中组件通过fallback传入

return (<Suspense fallback={<Spinner />}><MyComponent /></Suspense>
);

那 React 是如何知道该显示MyComponent还是Spinner的?

答案就在于MyComponent内部进行fetch远程数据时做了一些手脚。

export const App = () => {return (<div><Suspense fallback={<Spining />}><MyComponent /></Suspense></div>);
};function Spining() {return <p>loading...</p>;
}let data = null;function MyComponent() {if (!data) {throw new Promise((resolve) => {setTimeout(() => {data = 'kunkun';resolve(true);}, 2000);});}return (<p>My Component, data is {data}</p>);
}

Suspense是根据捕获子组件内的异常来实现决定展示哪个组件的。这有点类似于ErrorBoundary ,不过ErrorBoundary是捕获 Error 时就展示回退组件,而Suspense 捕获到的 Error 需要是一个Promise对象(并非必须是 Promise 类型,thenable 的都可以)。

我们知道 Promise 有三个状态,pendingfullfilledrejected ,当我们进行远程数据获取时,会创建一个Promise,我们需要直接将这个Promise 作为Error进行抛出,由 Suspense 进行捕获,捕获后对该thenable对象的then方法进行回调注册thenable.then(retry) , 而 retry 方法就会开始一个调度任务进行更新,后面会详细讲。

file

知道了大致原理,这时还需要对我们的fetcher进行一层包裹才能实际运用。

// MyComponent.tsx
const getList = wrapPromise(fetcher('http://api/getList'));export function MyComponent() {const data = getList.read();return (<ul>{data?.map((item) => (<li>{item.name}</li>))}</ul>);
}function fetcher(url) {return new Promise((resove, reject) => {setTimeout(() => {resove([{ name: 'This is Item1' }, { name: 'This is Item2' }]);}, 1000);});
}// Promise包裹函数,用来满足Suspense的要求,在初始化时默认就会throw出去
function wrapPromise(promise) {let status = 'pending';let response;const suspend = promise.then((res) => {status = 'success';response = res;},(err) => {status = 'error';response = err;});const read = () => {switch (status) {case 'pending':throw suspend;default:return response;}};return { read };

从上述代码我们可以注意到,通过const data = getList.read() 这种同步的方式我们就能拿到数据了。 注意: 上面这种写法并非一种范式,目前官方也没有给出推荐的写法
为了与Suspense配合,则我们的请求可能会变得很不优雅 ,官方推荐是直接让我们使用第三方框架提供的能力使用Suspense请求数据,如 useSWR 等
下面时useSWR的示例,简明了很多,并且对于Profile组件,数据获取的写法可以看成是同步的了。

import { Suspense } from 'react'
import useSWR from 'swr'function Profile () {const { data } = useSWR('/api/user', fetcher, { suspense: true })return <div>hello, {data.name}</div>
}function App () {return (<Suspense fallback={<div>loading...</div>}><Profile/></Suspense>)
}

Suspense的另一种用法就是与懒加载lazy组件配合使用,在完成加载前展示Loading

<Suspense fallback={<GlobalLoading />}>{lazy(() => import('xxx/xxx.tsx'))}
</Suspense>

由此得出,通过lazy返回的组件也应该包裹一层类似如上的 Promise,我们看看 lazy 内部是如何实现的。
其中ctor就是我们传入的() => import('xxx/xxx.tsx'), 执行lazy也只是帮我们封装了层数据结构。

export function lazy<T>(ctor: () => Thenable<{default: T, ...}>,
): LazyComponent<T, Payload<T>> {const payload: Payload<T> = {// We use these fields to store the result._status: Uninitialized,_result: ctor,};const lazyType: LazyComponent<T, Payload<T>> = {$$typeof: REACT_LAZY_TYPE,_payload: payload,_init: lazyInitializer,};return lazyType;
}

React 会在Reconciler过程中去实际执行,在协调的render阶段beginWork中可以看到对lazy单独处理的逻辑。

function mountLazyComponent(_current,workInProgress,elementType,renderLanes,
) {const props = workInProgress.pendingProps;const lazyComponent: LazyComponentType<any, any> = elementType;const payload = lazyComponent._payload;const init = lazyComponent._init;// 在此处初始化lazylet Component = init(payload);// 下略
}

那我们再来看看init干了啥,也就是封装前的lazyInitializer方法,整体跟我们之前实现的 fetch 封装是一样的。

function lazyInitializer<T>(payload: Payload<T>): T {if (payload._status === Uninitialized) {const ctor = payload._result;// 这时候开始进行远程模块的导入const thenable = ctor();thenable.then(moduleObject => {if (payload._status === Pending || payload._status === Uninitialized) {// Transition to the next state.const resolved: ResolvedPayload<T> = (payload: any);resolved._status = Resolved;resolved._result = moduleObject;}},error => {if (payload._status === Pending || payload._status === Uninitialized) {// Transition to the next state.const rejected: RejectedPayload = (payload: any);rejected._status = Rejected;rejected._result = error;}},);}if (payload._status === Resolved) {const moduleObject = payload._result;}return moduleObject.default;} else {// 第一次执行肯定会先抛出异常throw payload._result;}
}

Suspense 底层是如何实现的?

其底层细节非常之多,在开始之前,我们先回顾下 React 的大致架构

Scheduler: 用于调度任务,我们每次setState可以看成是往其中塞入一个Task,由Scheduler内部的优先级策略进行判断何时调度运行该Task

Reconciler: 协调器,进行 diff 算法,构建 fiber 树

Renderer: 渲染器,将 fiber 渲染成 dom 节点

Fiber 树的结构, 在 reconciler 阶段,采用深度优先的方式进行遍历,往下递即调用beginWork的过程,往上回溯即调用ComplteWork的过程
 

file


我们先直接进入Reconciler 中分析下Suspensefiber节点是如何被创建的

function beginWork(current: Fiber | null,workInProgress: Fiber,renderLanes: Lanes,
): Fiber | null {switch (workInProgress.tag) {case HostText:return updateHostText(current, workInProgress);case SuspenseComponent:return updateSuspenseComponent(current, workInProgress, renderLanes);// 省略其他类型}
}
  • beginWork中会根据**不同的组件类型**执行不同的创建方法, 而Suspense 对应的会进入到updateSuspenseComponent
function updateSuspenseComponent(current, workInProgress, renderLanes) {const nextProps = workInProgress.pendingProps;let showFallback = false;// 标识该Suspense是否已经捕获过子组件的异常了const didSuspend = (workInProgress.flags & DidCapture) !== NoFlags;if (didSuspend) {showFallback = true;workInProgress.flags &= ~DidCapture;} // 第一次组件加载if (current === null) {const nextPrimaryChildren = nextProps.children;const nextFallbackChildren = nextProps.fallback;// 第一次默认不展示fallback,因为要先走到children后才会产生异常if (showFallback) {const fallbackFragment = mountSuspenseFallbackChildren(workInProgress,nextPrimaryChildren,nextFallbackChildren,renderLanes,);const primaryChildFragment: Fiber = (workInProgress.child: any);primaryChildFragment.memoizedState = mountSuspenseOffscreenState(renderLanes,);return fallbackFragment;} else {return mountSuspensePrimaryChildren(workInProgress,nextPrimaryChildren,renderLanes,);}} else {// 如果是更新,操作差不多,此处略}
}
  • 第一次updateSuspenseComponent 时 ,我们会把mountSuspensePrimaryChildren 的结果作为下一个需要创建的fiber , 因为需要先去触发异常。
  • 实际上mountSuspensePrimaryChildren  会为我们的PrimaryChildren 在包上一层OffscreenFiber 。
function mountSuspensePrimaryChildren(workInProgress,primaryChildren,renderLanes,
) {const mode = workInProgress.mode;const primaryChildProps: OffscreenProps = {mode: 'visible',children: primaryChildren,};const primaryChildFragment = mountWorkInProgressOffscreenFiber(primaryChildProps,mode,renderLanes,);primaryChildFragment.return = workInProgress;workInProgress.child = primaryChildFragment;return primaryChildFragment;
}

什么是OffscreenFiber/Component  ?
通过其需要的 mode 参数值,我们可以大胆的猜测,应该是一个能控制是否显示子组件的组件,如果hidden,则会通过 CSS 样式隐藏子元素。
 

file


在这之后的 Fiber 树结构
 

file


当我们向下执行到MyComponent 时,由于抛出了错误,当前的reconciler阶段会被暂停
让我们再回到 Reconciler 阶段的起始点可以看到有Catch语句。

function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {// 省略..do {try {workLoopConcurrent();break;} catch (thrownValue) {handleError(root, thrownValue);}} while (true);// 省略..
}performConcurrentWorkOnRoot(root, didTimeout) {// 省略..let exitStatus = shouldTimeSlice? renderRootConcurrent(root, lanes): renderRootSync(root, lanes);// 省略..
}

我们再看看错误处理函数handleError中做了些什么

function handleError(root, thrownValue): void {// 这时的workInProgress指向MyComponentlet erroredWork = workInProgress;try {throwException(root,erroredWork.return,erroredWork,thrownValue,workInProgressRootRenderLanes,);completeUnitOfWork(erroredWork);
}function throwException(root: FiberRoot, returnFiber: Fiber, sourceFiber: Fiber, value: mixed, rootRenderLanes: Lanes) 
{// 给MyComponent打上未完成标识sourceFiber.flags |= Incomplete;if (value !== null &&typeof value === 'object' &&typeof value.then === 'function') {// wakeable就是我们抛出的Promiseconst wakeable: Wakeable = (value: any);// 向上找到第一个Suspense边界const suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber);if (suspenseBoundary !== null) {// 打上标识suspenseBoundary.flags &= ~ForceClientRender;suspenseBoundary.flags |= ShouldCapture;// 注册监听器attachRetryListener(suspenseBoundary, root, wakeable, rootRenderLanes);return;}
}

主要做了三件事

  • 给抛出错误的组件打上Incomplete标识
  • 如果捕获的错误是 thenable 类型,则认定为是 Suspense 的子组件,向上找到最接近的一个Suspense 边界,并打上ShouldCapture 标识
  • 执行attachRetryListener 对 Promise 错误监听,当状态改变后开启一个调度任务重新渲染 Suspense

在错误处理的事情做完后,就不应该再往下递了,开始调用completeUnitOfWork往上归, 这时由于我们给 MyComponent 组件打上了Incomplete 标识,这个标识表示由于异常等原因渲染被搁置,那我们是不是就要开始往上找能够处理这个异常的组件?

我们再看看completeUnitOfWork 干了啥

function completeUnitOfWork(unitOfWork: Fiber): void {// 大致逻辑let completedWork = unitOfWork;if ((completedWork.flags & Incomplete) !== NoFlags) {const next = unwindWork(current, completedWork, subtreeRenderLanes);if (next) {workInProgress = next;return}// 给父节点打上Incomplete标记if (returnFiber !== null) {returnFiber.flags |= Incomplete;returnFiber.subtreeFlags = NoFlags;returnFiber.deletions = null;}}
}

可以看到最终打上Incomplete 标识的组件都会进入unwindWork流程 , 并一直将祖先节点打上Incomplete 标识,直到unwindWork 中找到一个能处理异常的边界组件,也就ClassComponentSuspenseComponent , 会去掉ShouldCapture标识,加上DidCapture标识

这时,对于Suspense来说需要的DidCapture已经拿到了,下面就是重新从Suspense 开始走一遍beginWork流程

再次回到 Suspense 组件, 这时由于有了DidCapture 标识,则展示fallback
对于fallback组件的fiber节点是通过mountSuspenseFallbackChildren 生成的

function mountSuspenseFallbackChildren(workInProgress,primaryChildren,fallbackChildren,renderLanes,
) {const primaryChildProps: OffscreenProps = {mode: 'hidden',children: primaryChildren,};let primaryChildFragment = mountWorkInProgressOffscreenFiber(primaryChildProps,mode,NoLanes,);let fallbackChildFragment = createFiberFromFragment(fallbackChildren,mode,renderLanes,null,);primaryChildFragment.return = workInProgress;fallbackChildFragment.return = workInProgress;primaryChildFragment.sibling = fallbackChildFragment;workInProgress.child = primaryChildFragment;return fallbackChildFragment;
}

它主要做了三件事

  • PrimaryChild 即Offscreen组件通过css隐藏
  • fallback组件又包了层Fragment 返回
  • fallbackChild 作为sibling链接至PrimaryChild

file


到这时渲染 fallback 的 fiber 树已经基本构建完了,之后进入commit阶段从根节点rootFiber开始深度遍历该fiber树 进行 render。

等待一段时间后,primary组件数据返回,我们之前在handleError中添加的监听器attachRetryListener 被触发,开始新的一轮任务调度。注:源码中调度回调实际在 Commit 阶段才添加的。

这时由于Suspense 节点已经存在,则走的是updateSuspensePrimaryChildren 中的逻辑,与之前首次加载时 monutSuspensePrimaryChildren不同的是多了删除的操作, 在 commit 阶段时则会删除fallback 组件, 展示primary组件。

if (currentFallbackChildFragment !== null) {// Delete the fallback child fragmentconst deletions = workInProgress.deletions;if (deletions === null) {workInProgress.deletions = [currentFallbackChildFragment];workInProgress.flags |= ChildDeletion;} else {deletions.push(currentFallbackChildFragment);}}

至此,Suspense 的一生我们粗略的过完了,在源码中对 Suspense 的处理非常多,涉及到优先级相关的本篇都略过。
Suspense 中使用了Offscreen组件来渲染子组件,这个组件的特性是能根据传入 mode 来控制子组件样式的显隐,这有一个好处,就是能保存组件的状态,有些许类似于 Vue 的keep-alive 。其次,它拥有着最低的调度优先级,比空闲时优先级还要低,这也意味着当 mode 切换时,它会被任何其他调度任务插队打断掉。

file

useTransition

useTransition 可以让我们在不阻塞 UI 渲染的情况下更新状态。useTransition 和 startTransition 允许将某些更新标记为低优先级更新。默认情况下,其他更新被视为紧急更新。React 将允许更紧急的更新(例如更新文本输入)来中断不太紧急的更新(例如展示搜索结果列表)。
其核心原理其实就是将startTransition 内调用的状态变更方法都标识为低优先级的lane

const [isPending, startTransition] = useTransition()startTransition(() => {setData(xxx)
})

一个输入框的例子

function Demo() {const [value, setValue] = useState();const [isPending, startTransition] = useTransition();return (<div><h1>useTramsotopm Demo</h1><inputonChange={(e) => {startTransition(() => {setValue(e.target.value);});}}/><hr />{isPending ? <p>加载中。。</p> : <List value={value} />}</div>);
}function List({ value }) {const items = new Array(5000).fill(1).map((_, index) => {return (<li><ListItem index={index} value={value} /></li>);});return <ul>{items}</ul>;
}function ListItem({ index, value }) {return (<div><span>index: </span><span>{index}</span><span>value: </span><span>{value}</span></div>);
}

当我每次进行输入时,会触发 List 进行大量更新,但由于我使用了startTransition  对List的更新进行延后 ,所以Input输入框不会出现明显卡顿现象

file

由于更新被滞后了,所以我们怎么知道当前有没有被更新呢?
这时候第一个返回参数isPending 就是用来告诉我们当前是否还在等待中。
但我们可以看到,input组件目前是非受控组件 ,如果改为受控组件 ,即使使用了startTransition 一样会出现卡顿,因为 input 响应输入事件进行状态更新应该是要同步的。
所以这时候下面介绍的useDeferredValue 作用就来了。

useDeferredValue

useDeferredValue 可让您推迟更新部分 UI, 它与useTransition 做的事差不多,不过useTransition 是在状态更新层,推迟状态更新来实现非阻塞,而useDeferredValue 则是在状态已经更新后,先使用状态更新前的值进行渲染,来延迟因状态变化而导致的组件重新渲染。

它的基本用法

function Page() {const [value, setValue] = useState('');const deferredValue = useDeferredValue(setValue);
}

我们再用useDeferredValue 去实现上面输入框的例子

function Demo() {const [value, setValue] = useState('');const deferredValue = useDeferredValue(value);return (<div><h1>useDeferedValue Demo</h1><inputvalue={value}onChange={(e) => {setValue(e.target.value)}}/><hr /><List value={deferredValue} /></div>);
}

我们将input作为受控组件 ,对于会因输入框值而造成大量渲染List,我们使用deferredValue 。

其变化过程如下

  1. 当输入变化时,deferredValue 首先会是变化前的旧值进行重新渲染,由于值没有变,所以 List 没有重新渲染,也就没有出现阻塞情况,这时,input 的值能够实时响应到页面上。
  2. 在这次旧值渲染完成后,deferredValue 变更为新的值,React 会在后台开始对新值进行重新渲染,List 组件开始 rerender,且此次 rerender 会被标识为低优先级渲染,能够被中断
  3. 如果此时又有输入框输入,则中断此次后台的重新渲染,重新走1,2的流程

我们可以打印下deferredValue  的值看下
初始情况输入框为1,打印了两次1

file

输入2时,再次打印了两次1,随后打印了两次2

file


http://www.ppmy.cn/ops/30325.html

相关文章

机器学习:深入解析SVM的核心概念【二、对偶问题】

神经网络之前最流行的算法SVM&#xff08;支持向量机&#xff09;&#xff0c;核心是拉格朗日对偶 凡是有最优化问题的地方&#xff0c;总能看到拉格朗日 对偶问题 **问题一&#xff1a;什么叫做凸二次优化问题&#xff1f;而且为什么符合凸二次优化问题&#xff1f;**为什么约…

网络之路29:三层链路聚合

正文共&#xff1a;1666 字 17 图&#xff0c;预估阅读时间&#xff1a;3 分钟 目录 网络之路第一章&#xff1a;Windows系统中的网络 0、序言 1、Windows系统中的网络1.1、桌面中的网卡1.2、命令行中的网卡1.3、路由表1.4、家用路由器 网络之路第二章&#xff1a;认识企业设备…

【Redis】Redis安装、配置、卸载使用可视化工具连接Redis

文章目录 1.前置条件2.安装Redis2.1下载Redis安装包并解压2.2在redis目录下执行make命令2.3修改Redis配置文件2.4启动Redis服务2.5连接redis服务 3.Redis卸载4.使用可视化工具连接Redis 1.前置条件 Linux操作系统需要要是64位.如果不清楚自己Linux上是多少位的,可以使用以下命…

nginx--location详细使用和账户认证

在没有使用正则表达式的时候&#xff0c;nginx会先在server中的多个location选取匹配度最高的一个uri&#xff0c;uri是用户请求的字符串&#xff0c;即域名后面的web文件路径&#xff0c;然后使用该location模块中的正则url和字符串串&#xff0c;如果匹配成功就结束搜索&…

【SQL Server】入门教程-基础篇(二)

上一篇写的是SQL Server的基础语言&#xff0c;这一篇文章讲的是SQL Server的高级语言。 SQL Server 高级言语学习 LIKE – 模糊查询 LIKE 语法是用来进行对表的模糊查询。 语法&#xff1a; SELECT 列名/(*) FROM 表名称 WHERE 列名称 LIKE 值; 实例&#xff1a; 我们用上…

HTML5本地存储账号密码

<!DOCTYPE html> <html lang"zh-CN"> <head><meta charset"UTF-8"><meta name"viewport" content"widthdevice-width, initial-scale1.0"><title>HTML5本地存储账号密码</title> </head…

C语言 循环语句 (3) for 循环语句

接下来 我们来看第三个 for语句 基本语句是 for关键字 然后小括号 括号中三个表达式 然后它对表达式2进行判断 如果表达式2条件成立 则走进循环体 执行完循环体 会回来执行表达式3 然后再返回来 继续对表达式2进行判断 如果表达式2 还是成立 这继续循环往复 直到表达式2的条件…

调教AI给我写了一个KD树的算法

我不擅长C&#xff0c;但是目前需要用C写一个KD树的算法。首先我有一份点云数据&#xff0c;需要找给定坐标范围0.1mm内的所有点。 于是我开始问AI&#xff0c;他一开始给的答案&#xff0c;完全是错误的&#xff0c;但是我一步步给出反馈&#xff0c;告诉他的问题&#xff0c;…