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

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

并且添加新的。

在线调试

相关推荐
子兮曰4 天前
jev-ultrafast 深度解析:7 秒订机票的浏览器 Agent 是如何炼成的
前端·后端·agent
子兮曰4 天前
Jev 爆发一周:7 秒 Agent 背后的 System One 生态与三场争议
前端·后端·ai编程
前端小万4 天前
写公众号赚了 3000 块后,我做了一款叫 "一键成稿" 的软件
前端·微信小程序
爱勇宝4 天前
ZCode 开源 24 小时:一份没有历史的账本,回答不了"有没有偷代码"
前端·后端·chatglm (智谱)
三十而立洋4 天前
Cookie 详解:从产生到安全,一次讲透
前端·javascript
卡布鲁4 天前
把一个 Vite + Vue3 应用塞进 qiankun (React + Umi3) 主站:十个坑的复盘
前端·javascript·react.js
汉堡大王95274 天前
Jev:不是聊天机器人, 而是一个智能 if 语句
前端·人工智能·后端
梦想很大很大4 天前
从运行事实到回归证据:Workrun 的 Telemetry 与 Evaluation 实践
前端·人工智能·后端
计算机魔术师4 天前
Meta Muse agent 接入 Shopify 的 Shop Pay 实现代理式购物
前端
沙蒿同学4 天前
我用 Go 搭了一条 AI Agent 流水线:从 1 张商品图到一整套淘宝详情页
前端·javascript·后端