Dom-Diff是如何实现?

Dom diff 则是通过JS层面的计算,返回一个patch对象,即补丁对象,在通过特定的操作解析patch对象,完成页面的重新渲染。

Diff 算法

规则:同层比较

Diff算法中有很多种情况,接下来我们以常见的几种情况做下讨论:

  1. 当节点类型相同时,去看一下属性是否相同 产生一个属性的补丁包 {type:’ATTRS’, attrs: {class: ‘list-group’}}
  2. 新的dom节点不存在 {type: ‘REMOVE’, index: xxx}
  3. 节点类型不相同 直接采用替换模式 {type: ‘REPLACE’, newNode: newNode}
  4. 文本的变化:{type: ‘TEXT’, text: 1}

比较两颗虚拟DOM树的核心diff方法接受oldTree旧DOM树、newTree新DOM树两个参数,根据两个虚拟对象创建出补丁,描述改变的内容,将这个补丁用来更新DOM。该方法的核心在于walk递归树,该方法将比较后的差异节点放到补丁包中,具体递归树核心逻辑请看下方walk递归树小节。

function diff(oldTree, newTree) {
    let patches = {};
    let index = 0; // 默认先比较树的第一层
    // 递归树  比较后的节点放到补丁包中
    walk(oldTree, newTree, index, patches);
    return patches;
}

注意:在比较两棵树之间的差异时,默认先比较树的第一层。

walk 递归树

该递归树方法接受oldNode老节点,newNode新节点,index比较层数,patches存放补丁包四个参数,返回多种判断情况的常见差异存放在补丁包中。

情形一:新节点中删除了子节点

currentPatch.push({type: REMOVE, index: index});

情形二:判断两个文本是否一样

currentPatch.push({type: TEXT, text: newNode});

注意:目前只判断了文本字符串的情况,也存在数字的情况。

在判断两个文本是否一致时,首先要判断是不是属于文本类型,为了程序的可扩展性,我们封装一个判断是否是字符串的公共方法:

function isString(node) {
    return Object.prototype.toString.call(node) === '[object String]';
}

情形三:两个节点数的元素类型相同, 就比较属性

在比较属性是否有更新时,我们需要封装一个diffAttr方法,具体核心逻辑请查看下方diffAttr 属性比较小节。

let attrs = diffAttr(oldNode.props, newNode.props);
// 判断变化的属性结果有没有值
if(Object.keys(attrs).length > 0) { // 属性有更改
    currentPatch.push({type: ATTRS, attrs})
}

注意:在第一层比较完后,若存在儿子节点,需要遍历递归儿子,找出两颗节点数中所有的不同的补丁包。我们需要封装一个diffChildren方法,具体核心逻辑请查看下方diffChildren 遍历儿子小节。

diffChildren(oldNode.children, newNode.children, patches);

情形四:都不相同,节点被替换了

currentPatch.push({type: REPLACE, newNode});

以上情形都判断后,需要判断对应的当前元素确实有补丁,然后返回赋值给自定义的patches补丁对象。

diffAttr 属性比较

该方法接受oldAttrs旧节点属性,newAttrs新节点属性两个参数,其作用是比较两个节点数的属性是否相同,把不同的存放在patch对象中。

在属性比较中,有两种情况来判新旧节点的属性是否有差异。

  1. 判断老的属性中和新的属性的关系

    for(let key in oldAttrs) {
        if(oldAttrs[key] !== newAttrs[key]) {
           patch[key] = newAttrs[key]; // 将新属性存放在patch对象中, 有可能是undefined(如果新的属性中没有老的属性中的属性)
        }
    }
    
  2. 老的节点中没有新节点中的属性

    for(let key in newAttrs) {
       // 老的节点中没有新节点中的属性
       if(!oldAttrs.hasOwnProperty(key)) {
           patch[key] = newAttrs[key];
       }
    }
    

diffChildren 遍历儿子

diffChildren方法中接受oldChildren老的儿子节点,newChildren新的儿子节点,patches补丁对象三个参数。

function diffChildren(oldChildren, newChildren, patches) {
    oldChildren.forEach((child, idx) => {
        walk(child, newChildren[idx], ++Index, patches);
    });
}

注意:索引递增不是遍历的idx和index,而是需要在全局定义一个Index=0

Patch 打补丁

当我们通过Diff算法获取补丁,然后通过patch打补丁来进行更新DOM,从而更新页面视图。

打补丁的核心方法就是patch,它接受node元素节点,patches所有的补丁两个参数,其作用就是给元素打补丁,重新更新视图。该方法最核心的逻辑就在walk方法,请继续阅读下方walk 给每个元素打补丁小节。

function patch(node, patches) {
    console.log(node)
    allPatches = patches;
    // 给某个元素打补丁
    walk(node);
}

walk 给每个元素打补丁

该方法接受node元素节点一个参数,将补丁一次次执行,获取元素的子节点进行递归遍历。若每一层存在补丁,则执行doPatch方法。该方法具体核心逻辑请阅读下方doPatch小节。

function walk(node) {
    let currentPatch = allPatches[index++];
    let childNodes = node.childNodes;
    childNodes.forEach(child => walk(child));
    if(currentPatch) {
        doPatch(node, currentPatch);
    }
}

注意:需要全局定义allPatchesindex变量。

doPatch

doPatch方法接受node那个节点、patches那个补丁两个参数,后序遍历补丁,判断补丁的类型来进行不同的操作:

  1. 当补丁的type为ATTR属性时,遍历属性attrs对象获取值。若值存在则setAttr设置属性值,若值不存在则删除对应的属性。setAttr方法具体的核心逻辑请阅读下方setAttr 设置属性小节。
  2. 当补丁的type为TEXT属性时,直接将补丁的text赋值给对应节点的textContent
  3. 当补丁的type为REMOVE属性时,直接调用父级的removeChild删除该节点。
  4. 当补丁的type为REPLACE属性时,首先需要判断新节点是否为Element元素类型,若是,则直接调用render方法重新渲染新节点;若不是,则通过createTextNode创建一个文本节点。最后调用父级的replaceChild方法替换新的节点即可。
function doPatch(node, patches) {
    patches.forEach(patch => {
        switch(patch.type) {
            case 'ATTRS':
                for(let key in patch.attrs) {
                    let value = patch.attrs[key];
                    if(value) {
                        setAttr(node, key, value);
                    } else{
                        node.removeAttribute(key);
                    }
                }
                break;
            case 'TEXT':
                node.textContext = patch.text;
                break;
            case 'REMOVE':
                node.parentNode.removeChild(node);
                break;
            case 'REPLACE':
                let newNode = (patch.newNode instanceof Element) ? render(patch.newNode) : document.createTextNode(patch.newNode);
                node.parentNode.replaceChild(newNode, node);
                break;
            default:
                break;
        }
    })
}

setAttr 设置属性

setAttr方法设置属性的具体逻辑,请查看上方元素设置属性小节。

验证

在项目根目录创建index.js文件,通过手写修改DOM结构来验证我们上方编写的diff算法逻辑是否正确。

import {createElement, render, renderDom} from './element';
import diff from './diff'
import patch from './patch'
let virtualDom = createElement('ul', {class: 'list'}, [
    createElement('li', {class: 'item'}, ['a']),
    createElement('li', {class: 'item'}, ['a']),
    createElement('li', {class: 'item'}, ['b'])
]);

let virtualDom2 = createElement('ul', {class: 'list-group'}, [
    createElement('li', {class: 'item'}, ['1']),
    createElement('li', {class: 'item'}, ['a']),
    createElement('div', {class: 'item'}, ['3'])
]);

let dom = render(virtualDom);
// 将虚拟DOM转换成了真实DOM渲染到页面上
renderDom(dom, window.root);
// console.log(dom);
let patches = diff(virtualDom, virtualDom2);
// 给元素打补丁,重新更新视图
patch(dom, patches);

总结

从头看到尾,相信很多小伙伴就会觉得DOM-Diff整个过程是很清晰明了的,具体步骤:

  1. 用JS对象模拟DOM(虚拟DOM)
  2. 把此虚拟DOM转成真实DOM并插入页面中(render)
  3. 如果有事件发生修改了虚拟DOM,比较两棵虚拟DOM树的差异,得到差异对象(diff)
  4. 把差异对象应用到真正的DOM树上(patch)
本文项目仓库地址:https://github.com/tangmengcheng/dom-diff.git
posted @ 2020-04-21 10:37  乡乡  阅读(558)  评论(0编辑  收藏  举报