mini-react 第五天:实现diff算法

在上一天的课程中,对于props触发的更新,我们仅仅处理了节点属性变化和新增了节点的逻辑。对于需要删除的老节点没有做处理。实际上,还有几种情况需要处理。在今天的课程中,我们需要完成这些处理,以及对组件更新进行优化:不要每次都从根节点开始重新渲染。

新的和老的节点type不同,删除老节点

App.jsx中定义条件渲染的div和p,点击按钮从div改为p节点

js 复制代码
import React from "./core/React.js"

let showBar = false
function Counter() {
  const foo = <div>foo</div>
  const bar = <p>bar</p>
  function handleShowBar() {
    showBar = !showBar
    React.update()
  }
  return (
    <div>
      counter
      <div>{showBar ? bar : foo}</div>
      <button onClick={handleShowBar}>showBar</button>
    </div>
  )
}

function App() {
  return (
    <div>
      mini-react
      <Counter></Counter>
    </div>
  )
}

export default App

点击之后发现并未删除老的dom,而是一直增加。我们需要在reconcileChildren 函数中添加删除老节点的操作。

【做法】

第一反应我们可以想到,将老的节点做个effectTag = "delete"。但是这样的话我们又得再遍历一遍fiber数才能完成delete操作。不如直接建立一个数组,存储所有需要删除的fiber节点,然后批量删除。

js 复制代码
function reconcileChildren(fiber, children) {  // reconcile 包含了init和update
  ...
  children.forEach((child, index) => {
    const isSameType = child && oldFiber && child.type === oldFiber.type;
    if (isSameType) {
      // update
      ...
    } else {
      //create
      ...

      if (oldFiber) {
        deletions.push(oldFiber);
      }
    ...
    }
}


let deletions = [] // 需要删除的节点集合
function commitRoot() {
  deletions.forEach(commitDeletion)
  commitWork(wipRoot.child);
  currentRoot = wipRoot; // 保存当前的fiber树, 给下次用来做diff
  wipRoot = null;
  deletions = [];
}

function commitDeletion(fiber) {
  fiber?.parent.dom.removeChild(fiber.dom)
}

【支持函数组件】 commitDeletion中的fiber.parent 也会遇到和之前类似的函数组件问题,需要进行逐级parent查找。

js 复制代码
function commitDeletion(fiber) {
  // 往上找实际存在dom的parent节点:对于函数组件Foo来说,要到App的div
  if (fiber.dom) {
    let fiberParent = fiber.parent;
    while (!fiberParent.dom) {
      fiberParent = fiberParent.parent
    }
    fiberParent.dom.removeChild(fiber.dom)
  } else {
    // 跳过函数组件本身,往下找实际存在dom的节点:对于函数组件Foo来说,是下面的div
    commitDeletion(fiber.child)
  }
}

新的比老的短,删除多余节点及其兄弟节点

如图所示,新的fiber树枝比老的短。这意味着,我们在删除老fiber.child 之外,还应该删除这个child的sibling们。

js 复制代码
let showBar = false
function Counter() {
  const foo = (
    <div>
      foo <div>child</div>
    </div>
  )
  const bar = <div>bar</div>
  function handleShowBar() {
    showBar = !showBar
    React.update()
  }
  return (
    <div>
      counter
      <div>{showBar ? bar : foo}</div>
      <button onClick={handleShowBar}>showBar</button>
    </div>
  )
}

当新的fiber节点遍历完其children之后,老的fiber节点只遍历完了text节点。在children循环之外,使用while循环在

js 复制代码
function reconcileChildren(fiber, children) { 
    children.forEach((child, index) => {
        ...
    }
    while (oldFiber) { 
        deletions.push(oldFiber) 
        oldFiber = oldFiber.sibling 
    }

条件渲染

js 复制代码
let showBar = false
function Counter() {
  const bar = <div>bar</div>
  function handleShowBar() {
    showBar = !showBar
    React.update()
  }
  return (
    <div>
      counter
      <div>{showBar && bar}</div>
      <button onClick={handleShowBar}>showBar</button>
    </div>
  )
}

可以用下面两种处理方式之一:

  • 处理vdom,过滤掉false的child
  • 处理fiber节点,跳过false的child

在vdom中过滤

这种方法比较直观

js 复制代码
function createElement(type, props, ...children) {
  return {
    type,
    props: {
      ...props,
      children: children.map((child) => {
        if (child) {
          const isTextNode = typeof child === "string" || typeof child === "number"
          return isTextNode ? createTextNode(child) : child
        }
      }).filter(child => child),
    },
  };
}

在fiber中过滤

这种方法有助于我们更多地学习如何操作fiber树。

  1. 条件渲染的组件作为唯一的或者最后的child - 直接跳过 newFiber 的创建

打印一下child:

js 复制代码
function reconcileChildren(fiber, children) {  
    ...
    if (child) {
        //只有当child 不是false或者空的时候创建
        newFiber = {
          type: child.type,
          props: child.props,
          child: null,
          parent: fiber,
          sibling: null,
          dom: null,
          effectTag: "placement",  // 标记一下,这个fiber节点是新建的
        };
      }
  1. 条件渲染的组件作为中间的child - 需要建立前后节点的关系 - newFiber
js 复制代码
  return (
    <div>
      counter
      {showBar && bar}
      <button onClick={handleShowBar}>showBar</button>
    </div>
  )

图中的红色的div的prevChild等于中间childfalse, 但是这个child并没有对应的fiber节点。

这就要求我们在链接节点的时候也要跳过false child ------ 没有newFiber的情况下,不将其作为fiber链表的一部分。

js 复制代码
    if (newFiber) {
      prevChild = newFiber;
    }

优化更新

js 复制代码
import React from "./core/React.js"

let countFoo1 = 1
function Foo() {
  console.log("Foo return ")
  function handleClick() {
    countFoo1++
    React.update()
  }
  return (
    <div>
      <h1>Foo : {countFoo1}</h1>
      <button onClick={handleClick}>click</button>
    </div>
  )
}
let countBar = 1
function Bar() {
  console.log("Bar return ")
  function handleClick() {
    countBar++
    React.update()
  }
  return (
    <div>
      <h1>Bar : {countBar}</h1>
      <button onClick={handleClick}>click</button>
    </div>
  )
}
let countApp = 1
function App() {
  console.log("App return ")
  function handleClick() {
    countApp++
    React.update()
  }
  return (
    <div>
      <h1>App : {countApp}</h1>
      <button onClick={handleClick}>click</button>
      <Foo></Foo>
      <Bar></Bar>
    </div>
  )
}

export default App

点击Foo或者Bar都会发现所有的函数组件都执行了一遍。

没更新的组件重新执行渲染,造成了浪费。 应当只重新渲染需要更新的节点。

【改进】

  1. 找出要更新的组件

    • 引入全局变量wipFiber let wipFiber = null;
    • 在updateFunctionComponent(fiber) 函数中对其赋值 wipFiber = fiber
  2. 开始渲染这个组件:以当前的组件节点作为wipRoot

    • 将原来的update做法 ------以App组件为root推送给workloop------ 改为:以wipFiber 记录的需要更新的组件推送给workloop

      • 第一次渲染运行过后,每一个函数组件都通过闭包的形式,在update函数中拥有自己的currentFiber。在点击按钮执行update函数的时候,wipRoot就是本身的fiber节点,其alternate也是。
      js 复制代码
      function update() {
        // 函数组件在运行此HOC的时候顺便记录当前fiber节点wipFiber
        let currentFiber = wipFiber;
        return () => {
          wipRoot = {
            ...currentFiber,
            alternate: currentFiber,
          };
          nextWorkOfUnit = wipRoot;
        }
      }

      【易错点】

      如果将alternate 错误地设置为wipFiber,将会得到错误的行为:第一轮渲染完成后,wipFiber 等于最后渲染的函数组件Bar。此时当我们点击Foo中的按钮,它触发运行update函数,dom和props都是之前记录的Foo的currentFiber.dom 和 props。然而alternate却错误地设置为了Bar fiber。这会导致diff认为这是不同的函数组件,进而保留原来的Foo和新增点击后的Foo。并且还会删除被当作旧版本Foo的Bar节点.

  3. 结束渲染这个组件:链表走到更新组件节点的兄弟节点

    • workloop开始重新渲染该函数组件,直至运行至它的兄弟节点:nextWorkofUnit.type === wipFiber.type
    js 复制代码
    function workLoop(deadline) {
      let shouldYield = false;
      while (!shouldYield && nextWorkOfUnit) {
        nextWorkOfUnit = performWorkOfUnit(nextWorkOfUnit);
    
        // 如果wipRoot(要更新的函数组件)的兄弟就是下一个work
        //那就代表当前函数组件已经渲染完成了
        if (wipRoot?.sibling?.type === nextWorkOfUnit?.type) {
          nextWorkOfUnit = undefined;
        }
    
        shouldYield = deadline.timeRemaining() < 1;
      }
      if (!nextWorkOfUnit && wipRoot) {
        commitRoot()
      }
    
      requestIdleCallback(workLoop);
    }

胜利结算

  • 电击Foo, Bar 组件中的按钮,发现只有他们自己各自重新渲染了。只有当点击App中的按钮,才会重新渲染所有组件。
  • 总的来说,这里实现的diff算法并不是去手动计算哪个节点更新了。而是在函数节点中埋伏一个更新函数,当用户发起动作时,调用它。diff主要是用于判断是更新属性,还是删旧建新,以及处理多余的树枝。
相关推荐
张开心_kx4 小时前
面试官又问我受控组件和非受控组件?
前端·javascript·react.js
cauyyl4 小时前
react nativeWebView跨页面通信
javascript·react native·react.js
APItesterCris4 小时前
跨平台数据采集方案:淘宝 API 对接 React Native 实现移动端实时监控
javascript·react native·react.js
YH丶浩4 小时前
React 实现爱心花园动画
前端·react.js·前端框架
-白 泽-6 小时前
2个小时1.5w字| React & Golang 全栈微服务实战
react.js·微服务·golang
前端付豪20 小时前
🚀 2025 年 React 全攻略:40 个高频问题深度解析与实战指南
前端·react.js
洋3321 小时前
[纯原创无Ai] 我把React调教成vue的模样了
vue.js·react.js
Aiolimp1 天前
React JSX 基本用法
前端·react.js
高峰君主1 天前
跨端时代的全栈新范式:React Server Components深度集成指南
前端·react.js·前端框架
萌萌哒草头将军1 天前
🚀🚀🚀 神了!RedwoodJS 轻松碾压 NextJS,成了我的最爱❤️
前端·react.js·全栈