在上一天的课程中,对于props触发的更新,我们仅仅处理了节点属性变化和新增了节点的逻辑。对于需要删除的老节点没有做处理。实际上,还有几种情况需要处理。在今天的课程中,我们需要完成这些处理,以及对组件更新进行优化:不要每次都从根节点开始重新渲染。
新的和老的节点type不同,删除老节点
在App.jsx
中定义条件渲染的div和p,点击按钮从div改为p节点
js
import React from "./core/React.js"
let showBar = false
function Counter() {
const foo = <div>foo</div>
const bar = <p>bar</p>
function handleShowBar() {
showBar = !showBar
React.update()
}
return (
<div>
counter
<div>{showBar ? bar : foo}</div>
<button onClick={handleShowBar}>showBar</button>
</div>
)
}
function App() {
return (
<div>
mini-react
<Counter></Counter>
</div>
)
}
export default App
点击之后发现并未删除老的dom,而是一直增加。我们需要在reconcileChildren
函数中添加删除老节点的操作。
【做法】
第一反应我们可以想到,将老的节点做个effectTag = "delete"。但是这样的话我们又得再遍历一遍fiber数才能完成delete操作。不如直接建立一个数组,存储所有需要删除的fiber节点,然后批量删除。
js
function reconcileChildren(fiber, children) { // reconcile 包含了init和update
...
children.forEach((child, index) => {
const isSameType = child && oldFiber && child.type === oldFiber.type;
if (isSameType) {
// update
...
} else {
//create
...
if (oldFiber) {
deletions.push(oldFiber);
}
...
}
}
let deletions = [] // 需要删除的节点集合
function commitRoot() {
deletions.forEach(commitDeletion)
commitWork(wipRoot.child);
currentRoot = wipRoot; // 保存当前的fiber树, 给下次用来做diff
wipRoot = null;
deletions = [];
}
function commitDeletion(fiber) {
fiber?.parent.dom.removeChild(fiber.dom)
}
【支持函数组件】 commitDeletion
中的fiber.parent
也会遇到和之前类似的函数组件问题,需要进行逐级parent查找。
js
function commitDeletion(fiber) {
// 往上找实际存在dom的parent节点:对于函数组件Foo来说,要到App的div
if (fiber.dom) {
let fiberParent = fiber.parent;
while (!fiberParent.dom) {
fiberParent = fiberParent.parent
}
fiberParent.dom.removeChild(fiber.dom)
} else {
// 跳过函数组件本身,往下找实际存在dom的节点:对于函数组件Foo来说,是下面的div
commitDeletion(fiber.child)
}
}
新的比老的短,删除多余节点及其兄弟节点
如图所示,新的fiber树枝比老的短。这意味着,我们在删除老fiber.child 之外,还应该删除这个child的sibling们。
js
let showBar = false
function Counter() {
const foo = (
<div>
foo <div>child</div>
</div>
)
const bar = <div>bar</div>
function handleShowBar() {
showBar = !showBar
React.update()
}
return (
<div>
counter
<div>{showBar ? bar : foo}</div>
<button onClick={handleShowBar}>showBar</button>
</div>
)
}
当新的fiber节点遍历完其children之后,老的fiber节点只遍历完了text节点。在children循环之外,使用while循环在
js
function reconcileChildren(fiber, children) {
children.forEach((child, index) => {
...
}
while (oldFiber) {
deletions.push(oldFiber)
oldFiber = oldFiber.sibling
}
条件渲染
js
let showBar = false
function Counter() {
const bar = <div>bar</div>
function handleShowBar() {
showBar = !showBar
React.update()
}
return (
<div>
counter
<div>{showBar && bar}</div>
<button onClick={handleShowBar}>showBar</button>
</div>
)
}
可以用下面两种处理方式之一:
- 处理vdom,过滤掉false的child
- 处理fiber节点,跳过false的child
在vdom中过滤
这种方法比较直观
js
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map((child) => {
if (child) {
const isTextNode = typeof child === "string" || typeof child === "number"
return isTextNode ? createTextNode(child) : child
}
}).filter(child => child),
},
};
}
在fiber中过滤
这种方法有助于我们更多地学习如何操作fiber树。
- 条件渲染的组件作为唯一的或者最后的child - 直接跳过 newFiber 的创建
打印一下child:
js
function reconcileChildren(fiber, children) {
...
if (child) {
//只有当child 不是false或者空的时候创建
newFiber = {
type: child.type,
props: child.props,
child: null,
parent: fiber,
sibling: null,
dom: null,
effectTag: "placement", // 标记一下,这个fiber节点是新建的
};
}
- 条件渲染的组件作为中间的child - 需要建立前后节点的关系 - newFiber
js
return (
<div>
counter
{showBar && bar}
<button onClick={handleShowBar}>showBar</button>
</div>
)
图中的红色的div的prevChild等于中间childfalse
, 但是这个child并没有对应的fiber节点。
这就要求我们在链接节点的时候也要跳过false child ------ 没有newFiber的情况下,不将其作为fiber链表的一部分。
js
if (newFiber) {
prevChild = newFiber;
}
优化更新
js
import React from "./core/React.js"
let countFoo1 = 1
function Foo() {
console.log("Foo return ")
function handleClick() {
countFoo1++
React.update()
}
return (
<div>
<h1>Foo : {countFoo1}</h1>
<button onClick={handleClick}>click</button>
</div>
)
}
let countBar = 1
function Bar() {
console.log("Bar return ")
function handleClick() {
countBar++
React.update()
}
return (
<div>
<h1>Bar : {countBar}</h1>
<button onClick={handleClick}>click</button>
</div>
)
}
let countApp = 1
function App() {
console.log("App return ")
function handleClick() {
countApp++
React.update()
}
return (
<div>
<h1>App : {countApp}</h1>
<button onClick={handleClick}>click</button>
<Foo></Foo>
<Bar></Bar>
</div>
)
}
export default App
点击Foo或者Bar都会发现所有的函数组件都执行了一遍。
没更新的组件重新执行渲染,造成了浪费。 应当只重新渲染需要更新的节点。
【改进】
-
找出要更新的组件
- 引入全局变量wipFiber
let wipFiber = null;
- 在updateFunctionComponent(fiber) 函数中对其赋值
wipFiber = fiber
- 引入全局变量wipFiber
-
开始渲染这个组件:以当前的组件节点作为
wipRoot
-
将原来的update做法 ------以App组件为root推送给workloop------ 改为:以
wipFiber
记录的需要更新的组件推送给workloop- 第一次渲染运行过后,每一个函数组件都通过闭包的形式,在update函数中拥有自己的
currentFiber
。在点击按钮执行update函数的时候,wipRoot
就是本身的fiber节点,其alternate也是。
jsfunction update() { // 函数组件在运行此HOC的时候顺便记录当前fiber节点wipFiber let currentFiber = wipFiber; return () => { wipRoot = { ...currentFiber, alternate: currentFiber, }; nextWorkOfUnit = wipRoot; } }
【易错点】
如果将
alternate
错误地设置为wipFiber
,将会得到错误的行为:第一轮渲染完成后,wipFiber
等于最后渲染的函数组件Bar。此时当我们点击Foo中的按钮,它触发运行update函数,dom和props都是之前记录的Foo的currentFiber.dom 和 props。然而alternate却错误地设置为了Bar fiber。这会导致diff认为这是不同的函数组件,进而保留原来的Foo和新增点击后的Foo。并且还会删除被当作旧版本Foo的Bar节点. - 第一次渲染运行过后,每一个函数组件都通过闭包的形式,在update函数中拥有自己的
-
-
结束渲染这个组件:链表走到更新组件节点的兄弟节点
- workloop开始重新渲染该函数组件,直至运行至它的兄弟节点:nextWorkofUnit.type === wipFiber.type
jsfunction workLoop(deadline) { let shouldYield = false; while (!shouldYield && nextWorkOfUnit) { nextWorkOfUnit = performWorkOfUnit(nextWorkOfUnit); // 如果wipRoot(要更新的函数组件)的兄弟就是下一个work //那就代表当前函数组件已经渲染完成了 if (wipRoot?.sibling?.type === nextWorkOfUnit?.type) { nextWorkOfUnit = undefined; } shouldYield = deadline.timeRemaining() < 1; } if (!nextWorkOfUnit && wipRoot) { commitRoot() } requestIdleCallback(workLoop); }
胜利结算
- 电击Foo, Bar 组件中的按钮,发现只有他们自己各自重新渲染了。只有当点击App中的按钮,才会重新渲染所有组件。
- 总的来说,这里实现的diff算法并不是去手动计算哪个节点更新了。而是在函数节点中埋伏一个更新函数,当用户发起动作时,调用它。diff主要是用于判断是更新属性,还是删旧建新,以及处理多余的树枝。