背景
React 组件是一个返回 Virtual DOM Tree 的方法
function SomeComponent() {
return (
<div>
<span>..</span>
<Button>..</Button>
</div>
)
}
//* 上述代码会转换为一个 React Virtual DOM,就像如下所示
class VirtualDOMNode {
type: SomeComponent // 如果是内置的就会描述成 "div" 等等
children
update() {
const newVirtualDOM = this.type()
// ...
}
}
更新策略有很多,而最划算的更新策略就是在原来的基础上进行新增、删除等操作,而不是对整个DOM进行替换。
大体过程
1. React 渲染函数执行时会生成一个树状结构
<div style={xxx}>...</div>
// 上面的jsx会转换为
React.createElement('div', {
style: {xxx},
...
})
createElement()
被调用之后将生成一个 Virtual DOM 节点
class ReactElement {
type: SomeComponent
props: {
children
}
}
Element 本身可以看做是对“数据”的描述,也就是元数据,它本身是没有行为的。
Element 可以看做是虚拟 DOM,因为它代表了真实的 DOM 结构。但是又因为它本身没有行为,所以要使它拥有像更新的能力,就得对该 Element 再进行一次封装 (FiberNode)。
class FiberNode {
type: SomeComponent // 函数组件本身,调用它的时候可以生成新的 Element,复制于 ElementNode
props: {},
update() {}
}
2. 更新
对于某个给定组件
function SomeComponent() {
return <div>...</div>
}
当组件 SomeComponent
触发更新时,React 会这样处理
// Fiber Context
{
let vDOMOld // 上一次调用 SomeComponent 产生的 VirtualDOM
//...
update() {
const vDOMNext = SomeComponent()
const updates = domDiff(vDOMOld, vDOMNext)
vDOMOld = vDOMNext
apply(updates)
}
}
React 更新产生虚拟 DOM 节点,然后通过 diff 算法比较两个 DOM 节点的差异,然后决定更新步骤,最后再向 DOM 应用这些更新。
从上述伪代码中可以看到所有的更新都依赖 diff,这就要求 diff 算法的效率必须足够高才能很好地支撑起整个项目。
FAQ
问:为什么不把更新方法放到 ElementNode 当中
答:因为组件的更新需要在特定的场景下,它可能是在浏览器端、Native等等;另外组件的更新涉及到特殊的算法,像 Fiber。在具体的场景下再封装具体的方法有利于代码的设计。
细节
对于相同类型的节点
if (vDOMOld.type === vDOMNext.type) {
// ...
}
比如下述组件发生变化时
function Button({text}) {
return <button>{text}</button>
}
只需要替换属性即可
<Button text="点击" />
// 转换为
<Button text="click" />
对于不同类型的节点
当遇到不同类型的节点时,React 会直接替换而不是继续往下比
<div>
<Counter />
</div>
// 转换为
<span>123</span>
对于子节点的处理
<ul>
<li>first</li>
<li>second</li>
</ul>
// 转换为
<ul>
<li>first</li>
<li>second</li>
<li>third</li>
</ul>
对于上述的节点,React 只会插入第三个 li
但是对于下述这种乱序的情况,React 会进行逐个替换
<ul>
<li>first</li>
<li>second</li>
</ul>
// 转换为
<ul>
<li>third</li>
<li>first</li>
<li>second</li>
</ul>
因为在对比第一个 li 时,React 发现(文本)属性不一样了,所以按照上文提到的属性不一样时进行替换。
对于第二个 li 的对比同理。
这是因为 DOM-Diff 会用简单的算法 —— 顺序比较,而不是动态规划(时间复杂度有O(n2))。由于直接替换元素不会明显影响速度(人对其目前的表现感觉还不错),因此这里没有必要使用更加复杂但可能高效的算法。
为了解决更新比较慢的问题,React 引用了 key 来解决
<ul>
<li key="first">first</li>
<li key="second">second</li>
</ul>
// 转换为
<ul>
<li key="third">third</li>
<li key="first">first</li>
<li key="second">second</li>
</ul>
然而,只有在列表很大的时候优化效果才会体现出来,因为更新过程的成本更多是在渲染而不是计算上面。
具体的 diff 细节如下面伪代码所示
// Diff 算法入口
function *domDiff(vDOM1, vDOM2) {
// 原来没有现在有,就是添加
if (!vDOM1) {
yield new InsertUpdate(vDOM1, vDOM2)
return
}
if (vDOM1.type === vDOM2.type) {
// 类型相同
if (vDOM1.key === vDOM2.key) {
// 类型相同而且 key 相同时就是对属性进行 update
yield new AttributeUpdate(vDOM1, vDOM2)
// 此时要继续 diff 它们的子节点
yield *domDiffArray(vDOM1.children, vDOM2.children)
} else {
// 类型相同但是 key 不同就会执行替换操作
yield new ReplaceUpdate(vDOM1, vDOM2)
}
} else {
// 类型不同
yield new ReplaceUpdate(vDOM1, vDOM2)
}
}
// 对子节点进行diff
function *domDiffArray(arr1, arr2) {
// 子节点也是原来的属性,如果新旧节点中有一个没有子节点,则对新旧节点进行替换
if (!arr1 || !arr2) {
yield new ReplaceUpdate(vDOM1, vDOM2)
return
}
const m1 = toMap(arr1)
const m2 = toMap(arr2)
// 1.查看是否存在需要删除的 VDOM
const deleted = arr1.filter((item, i) => {
// 没有 key 走的是顺序比较
// 当 arr1 的当前元素存在 key 时,如果 m2 中不存在这个 key(更新后没有了)时,就说明这个元素要删掉的
// 当 arr1 的当前元素不存在 key 而且更新后的节点数量比当前数量少时,说明当前节点是多余的,需要删掉
return item.key ? !m2.has(item.key) : i >= arr2.length
})
// 将需要删除的节点替换为 null
for (const item of deleted) {
yield new ReplaceUpdate(item, null)
}
// 2.查看是否存在要被替换的节点
for (let i = 0; i < arr1.length; i++) {
const a = arr1[i]
if (a.key) {
// 对于有 key 的节点,如果 m2 中也有相同的 key,则通过递归 diff 进行替换的判断
if (m2.has(a.key)) yield *domDiff(a, m2.get(a.key))
} else {
// 如果当前遍历节点没有 key,则对相同位置上的节点进行递归 diff 替换
if (i < arr2.length) yield *domDiff(a, arr2[i])
}
}
// 3.查看是否存在需要增加的节点 ( arr2 中有且 arr1 中没有)
for (let i = 0; i < arr2.length; i++) {
const b = arr2[i]
if (b.key) {
// arr2 的当前元素的 key 不存在于 m1,说明当前元素是新增的
if (!m1.has(b.key)) yield new InsertUpdate(i, b)
} else {
// arr2 的当前元素没有 key,但是当前元素的序号已经大于 arr1 的长度了
if (i >= arr1.length) yield new InsertUpdate(i, arr[2])
}
}
}
// 将数组转换为字典的方法
function toMap(arr) {
const map = new Map()
arr.forEach(item => {
if (item.key) map.set(item.key, item)
})
return map
}
class InsertUpdate {
constructor(pos, to) {
this.pos = pos
this.to = to
}
}
class ReplaceUpdate {
constructor(from ,to) {
this.from = from
this.to = to
}
}
简单整理一下:
diff 算法可以分为两大块,diff 算法入口
和 子节点比较方法
diff 算法入口
在 diff 算法入口
处,即上述的 function *domDiff(vDOM1, vDOM2) {}
,对传入的两个虚拟DOM节点进行比较 ( Current Fiber 和 WorkInProgress Fiber ) :
- 当出现原来不存在节点但是现在有时,执行 添加操作
// 原来没有现在有,就是添加 if (!vDOM1) { yield new InsertUpdate(vDOM1, vDOM2) return }
- 当两个节点均存在且出现 类型不同 或 类型相同时 key 不同 的情况时,进行替换操作
if (vDOM1.type === vDOM2.type) { // 类型相同 if (vDOM1.key === vDOM2.key) { // ... } else { // 类型相同但是 key 不同就会执行替换操作 yield new ReplaceUpdate(vDOM1, vDOM2) } } else { // 类型不同 yield new ReplaceUpdate(vDOM1, vDOM2) }
- 当两个节点 类型相同且key相同时,执行属性更新操作并且对它们的子节点调用
子节点比较方法
if (vDOM1.type === vDOM2.type) { // 类型相同 if (vDOM1.key === vDOM2.key) { // 类型相同而且 key 相同时就是对属性进行 update yield new AttributeUpdate(vDOM1, vDOM2) // 此时要继续 diff 它们的子节点 yield *domDiffArray(vDOM1.children, vDOM2.children) } else { // 类型相同但是 key 不同就会执行替换操作 ... } } else { // 类型不同 ... }
对子节点进行diff
- 当两个父节点存在其中一个节点没有子节点时,直接进行替换操作,后面的比较不再进行
// 子节点也是原来的属性,如果新旧节点中有一个没有子节点,则对新旧节点进行替换 if (!arr1 || !arr2) { yield new ReplaceUpdate(vDOM1, vDOM2) return }
- 将二者的子节点由数组转换成字典 (
key
能够不按顺序比较的原因 )const m1 = toMap(arr1) const m2 = toMap(arr2) // 将数组转换为字典的方法 function toMap(arr) { const map = new Map() arr.forEach(item => { if (item.key) map.set(item.key, item) }) return map }
- 查看是否存在需要删除的节点
// 查看是否存在需要删除的 VDOM const deleted = arr1.filter((item, i) => { // 没有 key 走的是顺序比较 // 当 arr1 的当前元素存在 key 时,如果 m2 中不存在这个 key(更新后没有了)时,就说明这个元素要删掉的 // 当 arr1 的当前元素不存在 key 而且更新后的节点数量比当前数量少时,说明当前节点是多余的,需要删掉 return item.key ? !m2.has(item.key) : i >= arr2.length }) // 将需要删除的节点替换为 null for (const item of deleted) { yield new ReplaceUpdate(item, null) }
- 查看是否存在需要被替换的节点
// 查看是否存在要被替换的节点 for (let i = 0; i < arr1.length; i++) { const a = arr1[i] if (a.key) { // 对于有 key 的节点,如果 m2 中也有相同的 key,则通过递归 diff 进行替换的判断 if (m2.has(a.key)) yield *domDiff(a, m2.get(a.key)) } else { // 如果当前遍历节点没有 key,则对相同位置上的节点进行递归 diff 替换 if (i < arr2.length) yield *domDiff(a, arr2[i]) } }
- 查看是否存在需要增加的节点 ( arr2 中有且 arr1 没有 )
// 查看是否存在需要增加的节点 ( arr2 中有且 arr1 中没有) for (let i = 0; i < arr2.length; i++) { const b = arr2[i] if (b.key) { // arr2 的当前元素的 key 不存在于 m1,说明当前元素是新增的 if (!m1.has(b.key)) yield new InsertUpdate(i, b) } else { // arr2 的当前元素没有 key,但是当前元素的序号已经大于 arr1 的长度了 if (i >= arr1.length) yield new InsertUpdate(i, arr[2]) } }