Reconciliation

到目前为止,我们只处理了向 DOM 中添加元素的情况,但是如何更新元素和删除元素呢?

需要解决这个问题,就需要在执行 render 构造 fiber tree 时,与上一次提交给 DOM 的 fiber tree 进行比较。因此,在完成 commit 阶段时,我们需要用一个变量来保存 最后一个提交给 DOM 的 fiber tree,如 currentRoot

此外,为每一个 fiber 节点添加属性 alternate,这个属性指向旧的 fiber 节点,即上一次提交给 DOM 时的 fiber。

function commitRoot() {
commitWork(wipRoot.child)
+ currentRoot = wipRoot
wipRoot = null
}
function commitWork(fiber) {
...
}
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
+ alternate: currentRoot,
}
nextUnitOfWork = wipRoot
}
+ let currentRoot = null

接下来,需要从 performUnitOfWork 中抽离出创建新的 fibers 的相关代码,也就是 第4节:Fibers 中 performUnitOfWork 函数的第二个功能。

function performUnitOfWork(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
const elements = fiber.props.children
reconcileChildren(fiber, elements)
if (fiber.child) {
return fiber.child
}
let nextFiber = fiber
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling
}
nextFiber = nextFiber.parent
}
}
function reconcileChildren(wipFiber, elements) {
// TODO:协调旧 fiber 与 新元素
}

同时迭代旧 fiber 的 chidren(wipFiber.alternate.child) 和 新 fiber 的 chidren(elements = fiber.wipFiber.children)。

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
if (oldFiber) {
oldFiber = oldFiber.sibling
}
}
}

如果我们忽略掉同时迭代链表和数组的模板的话,那么这个 while 中就只剩下最重要的两个内容:oldFiberelementelement 是我们想渲染到 DOM 上的内容,oldFiber 是我们上一次渲染到 DOM 上的内容。

我们需要比较 oldFiberelement,得出是否有变化需要更新到 DOM 上。

这里的比较规则是利用 type 进行比较的:

  • UPDATE:如果旧的 fiber 元素 和新元素具有相同的类型,则保留该元素,并进一步比较其属性 props
  • PLACEMENT:如果类型不同,并且有一个新元素,则需要创建一个新的 DOM 节点
  • DELETION:如果类型不同,并且有一个旧 fiber 元素,则移除旧的节点
const sameType = oldFiber && element && element.type == oldFiber.type
if (sameType) {
// TODO update the node
}
if (element && !sameType) {
// TODO add this node
}
if (oldFiber && !sameType) {
// TODO delete the oldFiber's node
}

若比较结果是更新(UPDATE)时,创建的新的 fiber 节点需要保留旧 fiber 的 DOM 节点类型和属性。此外,需要在 fiber 上添加一个新的属性 effectTag,该属性用于标记 fiber 节点的变更类型,在后面的 commit 阶段会用到该属性。

if (sameType) {
newFiber = {
type: oldFiber.type,
props: element.props,
dom: oldFiber.dom,
parent: wipFiber,
alternate: oldFiber,
effectTag: "UPDATE",
}
}

若比较结果是新增(PLACEMENT)时,则根据 element 创建新的 fiber 节点,并将 effectTag 标记为 PLACEMENT

if (element && !sameType) {
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: wipFiber,
alternate: null,
effectTag: "PLACEMENT",
}
}

若比较结果是删除(DELETION)时,因为没有新的 fiber 节点,所以我们将旧 fiber 的 effectTag 标记为 PLACEMENT

if (oldFiber && !sameType) {
oldFiber.effectTag = "DELETION"
deletions.push(oldFiber)
}

所以我们还需要一个数组 deletions 来跟踪我们要删除的节点。

function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
alternate: currentRoot,
}
+ deletions = []
nextUnitOfWork = wipRoot
}
let nextUnitOfWork = null
let currentRoot = null
let wipRoot = null
+let deletions = null

此外,在 commit 阶段时,也需要使用该数组中 fiber

function commitRoot() {
deletions.forEach(commitWork)
commitWork(wipRoot.child)
currentRoot = wipRoot
wipRoot = null
}

接下来,修改 commitWork 函数根据 effectTag 做出相应的处理

+function updateDom(dom, prevProps, nextProps) {
+ // TODO
+}
+
function commitWork(fiber) {
if (!fiber) {
return
}
const domParent = fiber.parent.dom
- domParent.appendChild(fiber.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)
+ }
commitWork(fiber.child)
commitWork(fiber.sibling)
}

最后,实现 updateDom 函数,在 updateDom 中需要比较新旧 fiber 的相关属性,删除已经消失的属性,并设置新的或者已经更改的属性。需要注意的是,对于事件属性,我们要做特殊处理,此处的是否为事件属性的判断条件是属性名称是否以 “on” 开头

const isEvent = key => key.startsWith("on")
const isProperty = key => key !== "children" && !isEvent(key)
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 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])
})
}

至此,react 的主要流程已经全部完成,点击查看完整代码,后面两节将是进一步增强,使其支持函数式组件和 hooks。

附:完整代码

function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map(child =>
typeof child === "object"
? child
: createTextElement(child)
),
},
}
}
function createTextElement(text) {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: [],
},
}
}
function createDom(fiber) {
const dom =
fiber.type == "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(fiber.type)
updateDom(dom, {}, fiber.props)
return dom
}
const isEvent = key => key.startsWith("on")
const isProperty = key =>
key !== "children" && !isEvent(key)
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 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]
)
})
}
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)
}
commitWork(fiber.child)
commitWork(fiber.sibling)
}
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
alternate: currentRoot,
}
deletions = []
nextUnitOfWork = wipRoot
}
let nextUnitOfWork = null
let currentRoot = null
let wipRoot = null
let deletions = null
function workLoop(deadline) {
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(
nextUnitOfWork
)
shouldYield = deadline.timeRemaining() < 1
}
if (!nextUnitOfWork && wipRoot) {
commitRoot()
}
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)
function performUnitOfWork(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
const elements = fiber.props.children
reconcileChildren(fiber, elements)
if (fiber.child) {
return fiber.child
}
let nextFiber = fiber
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling
}
nextFiber = nextFiber.parent
}
}
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
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) {
wipFiber.child = newFiber
} else if (element) {
prevSibling.sibling = newFiber
}
prevSibling = newFiber
index++
}
}
const Didact = {
createElement,
render,
}
/** @jsx Didact.createElement */
const container = document.getElementById("root")
const updateValue = e => {
rerender(e.target.value)
}
const rerender = value => {
const element = (
<div>
<input onInput={updateValue} value={value} />
<h2>Hello {value}</h2>
</div>
)
Didact.render(element, container)
}
rerender("World")