自己造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)

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

并且添加新的。

在线调试

相关推荐
mayaairi1 小时前
Vue2 事件绑定完全指南:从入门到原理
前端·javascript·vue.js
ℋᙚᵐⁱᒻᵉ鲸落7 小时前
移动端滑动手势冲突:overflow: auto导致外层横向滑动失效
前端·javascript·css·vue.js·html
默_笙8 小时前
🎃 前端学了 Next.js,后端该学啥?NestJS 就是 Node 版的蜜雪冰城
前端·javascript
前端snow9 小时前
ai agent --- 实现 openclaw 定时间效果
前端
码云之上10 小时前
换模型之后,Chatbot 为什么要自己做 compact?
前端·agent·前端工程化
星栈10 小时前
自定义 UI-UX-Pro-Max 的 CSV 知识库,把 AI 生成的后台拉回行业该有的样子
前端·agent·weui
研☆香10 小时前
简单的图片上传 删除 预览
前端
ITresearchGuest10 小时前
我用 AI 一小时写了一个世界杯数据可视化平台|前端 VibeCoding 初体验
前端·人工智能·信息可视化
慧一居士12 小时前
Vite项目中使用Less步骤,详细使用示例
前端·css
花间相见12 小时前
【LangChain组件03】—— LangChain Agents 执行与状态:工作流程与状态管理实战
java·前端·langchain