Build your own React(4)-diffing阶段

653 阅读4分钟

原文链接:Build your own React

  • Step I: The  createElement Function ✅
  • Step II: The  render Function ✅
  • Step III: Concurrent Mode 并发模式 ✅
  • Step IV: Fibers ✅
  • Step V: Render and Commit Phases ✅
  • Step VI: Reconciliation ✅
  • Step VII: Function Components
  • Step VIII: Hooks

** To avoid confusion, I’ll use “element” to refer to React elements and “node” for DOM elements.*

前言

前文我们提到过,每次处理一个元素时,都会向DOM添加一个新节点,但是记住,我们使用了RIC,所以浏览器可能会在我们完成渲染整个树之前中断我们的工作。在这种情况下,用户将看到一个不完整的 UI。在这节,我们将提交(commit)阶段修改为同步执行来解决此问题。

Render and Commit Phases

首先移除之前渲染DOM的代码:

// react/render.js
function performUnitOfWork(fiber) {
  // ...
- if (fiber.parent) {
-   fiber.parent.dom.append(fiber.dom);
- }
}

接下来思考下,在上节中,我们仅仅记录了每个DOM节点对应的fiber节点,如果要提交阶段改成同步执行,需要以下几个步骤:

  • 首先必须获取到一颗完整的Fiber树,记录了整个DOM节点的信息。我们将这颗完整的Fiber树命名wipRoot(work in progress root)
  • render阶段完成所有的工作后,一次性提交整颗Fiber树渲染界面

开始修改代码:

 // react/render.jsfunction render(element, container) {
   // 初始化下一个工作单元(根节点)
+  wipRoot = {
+    dom: container,
+    props: {
+      children: [element],
+    },
+    sibling: null,
+    child: null,
+    parent: null,
+  };
+  nextUnitOfWork = wipRoot;
 }
​
  let nextUnitOfWork = null;
  // 记录整颗Fiber树
+ let wipRoot = null;
​
  // commit阶段
+ function commitRoot() {
+  commitWork(wipRoot.child);
+  wipRoot = null;
+ }
​
+ function commitWork(fiber) {
+  if (!fiber) {
+    return;
+  }
+  const parentDOM = fiber.parent.dom;
+  parentDOM.append(fiber.dom);
+  commitWork(fiber.child);
+  commitWork(fiber.sibling);
+ }
​
 function workLoop(deadline) {
   // 剩余时间是否足够执行任务的标识
   let shouldYield = false;
   while (nextUnitOfWork && !shouldYield) {
     // 处理当前任务并返回下一个待执行的任务
     nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
     shouldYield = deadline.timeRemaining() < 1;
   }
   // commit阶段 保证异步渲染 同步提交
   // 此时可以理解为已完成全部工作
+  if (!nextUnitOfWork && wipRoot) {
+    commitRoot();
+  }
   // 本次没有足够空闲时间 请求下一次浏览器空闲的时候执行
   requestIdleCallback(workLoop);
 }

至今为止,我们只完成了添加DOM节点的工作,接下来让我们看看如何更新以及删除节点。

Reconciliation(Diff算法)

我们需要比较上一次render函数中处理的最后一课Fiber树(commit阶段的wipRoot),所以首先需要定义一个变量存储上一次commit的Fiber树:currentRoot,同时在每一颗fiber树上添加alternate属性(a link to the old fiber),记录上一次此节点对应的fiber。

// react/render.js
function render(element, container) {
  // 初始化下一个工作单元(根节点)
  wipRoot = {
    dom: container,
    props: {
      children: [element],
    },
    sibling: null,
    child: null,
    parent: null,
+   alternate: currentRoot,
  };
  nextUnitOfWork = wipRoot;
}
​
function commitRoot() {
   commitWork(wipRoot.child)
+  currentRoot = wipRoot
   wipRoot = null
}
// 记录上一次Fiber树
+ let currentRoot = null;

在之前performUnitOfWork函数的作用是每一次都会为dom节点生成对应的fiber树,现在我们需要在生成新fiber树的同时对比旧的fiber树,这里也是我们此处diffing算法的核心,接下来需要重构我们的performUnitOfWork函数:

function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDOM(fiber);
  }
​
  if (fiber.parent) {
    fiber.parent.dom.append(fiber.dom);
  }
​
  const elements = fiber.props.children;
+ reconcileChildren(fiber, elements);
- let prevSibling = null;
- for (let i = 0; i < elements.length; i++) {
-   const newFiber = {
-     type: elements[i].type,
-     props: elements[i].props,
-     parent: fiber,
-     dom: null,
-     child: null,
-     sibling: null,
-   };
-
-   if (i === 0) {
-     fiber.child = newFiber;
-   } else {
-     prevSibling.sibling = newFiber;
-   }
-   prevSibling = newFiber;
- }
​
  if (fiber.child) {
    return fiber.child;
  }
  let nextFiber = fiber;
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling;
    }
    nextFiber = nextFiber.parent;
  }
}

可以看到我们加了一个新的reconcileChildred函数,此函数接收两个参数:即将被处理的fiber(wipFiber)以及它的子元素(elements)。首先需清晰明白此函数的意义:

  • 同时遍历旧fiber以及当前待处理的子元素

  • 对两者进行对比(开始diffing),根据type相同与否分为以下三种情况处理

    • type类型相同:直接继承DOM,不需要重新生成新DOM,并使用自己的新属性进行更新,并为此fiber添加标识UPDATE
    • type类型不同,存在子元素(elements):新建此元素的fiber,添加标识PLACEMENT
    • type类型不同,存在旧fiber:删除旧fiber,添加标识DELETION
// react/render.jsfunction render(element, container) {
  // 初始化下一个工作单元(根节点)
  wipRoot = {
    dom: container,
    props: {
      children: [element],
    },
    sibling: null,
    child: null,
    parent: null,
    // 记录上一次此节点对应的fiber
    alternate: currentRoot,
  };
+ deletions = [];
  nextUnitOfWork = wipRoot;
}
​
// diffing中记录要删除的节点
+ let deletions = null;
​
function reconcileChildren(wipFiber, elements) {
  let index = 0;
  let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
  let prevSibling = null;
​
  while (index < elements.length || oldFiber) {
    const element = elements[index];
    const sameType = element && oldFiber && element.type === oldFiber.type;
    let newFiber = null;
​
    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);
    }
​
    if (index === 0) {
      wipFiber.child = newFiber;
    } else {
      prevSibling.sibling = newFiber;
    }
    prevSibling = newFiber;
​
    if (oldFiber) {
      oldFiber = oldFiber.sibling;
    }
    index++;
  }
}

接下来处理commit阶段:

// react/render.js
function commitRoot() {
+ deletions.forEach(commitWork);
  commitWork(wipRoot.child);
  // 记录上一次Fiber树
  currentRoot = wipRoot;
  wipRoot = null;
}
​
function commitWork(fiber) {
  if (!fiber) {
    return;
  }
  /**
   * 新建 删除操作比较容易,直接控制父节点的添加删除即可
   * 更新操作需要对比前后props
   */
  const parentDOM = fiber.parent.dom;
- parentDOM.append(fiber.dom);
+ if (fiber.effectTag === "PLACEMENT" && fiber.dom) {
+   parentDOM.append(fiber.dom);
+ } else if (fiber.effectTag === "DELETION" && fiber.dom) {
+   parentDOM.removeChild(fiber.dom);
+ } else if (fiber.effectTag === "UPDATE" && fiber.dom) {
+   updateDOM(fiber.dom, fiber.alternate.props, fiber.props);
+ }
  commitWork(fiber.child);
  commitWork(fiber.sibling);
}
​
// 新建updateDOM函数处理更新情况
function updateDOM(dom, prevProps, nextProps) {
  // 删除已经没有的props
  Object.keys(prevProps)
    .filter((key) => key !== "children")
    .filter((key) => !(key in nextProps))
    .forEach((name) => (dom[name] = ""));
​
  // 赋予新的或者改变的props
  Object.keys(nextProps)
    .filter((key) => key !== "children")
    .filter((key) => !(key in prevProps) || prevProps[key] !== nextProps[key])
    .forEach((name) => (dom[name] = nextProps[name]));
​
  // 删除已经没有的或者发生变化的事件处理函数
  Object.keys(prevProps)
    .filter((key) => key.startsWith("on"))
    .filter((key) => !(key in nextProps) || prevProps[key] !== nextProps[key])
    .forEach((name) => {
      const eventType = name.toLowerCase().substring(2);
      dom.removeEventListener(eventType, prevProps[name]);
    });
​
  // 添加新的事件处理函数
  Object.keys(nextProps)
    .filter((key) => key.startsWith("on"))
    .filter((key) => prevProps[key] !== nextProps[key])
    .forEach((name) => {
      const eventType = name.toLowerCase().substring(2);
      dom.addEventListener(eventType, prevProps[name]);
    });
}

至此,我们的diffing算法就已经基本完成了,在通过示例验证之前,先来总结下至今为止从进入render函数之后的函数调用栈,方便大家理解。

调用栈

进入render函数,初始化rootFiber,同时设为nextUnitOfWork

初始化执行workLoop

=> performUnitOfWork

=> reconcileChildren(diffing阶段)构建fiber

第一次diffing阶段时,由于不存在oldFiber,所以所有节点都属于新增节点,都会被打上PLACEMENT标记

后续diffing时,根据type相同与否,分别对应不同的处理

=> 出reconcileChildren,此DOM对应的fiber构建完成

=> 出performUnitOfWork,返回下一个fiber

循环直至整颗Fiber Tree构成

=> 进入commitRoot

  • 先处理deletion中记录的待删除的dom,直接从DOM树中移除

  • 处理子节点

    • PLACEMENT新增,直接append进DOM树中
    • UPDATE更新,进入updateDOM函数

=> 记录currentRoot(赋值为当前的渲染,currentRoot = wipRoot)

=> 初始化wipRoot为null,等待下一次渲染

通过示例加深Diffing算法

我分别提供两种示例供大家加深理解,第一种主要是UPDATE更新操作:

// main.js
/** diffing示例 update*/
const handleInput = (e) => {
  renderer(e.target.value);
};
const renderer = (value) => {
  const container = document.querySelector("#root");
  const element = createElement(
    "div",
    null,
    createElement("input", { oninput: (e) => handleInput(e) }, null),
    createElement("h1", null, value)
  );
  render(element, container);
};
​
renderer("hello");

现象:h1标签值根据input输入框实时变化

diffing过程:

  • 第一次diffing,不存在oldFiber,所有节点属于新增
  • input输入框输入值,触发下一次的diffing
  • 前后对比,发现前后节点类型type相同,只是属性值有变化,所有标签被打上UPDATE标识


第二个实例涉及到删除操作:

// main.js
/** diff示例 delete */
const container = document.querySelector("#root");
const handleDel = () => {
  renderer("new");
};
const renderer = (type) => {
  const initElement = createElement(
    "div",
    null,
    createElement("button", { onclick: () => handleDel() }, "delete h1"),
    createElement("h1", null, "hello h1")
  );
  const newElement = createElement(
    "div",
    null,
    createElement("button", { onclick: () => handleDel() }, "delete h1"),
    createElement("h2", null, "hello h2")
  );
  render(type === "new" ? newElement : initElement, container);
};
​
renderer("init");

现象:点击button按钮,删除h1标签,添加h2标签

diffing过程:

  • 第一次diffing,不存在oldFiber,所有节点属于新增
  • 点击button按钮,前后对比,h1标签进入deletions删除队列中,并打上DELETION标记
  • h2标签添加上PLACEMENT,属于新增节点
  • 其余button节点,外层div节点type类型没有变化,属于UPDATE操作

总结

至此,我们已经完成了react中重要的Reconciliation阶段,如果单独看代码理解起来可能不是很容易,建议大家根据我提供的两个示例,一步一步debugger,根据其内部函数调用栈了解diffing阶段是如何工作的。在此系列完结的最后一章中,我会将代码更新到Github。