自己造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中的一些标准模板,我们就剩下两个最重要的东西: oldFiber 和 element. 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)

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

并且添加新的。

在线调试

相关推荐
小码哥_常6 小时前
一套自己部署的 IM,客户端是同一份 Flutter
前端·后端
蜗牛攻城狮8 小时前
微前端四大方案选型对比与最佳实践:qiankun、micro-app、wujie、Module Federation
前端·微前端·qiankun·前端架构·micro-app·wujie
她说可以呀9 小时前
Vue插槽
前端·javascript·vue.js
qq_448011169 小时前
Vue + Jinja2两套模版与法冲突造成undefinedError
前端·javascript·vue.js
aixingpan10 小时前
aixingpan.cn API开发文档:前端查询夏令时和时区接口指南
前端·php
2601_9583529010 小时前
23.5×19mm 换掉一整条音频链?|A-68 集成了什么:ADC / DAC / 24.576MHz 时钟 / AEC / 波束降噪,主控仅需 6 根线
前端·音视频·ai降噪·回声消除·语音模块
志尊宝11 小时前
AI原生前端开发(039):AI辅助的Nuxt 4全栈开发
前端·vue.js·ai-native
FRing0312 小时前
JavaScript入门到精通三十二(深拷贝、浅拷贝、递归函数)
开发语言·前端·javascript
excel12 小时前
双 Token 与前端使用 Crypto 公私钥鉴权换 Token,哪种方案更好?
前端·后端
外收内放12 小时前
Python基础语法练习题(73)
java·前端·python