自己造React系列——为什么在列表组件中需要指定唯一Key?

开始

前面我们只完成了 添加 东西到 DOM 上这个操作,那么更新和删除 node 节点呢?

我们还需要比较 render 中新接收的 element 生成的 fiber 树和上次提交到 DOM 的 fiber 树。

这里需要保存"上次提交到 DOM 节点的 fiber 树" 的"引用"(reference)。我们称之为 currentRoot

js 复制代码
function commitRoot() {
    deletions.forEach(commitWork)
    commitWork(wipRoot.child)
    currentRoot = wipRoot
    wipRoot = null

}

function commitWork(fiber) {

    if (!fiber) {
        return
    }
    const domParent = fiber.parent.dom
    if (
        fiber.effectTag === "PLACEMENT" &&
        fiber.dom != null
    ) {
        domParent.appendChild(fiber.dom)
    } else if (fiber.effectTag === "UPDATE" && fiber.dom != null) {

        updateDom(
            fiber.dom,
            fiber.alternate.props,
            fiber.props
        )
    }else if (fiber.effectTag === "DELETION") {
        domParent.removeChild(fiber.dom)
    }
    
    domParent.appendChild(fiber.dom)
    commitWork(fiber.child)
    commitWork(fiber.sibling)
    
}

在每一个 fiber 节点上添加 alternate 属性用于记录旧 fiber 节点(上一个 commit 阶段使用的 fiber 节点)的引用。

js 复制代码
function render(element, container) {

    wipRoot = {
        dom: container,
        props: {
            children: [element],
        },
        // 记录上一次的fiber 节点
        alternate: currentRoot,
    }
    deletions = []
    nextUnitOfWork = wipRoot
}

let nextUnitOfWork = null
let currentRoot = null
let wipRoot = null
let deletions = null

requestIdleCallback(workLoop)

function reconcileChildren(wipFiber, elements) {

    let index = 0
    let oldFiber = wipFiber.alternate && wipFiber.alternate.child
    let prevSibling = null

     while (
        index < elements.length ||
        oldFiber != null
     ) {
        const element = elements[index]
        let newFiber = null

        // TODO compare oldFiber to element
        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)
        }
        if (oldFiber) {
            oldFiber = oldFiber.sibling
        }

        if (index === 0) {
            fiber.child = newFiber
        } else {
            prevSibling.sibling = newFiber
        }
        
        prevSibling = newFiber
        index++

    }

    if (fiber.child) {
        return fiber.child
    }

    let nextFiber = fiber
    while (nextFiber) {
        if (nextFiber.sibling) {
            return nextFiber.sibling
        }
        nextFiber = nextFiber.parent
    }
}

performUnitOfWork 中创建新 fiber 节点的代码抽出来

扔到 reconcileChildren 函数中。

js 复制代码
function reconcileChildren(wipFiber, elements) {

    let index = 0
    let prevSibling = null

    while (index < elements.length) {
        const element = elements[index]
        const newFiber = {
            type: element.type,
            props: element.props,
            parent: wipFiber,
            dom: null,
        }

        if (index === 0) {
            wipFiber.child = newFiber
        } else {
            prevSibling.sibling = newFiber
        }
        prevSibling = newFiber
        index++
    }
}

这个函数会调和(reconcile)旧的 fiber 节点 和新的 react elements。

在迭代整个 react elements 数组的同时我们也会迭代旧的 fiber 节点(wipFiber.alternate)。

如果我们忽略掉同时迭代数组和对应的link中的一些标准模板,我们就剩下两个最重要的东西: oldFiberelement. element 是我们想要渲染到 DOM 上的东西,oldFiber 是我们上次渲染 fiber 树.

我们需要比较这两者之间的差异,看看需要在 DOM 上应用哪些改变。

以下是比较的步骤:

  • 对于新旧节点类型是相同的情况,我们可以复用旧的 DOM,仅修改上面的属性
  • 如果类型不同,意味着我们需要创建一个新的 DOM 节点
  • 如果类型不同,并且旧节点存在的话,需要把旧节点的 DOM 给移除

React中使用Key来优化reconciliation 过程

React使用 key 这个属性来优化 reconciliation 过程。比如, key 属性可以用来检测 elements 数组中的子组件是否仅仅是更换了位置。

当新的 element 和旧的 fiber 类型相同, 我们对 element 创建新的 fiber 节点,并且复用旧的 DOM 节点,但是使用 element 上的 props。

我们需要在生成的fiber上添加新的属性:effectTag。在 commit 阶段(commit phase)会用到它。

对于需要生成新 DOM 节点的 fiber,我们需要标记其为 PLACEMENT

对于需要删除的节点,我们并不会去生成 fiber,因此我们在旧的fiber上添加标记。

但是当我们提交(commit)整颗 fiber 树(wipRoot)的变更到 DOM 上的时候,并不会遍历旧 fiber。

因此我们需要一个数组去保存要移除的 dom 节点。

之后我们提交变更到 DOM 上的时候,也需要把这个数组中的 fiber 的变更(其实是移除 DOM)给提交上去。

现在,我们对 commitWork 函数略作修改来处理我们新添加的 effectTags

如果 fiber 节点有我们之前打上的 PLACEMENT 标,那么在其父 fiber 节点的 DOM 节点上添加该 fiber 的 DOM。(有点拗口)

相反地,如果是 DELETION 标记,我们移除该子节点。

如果是 UPDATE 标记,我们需要更新已经存在的旧 DOM 节点的属性值。

我们把上述操作封装在 updateDom 函数中。

js 复制代码
function updateDom(dom, prevProps, nextProps) {

// TODO

}

比较新老 fiber 节点的属性, 移除、新增或修改对应属性。

js 复制代码
const isProperty = key => key !== "children"
const isNew = (prev, next) => key => prev[key] !== next[key]
const isGone = (prev, next) => key => !(key in next)
    
function updateDom(dom, prevProps, nextProps) {

    //Remove old or changed event listeners
    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]
        )
    })

    //Remove old or changed event listeners
    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])

    })

    // Remove old properties
    Object.keys(prevProps)
    .filter(isProperty)
    .filter(isGone(prevProps, nextProps))
    .forEach(name => {
        dom[name] = ""
    })

     // Set new or changed properties
     Object.keys(nextProps)
    .filter(isProperty)
    .filter(isNew(prevProps, nextProps))
    .forEach(name => {
        dom[name] = nextProps[name]
    })
    
    // Add event listeners
    Object.keys(nextProps)
    .filter(isEvent)
    .filter(isNew(prevProps, nextProps))
    .forEach(name => {
        const eventType = name
            .toLowerCase()
            .substring(2)
        dom.addEventListener(
            eventType,
            nextProps[name]
        )
    })
}

比较特殊的属性值是事件监听,如果属性值以 "on" 作为前缀,我们需要以不同的方式来处理这个属性。

js 复制代码
    const isEvent = key => key.startsWith("on")
    const isProperty = key => key !== "children" && !isEvent(key)

对应的监听事件如果改变了我们需要移除旧的。

并且添加新的。

在线调试

相关推荐
2601_958492552 小时前
Optimizing Engagement with Freehead Skate - HTML5 Game - Construct 3
前端·html·html5
茉莉玫瑰花茶3 小时前
工作流的常见模式 [ 1 ]
java·服务器·前端
zhangxingchao3 小时前
AI应用开发六:企业知识库
前端·人工智能·后端
山峰哥4 小时前
SQL慢查询调优实战:从全表扫描到索引覆盖的完整复盘
前端·数据库·sql·性能优化
红尘散仙4 小时前
一个 `#[uniffi::export]`,把 Rust 接进 React Native
前端·后端·rust
moshuying4 小时前
AI Coding 最大的 token 黑洞,可能根本不是 prompt
前端
红尘散仙4 小时前
一行 `#[specta::specta]`,让 Tauri IPC 有类型
前端·后端·rust
lichenyang4535 小时前
HarmonyOS HMRouter 接入记录:从普通 Tab Demo 到路由跳转
前端
木斯佳5 小时前
前端八股文面经大全:腾讯WXG暑期前端一面(2026-05-15)·面经深度解析
前端·面试·笔试