什么是Diff算法?当新旧vnode的子节点都是一组节点时,为了以最小的性能开销完成更新操作,需要比较两组子节点,用于比较的算法就叫diff算法;操作dom的性能开销通常比较大,diff算法就是为了解决这个问题诞生的
简单diff算法
减少DOM操作开销
核心diff只关心新旧虚拟节点都存在一组子节点的情况。如果直接卸载全部子节点,再挂载全部新子节点,没有复用任何DOM元素,会产生极大的性能开销
const oldVnode = {
type: 'div',
children: [
{type: 'p', children: '1'},
{type: 'p', children: '2'},
{type: 'p', children: '3'},
]
}
const newVnode = {
type: 'div',
children: [
{type: 'p', children: '1'},
{type: 'p', children: '5'},
{type: 'p', children: '6'},
]
}
上面的例子中,如果直接卸载后挂载,需要进行6次DOM操作,但是如果仔细看可以发现,子节点的标签类型是没有变化的,并且有一个子节点没有修改,那么可以直接修改有变化的子节点的文本内容,这样就只需要进行两次DOM操作
function patchChildren(n1, n2, container){
if(typeof n2.children === 'string') {
//无子节点
} else if(Array.isArray(n2.children)){
const oldChildren = n1.children
const newChildren = n2.children
for(let i = 0; i < oldChildren.length; i++){
patch(oldChildren[i], newChildren[i], container)
}
}
else {
//其他节点,例如组件
}
}
可以遍历旧节点,循环调用patch函数进行更新(此处的patch函数可以实现挂载更新等操作,此处patch进行更新)
这种做法虽然能减少DOM节点操作次数,但是新旧节点子节点数量不一定相同,会有一些节点必须被卸载或者新挂载一些节点;因此,我们在遍历时不应该总是遍历旧节点的长度,而是应该遍历长度较短的那一组节点,这样我们能尽可能调用patch进行更新,然后再处理新挂载的子节点或者需要卸载的子节点。(可以自己尝试写出伪代码)
function patchChildren(n1, n2, container){
if(typeof n2.children === 'string') {
//无子节点
} 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], container)
}
if(newLen > oldLen) { // 有新子节点需要挂载
for(let i = commonLength; i < newLen; i++){
patch(null, newChildren[i], container)
}
} else if(oldLen > newLen) { // 有旧子节点需要卸载
for(let i = commonLength; i < newLen; i++){
unmount(oldChildren[i])
}
}
}
else {
//其他节点,例如组件
}
}
复用DOM节点
const oldVnode = {
type: 'div',
children: [
{type: 'p'},
{type: 'div'},
{type: 'span'},
]
}
const newVnode = {
type: 'div',
children: [
{type: 'span'},
{type: 'p'},
{type: 'div'},
]
}
以上例子中,如果使用上一节的方法进行更新,需要6次DOM操作,显然不太合适;观察两组节点,我们发现子节点只是顺序不同,所以最优的处理方式就是通过DOM的移动来完成子节点的更新。所以问题变成了怎么确定旧子节点是否出现在新子节点中,通过标签类型比较显然不可靠,因为还有其他的一些属性可能不相同。这时,我们就需要引入key值来作为vnode的标识,只要标签类型与key值都相同,我们就认为这个DOM元素是可复用的。如果没有key,我们无法知道新旧子节点之间的映射关系,也就无法知道该怎么移动节点。
需要强调一点,DOM元素可以复用并不意味着不需要更新,我们可以复用的同时更新节点其他的属性;因此在讨论如何移动DOM之前,我们需要先完成更新操作:
function patchChildren(n1, n2, container){
if(typeof n2.children === 'string') {
//无子节点
} else if(Array.isArray(n2.children)){
const oldChildren = n1.children
const newChildren = n2.children
//遍历新的children
for(let i = 0; i < newChildren.lenght; i++){
const newVnode = newChildren[i]
//遍历旧的children
for(let j = 0; j < oldChildren.lenght; j++){
const oldVnode = oldChildren[j]
//key相等,可以复用,但仍然需要先进行patch更新
if(newVnode.key === oldVnode.key) {
patch(oldVnode, newVnode, container)
break;
}
}
}
}
else {
//其他节点,例如组件
}
}
找到需要移动的元素
当新旧两组子节点的节点顺序不变时,就不需要额外的移动操作。
我们根据上图,按照上一节的更新算法的顺序遍历一下这组节点:
- 取第一个节点p-3,key为3,索引为0,在旧节点中尝试找到具有相同key值的节点,能够找到,并且在旧子节点中索引为2
- 取新的一组子节点中的第二个子节点p-1,key为1,在旧子节点中索引为2,在旧节点中尝试找到具有相同key值的节点,能够找到,并且在旧子节点中索引为0
- 取新的一组子节点中的第三个子节点p-2,key为2,在旧子节点中索引为3,在旧节点中尝试找到具有相同key值的节点,能够找到,并且在旧子节点中索引为1
我们按先后顺序记录在寻找节点中所遇到的位置索引,将会得到序列2、0、1,可以发现,这个序列没有递增趋势;而如果是一组顺序完全享用的节点,位置索引的序列将会呈现递增趋势,因此当位置索引开始出现不递增的索引时,就代表该索引的节点需要移动,如上面的p-1与p-2;我们可以将节点在旧children中的索引定义为在旧children中寻找具有相同key值节点的过程中,遇到的最大索引值(这里不说定义为key值相同的节点的索引值的原因是后面可能会出现找不到key值相同节点的情况)。在后续的寻找过程中,存在索引值比当前遇到的最大索引值还要小的节点,则意味着该节点需要移动;可以用lastIndex存储整个寻找过程中遇到的最大索引值,伪代码如下:
function patchChildren(n1, n2, container){
if(typeof n2.children === 'string') {
//无子节点
} else if(Array.isArray(n2.children)){
const oldChildren = n1.children
const newChildren = n2.children
//遍历新的children
for(let i = 0; i < newChildren.lenght; i++){
const newVnode = newChildren[i]
//遍历旧的children
for(let j = 0; j < oldChildren.lenght; j++){
const oldVnode = oldChildren[j]
//key相等,可以复用,但仍然需要先进行patch更新
if(newVnode.key === oldVnode.key) {
patch(oldVnode, newVnode, container) // 更新节点内容】
if(j < lastIndex){
// 如果当前找到的节点在旧children中的索引小于最大索引值lastIndex
// 说明该节点对应的DOM元素需要移动
} else {
lastIndex = j
}
break;
}
}
}
}
else {
//其他节点,例如组件
}
}
这里只看代码可能有点难理解lastIndex的作用,可以代入例子里循环一遍理解一下
如何移动元素
移动节点指的是移动一个虚拟节点所对应的真实DOM节点,并不是移动虚拟节点本身。既然移动的是真实DOM节点,那么就要取得引用。真实DOM节点会存在vnode.el属性中。
更新操作发生时patch函数其实就是DOM元素的复用,复用之后新节点会持有对真实DOM的引用
可以看到无论是新节点还是旧节点,都存在对真实DOM的引用;在此基础上就可以进行DOM移动操作了
- 取新的一组子节点中的第一个节点p-3,key为3,尝试在旧子节点中找到具有相同key值的可复用节点;发现能够找到,并且该节点在旧子节点中的索引为2,此时lastIndex变量的值为0,索引2不小于0,因此不需要移动,但需要更新吧lastIndex的值为2
- 取新的一组子节点中的第二个节点p-1,key为1,尝试在旧子节点中找到具有相同key值的可复用节点;发现能够找到,并且该节点在旧子节点中的索引为0,此时lastIndex变量的值为2,索引0小于2,因此需要移动;
我们发现需要移动,但是要移动到哪里呢?我们知道,新children的顺序就是更新后真实DOM的顺序,所以p-1在新children中的位置就代表了真实DOM更新后的位置,所以我们应该把p-1移动到p-3的houmian,如上图
第三步同理,如上图
接下来我们可以实现下伪代码
function patchChildren(n1, n2, container){
if(typeof n2.children === 'string') {
//无子节点
} else if(Array.isArray(n2.children)){
const oldChildren = n1.children
const newChildren = n2.children
//遍历新的children
for(let i = 0; i < newChildren.lenght; i++){
const newVnode = newChildren[i]
//遍历旧的children
for(let j = 0; j < oldChildren.lenght; j++){
const oldVnode = oldChildren[j]
//key相等,可以复用,但仍然需要先进行patch更新
if(newVnode.key === oldVnode.key) {
patch(oldVnode, newVnode, container) // 更新节点内容】
if(j < lastIndex){
// 如果当前找到的节点在旧children中的索引小于最大索引值lastIndex
// 说明该节点对应的DOM元素需要移动
const prevVnode = newChildren[i-1] //获取newVnode的前一个vnode,若不存在不需要移动
if(prevVnode){
// 获取prevVNode所对应真实DOM的下一个兄弟节点,并将其作为锚点,调用insert方法插入
const anchor = prevVnode.el.nextSibling
insert(newVnode.el, container, anchor)
}
} else {
lastIndex = j
}
break;
}
}
}
}
else {
//其他节点,例如组件
}
}
添加新元素
对于新增节点,在更新时我们应该正确地将它挂在,主要分为两步:
- 想办法找到新增节点
- 将新增节点挂载到正确位置
···省略前几步
取新子节点的第三个节点p-4,key值为4,在旧子节点中没有key值为4的节点,因此需要挂载它,
需要观察p-4在新子节点中的位置,由于出现在p-1后面,所以我们应该挂在到p-1所对应的真实DOM后面
···省略后几步
伪代码如下:
function patchChildren(n1, n2, container){
if(typeof n2.children === 'string') {
//无子节点
} else if(Array.isArray(n2.children)){
const oldChildren = n1.children
const newChildren = n2.children
//遍历新的children
for(let i = 0; i < newChildren.lenght; i++){
const newVnode = newChildren[i]
//遍历旧的children
let j = 0
let find = false
for(j; j < oldChildren.lenght; j++){
const oldVnode = oldChildren[j]
//key相等,可以复用,但仍然需要先进行patch更新
if(newVnode.key === oldVnode.key) {
find = true
patch(oldVnode, newVnode, container) // 更新节点内容】
if(j < lastIndex){
// 如果当前找到的节点在旧children中的索引小于最大索引值lastIndex
// 说明该节点对应的DOM元素需要移动
const prevVnode = newChildren[i-1] //获取newVnode的前一个vnode,若不存在不需要移动
if(prevVnode){
// 获取prevVNode所对应真实DOM的下一个兄弟节点,并将其作为锚点,调用insert方法插入
const anchor = prevVnode.el.nextSibling
insert(newVnode.el, container, anchor)
}
} else {
lastIndex = j
}
break;
}
if(!find){
//先获取锚点元素
//首先获取当前newVnode的前一个vnode节点
const prevVnode = newChildren[i-1]
let anchor = null
if(prevVnode){
anchor = prevVnode.el.nextSibling
} else {
//如果没有前一个节点,说明即将挂载的新节点时第一个子节点
// 这时我们使用容器元素的firstChildren作为锚点
anchor = container.firstChild
}
patch(null,newVnode,container,anchor)
}
}
}
}
else {
//其他节点,例如组件
}
移除不存在的元素
进行移动与挂载节点后,我们需要遍历旧子节点一遍,然后取新子节点中寻找具有相同key值的节点,如果找不到,说明应该删除该节点,伪代码如下:
function patchChildren(n1, n2, container){
if(typeof n2.children === 'string') {
//无子节点
} else if(Array.isArray(n2.children)){
const oldChildren = n1.children
const newChildren = n2.children
//遍历新的children
for(let i = 0; i < newChildren.lenght; i++){
//省略更新、插入代码
}
//上一步更新操作完成后
//遍历旧的一组子节点
for(let -i=0;i<oldChildren.length;i++){
const oldVnode = oldChildren[i]
const has = newChildren.find(
vnode=>vnode.key === oldVnode.key
)
if(!has){
//如果没有相同key的节点,说明需要删除该节点
//调用unmount函数将其卸载
unmount(oldVnode)
}
}
}
else {
//其他节点,例如组件
}
第二节-双端Diff算法正在写作中...
欢迎关注B站小南前端の干货分享