React18 之 Suspense

我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。
本文作者:佳岚

Suspense

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

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

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

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

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

jsx 复制代码
return (
  <Suspense fallback={<Spinner />}>
    <MyComponent />
  </Suspense>
);

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

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

jsx 复制代码
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 方法就会开始一个调度任务进行更新,后面会详细讲。

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

jsx 复制代码
// 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组件,数据获取的写法可以看成是同步的了。

jsx 复制代码
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

jsx 复制代码
<Suspense fallback={<GlobalLoading />}>
   {lazy(() => import('xxx/xxx.tsx'))}
</Suspense>

由此得出,通过lazy返回的组件也应该包裹一层类似如上的 Promise,我们看看 lazy 内部是如何实现的。

其中ctor就是我们传入的() => import('xxx/xxx.tsx'), 执行lazy也只是帮我们封装了层数据结构。ReactLazy.js

jsx 复制代码
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单独处理的逻辑。 ReactFiberBeginWork.js

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

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

tsx 复制代码
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的过程

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

tsx 复制代码
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

updateSuspenseComponent

tsx 复制代码
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
tsx 复制代码
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 样式隐藏子元素。

在这之后的 Fiber 树结构

当我们向下执行到MyComponent 时,由于抛出了错误,当前的reconciler阶段会被暂停

让我们再回到 Reconciler 阶段的起始点可以看到有Catch语句。renderRootConcurrent

tsx 复制代码
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中做了些什么 handleError

tsx 复制代码
function handleError(root, thrownValue): void {
	// 这时的workInProgress指向MyComponent
  let 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就是我们抛出的Promise
    const 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 干了啥

tsx 复制代码
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 中找到一个能处理异常的边界组件,也就ClassComponent, SuspenseComponent , 会去掉ShouldCapture标识,加上DidCapture标识

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

再次回到 Suspense 组件, 这时由于有了DidCapture 标识,则展示fallback

对于fallback组件的fiber节点是通过mountSuspenseFallbackChildren 生成的

tsx 复制代码
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;
}

它主要做了三件事

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

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

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

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

jsx 复制代码
if (currentFallbackChildFragment !== null) {
    // Delete the fallback child fragment
    const 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 切换时,它会被任何其他调度任务插队打断掉。

useTransition

useTransition 可以让我们在不阻塞 UI 渲染的情况下更新状态。useTransitionstartTransition 允许将某些更新标记为低优先级更新。默认情况下,其他更新被视为紧急更新。React 将允许更紧急的更新(例如更新文本输入)来中断不太紧急的更新(例如展示搜索结果列表)。

其核心原理其实就是将startTransition 内调用的状态变更方法都标识为低优先级的lane (lane优先级参考)去更新。

jsx 复制代码
const [isPending, startTransition] = useTransition()

startTransition(() => {
	setData(xxx)
})

一个输入框的例子

tsx 复制代码
function Demo() {
  const [value, setValue] = useState();
  const [isPending, startTransition] = useTransition();

  return (
    <div>
      <h1>useTramsotopm Demo</h1>
      <input
        onChange={(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 进行大量更新,但由于我使用了startTransitionList的更新进行延后 ,所以Input输入框不会出现明显卡顿现象

演示地址https://stackblitz.com/edit/stackblitz-starters-kmkcjs?file=src%2Ftransition%2FList.tsx

由于更新被滞后了,所以我们怎么知道当前有没有被更新呢?

这时候第一个返回参数isPending 就是用来告诉我们当前是否还在等待中。

但我们可以看到,input组件目前是非受控组件 ,如果改为受控组件 ,即使使用了startTransition 一样会出现卡顿,因为 input 响应输入事件进行状态更新应该是要同步的。

所以这时候下面介绍的useDeferredValue 作用就来了。

useDeferredValue

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

它的基本用法

jsx 复制代码
function Page() {
  const [value, setValue] = useState('');
  const deferredValue = useDeferredValue(setValue);
}

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

jsx 复制代码
function Demo() {
  const [value, setValue] = useState('');
  const deferredValue = useDeferredValue(value);

  return (
    <div>
      <h1>useDeferedValue Demo</h1>
      <input
        value={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

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

参考

最后

欢迎关注【袋鼠云数栈UED团队】~

袋鼠云数栈 UED 团队持续为广大开发者分享技术成果,相继参与开源了欢迎 star

相关推荐
刻刻帝的海角2 小时前
CSS 颜色
前端·css
python算法(魔法师版)17 小时前
html,css,js的粒子效果
javascript·css·html
LBJ辉1 天前
1. 小众但非常实用的 CSS 属性
前端·css
学不完了是吧1 天前
html、js、css实现爱心效果
前端·css·css3
Zaly.1 天前
【前端】CSS实战之音乐播放器
前端·css
孤客网络科技工作室1 天前
不使用 JS 纯 CSS 获取屏幕宽高
开发语言·javascript·css
m0_748247551 天前
【HTML+CSS】使用HTML与后端技术连接数据库
css·数据库·html
肖老师xy2 天前
css动画水球图
前端·css
LBJ辉2 天前
2. CSS 中的单位
前端·css
wang.wenchao2 天前
十六进制文本码流转pcap(text2pcap)
前端·css