虚拟DOM

4,426 阅读2分钟

这里有一份简洁的前端知识体系等待你查收,看看吧,会有惊喜哦~如果觉得不错,恳求star哈~


随处可见的VDOM

VDOM,也叫虚拟DOM,并不是什么高大上的新事物,它是仅存于内存中的DOM,因为还未展示到页面中,所以称为VDOM。

var a = document.createElement("div");

如上所示,大家对此应该不陌生吧?没错,这就是VDOM。

问题来了,如果让VDOM变成真实的DOM呢?

其实很简单……只需将节点append到页面中

var a = document.createElement("div");
document.body.append(a);

所以,请大家不要把VDOM想得太复杂!它随处可见~

React中的VDOM

常见的DOM操作

在讲React中的VDOM前,有必要说下,我们日常中常见的DOM操作有哪些?

事实上,就三类:增、删、改。对应的DOM操作如下:

  1. 增加一个节点 => appendChild
  2. 删除一个节点 => removeChild
  3. 更改一个节点 => replaceChild

现实中,很多前端小伙伴在处理前端模板变动时,是简单粗暴的,不管是哪种情况,都会直接使用类似jQuery的html方法整块替换~(全局搜下你代码,是不是有不少$(...).html())

这样做有什么问题呢?——性能问题。如果页面比较小,问题还不是很大,如果页面庞大,这样做势必会出现卡顿,用户体验绝对是不好的。

如何解决呢?——这就引入了差量更新!

差量更新

什么是差量更新?就是只对局面的HTML片段进行更新。比如你加了一个节点,那么我就只更新这个节点,我无需整个模板替换。

这样一来,效率就提高了。

可问题来了,我怎么知道哪个节点更新了,哪个节点删除了,哪个节点替换了呢?——我们需要对DOM建模!

VDOM建模

说是建模,简单点说就是用一个JS对象来表示VDOM。

如果我们可以用一个JS对象来表示VDOM,那么这个对象上多一个属性(增加节点),少一个属性(删除节点),或者属性值变了(更改节点),就一目了然了!

那如何建模呢?

这个麽!我们就要化繁为简了。思考下,DOM也叫DOM树,是一个树形结构,DOM树上有很多元素节点。

我们要对VDOM进行建模,本质上就是对一个个元素节点进行建模,然后再把节点放回DOM树的指定位置,这样不就完成对DOM树的建模了么?

别把建模想得太复杂,无非就是用JS对象的形式来展示罢了。

如何对元素节点进行建模呢?

这个简单,我们不难发现,每个节点无非都是由以下三部分组成:

  1. type : 元素类型
  2. props : 元素属性
  3. children : 子元素集合

比如

test
,type是div,props是id="main",children是“test”。

我们希望的结果是:

{type:"div",props:{"id":"main"},children:[
       test
]}

如果是更复杂的结构,比如div中有一个图片,我们可以写成

{type:"div",props:{"style":""},children:[
        {type:"img",props:{"src":"..."}}
    ]}

以上也是React对VDOM建模的结果。是不是很简单呢?

如何快捷建模?

如何把真实的DOM,转化成建模后的VDOM呢?

这个简单,transform-react-jsx已经帮我们实现了,使用webpack或者rollup的朋友可以直接使用这个插件。

以下附加rollup的配置文件,供参考:

import babel from 'rollup-plugin-babel';

export default {
    input : 'src/main.js',
    output : {
        file : 'dist/main.js',
        format : 'cjs'
    },
    banner : "/* fed123.com */",
    plugins : [
        babel({
            'presets' : [[
                'env',
                {
                    modules : false
                }
            ]],
            "plugins" : [
                ["transform-react-jsx" , {
                    "pragma" : "vnode"
                }]
            ]
        })
    ]
}

可能你还是一知半解,下面给出一个例子:

// React 常见的DOM写法
const vdom = (
    <div id="_Q5" style="border:1px solid red">
        <div style="text-align:center;">
            <img src="https://m.baidu.com/static/index/plus/plus_logo.png" height="56"/>
        </div>
        Hello
    </div>
);

// 转义后的
var vdom = vnode(
    "div",
    { id: "_Q5", style: "border:1px solid red" },
    vnode(
        "div",
        { style: "text-align:center;" },
        vnode("img", { src: "https://m.baidu.com/static/index/plus/plus_logo.png", height: "56", onClick: function onClick() {
                alert(1);
            } })
    ),
    "Hello"
);
如何将VDOM变成真实DOM呢?

我们知道,将DOM变成VDOM,是为了差量更新,最终我们还是要把VDOM还原成DOM的!VDOM只是个桥梁,如果不能还原成DOM,VDOM就没意义了!

怎么做呢?

可以参考如下代码:

 // 把vdom挂载到页面上
 function createElement(node) {
    if (typeof node === 'string') {
        return document.createTextNode(node);
    }
    const $el = document.createElement(node.type);
    let appendChild = $el.appendChild.bind($el);
    node.children
        .map(createElement)
        .map(appendChild);
    return $el;
}

我们判断,如果是子节点是个字符串节点,直接插入页面即可,如果子节点是个DOM节点,那么就递归调用~

通过这个思路,我们就可以将VDOM还原成DOM了。

DIFF Virtual DOM & Update

以上,是VDOM的准备工作,主要包括两个步骤:

  1. 对VDOM进行建模,方便后续的差量更新
  2. 将VDOM转成真实的DOM

接下来才是主菜。

我们先看思考下,如何判断DOM发生了变化,并找到这个变化?

DIFF算法

DIFF算法是React框架采用的方法。也就是判断DOM是否发生了变化、然后找到这个变化,这样我们才能实现差量更新。

DOM的变化主要有三种:appendChild、replaceChild、removeChild.

还记得我们对VDOM的建模么?

{type:"div",props:{"style":""},children:[
        {type:"img",props:{"src":"..."}}
    ]}

每个节点都包含一个children,DIFF的过程,其实也是diff children的过程。通过递归children的方式,就可以判断不同的children并对其操作。有以下几种情况:

  1. 没有旧的节点,则创建新的节点,并插入父节点。
  2. 如果没有新的节点,则摧毁旧的节点。
  3. 如果节点发生了变化,则用replaceChild改变节点信息
  4. 如果节点没有变化,则对比该节点的子节点进行判断,使用递归调用

function updateElement($parent, newNode, oldNode, index = 0) {
    if(!oldNode) {
        $parent.appendChild(
            createElement(newNode)
        );
    } else if (!newNode) {
        $parent.removeChild(
            $parent.childNodes[index]
        );
    } else if (changed(newNode, oldNode)) {
        $parent.replaceChild(
            createElement(newNode),
            $parent.childNodes[index]
        );
    } else if(newNode.type) {
        const newLength = newNode.children.length;
        const oldLength = oldNode.children.length;
        for(let i = 0; i < newLength || i < oldLength; i++) {
            updateElement(
                $parent.childNodes[index],
                newNode.children[i],
                oldNode.children[i],
                i
            );
        }
    }
}

为什么要DIFF children呢?因为我们必须DOM树是由一个个元素节点组成的,DOM树变化的最小单位也是元素节点。

通过递归的方式,我们就可以从最底层的children开始,层层遍历,找到变化的节点,然后对这些节点差量更新了。

而所谓的差量更新,就是上述提到的三种操作:appendChild、replaceChild、removeChild。这在上面的代码都有体现到。

Handle Props & Event

通过上述的步骤,我们就可以把DOM树进行差量更新并呈现到页面上,但我们知道,DOM树可不只有节点,还有参数跟事件,所以我们需要把参数跟事件加上。

再看一眼我们对VDOM的建模!

{type:"div",props:{"style":""},children:[
        {type:"img",props:{"src":"..."}}
    ]}

我们要做的,就是把props加载到对应的元素节点上,这个步骤简称:DIFF props。

DIFF props,同DIFF VDOM,找到props的不同,然后setAttribute跟removeAttribute。

这里直接上代码:

function updateProps ($target, newProps, oldProps = {}){
    const props = Object.assign({},oldProps, newProps);
    Object.keys(props).forEach(name => {
        updateProp($target, name, newProps[name], oldProps[name]);
    })
}
function updateProp ($target, name, newVal, oldVal) {
    if (!newVal) {
        removeProp($target, name, oldVal);
    } else if (!oldVal || newVal !== oldVal) {
        setProp($target, name, newVal);
    }
}
function setProp ($target, name, value) {
    if (typeof value === "boolean") {
        handleBooleanProp($target, name, value);
    }
    $target.setAttribute(name, value);
}

function setBooleanProp($target, name, value) {
    if (!!value) {
        $target.setAttribute(name, value);
        $target[name] = true;
    } else {
        $target[name] = false;
    }
}

function removeProp($target, name, value) {
    if (typeof value === 'boolean') {
        $target[name] = false;
    } 
    $target.removeAttribute(name);
}

项目源码地址