自己造React系列——为什么在列表组件中需要指定唯一Key
开始
前面我们只完成了 添加 东西到 DOM 上这个操作,那么更新和删除 node 节点呢?
我们还需要比较 render
中新接收的 element 生成的 fiber 树和上次提交到 DOM 的 fiber 树。
这里需要保存”上次提交到 DOM 节点的 fiber 树” 的”引用”(reference)。我们称之为 currentRoot
。
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 节点)的引用。
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
函数中。
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
函数中。
function updateDom(dom, prevProps, nextProps) {
// TODO
}
比较新老 fiber 节点的属性, 移除、新增或修改对应属性。
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” 作为前缀,我们需要以不同的方式来处理这个属性。
const isEvent = key => key.startsWith("on")
const isProperty = key => key !== "children" && !isEvent(key)
对应的监听事件如果改变了我们需要移除旧的。
并且添加新的。