• React18 之 Suspense


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

    本文作者:佳岚

    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也只是帮我们封装了层数据结构。ReactLazy.js

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

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

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

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

    function lazyInitializer(payload: Payload): 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 = (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节点是如何被创建的
    beginWork

    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

    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语句。renderRootConcurrent

    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

    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 干了啥

    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 生成的

    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

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

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

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

    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 切换时,它会被任何其他调度任务插队打断掉。
    file

    useTransition

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

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

    一个输入框的例子

    function Demo() {
      const [value, setValue] = useState();
      const [isPending, startTransition] = useTransition();
    
      return (
        <div>
          <h1>useTramsotopm Demoh1>
          <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 进行大量更新,但由于我使用了startTransition  对List的更新进行延后 ,所以Input输入框不会出现明显卡顿现象
    演示地址https://stackblitz.com/edit/stackblitz-starters-kmkcjs?file=src%2Ftransition%2FList.tsx
    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 Demoh1>
          <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
    file

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

    参考

    最后

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

  • 相关阅读:
    2022面试相关 - react相关原理
    Elasticsearch:ingest pipeline 使用示例 - 解析常用日志格式
    在Mac上使用安卓桌面模式
    PyCharm 常用快捷键
    JVM是什么?Java程序为啥需要运行在JVM中?
    MySQL:12-Java中使用MySQL(JDBC)
    【AIGC】如何在使用stable-diffusion-webui生成图片时看到完整请求参数
    纳米/聚合物/化合物/无机材料/多肽/多糖修饰聚苯乙烯微球的研究
    国际航运管理考试整理
    4:协调者布局+屏幕适配+国际化+沉浸式状态栏
  • 原文地址:https://www.cnblogs.com/dtux/p/18028903