书接上回,我们已经实现了createElement函数、render函数和Fibers,接下来就要开始处理组件更新的部分了~
5.Render和Commit阶段
但在这之前!不知道你有没有注意到一个问题:在performUnitOfWork中每次我们处理一个元素时都会在DOM 中添加一个新的节点。而且,浏览器还会在完成整棵树的渲染之前打断我们的工作。这样,用户将会看到一个不完整的UI,这是我们不希望的。
所以我们把修改DOM的这部分代码移除:
function performUnitOfWork(fiber) {
console.log("performUnitOfWork", fiber)
// TODO add dom node
if(!fiber.dom) {
fiber.dom = createDom(fiber)
}
// TODO create new fibers
const elements = fiber.props.children
let index = 0
let prevSibling = null
...
并且在render函数中去跟踪fiber树的根节点,我们叫它“正在进行的工作”root或者wipRoot。
当我们完成了所有任务(也就是任务中没有下一个单元的时候),我们就把整个fiber树提交到DOM中:
function commitRoot() {
// TODO add nodes to dom
}
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
}
nextUnitOfWork = wipRoot
}
let nextUnitOfWork = null
let wipRoot = null
function workLoop(deadline) {
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(
nextUnitOfWork
)
shouldYield = deadline.timeRemaining() < 1
}
if (!nextUnitOfWork && wipRoot) {
commitRoot()
}
requestIdleCallback(workLoop)
}
我们在commitRoot函数中完成提交。这里我们递归地将所有节点添加到DOM中:
function commitRoot() {
commitWork(wipRoot.child)
wipRoot = null
}
function commitWork(fiber) {
if (!fiber) {
return
}
const domParent = fiber.parent.dom
domParent.appendChild(fiber.dom)
commitWork(fiber.child)
commitWork(fiber.sibling)
}
(也就是不断执行requestIdleCallback(workLoop)生成fiber树,修改DOM的操作集中在commit阶段)
6.协调
带目前为止我们只是在DOM中添加东西,怎么去更新或者删除节点呢?
这就是所谓的协调(reconciliation)了,我们需要将在render函数上接收到的元素与提交给DOM的最后一个fiber树进行比较。
所以完成提交后,我们需要保存“上一个提交到DOM的fiber树”的引用。我们称它为currentRoot。
我们还要为每一个fiber添加一个alternate属性。这个属性是到到旧fiber的链接,也就是我们在上一次提交阶段中提交到DOM中的fiber。
function commitRoot() {
...
currentRoot = wipRoot
}
funciton render(element, container) {
wipRoot = {
...
alternate: currentRoot
}
}
let currentRoot = null
现在让我们把创建新fibers的代码从performUnitOfWork中提取出来,放在一个新的reconcileChildren函数中。在这里我们把旧fibers和新元素调和起来:
function performUnitOfWork(fiber) {
...
const elements = fiber.props.children
reconcileChildren(fiber, element)
...
}
function reconcileChildren(wipFiber, elements) {}
我们同时迭代旧fiber的孩子(wipFiber.alternate)和我们想调和的元素数组。
如果忽略同时遍历一个数组和链表的模板代码,在while循环中只剩下最重要的部分:oldFiber和element。element是我们想渲染在DOM中的东西,oldFiber则是我们上次的渲染的内容。
我们需要比较它们,来看看是不是有需要应用在DOM中的更改:
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
}
我们使用类型来比较它们:
- 如果旧fiber和新元素类型一样,我们可以保留DOM节点并且只更新的属性
- 如果类型不同,并且存在新元素,意味着我们需要创建一个新的DOM节点
- 如果类型不同,并且存在旧fiber,我们需要移除旧节点
在这里React还用了keys,来实现更高效的调和。比如,它检测子元素在元素数组中的位置何时改变。
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
}
当旧fiber和元素有相同的类型时,我们创建一个新fiber,使DOM节点和旧fiber保持一致,属性和元素保持一致。
我们还给fiber添加了一个新的属性:effectTag。接下来,我们将在提交阶段使用这个属性:
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",
}
}
对于需要一个新的DOM节点的元素,我们用PLACEMENT来标记新fiber:
if (element && !sameType) {
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: wipFiber,
alternate: null,
effectTag: "PLACEMENT",
}
}
对于需要删除节点的情况,我们不需要新fiber,只要在旧fiber上添加effect tag就好了:
if (oldFiber && !sameType) {
oldFiber.effectTag = "DELETION"
deletions.push(oldFiber)
}
但是,当我们把fiber树提交到DOM时,我们从“正在工作中的”根执行,它并没有旧fibers呀。
因此我们需要一个数组deletions来跟踪想要移除的节点。接着,当我们向DOM提交更改时,我们仍然使用deletions数组中的fibers:
function commitRoot() {
deletions.forEach(commitWork)
commitWork(wipRoot.child)
currentRoot = wipRoot
wipRoot = null
}
现在,让我们修改commitWork函数来处理新的effectTags。
function commitWork(fiber) {
if (!fiber) {
return
}
const domParent = fiber.parent.dom
domParent.appendChild(fiber.dom)
commitWork(fiber.child)
commitWork(fiber.sibling)
}
如果fiber的effect tag值是PLACEMENT,我们和之前做的一样,把DOM节点追加到父fiber的节点上;
如果是DELETION,则相反,我们要删除子节点;
如果是UPDATE,我们需要用更改后的属性更新现存的DOM节点:
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)
}
我们把更新的操作写在updateDom函数中:我们对比新旧fibers的属性,移除无用属性,设置新的或改变的属性:
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 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]
})
}
对于事件监听属性我们需要做特殊处理,也就是属性名以“on”前缀开头的属性:
const isEvent = key => key.startsWith("on")
const isProperty = key =>
key !== "children" && !isEvent(key)
如果事件处理程序发生了改变,我们把它从节点中移除:
//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]
)
})
然后添加一个新的处理程序:
// 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]
)
})
(其实就是初次渲染的时候要从头(根)到尾(叶)生成一棵fiber树,之后有更新只需要调整部分节点,保留不变的节点)
7.函数组件
接下来我们要做的是支持函数式组件。首先让我们把原来的例子修改成函数式组件,它返回一个h1元素:
/** @jsx Didact.createElement */
function App(props) {
return <h1>Hi {props.name}</h1>
}
const element = <App name="foo" />
const container = document.getElementById("root")
Didact.render(element, container)
如果把JSX语法转换成JS呢,应该是下面这样的:
function App(props) {
return Didact.createElement(
"h1",
null,
"Hi ",
props.name
)
}
const element = Didact.createElement(App, {
name: "foo",
})
函数式组件有两个不同点:
- 函数式组件的fiber(本身)没有DOM节点(本身是由其他由DOM节点的fiber组成的嘛)
- 而且组件的孩子来自于函数的执行而不是直接来自于
props
我们检查fiber type是不是一个函数,然后据此切换到一个不同的更新函数。
const isFunctionComponent =
fiber.type instanceof Function
if (isFunctionComponent) {
updateFunctionComponent(fiber)
} else {
updateHostComponent(fiber)
在updateHostComponent中我们做的事和之前一样:
function updateFunctionComponent(fiber) {
// TODO
}
function updateHostComponent(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
reconcileChildren(fiber, fiber.props.children)
}
而在updateFunctionComponent中我们运行函数来获得children。
在我们的例子中,这里的fiber.type是App函数,当我们运行它时会返回一个h1元素。
接着,一旦我们得到了children,调和函数也会以同样的方式执行,我们不需要对它做任何修改
function updateFunctionComponent(fiber) {
const children = [fiber.type(fiber.props)]
reconcileChildren(fiber, children)
}
我们需要修改的是commitWork函数,现在我们的到了一些没有DOM节点的fibers,我们要修改两件事。
首先,为了找到DOM节点的父亲,我们需要在fiber tree中向上查找,直到找到了拥有DOM节点的fiber:
let domParentFiber = fiber.parent
while (!domParentFiber.dom) {
domParentFiber = domParentFiber.parent
}
const domParent = domParentFiber.dom
if (
fiber.effectTag === "PLACEMENT" &&
fiber.dom != null
) {
domParent.appendChild(fiber.dom)
}
接着当删除节点时,我们也需要继续查找,直到找到一个具有DOM节点的子元素:
function commitDeletion(fiber, domParent) {
if (fiber.dom) {
domParent.removeChild(fiber.dom)
} else {
commitDeletion(fiber.child, domParent)
}
}
8.Hooks
最后一步了!现在我们实现了函数式组件,现在让我们加入状态。
让我们写一个经典的计数器例子。每次我们点击它,它的state就加一。注意我们用的是Diadact.useState来获取和更新counter值:
function Counter() {
const [state, setState] = Didact.useState(1)
return (
<h1 onClick={() => setState(c => c + 1)}>
Count: {state}
</h1>
)
}
const element = <Counter />
updateFunctionComponent是我们调用示例中Counter函数的地方,在函数内部我们还调用了useState:
function updateFunctionComponent(fiber) {
const children = [fiber.type(fiber.props)]
reconcileChildren(fiber, children)
}
function useState(initial) {
// TODO
}
在调用函数组件之前,我们需要初始化一些全局变量,供我们在useState函数中使用。
首先,我们定义“正在工作的”fiber。
我们还在fiber中添加了hooks数组,来实现在一个组件中多次调用useState。同时我们跟踪当前的hook下标:
let wipFiber = null
let hookIndex = null
function updateFunctionComponent(fiber) {
wipFiber = fiber
hookIndex = 0
wipFiber.hooks = []
const children = [fiber.type(fiber.props)]
reconcileChildren(fiber, children)
}
当函数式组件调用useState时,我们检查是否有一个旧hook。我们用hook下标在fiber的alternate属性中检查。
如果我们有一个旧hook,就把旧hook中的state复制到新hook中,没有的话呢就初始化state。
接着我们把新hook添加到fiber,把hook下标加一,并且返回state:
function useState(initial) {
const oldHook =
wipFiber.alternate &&
wipFiber.alternate.hooks &&
wipFiber.alternate.hooks[hookIndex]
const hook = {
state: oldHook ? oldHook.state : initial,
}
wipFiber.hooks.push(hook)
hookIndex++
return [hook.state]
}
useState还应该返回一个更新state的函数,所以我们定义一个setState函数来接收一个动作(在Counter例子中这个动作是把state加一的函数)。
我们把这个动作推入一个队列,这个队列是添加在hook上的。
接下来我们要做的事和之前在render函数中的类似,设置一个新的“工作中的”root作为任务的下一个单元,使workLoop能开启一个新的渲染阶段:
const hook = {
state: oldHook ? oldHook.state : initial,
queue: [],
}
const setState = action => {
hook.queue.push(action)
wipRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
}
nextUnitOfWork = wipRoot
deletions = []
}
•
wipFiber.hooks.push(hook)
hookIndex++
return [hook.state, setState]
}
我们还没执行动作呢!
我们在下次渲染组件的时候再执行,我们从旧hook队列中拿到所有动作,再把他们一个个应用在新hook state上,所以当我们返回state的时候它已经更新过了:
const actions = oldHook ? oldHook.queue : []
actions.forEach(action => {
hook.state = action(hook.state)
})
大功告成!我们写了一个自己的React😀