Suspense的介绍和原理(下篇)

Suspense的介绍和原理

仓库地址 本节代码分支

系列文章:

  1. React实现系列一 - jsx
  2. 剖析React系列二-reconciler
  3. 剖析React系列三-打标记
  4. 剖析React系列四-commit
  5. 剖析React系列五-update流程
  6. 剖析React系列六-dispatch update流程
  7. 剖析React系列七-事件系统
  8. 剖析React系列八-同级节点diff
  9. 剖析React系列九-Fragment的部分实现逻辑
  10. 剖析React系列十- 调度<合并更新、优先级>
  11. 剖析React系列十一- useEffect的实现原理
  12. 剖析React系列十二-调度器的实现
  13. 剖析React系列十三-react调度
  14. useTransition的实现
  15. useRef的实现
  16. Suspense的介绍和原理(上篇)

Suspense介绍

suspense是React 16.6新出的一个功能,用于异步加载组件,可以让组件在等待异步加载的时候,渲染一些fallback的内容,让用户有更好的体验。

上一章节中,我们讲解了suspensemount的时候的情况,如果包裹的组件数据未返回之前的一些步骤,经历了mount阶段的mountSuspensePrimaryChildren正常流程 和 mountSuspenseFallbackChildren挂起流程,可以回顾一下上一篇文章Suspense的介绍和原理(上篇)

这一章节我们讲解当我们监听到数据返回重新渲染的逻辑以及触发更新操作的情况。

attachPingListener

我们在上一章中### 请求返回后触发更新的小结中提到,如果返回数据后需要ping一下告诉程序数据请求回来。

attachPingListener中新增优先级lane标识,并开启新的一轮调度

javascript 复制代码
function attachPingListener(
  root: FiberRootNode,
  wakeable: Wakeable<any>,
  lane: Lane
) {
    function ping() {
      // fiberRootNode
      markRootPinged(root, lane);
      markRootUpdated(root, lane);
      ensureRootIsScheduled(root); // 开启新的调度
    }

    wakeable.then(ping, ping);
  }
}

从根节点开始调度,当调和到suspense的时候,执行updateSuspenseComponent方法,由于此时界面上已经展示了loading节点。所以wip.alternate节点此时不为null,同时由于之前是挂起状态,清除DidCapture标记,再次进入的时候didSuspend的值为false

所以会走到如下这个分支updateSuspensePrimaryChildren分支,用于展示正常的节点渲染。

JavaScript 复制代码
function updateSuspenseComponent(wip: FiberNode) {
  const current = wip.alternate;
  const nextProps = wip.pendingProps;

  let showFallback = false; // 是否显示fallback
  const didSuspend = (wip.flags & DidCapture) !== NoFlags; // 是否挂起
  if (didSuspend) {
    // 显示fallback
    showFallback = true;
    wip.flags &= ~DidCapture; // 清除DidCapture
  }

  const nextPrimaryChildren = nextProps.children; // 主渲染的内容
  const nextFallbackChildren = nextProps.fallback;

  pushSuspenseHandler(wip);

  if (current === null) {
    // mount
    if (showFallback) {
      // 挂起
      return mountSuspenseFallbackChildren(
        wip,
        nextPrimaryChildren,
        nextFallbackChildren
      );
    } else {
      // 正常
      return mountSuspensePrimaryChildren(wip, nextPrimaryChildren);
    }
  } else {
    // update
    if (showFallback) {
      // 挂起
      return updateSuspenseFallbackChildren(
        wip,
        nextPrimaryChildren,
        nextFallbackChildren
      );
    } else {
      // 正常
      return updateSuspensePrimaryChildren(wip, nextPrimaryChildren);
    }
  }
}

updateSuspensePrimaryChildren方法

回顾一下之前我们了解的suspensefiber结构:

  1. suspensechild元素指向Offscreen节点。
  2. Offscreen的节点的子节点是我们真正的children节点。

有了上面的fiber的结构图,我们再理解updateSuspensePrimaryChildren的作用

  1. Offscreenmode属性标记为visible,渲染正在的节点。
  2. 清理掉正在渲染的fragment包裹的fallbackloading节点。
    • 清理sibling的指向
    • suspanse添加删除标记以及删除的元素
  3. 然后返回Offscreen对应的fiber节点。

全部代码如下所示:

javascript 复制代码
function updateSuspensePrimaryChildren(wip, primaryChildren) {
    const current = wip.alternate;
    const currentPrimaryChildFragment = current.child;
    const currentFallbackChildFragment = currentPrimaryChildFragment.sibling;
    const primaryChildProps = {
        mode: "visible",
        children: primaryChildren
    };
    const primaryChildFragment = createWorkInProgress(currentPrimaryChildFragment, primaryChildProps);
    primaryChildFragment.return = wip;
    primaryChildFragment.sibling = null;
    wip.child = primaryChildFragment;
    if (currentFallbackChildFragment) {
        const deletions = wip.deletions;
        if (deletions === null) {
            wip.deletions = [currentFallbackChildFragment];
            wip.flags |= ChildDeletion;
        } else {
            deletions.push(currentFallbackChildFragment);
        }
    }
    return primaryChildFragment;
}

继续调和

返回Offscreen对应的fiber节点后,继续beginWork的调和阶段。进入到updateOffscreenComponent的执行。正常的调和流程,然后到达我们例子中的真正的子节点渲染(Cpn函数节点)。进入到函数组件的调和。

伪代码如下:

javascript 复制代码
    case OffscreenComponent:
        return updateOffscreenComponent(wip);
        
    function updateOffscreenComponent(wip) {
        const nextProps = wip.pendingProps;
        const nextChildren = nextProps.children;
        reconcileChildren(wip, nextChildren);
        return wip.child;
    }

包裹的函数组件调和

再次进入Cpn组件的时候,我们会再次的执行到use这个hooks。但是此时fetchData这个promise的状态已经不再是pending了,转换成了fulfilled

JavaScript 复制代码
export function Cpn({ id, timeout }) {
  const [num, updateNum] = useState(0);
  const { data } = use(fetchData(id, timeout));

  if (num !== 0 && num % 5 === 0) {
    cachePool[id] = null;
  }

  useEffect(() => {
    console.log("effect create");
    return () => console.log("effect destroy");
  }, []);

  return (
    <ul onClick={() => updateNum(num + 1)}>
      <li>ID: {id}</li>
      <li>随机数: {data}</li>
      <li>状态: {num}</li>
    </ul>
  );
}

当进入use的实现逻辑后,会执行到trackUsedThenable,由于收到的是fulfilled状态,会直接返回对应的value的值。

javascript 复制代码
// use hooks的实现
function use(usable) {
    if (usable !== null && typeof usable === "object") {
        if (typeof usable.then === "function") {
            const thenable = usable;
            return trackUsedThenable(thenable);
        } else if (usable.$$typeof === REACT_CONTEXT_TYPE) {
            const context = usable;
            return readContext(context);
        }
    }
    throw new Error("不支持的use参数");
}

export function trackUsedThenable(thenable) {
    switch (thenable.status) {
    case "fulfilled":
        return thenable.value;
    case "rejected":
        throw thenable.reason;
    default:
        if (typeof thenable.status === "string") {
            thenable.then(noop, noop);
        } else {
            const pending = thenable;
            pending.status = "pending";
            pending.then((val)=>{
                if (pending.status === "pending") {
                    const fulfilled = pending;
                    fulfilled.status = "fulfilled";
                    fulfilled.value = val;
                }
            }
            , (err)=>{
                const rejected = pending;
                rejected.status = "rejected";
                rejected.reason = err;
            }
            );
        }
        break;
    }
    suspendedThenable = thenable;
    throw SuspenseException;
}

这样use就可以拿到真实返回的值,然后在子组件的调和过程中进行使用。

自此,suspense初始化显示loading,以及得到数据后展示真实的数据的过程就完成了。

结合上下2篇,我们目前整体的流程大概如下:

如果我们点击某一个操作,触发更新的话,会再次展示loading等待数据返回后,才会渲染真实的组件数据。如下图所示:

接下来我们来讨论更新后的执行流程,是如何做到属性值的显示和隐藏的。

触发更新后

如果外部条件发生变化触发更新操作,会先隐藏界面并展示loading,等待数据返回后再次展示界面内容。

整体的流程如下图:

  1. 首先由于界面已经有渲染元素,所以会走到update的流程。当渲染到包裹组件的use方法的时候,抛出错误。
  2. unwind到最近的suspense节点,走update挂起流程,展示loading的界面。
  3. 当接口数据返回后,会触发一次新的更新,然后走到update的正常流程,渲染数据

这里个地方需要注意,这也是在更新的时候隐藏和显示的判断依据,在update挂起流程的时候,mode的值被标记为hidden,但是在正常流程mode值为visible

隐藏和显示的切换

回归阶段打标记

由于mode值在挂起和正常渲染的时候的不同,我们在向上递归的时候,可以根据前后对比,进行flag标记是否有变化。

javascript 复制代码
export const completeWork = (wip: FiberNode) => {
    /**
     * 对比Offscreen的mode(hide/visibity) 需要再suspense中
     * 因为如果在OffscreenComponent中比较的话,当在Fragment分支的时候
     * completeWork并不会走到OffscreenComponent
     *
     * current Offscreen mode 和 wip Offscreen mode 的对比
     */
    // 比较变化mode的变化(visible | hide)
    const offscreenFiber = wip.child as FiberNode;
    const isHidden = offscreenFiber.pendingProps.mode === "hidden";
    const currentOffscreenFiber = offscreenFiber.alternate;

    if (currentOffscreenFiber !== null) {
      // update
      const wasHidden = currentOffscreenFiber.pendingProps.mode === "hidden";
      if (wasHidden !== isHidden) {
        // 可见性发生了变化
        offscreenFiber.flags |= Visibility;
        bubbleProperties(offscreenFiber);
      }
    } else if (isHidden) {
      // mount 并且 hidden的状态 todo: 这里什么流程走到
      offscreenFiber.flags |= Visibility;
      bubbleProperties(offscreenFiber);
    }
    bubbleProperties(wip);
    return null;
}

如果前后2次的对比值不同的话 ,就添加Visibility标记,用于commit阶段去判断是否展示内容。

commit阶段根据标记处理渲染

在对每一个fiber进行处理的过程中,判断是否是OffscreenComponent并且有Visibility标记

javascript 复制代码
if ((flags & Visibility) !== NoFlags && tag === OffscreenComponent) {
  const isHidden = finishedWork.pendingProps.mode === "hidden";
  // 处理suspense 的offscreen
  hideOrUnhideAllChildren(finishedWork, isHidden);
  finishedWork.flags &= ~Visibility;
}

hideOrUnhideAllChildren的函数中,我们需要找到所有的子树的host节点,然后根据状态处理是隐藏还是显示

JavaScript 复制代码
/** OffscreenComponent中的子host 处理,可能是一个或者多个
function Cpn() {
  return (
    <p>123</p>
  )
}
情况1,一个host节点:
<Suspense fallback={<div>loading...</div>}>
    <Cpn/>
</Suspense>

情况2,多个host节点:
<Suspense fallback={<div>loading...</div>}>
    <Cpn/>
    <div>
        <p>你好</p>
    </div>
</Suspense>
*/

function hideOrUnhideAllChildren(finishedWork: FiberNode, isHidden: boolean) {
  //1. 找到所有子树的顶层host节点
  findHostSubtreeRoot(finishedWork, (hostRoot) => {
    //2. 标记隐藏或者展示
    const instance = hostRoot.stateNode;
    if (hostRoot.tag === HostComponent) {
      isHidden ? hideInstance(instance) : unhideInstance(instance);
    } else if (hostRoot.tag === HostText) {
      isHidden
        ? hideTextInstance(instance)
        : unhideTextInstance(instance, hostRoot.memoizedProps.content);
    }
  });
}

hideInstanceunhideInstance就是设置host节点的display属性,这样我们就可以在更新的时候隐藏或显示元素了。

JavaScript 复制代码
export function hideInstance(instance: Instance) {
  const style = (instance as HTMLElement).style;
  style.setProperty("display", "none", "important");
}

export function unhideInstance(instance: Instance) {
  const style = (instance as HTMLElement).style;
  style.display = "";
}

至此我们的suspense的部分就基本讲完了,下一讲我们将性能优化方面,比如bailouteagerState等策略。

相关推荐
贩卖纯净水.2 分钟前
Chrome调试工具(查看CSS属性)
前端·chrome
栈老师不回家1 小时前
Vue 计算属性和监听器
前端·javascript·vue.js
前端啊龙1 小时前
用vue3封装丶高仿element-plus里面的日期联级选择器,日期选择器
前端·javascript·vue.js
一颗松鼠1 小时前
JavaScript 闭包是什么?简单到看完就理解!
开发语言·前端·javascript·ecmascript
小远yyds1 小时前
前端Web用户 token 持久化
开发语言·前端·javascript·vue.js
阿伟来咯~2 小时前
记录学习react的一些内容
javascript·学习·react.js
吕彬-前端2 小时前
使用vite+react+ts+Ant Design开发后台管理项目(五)
前端·javascript·react.js
学前端的小朱2 小时前
Redux的简介及其在React中的应用
前端·javascript·react.js·redux·store
guai_guai_guai3 小时前
uniapp
前端·javascript·vue.js·uni-app
帅比九日3 小时前
【HarmonyOS Next】封装一个网络请求模块
前端·harmonyos