当新旧vnode的子节点都是一组子节点,最小性能开销完成更新操作,需要比较两组子节点,用于比较的算法旧叫Diff算法。
1、减少DOM操作的性能开销
核心的Diff算法只关心新旧虚拟节点都存在一组子节点的情况,之前的渲染器采用的全部卸载,再全部挂载,没有复用如何DOM元素,会产生极大的性能开销
调整方法:
1、在子节点只有文本子节点不同时,只更新文本,可以提高一倍的性能.
2、比较新旧两组子节点的长度,如果新的长,则说明有新的子节点需要挂载,如果旧的长。则说明有旧的需要被卸载。
新旧子节点三种状况图
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
if (Array.isArray(n1.children)) {
n1.children.forEach((c) => unmount(c))
}
setElementText(container, n2.children)
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children
const newChildren = n2.children
const oldLen = oldChildren.length
const newLen = newChildren.length
const commonLength = Math.min(oldLen, newLen)
for (let i = 0; i < commonLength; i++) {
patch(oldChildren[i], newChildren[i])
}
// 如果 nextLen > prevLen,将多出来的元素添加
if (newLen > oldLen) {
for (let i = commonLength; i < newLen; i++) {
patch(null, newChildren[i], container)
}
} else if (oldLen > newLen) {
// 如果 prevLen > nextLen,将多出来的元素移除
for (let i = commonLength; i < oldLen; i++) {
unmount(oldChildren[i])
}
}
} else {
if (Array.isArray(n1.children)) {
n1.children.forEach(c => unmount(c))
} else if (typeof n1.children === 'string') {
setElementText(container, '')
}
}
}
2、DOM复用与key的作用
如果新旧子节点并不完全相同,那么上面减少DOM的操作并不会减少性能的消耗,举个栗子:
// oldChildren
[
{ type: 'p' },
{ type: 'div' },
{ type: 'span' }
]
// newChildren
[
{ type: 'span' },
{ type: 'p' },
{ type: 'div' }
]
这种情况下,之前的优化操作就不好使了,需要移动DOM来进行优化。
注意:移动DOM解决性能问题必须要有可复用的的节点
进一步的优化:在vnode中加上标识key字段,通过type和key双重确定vnode相同,DOM可以进行复用
无key、有key图
无key无法确定映射关系,有key可以确定映射关系。
具体比较key是否系统,代码如下:
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
if (Array.isArray(n1.children)) {
n1.children.forEach((c) => unmount(c))
}
setElementText(container, n2.children)
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children
const newChildren = n2.children
// 遍历新的 children
for (let i = 0; i < newChildren.length; i++) {
const newVNode = newChildren[i]
let j = 0
// 遍历旧的 children
for (j; j < oldChildren.length; j++) {
const oldVNode = oldChildren[j]
// 如果找到了具有相同 key 值的两个节点,则调用 `patch` 函数更新之
if (newVNode.key === oldVNode.key) {
patch(oldVNode, newVNode, container)
break // 这里需要 break
}
}
}
} else {
if (Array.isArray(n1.children)) {
n1.children.forEach(c => unmount(c))
} else if (typeof n1.children === 'string') {
setElementText(container, '')
}
}
}
两层for循环,一层获取新节点,另一层获取旧节点,比较key相同,调用patch打补丁。
3、找到需要移动的元素
刚刚学习了通过key确定哪些DOM可以复用,接下来学习如何查找需要移动的元素,思路如下:添加lastIndex变量,在旧的children中找到了可复用的DOM节点,使用节点中的旧children中的索引j与lastIndex进行比较,如果j小于lastindex,则说明当前oldvnode对应真实DOM需要移动,否则不需要移动,此时把j的值付给变量lastIIndex,lastIIndex始终存储着当前遇到的最大索引值,代码如下:
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
if (Array.isArray(n1.children)) {
n1.children.forEach((c) => unmount(c))
}
setElementText(container, n2.children)
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children
const newChildren = n2.children
// 遍历新的 children
for (let i = 0; i < newChildren.length; i++) {
const newVNode = newChildren[i]
let j = 0
// 遍历旧的 children
for (j; j < oldChildren.length; j++) {
const oldVNode = oldChildren[j]
// 如果找到了具有相同 key 值的两个节点,则调用 `patch` 函数更新之
if (newVNode.key === oldVNode.key) {
patch(oldVNode, newVNode, container)
if (j < lastIndex) {
// 需要移动
} else {
// 更新 lastIndex
lastIndex = j
}
break // 这里需要 break
}
}
}
} else {
if (Array.isArray(n1.children)) {
n1.children.forEach(c => unmount(c))
} else if (typeof n1.children === 'string') {
setElementText(container, '')
}
}
}
4、如何移动元素
确定了要移动哪些元素,接下来就是如何对元素进行移动,思路大致如下,先让新旧节点都对真实DOM进行引用,取新的一组子节点,找到可以复用的key比较lastIndex大小,小则不需要移动真实DOM,代码如下:
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
if (Array.isArray(n1.children)) {
n1.children.forEach((c) => unmount(c))
}
setElementText(container, n2.children)
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children
const newChildren = n2.children
let lastIndex = 0
// 遍历新的 children
for (let i = 0; i < newChildren.length; i++) {
const newVNode = newChildren[i]
let j = 0
// 遍历旧的 children
for (j; j < oldChildren.length; j++) {
const oldVNode = oldChildren[j]
// 如果找到了具有相同 key 值的两个节点,则调用 `patch` 函数更新之
if (newVNode.key === oldVNode.key) {
patch(oldVNode, newVNode, container)
if (j < lastIndex) {
// 需要移动
const prevVNode = newChildren[i - 1]
if (prevVNode) {
const anchor = prevVNode.el.nextSibling
insert(newVNode.el, container, anchor)
}
} else {
// 更新 lastIndex
lastIndex = j
}
break // 这里需要 break
}
}
}
} else {
if (Array.isArray(n1.children)) {
n1.children.forEach(c => unmount(c))
} else if (typeof n1.children === 'string') {
setElementText(container, '')
}
}
}
5、添加新元素
新的子节点中多出key,旧子节点不存在,应该进行挂载操作。
定义名为find变量,表示渲染器能否在旧的一组子节点找到可复用的节点。find初始值为false,找到可复用的节点,则find值设置为true。如果结束内层循环后find仍然为false,说明当前的newVNode是个全新的节点,需要挂载。获取锚点元素,使用虚拟节点的前一个节点作为锚点元素,将锚点元素作为patch函数的第四个参数,调用patch完成挂载,修改代码如下:
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
if (Array.isArray(n1.children)) {
n1.children.forEach((c) => unmount(c))
}
setElementText(container, n2.children)
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children
const newChildren = n2.children
let lastIndex = 0
// 遍历新的 children
for (let i = 0; i < newChildren.length; i++) {
const newVNode = newChildren[i]
let j = 0
let find = false
// 遍历旧的 children
for (j; j < oldChildren.length; j++) {
const oldVNode = oldChildren[j]
// 如果找到了具有相同 key 值的两个节点,则调用 `patch` 函数更新之
if (newVNode.key === oldVNode.key) {
find = true
patch(oldVNode, newVNode, container)
if (j < lastIndex) {
// 需要移动
const prevVNode = newChildren[i - 1]
if (prevVNode) {
const anchor = prevVNode.el.nextSibling
insert(newVNode.el, container, anchor)
}
} else {
// 更新 lastIndex
lastIndex = j
}
break // 这里需要 break
}
}
if (!find) {
const prevVNode = newChildren[i - 1]
let anchor = null
if (prevVNode) {
anchor = prevVNode.el.nextSibling
} else {
anchor = container.firstChild
}
patch(null, newVNode, container, anchor)
}
}
} else {
if (Array.isArray(n1.children)) {
n1.children.forEach(c => unmount(c))
} else if (typeof n1.children === 'string') {
setElementText(container, '')
}
}
}
function patch(n1, n2, container, anchor) {
if (n1 && n1.type !== n2.type) {
unmount(n1)
n1 = null
}
const { type } = n2
if (typeof type === 'string') {
if (!n1) {
mountElement(n2, container, anchor)
} else {
patchElement(n1, n2)
}
} else if (type === Text) {
if (!n1) {
const el = n2.el = createText(n2.children)
insert(el, container)
} else {
const el = n2.el = n1.el
if (n2.children !== n1.children) {
setText(el, n2.children)
}
}
} else if (type === Fragment) {
if (!n1) {
n2.children.forEach(c => patch(null, c, container))
} else {
patchChildren(n1, n2, container)
}
}
}
function render(vnode, container) {
if (vnode) {
// 新 vnode 存在,将其与旧 vnode 一起传递给 patch 函数进行打补丁
patch(container._vnode, vnode, container)
} else {
if (container._vnode) {
// 旧 vnode 存在,且新 vnode 不存在,说明是卸载(unmount)操作
unmount(container._vnode)
}
}
// 把 vnode 存储到 container._vnode 下,即后续渲染中的旧 vnode
container._vnode = vnode
}
return {
render
}
}
6、删除元素
当基本的更新结束时,遍历旧的子节点,然后去新的子节点中寻找相同key值的节点,如果找不到,说明需要删除该节点。代码如下
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
if (Array.isArray(n1.children)) {
n1.children.forEach((c) => unmount(c))
}
setElementText(container, n2.children)
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children
const newChildren = n2.children
let lastIndex = 0
// 遍历新的 children
for (let i = 0; i < newChildren.length; i++) {
const newVNode = newChildren[i]
let j = 0
let find = false
// 遍历旧的 children
for (j; j < oldChildren.length; j++) {
const oldVNode = oldChildren[j]
// 如果找到了具有相同 key 值的两个节点,则调用 `patch` 函数更新之
if (newVNode.key === oldVNode.key) {
find = true
patch(oldVNode, newVNode, container)
if (j < lastIndex) {
// 需要移动
const prevVNode = newChildren[i - 1]
if (prevVNode) {
const anchor = prevVNode.el.nextSibling
insert(newVNode.el, container, anchor)
}
} else {
// 更新 lastIndex
lastIndex = j
}
break // 这里需要 break
}
}
if (!find) {
const prevVNode = newChildren[i - 1]
let anchor = null
if (prevVNode) {
anchor = prevVNode.el.nextSibling
} else {
anchor = container.firstChild
}
patch(null, newVNode, container, anchor)
}
}
// 遍历旧的节点
for (let i = 0; i < oldChildren.length; i++) {
const oldVNode = oldChildren[i]
// 拿着旧 VNode 去新 children 中寻找相同的节点
const has = newChildren.find(
vnode => vnode.key === oldVNode.key
)
if (!has) {
// 如果没有找到相同的节点,则移除
unmount(oldVNode)
}
}
} else {
if (Array.isArray(n1.children)) {
n1.children.forEach(c => unmount(c))
} else if (typeof n1.children === 'string') {
setElementText(container, '')
}
}
}
总结
1、学习了简单的Diff算法,Diff的算法核心意义就是为了减少DOM操作,减少性能开销
2、学习了DOM的复用,加入key的必要性,用来确定是否能复用
3、查找需要移动的元素,如何移动元素、添加元素、删除元素