React 源码:优化渲染过程之 dom diff
最近在学习慕课网 手写 React 高质量源码迈向高阶开发,之前自己也尝试看过源码,不过最终放弃了
放弃的最主要原因是 react 内部的调用链太长了,每天在缕清调用链上都花了不少时间,createRoot 都没有看完
最近看到慕课网有一个 react 源码课,就想着跟着课程然后在自己源码,看看这次能够看到什么地步
它这个课程前八章 是 react@16 的源码,从第九章开始才是 react@18 的源码
React 源码系列:
问题存在
我们在 setState 时,会触发试图的更新
之前的实现是将整个 dom 树替换掉,这里的 oldDOM 是函数组件或者类组件 render 出来的 dom 树,这里是整体替换
// 这里的 oldDOM 是函数组件或者类组件 render 出来的 dom 树
function updateDomTree(oldDOM, newVNode) {
let parentNode = oldDOM.parentNode;
parentNode.removeChild(oldDOM);
let newDOM = createDOM(newVNode);
parentNode.appendChild(newDOM);
}
这样做,性能是比较差的,因为我们只是改变了一小部分,但是却要替换整个 dom 树
这里我们要做的是实现 diff 算法,只更新改变的部分
我们知道 react 在渲染时分为三个步骤
- 生成虚拟
DOM - 将虚拟
DOM转换为真实DOM - 将真实
DOM挂载到页面上
不管是初始化渲染,还是更新渲染,都会经过这三个步骤
初始化渲染时,是 0 - 1 的过程,所有节点都要渲染,这里没有什么可以优化的地方
但是在更新时,更新的部分只是一小块,没有必要重新渲染整个 dom 树,所以这里就有优化空间了
所以就有了 diff 算法,diff 算法的目的就是找出两个对象的差异
diff 算法
diff 在没有 key 的情况下比较,如下结构:
// 变化前
<div className="a">a</div>
<spn className="b">b</spn>
<p className="c">c</p>
<img className="d"/>
// 变化后
<img className="d"/>
<div className="b" style={{color: "red"}}>b</div>
<div className="a">a</div>
变化前的 div.a 不知道和变化后的哪个 div 进行比较,只能按照顺序进行比较,也就是说 div.a 和 img.d 进行比较
发现 div.a 和 img.d 不一样,那就删除 div.a,并创建 img.d 的标签,这是比较低效的
因为 div.a 和 img.d 在原有的虚拟 DOM 上是存在的,而现在需要重新创建
变化前的 div.b 和变化后的 div.b 进行比较,发现变化后的 div.b 多了一个 style 属性,直接更新属性就行了
基于这种情况,我们可以得出结论:按照顺序比较,如果不一样,就删除原来的,创建新的
这种比较方式相比我们直接替换整个 dom 树,性能是有提升的,但是 react 并没有满足于此
它引入了 key,通过 key 来找到变化前后的节点,这样就不需要按照顺序比较了,拿到变化前的 key 找到变化后的 key,这两个直接进行比较
代码优化
在开始 diff 算法之前,先把之前写的代码优化一下
之前我们在处理文本时,直接使用 document.createTextNode,后面处理文本节点的地方有点多,需要将它统一一下,方便后续处理
首先我们创建一个 toVNode 函数,用来处理文本节点
const toVNode = (node) => {
// 如何 node 是 string 或者 number,就认为是文本节点
// 返回一个对象,对象中有 type 和 props 属性
// 否则返回 node
return typeof node === "string" || typeof node === "number"
? { type: REACT_TEXT, props: { text: node } }
: node;
};
在 createDOM 中,我们增加一个文本节点的判断
if (type === REACT_TEXT) {
dom = document.createTextNode(props.text);
}
在 createElement 中,对 children 处理时,先进行判断一下,如果是文本节点,返回一个文本节点对象
if (arguments.length > 3) {
// 将 children 中的每一项都使用 toVNode 方法进行处理
props.children = Array.prototype.slice.call(arguments, 2).map(toVNode);
} else {
// 对 children 使用 toVNode 方法进行处理
props.children = toVNode(children);
}
之前在 mountArray 中和 createDOM 中处理文本节点的地方都可以将它删除
在 mountArray 中需要为 children 增加一个 index 属性,方便后面进行 diff 比较
function mountArray(children, parent) {
// ...
for (let i = 0; i < children.length; i++) {
// 为每一个子元素添加 index 属性
children[i].index = i;
// ...
}
}
updateTree
diff 算法从 updateTree 开始
我们如何知道现在是要删除、新增、还是修改呢?
我们可以通过 oldVNode 和 newVNode 的来进行判断
但是之前 updateTree 只接收两个参数:oldDOM 和 newVNode
所以现在要改成三个参数:oldVNode、newVNode、oldDOM
更新有这五种情况:
- 新节点,旧节点都不存在
- 新节点存在,旧节点不存在
- 新节点不存在,旧节点存在
- 新节点存在,旧节点也存在,但是类型不一样
- 新节点存在,旧节点也存在,类型一样 --> 我们需要进行深入的比较
基于这五种情况,可以使用一个对象来表示,最后一种情况可以认为是默认情况
const typeMap = {
// 不需要进行任何操作
NO_OPERATE: !oldVNode && !newVNode,
// 新增节点
ADD: !oldVNode && newVNode,
// 删除节点
DELETE: oldVNode && !newVNode,
// 替换节点
REPLACE: oldVNode && newVNode && oldVNode.type !== newVNode.type,
};
如何知道当前是哪种情况呢?
使用 filter 方法,过滤出是 true 的结果,然后取第一个
const UPDATE_TYPE = Object.keys(typeMap).filter((key) => typeMap[key])[0];
使用 switch 语法根据不同的情况进行不同的操作:
NO_OPERATE:不需要进行任何操作- 因为
oldVNode和newVNode都不存在
- 因为
DELETE:删除节点- 因为
oldVNode存在,newVNode不存在,删除节点交给removeVNode函数处理
- 因为
ADD:新增节点- 因为
oldVNode不存在,newVNode存在 - 直接调用
createDOM创建新节点,并添加到父节点中
- 因为
REPLACE:替换节点- 因为
oldVNode和newVNode都存在,但是类型不一样 - 先删除旧节点,再创建新节点,添加到父节点中
- 删除节点,直接调用
removeVNode函数
- 删除节点,直接调用
- 因为
- 默认情况:新旧节点都一样,且类型也相同
- 这种情况下,我们需要对它内部的属性,子元素进行比较,是
DOM Diff的核心,我们将它放到deepDOMDiff函数讲解
- 这种情况下,我们需要对它内部的属性,子元素进行比较,是
switch (UPDATE_TYPE) {
case "NO_OPERATE":
break;
case "DELETE":
removeVNode(oldVNode);
break;
case "ADD":
oldDOM.parentNode.appendChild(createDOM(newVNode));
break;
case "REPLACE":
removeVNode(oldVNode);
oldDOM.parentNode.appendChild(createDOM(newVNode));
break;
default:
// 深度的 dom diff,新老虚拟 DOM 都存在且类型相同
deepDOMDiff(oldVNode, newVNode);
break;
}
removeVNode 函数比较简单,直接调用 findDOMByVNode 找到 dom 节点,然后删除即可
function removeVNode(VNode) {
// 找到 dom 节点
const currentDOM = findDOMByVNode(VNode);
// 删除 dom 节点
if (currentDOM) currentDOM.remove();
}
deepDOMDiff
deepDOMDiff 函数是 DOM Diff 的核心
它是在新老节点都一样的情况下,对其内部的属性、子元素进行比较
所以它有这 4 种情况:
- 原生节点,比如
div、span、p、img等 - 函数组件
- 类组件
- 文本节点
let diffTypeMap = {
// 原生节点,type 是一个字符串
ORIGIN_NODE: typeof oldVNode.type === "string",
// 类组件,type 是一个函数,但是有 IS_CLASS_COMPONENT 属性
CLASS_COMPONENT:
typeof oldVNode === "function" && oldVNode.type.IS_CLASS_COMPONENT,
// 函数组件,type 是一个函数
FUNCTION_COMPONENT: typeof oldVNode === "function",
// 文本节点,type 是一个字符串,值是 REACT_TEXT
TEXT: oldVNode.type === REACT_TEXT,
};
然后通过 filter 方法过滤出是 true 的结果,取第一个
因为上面的 4 种情况是互斥的,所以只会有一个结果
const DIFF_TYPE = Object.keys(diffTypeMap).filter((key) => diffTypeMap[key])[0];
通过 switch 判断不同的情况,然后进行不同的操作:
ORIGIN_NODE:原生节点CLASS_COMPONENT:类组件FUNCTION_COMPONENT:函数组件TEXT:文本节点
文本节点
文件节点更新比较简单
首先通过 findDOMByVNode 找到 oldVNode 所对应的 oldDOM,然后将 oldDOM 赋值给 newVNode 的 dom 属性
然后直接更新 newVNode.dom.textContent,新的文本在 newVNode.props.text
// 找到 oldDOM 节点
// 赋值给 newVNode.dom
newVNode.dom = findDOMByVNode(oldVNode);
// 更新文本节点的值
newVNode.dom.textContent = newVNode.props.text;
函数组件
首先需要对 getDomByFunctionComponent 做一些小改动
需要在 VNode 上挂载一个属性:oldRenderVNode 旧的渲染树
function getDomByFunctionComponent(VNode) {
let renderVNode = type(props);
// 将 renderVNode 挂载到 VNode.oldRenderVNode 上
VNode.oldRenderVNode = renderVNode;
// ...
}
然后通过findDOMByVNode找到oldVNode所对应的oldDOM,然后将 oldDOM赋值给newVNode的dom 属性
这一步和文本节点一样
然后调用新的 VNode 获取到新的渲染树,再次调用 updateDomTree 更新 DOM(递归)
最后将新的 DOM 赋值给 newVNode.oldRenderVNode
function updateFunctionComponent(oldVNode, newVNode) {
// 找到 oldDOM 节点
// 赋值给 newVNode.dom
let oldDOM = (newVNode.dom = findDOMByVNode(oldVNode));
if (!oldDOM) return;
// 从 newVNode 中获取 type、props
const { type, props } = newVNode;
// 调用 type 函数,传入 props 获取新的 DOM 结构
let newRenderVNode = type(props);
// 递归调用 updateDomTree,更新整个 DOM 树
updateDomTree(oldVNode.oldRenderVNode, newRenderVNode, oldDOM);
// 将 newRenderVNode 赋值给 newVNode.oldRenderVNode
newVNode.oldRenderVNode = newRenderVNode;
}
类组件
类组件也是,需要对 getDomByClassComponent 做一些小改动
需要在 VNode 上挂载一个属性:classInstance 类组件实例
function getDomByClassComponent(VNode) {
let instance = new type(props);
// 将 instance 挂载到 VNode.classInstance 上
VNode.classInstance = instance;
// ...
}
类组件更新比较简单,我们获取到旧的组件实例后,将这个实例赋值给 newVNode.classInstance
launchUpdate 内部会进行 state 合并,并更新视图
function updateClassComponent(oldVNode, newVNode) {
// 对于当前界面,旧的实例是与页面上已渲染的组件是相对应的,在生命周期函数中,会尝试比较 newProps 和 oldProps
const classInstance = (newVNode.classInstance = oldVNode.classInstance);
classInstance.updater.launchUpdate();
}
不过这里为什么要舍去 newVNode 上的实例,转而使用 oldVNode 上的实例呢?
因为这是为之后的生命周期函数做考虑,
对于当前界面,旧的实例是与页面上已渲染的组件是相对应的,在生命周期函数中,会尝试比较 newProps 和 oldProps
这个在之后的生命周期函数章节中会提到,这里先暂时不表
原生节点
原生节点的更新也是 DOM Diff 的核心,也是最复杂的
其主要核心是 updateChildren 函数,这个函数我们会在下面讲解
首先我们需要找到 oldVNode 所对应的 oldDOM,然后将 oldDOM 赋值给 newVNode 的 dom 属性
然后调用 setPropsForDOM 更新 DOM 属性
最后调用 updateChildren 更新子元素,传入三个参数
currentDOM:DOM,oldVNode对应的真实DOMoldVNode.props.children:旧的虚拟DOM的子节点newVNode.props.children:新的虚拟DOM的子节点
let currentDOM = (newVNode.dom = findDOMByVNode(oldVNode));
setPropsForDOM(currentDOM, newVNode.props);
updateChildren(currentDOM, oldVNode.props.children, newVNode.props.children);
最终代码
switch (DIFF_TYPE) {
case "ORIGIN_NODE":
let currentDOM = (newVNode.dom = findDOMByVNode(oldVNode));
setPropsForDOM(currentDOM, newVNode.props);
updateChildren(
currentDOM,
oldVNode.props.children,
newVNode.props.children
);
break;
case "CLASS_COMPONENT":
updateClassComponent(oldVNode, newVNode);
break;
case "FUNCTION_COMPONENT":
updateFunctionComponent(oldVNode, newVNode);
break;
case "TEXT":
// 找到 oldDOM 节点
// 赋值给 newVNode.dom
newVNode.dom = findDOMByVNode(oldVNode);
// 更新文本节点的值
newVNode.dom.textContent = newVNode.props.text;
break;
default:
break;
}
updateChildren
updateChildren 函数是 DOM Diff 的核心,也是最复杂的
为什么它是最复杂的呢?
无论是函数组件还是类组件,最终都会渲染成原生节点
updateChildren 函数接受三个参数:
currentDOM:DOM,oldVNode对应的真实DOMoldVNode.props.children:旧的虚拟DOM的子节点newVNode.props.children:新的虚拟DOM的子节点
首先处理 children,将 oldVNodeChildren 和 newVNodeChildren 的 children 都处理成数组,并过滤掉空的 children
// 将 oldVNodeChildren 的 chidlren 处理成数组
oldVNodeChildren = (
Array.isArray(oldVNodeChildren) ? oldVNodeChildren : [oldVNodeChildren]
).filter(Boolean);
// 将 newVNodeChildren 的 chidlren 处理成数组
newVNodeChildren = (
Array.isArray(newVNodeChildren) ? newVNodeChildren : [newVNodeChildren]
).filter(Boolean);
然后将 oldVNodeChildren 中的每一项 children 和 oldVNode 做一个映射:
- 如果
oldVNode有key,就使用key - 如果
oldVNode没有key,就使用index
// 保存 oldVNodeChildren 中 key 和 children 的映射关系
let oldKeyChildMap = {};
oldVNodeChildren.forEach((oldVNode, index) => {
// 如果没有 key,就使用 index
let oldKey = oldVNode && oldVNode.key ? oldVNode.key : index;
// 保存 key 和 children 的映射关系
oldKeyChildMap[oldKey] = oldVNode;
});
在这些工作准备好之后,我们应该怎么处理 newVNodeChildren 呢?
通过遍历新的 newVNodeChildren 数组,完成以下四件事:
- 可以复用但需要移动的节点
- 需要重新创建的节点
- 需要删除的节点
- 可以复用且不用移动的节点
将需要移动,创建,删除的节点找出来
我们声明一个 actions 数组用来保存遍历 newVNodeChildren 每项时 children 的状态,在这里 children 只有 MOVE 和 CREATE 两种状态
比如将 ABCDE 变为 CBEFA
// oldVNodeChildren
<ul>
<li key="A">A</li>
<li key="B">B</li>
<li key="C">C</li>
<li key="E">D</li>
<li key="D">E</li>
</ul>
🔽
// newVNodeChildren
CBEFA;
<ul>
<li key="C">C</li>
<li key="B">B</li>
<li key="E">E</li>
<li key="F">F</li>
<li key="A">A</li>
</ul>
我们要知道的一点是,这里的遍历是遍历 newVNodeChildren,拿到 newVNode,然后跟 oldVNodeChildren 中的 oldVNode 进行比较
我们开始遍历 newVNodeChildren:
- 第一次遍历时:
- 拿到的
newVNode是C,同时lastNotChangedIndex为-1,所以C.key是C newVNode C存在于oldKeyChildMap所以可以拿到oldVNodeoldVNode.index也就是C.index为2oldVNode.index < lastNotChangedIndex也就是C.index < -1=>2 < -1不成立- 所以
C节点不需要动,lastNotChangedIndex取C.index和lastNotChangedIndex的最大值,结果为2 - 将
C节点从oldKeyChildMap中删除
- 拿到的
- 第二次遍历时:
- 拿到的
newVNode是B,同时lastNotChangedIndex为2,所以B.key是B newVNode B存在于oldKeyChildMap所以可以拿到oldVNodeoldVNode.index就是B.index为1oldVNode.index < lastNotChangedIndex也就是B.index < 2=>1 < 2成立- 所以
B节点需要移动,lastNotChangedIndex取B.index和lastNotChangedIndex的最大值,结果不变,还是为2 action为{type: MOVE, oldVNode: B, newVNode: B, index: 1}- 将
B节点从oldKeyChildMap中删除
- 拿到的
- 第三次遍历时:
- 拿到的
newVNode是E,同时lastNotChangedIndex为2,所以E.key是E newVNode E存在于oldKeyChildMap所以可以拿到oldVNodeoldVNode.index就是E.index为4oldVNode.index < lastNotChangedIndex也就是E.index < 2=>4 < 2不成立- 所以
E节点不需要动,lastNotChangedIndex取E.index和lastNotChangedIndex的最大值,结果为4 - 将
E节点从oldKeyChildMap中删除
- 拿到的
- 第四次遍历时:
- 拿到的
newVNode是F,同时lastNotChangedIndex为4,F.key是F newVNode F不存在oldKeyChildMap,所以拿不到oldVNodeaction为{type: CREATE, newVNode: F, index: 3}- 所以
F节点需要创建,lastNotChangedIndex不变
- 拿到的
- 第五次遍历时:
- 拿到的
newVNode是A,同时lastNotChangedIndex为4,所以A.key是A newVNode A存在于oldKeyChildMap,所以可以拿到oldVNodeoldVNode.index就是A.index为0oldVNode.index < lastNotChangedIndex也就是A.index < 4=>0 < 4成立- 所以
A节点需要移动,lastNotChangedIndex取A.index和lastNotChangedIndex的最大值,结果不变,还是4 action为{type: MOVE, oldVNode: A, newVNode: A, index: 4}- 将
A节点从oldKeyChildMap中删除
- 拿到的
- 遍历结束,退出循环
这段处理的源码如下:
// lastNotChangedIndex 用来保存上一次没有变化的节点的索引,初始值为 -1
let lastNotChangedIndex = -1;
let actions = [];
newVNodeChildren.forEach((newVNode, index) => {
// 将 newVNode.index 设置为 index
newVNode.index = index;
// 如果 newVNode.key 存在就用 key,否则用 index
// 这个 key 是 <div key={xxx}></div> 中的 key
let newKey = newVNode.key ? newVNode.key : index;
// 通过 key 从 oldKeyChildMap 中找到有没有 oldVNode
let oldVNode = oldKeyChildMap[newKey];
if (oldVNode) {
// 如果有,调用 deepDOMDiff 进行深度比较,里面可能还有子元素,属性需要比较
deepDOMDiff(oldVNode, newVNode);
// 如果 oldVNode.index < lastNotChangedIndex,说明这个节点需要移动
if (oldVNode.index < lastNotChangedIndex) {
actions.push({ type: MOVE, oldVNode, newVNode, index });
}
// 操作过的节点,从 oldKeyChildMap 中删除,oldKeyChildMap 中剩下的就是需要删除的节点
delete oldKeyChildMap[newKey];
// 更新 lastNotChangedIndex
lastNotChangedIndex = Math.max(lastNotChangedIndex, oldVNode.index);
} else {
// 如果没有,说明这个这个节点需要创建
actions.push({ type: CREATE, newVNode, index });
}
});
在遍历结束后,actions 数组中就有了 MOVE 和 CREATE 两种状态,如下所示:
{type: MOVE, oldVNode: B, newVNode: B, index: 1}
{type: CREATE, newVNode: F, index: 3}
{type: MOVE, oldVNode: A, newVNode: A, index: 4}
oldKeyChildMap 中剩下的是需要被删除的节点,如下所示:
{D: VNode}
将需要删除的节点从页面中删除
现在 oldKeyChildMap 中剩下的节点是需要从页面中删除的,actions 中标记为 MOVE 节点也需要从页面中删除
所以先从 actions 中找出需要移动的节点
let VNodeToMove = actions
// 过滤出 type 为 MOVE 的节点
.filter((action) => action.type === MOVE)
// 将节点对应的 oldVNode 取出来
.map((action) => action.oldVNode);
将 oldKeyChildMap 中剩下的节点和 VNodeToMove 节点合并,然后遍历这个合并后的数组,将这些节点从页面中删除
let VNodeToDelete = Object.values(oldKeyChildMap);
// 将 oldKeyChildMap 中剩下的节点和 VNodeToMove 节点合并
VNodeToMove.concat(VNodeToDelete).forEach((oldVNode) => {
// 找到 oldVNode 对应的 dom 节点
let currentDOM = findDOMByVNode(oldVNode);
// 从页面中删除 dom 节点
currentDOM.remove();
});
处理需要创建的节点和需要移动的节点
actions 中的节点最终是要渲染到页面中的,但是它有两种状态:
CREATE:对于CREATE的状态,我们调用createDOM创建新的DOM,然后将这个DOM插入到页面中MOVE:对于MOVE的状态,我们调用findDOMByVNode找到oldVNode对应的DOM,然后将这个DOM移动到index位置
具体的更新过程如下:
- 我们拿到
parentDOM中所有的childNodes(这里的parentDOM是更新的root节点,也就是return中的root节点)- 此时页面中还剩
C和E两个元素
- 此时页面中还剩
- 从
action中拿到index- 第一个
action是B,B的index是1childNodes索引为1的childNode是E- 所以
B是需要移动到E的前面
- 第二个
action是F,F的index是3childNodes没有索引为3的childNode- 所以直接插入到
parentDOM的最后
- 第三个
action是A,A的index是4childNodes没有索引为4的childNode- 所以直接插入到
parentDOM的最后
- 第一个
插入到某个 childNode 之前是使用 API insertBefore,插入到最后是使用 API appendChild
actions.forEach((action) => {
let { type, oldVNode, newVNode, index } = action;
// 拿到需要更新的节点的 childNodes
let childNodes = parentDOM.childNodes;
// 通过 index 找到 childNodes 中的 childNode
let childNode = childNodes[index];
// 根据不同的 type,创建不同的 dom
const getDomForInsert = () => {
// 如果 type 是 CREATE,就创建新的 dom
if (type === CREATE) {
return createDOM(newVNode);
}
// 如果 type 是 MOVE,就找到 oldVNode 对应的 dom
if (type === MOVE) {
return findDOMByVNode(oldVNode);
}
};
// 如果 childNode 存在,就插入到 childNode 之前
if (childNode) {
// 插入到某个 childNode 之前是使用 insertBefore
parentDOM.insertBefore(getDomForInsert(), childNode);
} else {
// 如果 childNode 不存在,就插入到最后
parentDOM.appendChild(getDomForInsert());
}
});
总结
diff 算法是 react 的核心,通过比较新旧 DOM 树,找出差异,然后更新差异部分
更新分为五种情况:
- 不需要操作:新节点,旧节点都不存在
- 新增:新节点存在,旧节点不存在
- 删除:新节点不存在,旧节点存在
- 替换:新节点存在,旧节点也存在,但是类型不一样
- 更深入比较:新节点存在,旧节点也存在,类型一样 --> 我们需要进行深入的比较
DOM 树处理,主要是这四种,最终落脚点是原生节点:
- 原生节点
- 类组件
- 函数组件
- 文本节点
对于原生节点的处理,是 DOM Diff 的核心,也是最复杂的,通过 key 找到需要移动,创建,删除的节点,然后进行处理
源码
转载自:https://juejin.cn/post/7305983860251885604