深入理解react——2. Concurrent Mode

接下来我们需要解决上文《深入理解react------1. jsx与虚拟dom》留下来的问题。

一 Fiber

比较正式的fiber架构介绍的文章各种各样,我这里就不再重复,我只用比较简单接地气的方式描述一下我们怎么解决上面的问题,以及我们接下来应该怎么做。相信做完后大家自己能够去总结,到底什么是fiber。

1.1 问题分析以及解决策略

1.1.1问题

这是我们将虚拟dom渲染到界面的代码,很容易可以看出在第十五行有一个递归调用,当界面节点过多时js主线程就会一直卡在这里,这就造成了页面的卡顿。

js 复制代码
function render(element, container) {
  const dom =
    element.type == "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(element.type)

  const isProperty = key => key !== "children"
  Object.keys(element.props)
    .filter(isProperty)
    .forEach(name => {
      dom[name] = element.props[name]
    })

  element.props.children.forEach(child =>
    render(child, dom)
  )

  container.appendChild(dom)
}

1.1.2 解决方案

要说明方案,我们先澄清几个概念,虚拟dom,工作单元(nextUnitOfWork)。

这里给出的概念不是官方概念,而是我自己理解给出的内容

  • 虚拟dom,一个纯js的对象,也就是我们使用createElement 得到的js对象

  • 工作单元(nextUnitOfWork),根据虚拟dom进行的一系列操作最小单元。

    • 打上标记,以便于分辨下次更新是增删还是改
    • 判断是否还有剩余时间进行下一个工作单元的计算
    • 返回下一个工作单元
    • 判断是否全部计算完成,能够在界面上进行渲染(commit阶段)

我们假设界面上有如上图的结构,我们对A进行修改。

  • 首先我们需要能够感受到A的变化
  • 其次我们从A开始处理,并返回下一个工作单元,直至整个树计算完成
  • 最后在合适的时机进行提交(渲染到界面上)

二 代码实现

2.1 workLoop

js 复制代码
// 全局变量
// nextUnitOfWork: 下一个需要处理的工作单元(Fiber节点)
let nextUnitOfWork = null;
// wipRoot: 当前正在构建的Fiber树的根节点
let wipRoot = null;
//  currentRoot: 上一次提交到DOM的Fiber树的根节点
let currentRoot = null;
let deletions = null;

// 启动Fiber树的构建循环
requestIdleCallback(workLoop);

/**
 * workLoop 函数
 * 浏览器空闲时执行的主循环,用于协调Fiber树的构建
 * @param {IdleDeadline} deadline - 浏览器提供的空闲时间对象
 */
export function workLoop(deadline) {
  let shouldYield = false; // 是否需要暂停工作
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork); // 执行当前工作单元
    shouldYield = deadline.timeRemaining() < 1; // 如果剩余时间不足,暂停工作
  }

  if (!nextUnitOfWork && wipRoot) {
    commitRoot(); // 如果没有工作单元且Fiber树构建完成,提交更新
  }

  requestIdleCallback(workLoop); // 继续下一次工作
}

此处使用requestIdleCallback模仿真实react的行为,利用浏览器空闲时间进行计算,避免阻塞优先级更高的任务。真实的react使用MessageChannel 实现。

从以上代码可以看到,workLoop一直在被递归调用,直到没有下一个工作单元,并且wipRoot构建完成,则进行更新。

2.2 render

那结合上面的例子,当我们有了新的nextUnitOfWork 并且浏览器有足够的空闲时间,就会开启新一轮的渲染。所以我们需要将render函数进行一些修改。render将不再操作dom更新,只是赋值变量,方便下一次的workLoop开启计算。

js 复制代码
/**
 * render 函数
 * 用于初始化Fiber树并设置根节点的属性
 * @param {Object} element - React元素对象
 * @param {HTMLElement} container - 容器DOM节点
 */
export function render(element, container) {
  wipRoot = {
    dom: container, // 容器DOM节点
    props: { children: [element] }, // 根节点的子元素
    alternate: currentRoot, // 保存上一次的Fiber树,用于比较
  };
  console.log("wipRoot:", wipRoot);
  deletions = [];
  nextUnitOfWork = wipRoot; // 设置下一个工作单元为根节点
}

2.3 performUnitOfWork

接下来是最为核心的部分,最小工作单元的计算工作,且返回下一个工作单元。其实也就是我们常常说的"调和"(reconcile)

每一个fiber打上不同的标记effectTag,用于判断增删改。

js 复制代码
/**
 * performUnitOfWork 函数
 * 执行当前Fiber节点的工作,并返回下一个工作单元
 * @param {Object} fiber - 当前的Fiber节点
 * @returns {Object|null} - 下一个需要处理的Fiber节点
 */
export function performUnitOfWork(fiber) {
  // 1. 创建当前Fiber节点对应的DOM节点
  if (!fiber.dom) {
    fiber.dom = createDom(fiber);
  }

  // 2. 为当前Fiber节点的子元素创建Fiber节点
  const elements = fiber.props.children;
  reconcileChildren(fiber, elements);

  // 3. 返回下一个工作单元
  if (fiber.child) {
    return fiber.child;
  }
  let nextFiber = fiber;
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling;
    }
    nextFiber = nextFiber.parent;
  }
}


export function reconcileChildren(wipFiber, elements) {
  // TODO 调和变更
  let index = 0;
  let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
  let prevSibling = null;

  // TODO 思考:为什么不能用 oldFiber !== null
  // oldFiber一直为undefined,会造成死循环
  while (index < elements.length || oldFiber != null) {
    const element = elements[index];
    let newFiber = null;

    const sameType = oldFiber && element && element.type == oldFiber.type;

    // 更新
    if (sameType) {
      newFiber = {
        type: oldFiber.type,
        props: element.props,
        dom: oldFiber.dom,
        parent: wipFiber,
        alternate: oldFiber,
        effectTag: "UPDATE",
      };
    }

    // 重新创建
    if (element && !sameType) {
      newFiber = {
        type: element.type,
        props: element.props,
        dom: null,
        parent: wipFiber,
        alternate: null,
        effectTag: "PLACEMENT",
      };
    }

    // 删除
    if (oldFiber && !sameType) {
      oldFiber.effectTag = "DELETION";
      deletions.push(oldFiber);
    }

    // 同时遍历旧fiber树
    if (oldFiber) {
      oldFiber = oldFiber.sibling;
    }

    // 父fiber的child指向第一个子fiber
    if (index === 0) {
      wipFiber.child = newFiber;
    } else if (element) {
      /* 当oldFiber != null时,需要判断element存在才设置sibling */
      // 如果存在兄弟节点,通过sibling关联
      prevSibling.sibling = newFiber;
    }

    // 暂存上一个兄弟节点
    prevSibling = newFiber;
    index++;
  }
}

对于我们上面举的例子,它的遍历顺序是这样的。A->B->D->C->E。

2.4 commitRoot

让我们来恭喜自己,已经完成了最核心也是最困难的部分。只需要最后最简单的一步,将计算过后的fiber树提交,变成真实的dom渲染到界面上就好了。

scss 复制代码
/**
 * commitWork 函数
 * 递归提交Fiber节点的DOM更新
 * @param {Object} fiber - 当前的Fiber节点
 */
export function commitWork(fiber) {
  if (!fiber) {
    return;
  }

  const domParent = fiber.parent.dom; // 获取父DOM节点
  if (fiber.effectTag === "PLACEMENT" && fiber.dom != null) {
    domParent.appendChild(fiber.dom);
  }

  if (fiber.effectTag === "UPDATE" && fiber.dom != null) {
    updateDom(fiber.dom, fiber.alternate.props, fiber.props);
  }

  if (fiber.effectTag === "DELETION") {
    domParent.removeChild(fiber.dom);
    return;
  }

  commitWork(fiber.child); // 递归提交子节点
  commitWork(fiber.sibling); // 递归提交兄弟节点
}



const isEvent = (key) => key.startsWith("on");
const isProperty = (key) => key !== "children" && !isEvent(key);
const isNew = (prev, next) => (key) => prev[key] !== next[key];
const isGone = (next) => (key) => !(key in next);

export function updateDom(dom, prevProps, nextProps) {
  // 移除旧事件
  Object.keys(prevProps)
    .filter(isEvent)
    .filter((key) => !(key in nextProps) || isNew(prevProps, nextProps)(key))
    .forEach((name) => {
      const eventType = name.toLowerCase().substring(2);
      dom.removeEventListener(eventType, prevProps[name]);
    });

  // 删除旧属性
  Object.keys(prevProps)
    .filter(isProperty)
    .filter(isGone(nextProps))
    .forEach((name) => {
      dom[name] = "";
    });

  // 设置新属性
  Object.keys(nextProps)
    .filter(isProperty)
    .filter(isNew(prevProps, nextProps))
    .forEach((name) => {
      dom[name] = nextProps[name];
    });

  // 添加新事件
  Object.keys(nextProps)
    .filter(isEvent)
    .filter(isNew(prevProps, nextProps))
    .forEach((name) => {
      const eventType = name.toLowerCase().substring(2);
      dom.addEventListener(eventType, nextProps[name]);
    });
}

三,测试

大功告成,接下来我们做点小测试。

ini 复制代码
const childrenElements = [
  React.createElement("div", { key: "B" }, "B", React.createElement("div", { key: "D" }, "D")),
  React.createElement("div", { key: "C" }, "C", React.createElement("div", { key: "E" }, "E")),
];
const element_0 = React.createElement("h1", "A", "A1", ...childrenElements);
const element_1 = React.createElement("h1", "A", "A2", ...childrenElements);
ReactDom.render(element_0, rootDOM);

setTimeout(() => {
  ReactDom.render(element_1, rootDOM);
}, 1000);

四,后续内容

如果这里做完,晚上吃饭可以狠狠奖励一下自己了。大概的架子和核心的部分已经完成,接下来会依次添加函数式组件的支持,以及useState,useEffect。

相关推荐
0_12 小时前
封装了一个vue版本 Pag组件
前端·javascript·vue.js
Stirner2 小时前
A2UI : 以动态 UI 代替 LLM 文本输出的方案
前端·llm·agent
Code知行合壹2 小时前
Vue.js进阶
前端·javascript·vue.js
我叫唧唧波2 小时前
【微前端】qiankun基础
前端·前端框架
摸鱼的春哥2 小时前
企业自建低代码平台正在扼杀AI编程的生长
前端·javascript·后端
-凌凌漆-2 小时前
【JS】var与let的区别
开发语言·前端·javascript
火车叼位2 小时前
使ast-grep-vscode 识别Vue组件
前端·visual studio code
YAY_tyy2 小时前
综合实战:基于 Turfjs 的智慧园区空间管理系统
前端·3d·cesium·turfjs
生活在一步步变好i2 小时前
模块化与包管理核心知识点详解
前端