04|实现React的节点删除与更新

304 阅读2分钟

代码仓库👉mini-react,本节代码在 v0.0.4 分支

在上一节我们实现了useRedueruseState来更新数据,但是当我们需要根据数据来决定应该渲染怎样的dom内容时却发现出现了一些问题。

如下代码。当count % 2时使用<div>学习React</div> 否则使用<span>学习算法</span>

function FunctionComponent(props) {

  const [count, setCount] = useState(0)

  return (
    <div className='function'>
      <div>
        <button onClick={() => setCount(count + 1)}>count值 + 1</button>
      </div>
      <div>{count}</div>
      {count % 2 ? <div>学习React</div> : <span>学习算法</span>}
    </div>
  )
}

渲染到页面上,点击按钮会发现原本需要删除的节点没有被删除掉,反而不停的增加新的节点。我们处理一下在reconcileChildren中不能复用的情况——也就是将不能复用的节点删除。

当然我们这里并不是真正的删除,只是将需要删除的节点收集到 父fiber 的 deletions 数组中

// src/ReactFiberReconciler.ts

// 将需要删除的 fiber 添加到 父fiber 的 deletions 数组中
function deleteChild(returnFiber: Fiber, childToDelete: Fiber) {
    if (returnFiber.deletions) {
        returnFiber.deletions.push(childToDelete)
    } else {
        returnFiber.deletions = [childToDelete]
    }
}

function reconcileChildren(workInProgress: Fiber, children) {
    if (isStringOrNumber(children)) {

            return
    }
    const newChildren: any[] = isArray(children) ? children : [children]

    let oldFiber = workInProgress.alternate?.child

    let previousNewFiber: Fiber | null = null
    for (let i = 0; i < newChildren.length; i++) {
        const newChild = newChildren[i]
        if (newChild === null) {
            continue
        }
        const newFiber = createFiber(newChild, workInProgress)
        // 能否复用
        const same = sameNode(newFiber, oldFiber)

        if (same) {
            // 能复用
            Object.assign(newFiber, {
                stateNode: (oldFiber as Fiber).stateNode,
                alternate: oldFiber as Fiber,
                flags: Update
            })
        }

        // 不能复用的老节点需要删除
        if (!same && oldFiber) {
            deleteChild(workInProgress, oldFiber)
        }

        if (oldFiber) {
            oldFiber = oldFiber.sibling
        }

        if (previousNewFiber === null) {
            workInProgress.child = newFiber
        } else {
            previousNewFiber.sibling = newFiber
        }
        previousNewFiber = newFiber
    }
}

收集到returnFiber.delations属性中以后,我们需要在commit时操作真正的DOM,将这些节点删除掉。

// src/ReactWorkLoop.ts
function commitWorker(workInProgress: Fiber | null) {
    if (!workInProgress) return

    let parentNode = getParentNode(workInProgress.return)

    const { flags, stateNode } = workInProgress
    if (flags & Placement && stateNode) {
        parentNode.appendChild(stateNode)
    }

    if (flags & Update && stateNode) {
        updateNode(
            stateNode,
            (workInProgress.alternate as Fiber).props,
            workInProgress.props
        )
    }

// 如果有 deletions 属性,说明有节点需要被删除掉
    if (workInProgress.deletions) {
        commitDeletions(workInProgress.deletions, stateNode || parentNode)
    }

    commitWorker(workInProgress.child)
    commitWorker(workInProgress.sibling)
}



function commitDeletions(deletions: , parentNode: HTMLElement) {
    for(let i = 0; i < deletions.length; i++) {
        parentNode.removeChild(getStateNode(deletions[i]))
    }
}

// function组件 或 fragment组件需要往下找原生组件
function getStateNode(fiber: Fiber) {
    let temp: Fiber | null = fiber

    while(!temp?.stateNode) {
        temp = temp?.child || null
    }
    return temp.stateNode
}

删除多个节点

上面的删除单个节点还行,但是如果将代码改成如下,count默认为4,每次点击-2这样也就意味着每次点击(不算复原的setCount(4))都将删除最后两个节点(多节点删除)。

function FunctionComponent(props) {
  const [count, setCount] = useState(4)

	const handler = () => {
    if (count === 0) {
      setCount(4)
    } else {
      setCount(count - 2)
    }
  }

  return (
    <div className='function'>
      <div>
        <button onClick={handler}>{count}</button>
        <ul>
          {
            [0, 1, 2, 3, 4].map(item => count >= item ? <li key={item}>{item}</li> : null)
          }
        </ul>
      </div>
    </div>
  )
}

在浏览器中跑发现并没有如我们的预期那样删除。接下来我们实现多节点删除。

将原本的for (let i = 0; i < newChildren.length newIndex++) { /.../ } 中的i提升到reconcileChildren中,变量名更改为newIndex。 遍历完以后,比较newIndex是否与newChildren.length相等,如果相等则说明新的节点已经全部复用完,可能后面还有兄弟节点们需要删除,那么只需要将剩余的节点删除即可。

// 删除多个节点
function deleteRemainingChildren(returnFiber: Fiber, currentFirstChild: Fiber | null | undefined) {
    let childToDelete = currentFirstChild

    while(childToDelete) { // 遍历链表
        deleteChild(returnFiber, childToDelete)
        childToDelete = childToDelete.sibling
    }
}

function reconcileChildren(returnFiber: Fiber, children) {
    if (isStringOrNumber(children)) {

            return
    }
    const newChildren: any[] = isArray(children) ? children : [children]

    let oldFiber = returnFiber.alternate?.child
    let previousNewFiber: Fiber | null = null
    let newIndex = 0
    for (; newIndex  < newChildren.length; newIndex++) {
        const newChild = newChildren[newIndex]
        if (newChild === null) {
            continue
        }
        const newFiber = createFiber(newChild, returnFiber)
        const same = sameNode(newFiber, oldFiber)

        if (same) {
            Object.assign(newFiber, {
                stateNode: (oldFiber as Fiber).stateNode,
                alternate: oldFiber as Fiber,
                flags: Update
            })
        }

        if (!same && oldFiber) {
            deleteChild(returnFiber, oldFiber)
        }

        if (oldFiber) {
            oldFiber = oldFiber.sibling
        }

        if (previousNewFiber === null) {
            returnFiber.child = newFiber
        } else {
            previousNewFiber.sibling = newFiber
        }
        previousNewFiber = newFiber
    }

    if (newIndex === newChildren.length) {
        // 已经到了新 child 的尽头,可以删除其余的节点了
        deleteRemainingChildren(returnFiber, oldFiber)
        return
    }
}

我们再回到浏览器中进行操作,就会发现展示结果与我们的预期一致了。实际上在 react 中实现节点的删除与更新比这复杂得多,我们这里的节点复用只有前面的节点对比相等才可以复用,但是实际场景中,在多个节点中,可能发生位移,或者删除单个节点,那么此时我们实现的reconcileChidlren就不能做到节点复用了,这就造成了浪费。

下一节,我们一步一步的实现 react 中的 diff 算法。