2022年4月29日

虚拟DOM

虚拟DOM

virtual dom也就是虚拟节点,是指通过JS的Object对象模拟DOM中的节点,然后再通过特定的方法渲染成真实的DOM节点。

简易实现

createElement => {type, props, children}

DOM DIFF

DOM DIFF就是比较两个virtual dom对象的区别。根据两个UNI对象创建出补丁,描述改变的内容,将这个补丁用来更新DOM。 DOM DIFF

DIFF三种优化策略

平级比较

平级比较 如上图所示,父div只和父div比较,子级元素只和子级比较,不会跨级比较。 这个图虽然节点数都一样,但是A节点移动到了M节点下,平级比较的话就会删除整个A节点,然后再渲染到M节点下。 同一层节点只是更换了位置,可以不用重新渲染节点,只是更换位置。

差异计算

代码逻辑流程

  • 用JavaScript对象模拟DOM。
  • 把此虚拟DOM转成真是DOM并插入到页面中。
  • 如果有事件发生修改了虚拟DOM比较两颗虚拟DOM树的差异,得到差异对象。
  • 把差异对象应用到真正的DOM树上。

先序深度优先遍历

先遍历顶层节点ul,然后遍历子节点li1,发现并没有li1节点下并没有子节点,接着遍历子节点li2,如果子节点li2下面有子节点,开始遍历子节点li2下面的子节点,以此类推。。。

virtual dom简易实现

实现思路,定义一个Class对象ElementClass有三个属性,type属性映射DOM元素的节点类型;props映射DOM元素上的属性,children表示DOM元素的子节点。通过一个createElement的方法实例化一个Element对象并返回该实例。这个时候就可以生成一个虚拟的DOM树映射真实的DOM树。如果要将virtual dom真实渲染到浏览器上,需要调用一个render方法。render方法接受一个Elment实例,根据实例的type类型调用document.createElement方法来创建一个节点,并根据props字段将其键跟值以及当前创建的DOM传给setAttr方法来设置节点属性。setAttr方法会根据传入DOM的类型来使用不用的方式设置属性。设置完属性后,根据children字段来递归render方法,以此组成完整的DOM树(如果children字段里面的child是文本节点则直接创建一个textNode,不调用render方法)。

Element对象

Element对象就是virtual dom树的JavaScript表示。

class Element {
    constructor(type, props, children) {
        this.type = type;
        this.props = props;
        this.children = children;
    }
}
  • type type字段映射真实DOM的类型
  • props props字段映射真实DOM上的属性信息
  • children children字段表示DOM下的子节点信息,由多个Element对象或者字符串(textNode)组成。

creatElement()方法

createElement方法就是一个工厂方法,用于实例化一个Element对象并返回该实例。

const createElement = function(type, props, children) {
    return new Element(type, props, children);
}

render()方法

render()方法就是返回把虚拟DOM转成真是DOM后生成的字符串,递归循环children字段将所有的Element转成DOM节点。如果render()方法接受的是字符串,就直接转换成textNode

/**
 * render方法将virtual DOM转换成真实DOM
 * @param {Element} eleObj 虚拟DOM对象
 */
function render(eleObj) {
    let el = document.createElement(eleObj.type);
    // Object.keys(eleObj.props)
    for (let key in eleObj.props) {
        // el.setAttribute(key, eleObj.type[key]);
        // 设置属性,不能纯用setAttribute的方式。
        // 比如input的value属性就不能通过setAttribute的方式来设,style样式也不是key-value的形式
        if (eleObj.props.hasOwnProperty(key)) {
            setAttr(
                el,
                key,
                eleObj.props[key]
            )
        }
    }

    if (eleObj.children && eleObj.children instanceof Array) {
        eleObj.children.forEach((child) => {
            let childEl = '';
            childEl = child instanceof Element ? render(child) : document.createTextNode(child);
            el.appendChild(childEl);
        })
    }
    return el;
}

setAttr()

这个方法用于给DOM节点设置属性值。注意DOM节点的prop(属性)和attribute(特性)的区别。

  • 属性和特性两者不是一一对应的:Attr节点对应的就是HTML各标签中的特性,这些特性有的未必会被内置为DOM元素的属性,比如HTML5的data-*特性等自定义特性;而DOM元素的属性也未必都是HTML中的特性,比如一些DOM元素的操作方法。
  • 即使特性节点名和DOM元素的属性名一致,这两者的操作和行为也是不同的:
    • DOM元素的属性是DOM对象原生实现的,符合一般对象属性的行为;这些属性操作和同名的HTML特性节点无关,但可以在显示上覆盖HTML特性节点的设置
    • 对于特性节点的操作都是针对HTML文档上的特性;对特性的操作不会改变同名属性值,只是改变HTML的文档内容而已

DOM元素的属性(property)是该对象所拥有的属性,而特性(attribute)则是该元素在HTML中的所拥有的特性节点。property是对象属性,本身不操作特性节点,但可以覆盖HTML中的同名特性的效果,是js操作;attribute是DOM节点对象,只用于获取和设置HTML特性,是文本操作。

/**
 * 设置属性
 * @param {HTMLElement} node 设置属性的节点
 * @param {String} key 设置属性的属性名
 * @param {any} value 设置属性的属性值
 */
function setAttr(node, key, value) {
    switch(key) {
        case 'value': // input类型或者textarea
            if (node.tagName.toUpperCase() === 'INPUT' ||
                node.tagName.toUpperCase() === 'TEXTAREA') {
                node.value = value;
            } else {
                node.setAttribute(key, value);
            }
            break;
        case 'style':
            node.style.cssText = value;
            break;
        default:
            node.setAttribute(key, value);
            break;
    }
}

renderDom()

经过上述的方法,已经把虚拟DOM转成了真实DOM字符串

// 渲染虚拟DOM到浏览器
function renderDom(el, target) {
    target.appendChild(el);
}

diff算法

diff算法用来查找新旧virtual dom的差异,采用先序深度优先遍历的方法来寻找差异。先对比根节点,然后对比子节点,如果子节点存在子节点,先比较子节点,直到没有子节点后,再比较兄弟节点。通过比较返回一个差异补丁包,然后根据补丁包更新真实DOM。

diff()

diff方法相当于一个入口函数,传入新旧虚拟DOM树,使用先序深度优先遍历的方式比较新旧树的差异并返回差异的补丁包

function diff(oldTree, newTree ) {
    /**
     * 实现思路
     * 当节点类型相同时,看一下属性是否相同,产生一个属性的补丁包{type: 'ATTRS', attrs: {class: 'list-group'}}
     * 新的DOM节点不存在时,补丁包{type: 'REMOVE', index: xxx}
     * 节点类型不相同时,直接采用替换模式,{type: 'REPLACE', newNode: newNode}
     * 文本的变化 {type: 'TEXT', text: 1}
     */
    let patches = {};
    let index = 0;
    // 递归树,比较后的结果帮到补丁包中。
    walk(oldTree, newTree, index, patches);

    return patches;
}

walk()

walk方法就是用于比较新旧树的异同,接受新旧Element节点对象和一个索引以及补丁包。方法内定义一个变量currentPatch数组用于存储当前节点的补丁包。先判断新的Element相同索引节点是否还存在,如果不存在表示已经将该节点删除了,此时currentPatch数组推入一个补丁包信息;如果新Element节点存在,先判断新旧节点是否是字符串,如果是字符串,直接就比较字符串,并push一个补丁报信息;然后比较两个节点的type属性,如果一致的话,调用diffAttr方法,传入当前两个节点的props信息,如果存在children字段,则调用diffChildren方法;如果type字段也不相同说明节点被替换了。

function walk(oldNode, newNode, index, patches) {
    let currentPatch = []; // 每个元素当前的补丁对象
    if (!newNode) { // 如果新节点被删除了
        currentPatch.push({
            type: PATCH_TYPE.REMOVE,
            index: index
        })
    } else if (isString(oldNode) && isString(newNode)) {  //如果node是sting类型 // 判断文本是否一致
        if (oldNode !== newNode) {
            currentPatch.push({
                type: PATCH_TYPE.TEXT,
                text: newNode
            })
        }
    } else if (oldNode.type === newNode.type) {
        // 比较属性是否有更改
        let diffAttrs = diffAttr(oldNode.props, newNode.props);
        if (Object.keys(diffAttrs).length) {
            currentPatch.push({
                type: PATCH_TYPE.ATTRS,
                attrs: diffAttrs
            })
        }
        // 比较子节点
        diffChildren(oldNode.children, newNode.children, index, patches)
    } else {
        // 节点被替换了
        currentPatch.push({
            type: PATCH_TYPE.REPLACE,
            newNode
        })
    }

    if (currentPatch.length) { // 当前元素存在补丁,将元素和补丁对应起来放到patches中
        patches[index] = currentPatch;
    }
}

diffAttr()

比较节点新旧属性的异同点,先遍历旧的属性跟新的属性进行比较,找出新属性修改了的和删除了的属性,然后遍历新属性与旧属性进行比较,找出新增的属性。

/**
 * 比较新老props
 * @param oldProps
 * @param newProps
 */
function diffAttr(oldProps, newProps) {
    let patch = {};
    // 判断旧的属性和新的属性关系
    for (let key in oldProps) {
        if (oldProps.hasOwnProperty(key)) {
             if (oldProps[key] !== newProps[key]) {
                 patch[key] =  newProps[key] // 新的props如果删除了属性,直接属性值变成undefined就行
             }
        }
    }
    // 判断新的属性与旧的属性的关系,有可能新的属性新增了属性了
    for (let key in newProps) {
        if (newProps.hasOwnProperty(key)) {
            if (!oldProps.hasOwnProperty(key)) { // 如果新的属性不存在于旧的props的自身属性中
                patch[key] = newProps[key]
            }
        }
    }
    return patch;
}

diffChildren()

这个方法用来比较子节点的异同,并返回补丁包。值得注意的是,这里调用walk方法的时候传入的索引值应该是一个全局的索引,用以保证先序深度优先算法所对应的索引一致。

/**
 * 比较新老节点的子节点
 * @param oldNodeChildren
 * @param newNodeChildren
 * @param index
 * @param patches
 */
function diffChildren(oldNodeChildren, newNodeChildren, index, patches) {
    // 比较老的第一个和新的第一个
    oldNodeChildren.forEach((child, idx)  => {
        // 索引不应该是index了
        // index 每次传递给walk时 index是递增的。
        // 所有的index都基于一个全局Index
        walk(child, newNodeChildren[idx], ++Index, patches)
    })
}

打补丁

通过diff算法已经找出了差异,使用patch方法来将补丁包更新到DOM上

patch.js

import {PATCH_TYPE} from "./diff";
import {render, setAttr} from "./element";

let allPatches;
let index = 0; // 默认哪个需要打补丁
function patch(node, patches) {
    // 给元素打补丁
    allPatches = patches;
    walk(node)
}
// 递归树遍历
function walk(node) {
    let currentPatch = allPatches[index++];
    let childNodes = node.childNodes;
    childNodes.forEach(child => walk(child));
    // 先找后代,直到没有后代后再打补丁。补丁操作在查找后代后面。
    if (currentPatch && currentPatch.length) { // 如果补丁存在
        doPatch(node, currentPatch);
    }
}

// 打补丁
function doPatch(node, patches) {
    patches.forEach(path => {
        switch (path.type) {
            case PATCH_TYPE.ATTRS:
                for (let key in path.attrs) {
                    let value = path.attrs[key];
                    if (value) {
                        setAttr(node, key, value);
                    } else {
                        node.removeAttribute(key);
                    }
                }
                break;
            case PATCH_TYPE.TEXT:
                node.textContent = path.text;
                break;
            case PATCH_TYPE.REPLACE:
                const newNode = path.newNode ? render(path.newNode) : document.createTextNode(path.newNode);
                node.parentNode.replaceChild(newNode, node);
                break;
            case PATCH_TYPE.REMOVE:
                node.parentNode.removeChild(node);
                break;
        }
    })
}

export default patch;

创建一个全局的索引index并默认为0。在patch方法中调用walk方法遍历递归树,walk方法里根据索引找到当前节点的补丁包,然后先遍历子节点,递归调用walk方法,知道没有后代节点后,再进行补丁操作。

  • doPatch()doPatch()方法用于对DOM进行打补丁操作。遍历传入的补丁包列表,根据每个补丁包的type字段进行不同的补丁操作;

不足之处

  • 如果平级元素互换位置,会导致重新渲染
  • 新增节点也不会被更新
Share