# 虚拟DOM之更新

# 回顾

# 什么是虚拟DOM

虚拟DOM简而言之就是,用JS去按照DOM结构来实现的树形结构对象,一般称之为虚拟节点(VNode)

例1

<div class="container" style="color:red">
<div>
let VNode = {
    tag: 'div',
    data:{
        class:'container',
        style:{
            color:'red'
        }
    },
    children:[]
}

例2

我是文本
let VNode = {
    tag:null,
    children:'我是文本'
}

例3

<div class="container">
    <!-- 子元素1 -->
    <!-- 子元素2 -->
<div>
let VNode = {
    tag: 'div',
    data:{
        class:'container'
    },
    children:[
        VNode1, // 对应子元素1
        VNode2  // 对应子元素2
    ]
}

完整的例子:

<div class="container">
    <h1 style="color:red">标题</h1>
    <span style="color:grey">内容</span>
<div>

对应的VNode结构如下:

let VNode = {
    tag: 'div',
    data:{
        class:'container'
    },
    children:[
        {
            tag:'h1',
            data:null,
            children:{
                data: {
                    style:{
                        color:'red'
                    }
                },
                children: '标题'
            }
        },
        {
            tag:'span',
            data:null,
            children:{
                data: {
                    style:{
                        color:'grey'
                    }
                },
                children: '内容'
            }
        },
    ]
}

# 什么是h函数

h函数作为创建VNode对象的函数封装,React中通过babel将JSX转换为h函数的形式,Vue中通过vue-loader将模板转换为h函数。

function h(tag = null,data = null,children = null){
    // ...
}

假如在Vue中我们有如下模板:

<template>
  <div>
    <h1></h1>
  </div>
</template>

用 h 函数来创建与之相符的 VNode:

const VNode = h('div', null, h('span'))

得到的 VNode 对象如下:

const VNode = {
  tag: 'div',
  data: null,
  children: {
    tag: 'span',
    data: null,
    children: null
  }
}

# 什么是虚拟DOM的挂载

虚拟DOM的挂载就是将虚拟DOM转化为真实DOM的过程

主要用到如下原生属性或原生方法:

  • 创建标签:document.createElement(tag)
  • 创建文本:document.createTextNode(text);
  • 追加节点:parentElement.appendChild(element)

# render函数是做什么的

render函数的作用就是:将VNode转化为真实DOM

接收两个参数:

  • 虚拟节点
  • 挂载的容器
function render(VNode,container){
    //...
}

# 演示

通过 h函数 和 render函数,生成如下结构的html

容器:

<div id="container"></div>

像容器中插入如下html片段:

<div id="bingshan" class="c1 c2" style="background: rgba(0, 132, 255, 0.1); padding: 10px;">
    <span>我是span1</span>
    <br>
    <span>我是span2</span>
</div>

代码如下:

let container = document.getElementById('container');

let VNode = h('div',
    {
        style: {
            background: '#0084ff1a',
            padding:'10px'
        },
        id:'bingshan',
        class:['c1 c2'],
        onclick:function(){
            alert('VNode')
        }
    }, 
    [
        h('span',null,'我是span1'),
        h('br'),
        h('span',null,'我是span2')
    ]
)

// 挂载
render(VNode, container)

结果如下:

# 什么是虚拟DOM的更新

虚拟DOM的更新指的是:当节点对应的VNode发生变更时,比较新旧VNode的异同,更新真实DOM节点

虚拟DOM更新时依然会调用Render函数

本文暂不涉及Vue和React中当数据变化时是如何重新生成VNode以及如何调用Render函数的,在此通过手动调用的方式来模拟:

let prevVNode = {
    //...
}
let nextVNode = {
    //...
}

//挂载
render(prevVNode,container)

//更新
setTimeout(function(){
    render(nextVNode,container)
},2000)

# render

由于更新时需要获取prevVNode与nextVNode进行比较,所以在挂载时,将prevVNode存储在容器节点的属性上,方便更新时使用。

function render(VNode,container){
    //初始化渲染
    mount(vNode,container);
    container.vNode = vNode;
}

既然容器节点的属性存储了prevVNode,那么我们就可以在调用render函数时,通过判断是否有vNode这个属性,来判断是挂载还是更新。

function render(vNode,container){
    const prevVNode = container.vNode;
    //之前没有-挂载
    if(prevVNode === null || prevVNode === undefined){
        if(vNode){
            mount(vNode,container);
            container.vNode = vNode;
        }
    }
    //之前有-更新
    else{
        //....
    }
}

我们在更新的时候,又分为两种情况:

  1. prevVNode和nextVNode都有,执行比较操作
  2. 有prevVNode没有nextVNode,删除prevVNode对应的DOM即可
function render(vNode,container){
    const prevVNode = container.vNode;
    //之前没有-挂载
    if(prevVNode === null || prevVNode === undefined){
        if(vNode){
            mount(vNode,container);
            container.vNode = vNode;
        }
    }
    //之前有-更新
    else{
        //之前有,现在也有
        if(vNode){
            //比较
        }
        //以前有,现在没有,删除
        else{
            //删除原有节点
        }
    }
}

我们先考虑有prevVNode没有nextVNode的情况,此时需要删除prevVNode对应的DOM节点

那么如何获取prevVNode对应的DOM节点呢?

我们可以在挂载的阶段,将dom节点作为属性存储在prevVNode上:

function mountElement(VNode, container) {
    //省略...
    const el = createElement(tag);
    VNode.el = el;
    //省略...
}

function mountText = (VNode, container) {
    const el = createTextNode(VNode.children);
    vNode.el = el;
    appendChild(container, el);
}

再考虑有prevVNode也有nextVNode的情况,此时需要对二者进行对比,考虑实现patch函数

function patch(prevVNode,nextVNode,container){
    //...
}

最终render函数的代码如下:

function render(vNode,container){
    const prevVNode = container.vNode;
    //之前没有-挂载
    if(prevVNode === null || prevVNode === undefined){
        if(vNode){
            mount(vNode,container);
            container.vNode = vNode;
        }
    }
    //之前有-更新
    else{
        //之前有,现在也有
        if(vNode){
            patch(prevVNode,vNode,container);
            container.vNode = vNode;
        }
        //以前有,现在没有,删除
        else{
            removeChild(container,prevVNode.el);
            container.vNode = null;
        }
    }
}

# patch

现在我们来考虑,prevVNode 和 nextVNode 是如何进行对比的。

我们现在将VNode只分为了两类:

  1. 元素节点
  2. 文本节点

那么 prevVNode 和 nextVNode 可能出现的情况只会有以下三种:

  1. 二者类型不同
  2. 二者都是文本节点
  3. 二者都是元素节点,且标签相同

当二者类型不同时,只需删除原节点,挂载新节点即可:

function patch (prevVNode, nextVNode, container) {
    removeChild(container, prevVNode.el);
    mount(nextVNode, container);
}

当二者都是文本节点时,只需修改文本即可

function patch (prevVNode, nextVNode, container) {
    const el = (nextVNode.el = prevVNode.el)
    if(nextVNode.children !== prevVNode.children){
        el.nodeValue = nextVNode.children;
    }
}

当二者都是元素节点且标签相同时,此时比较麻烦,考虑是一个patchElement函数用于处理此种情况

function patch (prevVNode, nextVNode, container) {
    patchElement(prevVNode, nextVNode, container)
}

最终 patch 函数的代码如下:

function patch (prevVNode, nextVNode, container) {
    // 类型不同,直接替换
    if ((prevVNode.tag || nextVNode.tag) && prevVNode.tag !== nextVNode.tag) {
        removeChild(container, prevVNode.el);
        mount(nextVNode, container);
    }
    // 都是文本
    else if(!prevVNode.tag && !nextVNode.tag){
        const el = (nextVNode.el = prevVNode.el)
        if(nextVNode.children !== prevVNode.children){
            el.nodeValue = nextVNode.children;
        }
    }
    // 都是相同类型的元素
    else {
        patchElement(prevVNode, nextVNode, container)
    }
}

# 比较相同tag的VNode(patchElement)

因为tag相同,所以patchElement函数的功能主要有两个:

  1. 检查prevVNode和nextVNode对应的元素属性是否一致(style、class、event等),不一致更新
  2. 比较prevVNode和nextVNode对应的子节点(children)

关于元素属性的比较与挂载阶段的逻辑基本一致,就不在此继续展开,我们主要考虑如何对子节点进行比较

子节点可能出现的情况有三种:

  1. 没有子节点
  2. 一个子节点
  3. 多个子节点

所以关于prevVNode和nextVNode子节点的比较,共有9种情况:

  1. 旧:单个子节点 && 新:单个子节点
  2. 旧:单个子节点 && 新:没有子节点
  3. 旧:单个子节点 && 新:多个子节点
  4. 旧:没有子节点 && 新:单个子节点
  5. 旧:没有子节点 && 新:没有子节点
  6. 旧:没有子节点 && 新:多个子节点
  7. 旧:多个子节点 && 新:单个子节点
  8. 旧:多个子节点 && 新:没有子节点
  9. 旧:多个子节点 && 新:多个子节点

前8中情况都比较简单,这里简单概括一下:

# 1.旧:单个子节点 && 新:单个子节点

都为单个子节点,递归调用patch函数

# 2.旧:单个子节点 && 新:没有子节点

删除旧子节点对应的DOM

# 3.旧:单个子节点 && 新:多个子节点

删除旧子节点对应的DOM,并将多个新子节点依次递归调用mount函数进行挂载即可

# 4.旧:没有子节点 && 新:单个子节点

直接调用mount函数疆新单个子节点进行挂载即可

# 5.旧:没有子节点 && 新:没有子节点

什么也不做

# 6.旧:没有子节点 && 新:多个子节点

将多个新子节点依次递归调用mount函数进行挂载即可

# 7.旧:多个子节点 && 新:单个子节点

删除多个旧子节点对应的DOM,递归调用mount函数对单个新子节点进行挂载即可

# 8.旧:多个子节点 && 新:没有子节点

删除多个旧子节点对应的DOM即可

# 9.旧:多个子节点 && 新:多个子节点

对于新旧子节点均为多个子节点的情况,是VNode更新阶段最复杂的情况,无论是React还是Vue都有不同的实现方案,这些实现方案也就是我们常说的Diff算法。

今天先不涉及比较复杂的Diff算法,关于Diff算法的内容,留到日后进行讲解,我们先通过最简单的方式来实现多个新旧子节点的更新(性能最差的做法)。

遍历旧的子节点,将其全部移除:

for (let i = 0; i < prevChildren.length; i++) {
    removeChild(container,prevChildren[i].el)
}

遍历新的子节点,将其全部挂载

for (let i = 0; i < nextChildren.length; i++) {
    mount(nextChildren[i], container)
}

最终的代码如下:


export const patchElement = function (prevVNode, nextVNode, container) {

    const el = (nextVNode.el = prevVNode.el);

    const prevData = prevVNode.data;
    const nextData = nextVNode.data;

    if (nextData) {
        for (let key in nextData) {
            let prevValue = prevData[key];
            let nextValue = nextData[key];
            patchData(el, key, prevValue, nextValue);
        }
    }
    if (prevData) {
        for (let key in prevData) {
            let prevValue = prevData[key];
            if (prevValue && !nextData.hasOwnProperty(key)) {
                patchData(el, key, prevValue, null);
            }
        }
    }
    //比较子节点
    patchChildren(
        prevVNode.children,
        nextVNode.children,
        el
    )
}


function patchChildren(prevChildren, nextChildren, container) {
    //旧:单个子节点
    if(prevChildren && !Array.isArray(prevChildren)){
        //新:单个子节点
        if(nextChildren && !Array.isArray(nextChildren)){
            patch(prevChildren,nextChildren,container)
        }
        //新:没有子节点
        else if(!nextChildren){
            removeChild(container,prevChildren.el)
        }
        //新:多个子节点
        else{
            removeChild(container,prevChildren.el)
            for(let i = 0; i<nextChildren.length; i++){
                mount(nextChildren[i], container)
            }
        }
    }
    //旧:没有子节点
    else if(!prevChildren){
        //新:单个子节点
        if(nextChildren && !Array.isArray(nextChildren)){
            mount(nextChildren, container) 
        }
        //新:没有子节点
        else if(!nextChildren){
            //什么都不做
        }
        //新:多个子节点
        else{
            for (let i = 0; i < nextChildren.length; i++) {
                mount(nextChildren[i], container)
            }
        }
    }
    //旧:多个子节点
    else {
        //新:单个子节点
        if(nextChildren && !Array.isArray(nextChildren)){
            for(let i = 0; i<prevChildren.length; i++){
                removeChild(container,prevChildren[i].el)
            }
            mount(nextChildren,container)   
        }
        //新:没有子节点
        else if(!nextChildren){
            for(let i = 0; i<prevChildren.length; i++){
                removeChild(container,prevChildren[i].el)
            }
        }
        //新:多个子节点
        else{
            // 遍历旧的子节点,将其全部移除
            for (let i = 0; i < prevChildren.length; i++) {
                removeChild(container,prevChildren[i].el)
            }
            // 遍历新的子节点,将其全部添加
            for (let i = 0; i < nextChildren.length; i++) {
                mount(nextChildren[i], container)
            }
        }
    }

}