React Suspense实现原理深度解析 1

介绍

Suspense在整个React体系中都非常重要。简单的说,在异步操作(数据获取、模块加载)没有完成的时候显示fallback的内容。结合startTransition可以在异步操作没有完成前显示旧的内容,避免在fallback、其他内容和新的内容之间切换闪烁。

这里根据一个实践例子和源码深度解析Suspense的实现原理。

举一个例子

jsx 复制代码
// UserDashboard.js
import React from 'react';

// 假设这里是一个比较大的组件,包含很多逻辑和UI
// 并且为了演示,我们模拟一个延迟加载
function UserDashboard() {
  const [data, setData] = React.useState(null);

  React.useEffect(() => {
    // 模拟数据获取延迟
    const timer = setTimeout(() => {
      setData("Welcome to your personal dashboard!");
    }, 2000); // 模拟 2 秒钟的加载时间

    return () => clearTimeout(timer);
  }, []);

  if (!data) {
    // 在这里,如果数据没有准备好,我们不会抛出 Promise,
    // 而是显示一个内部加载状态,因为这个组件本身已经加载了。
    // 真正的 Suspense 是发生在组件代码加载的时候。
    return <div>Loading dashboard content...</div>;
  }

  return (
    <div style={{ border: '1px solid blue', padding: '20px', margin: '20px' }}>
      <h2>User Dashboard</h2>
      <p>{data}</p>
    </div>
  );
}

export default UserDashboard;
jsx 复制代码
// App.js
import React, { Suspense } from 'react';

// 1. 定义一个懒加载组件
// React.lazy 会返回一个组件,这个组件的实际代码会在需要渲染时才加载
const LazyUserDashboard = React.lazy(() => import('./UserDashboard'));

function App() {
  const [showDashboard, setShowDashboard] = React.useState(false);

  return (
    <div style={{ fontFamily: 'Arial', textAlign: 'center' }}>
      {/* <h1>My Application</h1> */}
      <button onClick={() => setShowDashboard(!showDashboard)}>
        {showDashboard ? 'Hide Dashboard' : 'Show Dashboard'}
      </button>

      {showDashboard && (
        // 2. 使用 <Suspense> 包裹懒加载组件
        // fallback prop 定义了在懒加载组件代码加载期间显示的内容
        <Suspense fallback={
          <div style={{ border: '1px dashed gray', padding: '20px', margin: '20px' }}>
            Loading User Dashboard module... (This is the Suspense Fallback)
          </div>
        }>
          {/* 3. 渲染懒加载组件 */}
          <LazyUserDashboard />
        </Suspense>
      )}

      {/* <p>This is the main application content, always visible.</p> */}
    </div>
  );
}

export default App;
jsx 复制代码
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
import React from 'react';

createRoot(document.getElementById('root')).render(<App /> )

原理

  1. 开始渲染Suspense, 但是组件还没有加载好,updateSuspenseComponent通过throw抛出文件加载的pending promise。

  2. 在错误处理(handleError)中completeUnitOfWork,直到Suspense后停下。再次渲染Suspense,这次updateSuspenseComponent会判断到DidCapture

  3. 从而进入到mountSuspenseFallbackChildren,它会构建Fragment-fallback子树,再把Fragment构建为Suspense的第二个子节点,并返回Fragment,被赋值给workInProgress作为beginWork的下一个节点,这样commit的时候就会渲染了Fragment-fallback分支了。完成一次渲染,显示fallback。

  4. 提交的时候给懒加载模块的promise pending绑定resolveRetryWakeable回调。

  5. 模块加载下来后,触发resolveRetryWakeable,再次渲染Suspense,移除fallback,渲染OffscreenComponent。更新显示真实内容。

graph TD SuspenseComponent["SuspenseComponent
beginWork unwindWork"] OffscreenComponent["OffscreenComponent
beginWork unwindWork
头节点"] Fragment["Fragment
beginWork completeWork
尾节点"] fallback["fallback
beginWork completeWork
<div style...>Loading...</div>"] LazyComponent["LazyComponent
beginWork unwindWork、throw promise pending
UserDashboard"] SuspenseComponent --> OffscreenComponent SuspenseComponent --> Fragment Fragment --> fallback OffscreenComponent --> LazyComponent

attachSuspenseRetryListeners

为每个 Promise(如懒加载返回的Promise)绑定一个 resolveRetryWakeable 回调。模块下载下来后触发回调再次渲染。

js 复制代码
function attachSuspenseRetryListeners(finishedWork) {
  var wakeables = finishedWork.updateQueue;
  if (wakeables !== null) {
    ...
    wakeables.forEach(function (wakeable) {
      // Memoize using the boundary fiber to prevent redundant listeners.
      var retry = resolveRetryWakeable.bind(null, finishedWork, wakeable); //wakeable是Promise pending
      if (!retryCache.has(wakeable)) {
        retryCache.add(wakeable);
        ...
        wakeable.then(retry, retry); //给promise pending绑定resolveRetryWakeable
      }
    });
  }
} 

function resolveRetryWakeable(boundaryFiber, wakeable) {
  ...
  retryTimedOutBoundary(boundaryFiber, retryLane);
} 

function retryTimedOutBoundary(boundaryFiber, retryLane) {
  ...
  var eventTime = requestEventTime();
  var root = enqueueConcurrentRenderForLane(boundaryFiber, retryLane);

  if (root !== null) {
    markRootUpdated(root, retryLane, eventTime);
    ensureRootIsScheduled(root, eventTime); //重新渲染
  }
}

updateSuspenseComponent

Lazycomponent抛出一个promise pending后,回退到suspense重新渲染suspense的时候,这次updateSuspenseComponent判断DidCapture,然后进入条件,构建Fragment-fallback为Suspense的第二个子节点。

js 复制代码
function updateSuspenseComponent(){
    var suspenseContext = suspenseStackCursor.current;
    var showFallback = false;
    var didSuspend = (workInProgress.flags & DidCapture) !== NoFlags;//成立

    if (didSuspend || shouldRemainOnFallback(suspenseContext, current)) {
      showFallback = true; //进入这里
      workInProgress.flags &= ~DidCapture;
    } else {
       ...
    }

    if (showFallback) {
      var fallbackFragment = mountSuspenseFallbackChildren(workInProgress, nextPrimaryChildren, nextFallbackChildren, renderLanes);
      var primaryChildFragment = workInProgress.child;
      primaryChildFragment.memoizedState = mountSuspenseOffscreenState(renderLanes);
      workInProgress.memoizedState = SUSPENDED_MARKER;
      return fallbackFragment; //返回Fragment
    }
}

返回fallbackFragment

协调器会沿着fallbackFragment这条路径渲染,准备fallback的UI,走Fragment->commitRoot,没有经过Offscreencomponent,而Offscreencomponent下方的主子树(UserDashboard)则会在后台继续工作。

mountSuspenseFallbackChildren构建了这样的子树:Fragment和Offscreencomponent是兄弟节点,Offscreencomponent是Suspense的第一个子节点,Fragment是第二个子节点。

第一次没有构建Fragment-fallback子树,在LazyComponent抛出Primose pending的时候,才构建了Fragment-fallback子树,并且

updateSuspenseComponentmountSuspenseFallbackChildren都返回第二个节点 ------ Fragment。

mountSuspenseFallbackChildren

构建Suspense树的结构

js 复制代码
function mountSuspenseFallbackChildren(workInProgress, primaryChildren, fallbackChildren, renderLanes) {
  var mode = workInProgress.mode;
  var progressedPrimaryFragment = workInProgress.child; // wip是suspense,child是offscreencomponent,child表示头子节点,目前suspense只有一个子节点,下面会把fallback构建第二个子节点
  var primaryChildProps = {
    mode: 'hidden',// 把Offscreencomponent fiber的mode改成'hidden'
    children: primaryChildren // Lazycomponent的内容
  };
  var primaryChildFragment; //OffscreenComponent Fiber 结合primaryChildProps,suspense头节点
  var fallbackChildFragment;//fallback 的 Fiber fallback内容,suspense第二个节点,被返回

  //这是遗留模式 (Legacy Mode) 的分支。如果你用的是 ReactDOM.render() 启动的 React 18 应用,或者更老的 React 17,就可能会进入这个分支。
  if ((mode & ConcurrentMode) === NoMode && progressedPrimaryFragment !== null) {
    // 创建OffscreenComponent
    primaryChildFragment = progressedPrimaryFragment;
    primaryChildFragment.childLanes = NoLanes;
    primaryChildFragment.pendingProps = primaryChildProps;
    ...
    // 创建Fragment
    fallbackChildFragment = createFiberFromFragment(fallbackChildren, mode, renderLanes, null);
  } else {//这是并发模式 (Concurrent Mode) 的分支。这是 React 18 中 ReactDOM.createRoot() 启动应用的默认路径。
    primaryChildFragment = mountWorkInProgressOffscreenFiber(primaryChildProps, mode);//创建OffscreenComponent
    fallbackChildFragment = createFiberFromFragment(fallbackChildren, mode, renderLanes, null);//创建Fragment
  }
  // 把Fragment构建成Suspense的第二个子节点
  primaryChildFragment.return = workInProgress;       // OffscreenComponent 的父级是 SuspenseComponent
  fallbackChildFragment.return = workInProgress;      // Fragment 的父级也是 SuspenseComponent
  primaryChildFragment.sibling = fallbackChildFragment; // OffscreenComponent 和 Fragment 是兄弟关系
  workInProgress.child = primaryChildFragment;        // 将 OffscreenComponent 设置为 SuspenseComponent 的第一个孩子
  return fallbackChildFragment;   // 实际返回的是 Fragment 节点 而不是头节点OffscreenComponent!!
}

fallbackChildren是fallback的内容,创建Fragment的时候作为Fragment的子节点。

Suspense上有mode属性,值"visible"/"hidden",初始为 "visible"

  • 这表示 React 默认该子树可以正常渲染并显示。
  • 只有当 OffscreenComponent 下的子节点真正"抛出"一个 Promise (pending)导致 Suspense 发生时,OffscreenComponentmode 才会被切换为 "hidden"

"hidden" 状态是告诉 React:"这个子树虽然我还在后台渲染,但它的内容目前不应该被提交到 DOM,同时,SuspenseComponent 也会被标记为需要显示 fallback

最后

可以看到Suspense利用throw Promise<pending>实现,并且构建了Fragment-fallback子树,和懒加载的组件形成了兄弟节点树。

相关推荐
AntBlack1 分钟前
Python : AI 太牛了 ,撸了两个 Markdown 阅读器 ,谈谈使用感受
前端·人工智能·后端
MK-mm20 分钟前
CSS盒子 flex弹性布局
前端·css·html
小小小小宇32 分钟前
CSP的使用
前端
sunbyte33 分钟前
50天50个小项目 (Vue3 + Tailwindcss V4) ✨ | AnimatedNavigation(动态导航)
前端·javascript·vue.js·tailwindcss
ifanatic43 分钟前
[每周一更]-(第147期):使用 Go 语言实现 JSON Web Token (JWT)
前端·golang·json
烛阴43 分钟前
深入浅出地理解Python元类【从入门到精通】
前端·python
米粒宝的爸爸1 小时前
uniapp中vue3 ,uview-plus使用!
前端·vue.js·uni-app
JustHappy1 小时前
啥是Hooks?为啥要用Hooks?Hooks该怎么用?像是Vue中的什么?React Hooks的使用姿势(下)
前端·javascript·react.js
董先生_ad986ad1 小时前
C# 解析 URL URI 中的参数
前端·c#
江城开朗的豌豆2 小时前
Vue中Token存储那点事儿:从localStorage到内存的避坑指南
前端·javascript·vue.js