9.1 减少DOM操作的性能开销
这一节要解决的问题,就是第八章的时候比较Vnode新与旧的的子节点,当新与旧的子节点都是一组的情况,之前的方法是直接将所以旧的子节点,调用unmount循环全部卸载掉,这样做会导致性能开销,要先卸载,然后再重新渲染,如果能复用节点,将节省性能,特别是如果子节点变化不大,如果能精准更新旧更好了,
这里做三种假设:
(1)新子节点数量和旧子节点数量一样,只是节点内容不同,只需要一个一个子节点的Vnode比较即可
(2)新子节点数量和旧子节点数量多,那么多的部分,就调用patch(null, newChildren[i], container)
添加新的子节点
(3)新子节点数量和旧子节点数量少,那么少的部分,就调用unmount(oldChildren[i])
删除多的旧的子节点
注意:这里没有考虑 新旧子节点之间是的顺序是乱的,预设他们子节点顺序是一致的
代码如下:
// 比较新旧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
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]) //按顺序最小长度比较两个的虚拟DOM
}
// 如果 nextLen > prevLen,将多出来的元素添加
if (newLen > oldLen) { //新子节点长度大,则要添加元素大于 commonLength
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, '')
}
}
}
// 比较虚拟DOM
function patch(n1, n2, container) {
if (n1 && n1.type !== n2.type) {
unmount(n1)
n1 = null
}
const { type } = n2
if (typeof type === 'string') {
if (!n1) { // 第一次或类型,不同
mountElement(n2, container)
} else {
patchElement(n1, n2)//新旧虚拟DOM类型一样,
}
} 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) { //新的是Fragment
if (!n1) {
n2.children.forEach(c => patch(null, c, container))
} else {
// 比较新旧DOM的子节点
patchChildren(n1, n2, container)
}
}
}
const Fragment = Symbol()
const newVnode = {
type: 'div',
children: [
{ type: 'p', children: '1' },
{ type: 'p', children: '2' },
]
}
renderer.render(newVnode, document.querySelector('#app'))
const oldVnode = {
type: 'div',
children: [
{ type: 'p', children: '4' },
{ type: 'p', children: '5' },
{ type: 'p', children: '6' }
]
}
setTimeout(() => {
console.log('update')
renderer.render(oldVnode, document.querySelector('#app'))
}, 400);
9.2 DOM复用与key的作用
这一节就是要解决上一节中,预设Vnode新旧子节点的顺序是一致的,如果他们是不一致的那么其实上面的代码和直接全部卸载,再全部添加的性能可能好一点,但不是最优解。
这里要解决的问题是如何判断新旧子节点中那些节点是共有的?
一种方法是使用新旧子节点的type来遍历新旧子节点,如果可复用(可复用不代表完全一样,也不一定是不需要更新的) 就表示他们是共有的,
但这种方法不靠谱,例如:
新子节点有个type是div,而旧的子节点中可能有多个type为div的,他不具有唯一性。
而且还有一个麻烦的问题,新旧子节点的这个相同,并不是完全相同,所以也不能直接比较对象是否相同
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, '')
}
}
}
const Fragment = Symbol()
const newVnode = {
type: 'div',
children: [
{ type: 'p', children: '1', key: 1 },
{ type: 'p', children: '2', key: 2 },
{ type: 'p', children: 'hello', key: 3 }
]
}
renderer.render(newVnode, document.querySelector('#app'))
const oldVnode = {
type: 'div',
children: [
{ type: 'p', children: 'world', key: 3 },
{ type: 'p', children: '1', key: 1 },
{ type: 'p', children: '2', key: 2 }
]
}
注意:这里的代码只是预设新旧子节点个数相对,都是可复用的,只是位置不同,然后执行完后,新的真实的DOM的位置与旧的Vnode的位置是一样的,顺序没有更新
9.3 找到需要移动的元素
这一节解决上一节,更新的问题,
如图,新子节点p-3对应的可复用的旧子节点的索引最大为2,而新的子节点p-1,p-2都比
p-3的小,所以p-1 p-2需要移动,注意:这里是找到最大索引p-3,后后面的节点需要移动,前面的不需要,找最大代码如下:
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);//更新旧的子节点el的内容
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, '')
}
}
}
const newVnode = {
type: 'div',
children: [
{ type: 'p', children: 'A', key: 1 },
{ type: 'p', children: 'F', key: 6 },
{ type: 'p', children: 'C', key: 3 },
{ type: 'p', children: 'E', key: 5 },
{ type: 'p', children: 'B', key: 2 },
{ type: 'p', children: 'D', key: 4 },
]
}
const oldVnode = {
type: 'div',
children: [
{ type: 'p', children: 'A', key: 1 },
{ type: 'p', children: 'B', key: 2 },
{ type: 'p', children: 'C', key: 3 },
{ type: 'p', children: 'D', key: 4 },
{ type: 'p', children: 'E', key: 5 },
{ type: 'p', children: 'F', key: 6 }
]
}
renderer.render(oldVnode, document.querySelector('#app'))
setTimeout(() => {
console.log('update')
renderer.render(newVnode, document.querySelector('#app'))
}, 400);
测试数据,
旧子节点 | 新子节点 | 索引 |
---|---|---|
A | A | 0 |
B | F | 1 |
C | C | 2 |
D | E | 3 |
E | B | 4 |
F | D | 5 |
需要移动的Vnode如下
注意是新的子节点的key是乱的,旧的是按顺序的
9.4 如何移动元素
只要遇到需要移动的就移动将虚拟DOM对应的真实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 // 当新Vnode上一个Vnode的真实DOM的下一个DOM,
insert(newVNode.el, container, anchor) // 当前新Vonde对应的DOM,插入到 当新Vnode上一个Vnode的真实DOM的下一个DOM之前
}
} 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, '')
}
}
}
insert(el, parent, anchor = null) {
parent.insertBefore(el, anchor)
},
以上的代码就完成了顺序不一样的情况下,更新内容,并且让真实DOM顺序跟新,但是这里没有考虑新增节点,和删除多余节点
9.5 添加新元素
考虑到上面的添加新元素,
有两个问题要解决,1.如何找到新元素,2.他对应的Dom该添加到哪里?
对于1,可以利用newChildre这一层循环开始,设置一个标记,oldChildren结束,看是否是可复用的节点,
这样就可以找到新元素了
对于2,对应的DOM和旧元素一样,添加到他上面Vnode的真实的DOM的后面
如下代码
如果prevVNode存在就插入到他上面Vnode的Dom下面的兄弟节点的上面
如果prevVNode不存在就插入到他父节点的第一个,说明自己就是第一个
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 // 这个newVnode是一个
patch(oldVNode, newVNode, container)
if (j < lastIndex) {
// 需要移动
const prevVNode = newChildren[i - 1]
if (prevVNode) {
const anchor = prevVNode.el.nextSibling ;// 为null就插入到最后,
insert(newVNode.el, container, anchor) // anchor不为null,el插入到anchor之前,也就是prevVNode之后
}
} else {
// 更新 lastIndex
lastIndex = j
}
break // 这里需要 break
}
}
// 这个newVnode循环完了
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)//挂载新节点,根据anchor插入到合适的位置
}
}
} else {
if (Array.isArray(n1.children)) {
n1.children.forEach(c => unmount(c))
} else if (typeof n1.children === 'string') {
setElementText(container, '')
}
}
}
9.6 移除不存在的元素
这一节,要解决的就是当新子节点中没有,旧子节点有的节点的删除,主要的问题是如何找到旧不可复用的子节点
解决方法很简单,当前面DOM调准完成,并且添加新的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
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)
}
}
// 上面已经将DOM顺序调整了,添加的行DOM,遍历旧的节点
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, '')
}
}
}
总结:以上就实现了新,旧节点的比较,事件添加,class,style特殊处理,并添加给DOM,新旧元素的子元素使用Diff算法进行复用DOM,
简单Diff算法简单来说:
就是遍历新Vnode的子节点,找到与旧子节点一样可以复用的节点(用key来鉴别)
如过当前子节点,比之前节点索引值小,就说明要移动,移动到他上面节点真实Dom的下面,如果是新增的子节点也是一样的移动方式,旧节点等移动完和添加完真实DOM,用旧Vnode遍历比较新Vnoe,就可以找到了需要删除的DOM了
简单Diff算法移动DOM流程,并且添加多新节点
完整代码如下:
<div id="app"></div>
<script>
// 识别是不是特别的属性(例如只读),或者不能使用props添加的属性
function shouldSetAsProps(el, key, value) {
if (key === 'form' && el.tagName === 'INPUT') return false
return key in el
}
function createRenderer(options) {
const {
// createElement,
// insert,
// setElementText,
// patchProps,
// createText,
// setText
createElement,// createElement(vnode.type) 根据字符串创建DOM
insert, // insert(el, container) 将el添加到夫标签上面
setElementText, // setElementText(el, vnode.children) 为el内添加文字
patchProps, // patchProps(el, key, oldProps[key], newProps[key]) 给标签添加属性
createText, // createText(text) 文本节点 text是字符串
createComment,
setText // 给文本节点设置el
} = options
function mountElement(vnode, container, anchor) {
const el = vnode.el = createElement(vnode.type)
if (typeof vnode.children === 'string') {
setElementText(el, vnode.children)
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach(child => {
patch(null, child, el)
})
}
if (vnode.props) {
for (const key in vnode.props) {
patchProps(el, key, null, vnode.props[key])
}
}
insert(el, container, anchor)
}
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, '')
}
}
}
function patchElement(n1, n2) {
const el = n2.el = n1.el
const oldProps = n1.props
const newProps = n2.props
for (const key in newProps) {
if (newProps[key] !== oldProps[key]) {
patchProps(el, key, oldProps[key], newProps[key])
}
}
for (const key in oldProps) {
if (!(key in newProps)) {
patchProps(el, key, oldProps[key], null)
}
}
patchChildren(n1, n2, el)
}
function unmount(vnode) {
if (vnode.type === Fragment) {
vnode.children.forEach(c => unmount(c))
return
}
const parent = vnode.el.parentNode
if (parent) {
parent.removeChild(vnode.el)
}
}
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
}
}
const renderer = createRenderer({
createElement(tag) {
return document.createElement(tag)
},
setElementText(el, text) {
el.textContent = text
},
insert(el, parent, anchor = null) {
parent.insertBefore(el, anchor)
},
createText(text) {
return document.createTextNode(text)
},
setText(el, text) {
el.nodeValue = text
},
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
const invokers = el._vei || (el._vei = {})
let invoker = invokers[key]
const name = key.slice(2).toLowerCase()
if (nextValue) {
if (!invoker) {
invoker = el._vei[key] = (e) => {
console.log(e.timeStamp)
console.log(invoker.attached)
if (e.timeStamp < invoker.attached) return
if (Array.isArray(invoker.value)) {
invoker.value.forEach(fn => fn(e))
} else {
invoker.value(e)
}
}
invoker.value = nextValue
invoker.attached = performance.now()
el.addEventListener(name, invoker)
} else {
invoker.value = nextValue
}
} else if (invoker) {
el.removeEventListener(name, invoker)
}
} else if (key === 'class') {
el.className = nextValue || ''
} else if (shouldSetAsProps(el, key, nextValue)) {
const type = typeof el[key]
if (type === 'boolean' && nextValue === '') {
el[key] = true
} else {
el[key] = nextValue
}
} else {
el.setAttribute(key, nextValue)
}
}
})
const Fragment = Symbol()
const VNode1 = {
type: 'div',
children: [
{ type: 'p', children: '1', key: 1 },
{ type: 'p', children: '2', key: 2 },
{ type: 'p', children: 'hello', key: 3 }
]
}
renderer.render(VNode1, document.querySelector('#app'))
const VNode2 = {
type: 'div',
children: [
{ type: 'p', children: '1', key: 1 },
{ type: 'p', children: '2', key: 2 }
]
}
setTimeout(() => {
console.log('update')
renderer.render(VNode2, document.querySelector('#app'))
}, 400);
</script>