关于前端:React18-之-Suspense

57次阅读

共计 12566 个字符,预计需要花费 32 分钟才能阅读完成。

咱们是袋鼠云数栈 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 办法就会开始一个调度工作进行更新,前面会具体讲。

晓得了大抵原理,这时还须要对咱们的 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<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

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

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

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 款式暗藏子元素。

在这之后的 Fiber 树结构

当咱们向下执行到 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

到这时渲染 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 切换时,它会被任何其余调度工作插队打断掉。

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 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 进行大量更新,但因为我应用了 startTransition  对List 的更新进行 延后 ,所以Input 输入框不会呈现显著卡顿景象
演示地址 https://stackblitz.com/edit/stackblitz-starters-kmkcjs?file=src%2Ftransition%2FList.tsx

因为更新被滞后了,所以咱们怎么晓得以后有没有被更新呢?
这时候第一个返回参数 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>
      <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

参考

  • React 从 v15 降级到 v16 后,为什么要重构底层架构
  • React 技术揭秘
  • React Suspense 官网文档

最初

欢送关注【袋鼠云数栈 UED 团队】\~\
袋鼠云数栈 UED 团队继续为宽广开发者分享技术成绩,相继参加开源了欢送 star

  • 大数据分布式任务调度零碎——Taier
  • 轻量级的 Web IDE UI 框架——Molecule
  • 针对大数据畛域的 SQL Parser 我的项目——dt-sql-parser
  • 袋鼠云数栈前端团队代码评审工程实际文档——code-review-practices
  • 一个速度更快、配置更灵便、应用更简略的模块打包器——ko
  • 一个针对 antd 的组件测试工具库——ant-design-testing

正文完
 0