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主要是用于判断是更新属性,还是删旧建新,以及处理多余的树枝。
相关推荐
qq_3325394514 分钟前
React 前端框架推荐
前端·react.js·前端框架
刺客-Andy3 小时前
React Vue 项开发中组件封装原则及注意事项
前端·vue.js·react.js
只会写Bug的程序员5 小时前
面试之《react服务器组件--RSC》
服务器·前端·react.js
资深上当者老范5 小时前
前端 UI 组件库模块化打包工具更新
前端·react.js·前端框架
一朵好运莲6 小时前
React Next项目中导入Echart世界航线图 并配置中文
javascript·react.js·ecmascript
鱼樱前端7 小时前
React经典常用动画库,让你一招变动画大佬!!!
前端·react.js
Tonychen8 小时前
【React 源码阅读】为什么 React Hooks 不能用条件语句来执行?
前端·react.js·源码阅读
关山月8 小时前
你在 Next.js 中用错 "use client" 了吗?
react.js
小成C9 小时前
为什么会演化出RSC,SSR和RSC关系大解密
前端·react.js