美文网首页
VUE、React中虚拟DOM(virtual DOM)技术 V

VUE、React中虚拟DOM(virtual DOM)技术 V

作者: IT修真院 | 来源:发表于2020-04-27 20:01 被阅读0次

    作者简介:
    李晓伟
    9年前端工作经验,
    主要分享:Javascript、HTML5、CSS3、Vue、React、Angular、小程序、hybird、前后端开发协作、互联网、人工智能、用户体验等多方面知识
    公众号:前端之阶

    前言

    前端主流框架 vue 和 react 中都使用了虚拟DOM(virtual DOM)技术,因为渲染真实DOM的开销是很大的,性能代价昂贵,比如有时候我们修改了某个数据,如果直接渲染到真实dom上会引起整个dom树的重绘和重排,而我们只需要更新修改过的那一小块dom而不要更新整个dom,这时使用diff算法能够帮助我们。那什么是虚拟DOM和diff算法呢?

    虚拟DOM和VNode介绍

    所谓虚拟DOM,是一个用于表示真实 DOM 结构和属性的 JavaScript 对象,这个对象用于对比虚拟 DOM 和当前真实 DOM 的差异化,然后进行局部渲染从而实现性能上的优化。在Vue.js 中虚拟 DOM 的 JavaScript 对象就是 VNode。

    VNode 表示 虚拟节点 Virtual DOM,为什么叫虚拟节点呢,因为不是真的 DOM 节点。
    他只是用 javascript 对象来描述真实 DOM,这么描述,把DOM标签,属性,内容都变成对象的属性。
    就像用 JavaScript 对象描述一个人一样:
    {sex:'女', name:'voanit', salary:5000,children:null}
    过程就是,把你的 template 模板 描述成 VNode,然后一系列操作之后通过 VNode 形成真实DOM进行挂载。

    什么用?

    1兼容性强,不受执行环境的影响。VNode 因为是 JS 对象,不管 Node 还是 浏览器,都可以统一操作, 从而获得了服务端渲染、原生渲染、手写渲染函数等能力

    2减少操作 DOM。任何页面的变化,都只使用 VNode 进行操作对比,只需要在最后一步挂载更新DOM,不需要频繁操作DOM,从而提高页面性能

    我们可以做个试验。打印出一个空元素的第一层属性,可以看到标准让元素实现的东西太多了。如果每次都重新生成新的元素,对性能是巨大的浪费。

    var mydiv = document.createElement('div');
    for(var k in mydiv ){
    console.log(k)
    }

    virtual dom就是解决这个问题的一个思路,用一个简单的对象去代替复杂的dom对象。
    举个简单的例子,我们在body里插入一个class为a的div。

    var mydiv = document.createElement('div');
    mydiv.className = 'a';
    document.body.appendChild(mydiv);

    对于这个div我们可以用一个简单的对象mydivVirtual代表它,它存储了对应dom的一些重要参数,在改变dom之前,会先比较相应虚拟dom的数据,如果需要改变,才会将改变应用到真实dom上。

    //伪代码
    var mydivVirtual = {
    tagName: 'DIV',
    className: 'a'
    };
    var newmydivVirtual = {
    tagName: 'DIV',
    className: 'b'
    }
    if(mydivVirtual.tagName !== newmydivVirtual.tagName || mydivVirtual.className !== newmydivVirtual.className){
    change(mydiv)
    }
    // 会执行相应的修改 mydiv.className = 'b';
    //最后 <div class='b'></div>

    读到这里就会产生一个疑问,为什么不直接修改dom而需要加一层virtual dom呢?
    很多时候手工优化dom确实会比virtual dom效率高,对于比较简单的dom结构用手工优化没有问题,但当页面结构很庞大,结构很复杂时,手工优化会花去大量时间,而且可维护性也不高,不能保证每个人都有手工优化的能力。至此,virtual dom的解决方案应运而生,virtual dom很多时候都不是最优的操作,但它具有普适性,在效率、可维护性之间达平衡。

    virtual dom 另一个重大意义就是提供一个中间层,js去写ui,ios安卓之类的负责渲染,就像reactNative一样。

    分析diff

    diff算法源自于:linux的基本命令,对比文本。vue和react的虚拟DOM的diff算法大致相同,其核心是基于两个简单的假设:1. 两个相同的组件产生类似的DOM结构,不同的组件产生不同的DOM结构。2. 同一层级的一组节点,他们可以通过唯一的id进行区分。

    例如

    <ul id='list'>
    <li class='item'>Item 1</li>
    <li class='item'>Item 1</li>
    </ul>

    生成的vdom为:
    {
    tag: 'url',
    attrs: {id: 'list'},
    children: [
    {
    tag: 'li',
    attrs:{className:'item'},
    children:['Item 1']
    },
    {
    tag: 'li',
    attrs:{className:'item'},
    children:['Item 2']
    },
    ]
    }

    image

    比较只会在同层级进行, 不会跨层级比较。

    举个形象的例子。


    <div>
    <p>
    <b> aoy </b>
    <span>diff</Span>
    </P>
    </div>


    <div>
    <p>
    <b> aoy </b>
    </p>
    <span>diff</Span>
    </div>

    我们可能期望将<span>直接移动到<p>的后边,这是最优的操作。但是实际的diff操作是移除<p>里的<span>在创建一个新的<span>插到<p>的后边。
    因为新加的<span>在层级2,旧的在层级3,属于不同层级的比较。

    源码分析

    diff的过程就是调用patch函数,就像打补丁一样修改真实dom。

    function patch (oldVnode, vnode) {
    if (sameVnode(oldVnode, vnode)) {
    patchVnode(oldVnode, vnode)
    } else {
    const oEl = oldVnode.el
    let parentEle = api.parentNode(oEl)
    createEle(vnode)
    if (parentEle !== null) {
    api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl))
    api.removeChild(parentEle, oldVnode.el)
    oldVnode = null
    }
    }
    return vnode
    }

    patch函数有两个参数,vnodeoldVnode,也就是新旧两个虚拟节点。在这之前,我们先了解完整的vnode都有什么属性,举个一个简单的例子:

    // body下的 <div id="v" class="classA"><div> 对应的 oldVnode 就是

    {
    el: div //对真实的节点的引用,本例中就是document.querySelector('#id.classA')
    tagName: 'DIV', //节点的标签
    sel: 'div#v.classA' //节点的选择器
    data: null, // 一个存储节点属性的对象,对应节点的el[prop]属性,例如onclick , style
    children: [], //存储子节点的数组,每个子节点也是vnode结构
    text: null, //如果是文本节点,对应文本节点的textContent,否则为null
    }

    需要注意的是,el属性引用的是此 virtual dom对应的真实dom,patchvnode参数的el最初是null,因为patch之前它还没有对应的真实dom。

    来到patch的第一部分,

    if (sameVnode(oldVnode, vnode)) {    patchVnode(oldVnode, vnode)}
    

    sameVnode函数就是看这两个节点是否值得比较,代码相当简单:

    function sameVnode(oldVnode, vnode){    return vnode.key === oldVnode.key && vnode.sel === oldVnode.sel}
    

    两个vnode的key和sel相同才去比较它们,比如pspandiv.classAdiv.classB都被认为是不同结构而不去比较它们。

    如果值得比较会执行patchVnode(oldVnode, vnode),稍后会详细讲patchVnode函数。

    当节点不值得比较,进入else中

    else {
    const oEl = oldVnode.el
    let parentEle = api.parentNode(oEl)
    createEle(vnode)
    if (parentEle !== null) {
    api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl))
    api.removeChild(parentEle, oldVnode.el)
    oldVnode = null
    }
    }
    过程如下:

    • 取得oldvnode.el的父节点,parentEle是真实dom

    • createEle(vnode)会为vnode创建它的真实dom,令vnode.el =真实dom

    • parentEle将新的dom插入,移除旧的dom
      当不值得比较时,新节点直接把老节点整个替换了

    最后

    return vnode
    

    patch最后会返回vnode,vnode和进入patch之前的不同在哪?
    没错,就是vnode.el,唯一的改变就是之前vnode.el = null, 而现在它引用的是对应的真实dom。

    var oldVnode = patch (oldVnode, vnode)
    

    至此完成一个patch过程。

    patchVnode

    两个节点值得比较时,会调用patchVnode函数

    patchVnode (oldVnode, vnode) {
    const el = vnode.el = oldVnode.el
    let i, oldCh = oldVnode.children, ch = vnode.children
    if (oldVnode === vnode) return
    if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) {
    api.setTextContent(el, vnode.text)
    }else {
    updateEle(el, vnode, oldVnode)
    if (oldCh && ch && oldCh !== ch) {
    updateChildren(el, oldCh, ch)
    }else if (ch){
    createEle(vnode) //create el's children dom
    }else if (oldCh){
    api.removeChildren(el)
    }
    }
    }

    const el = vnode.el = oldVnode.el 这是很重要的一步,让vnode.el引用到现在的真实dom,当el修改时,vnode.el会同步变化。

    节点的比较有5种情况

    1. if (oldVnode === vnode),他们的引用一致,可以认为没有变化。

    2. if(oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text),文本节点的比较,需要修改,则会调用Node.textContent = vnode.text

    3. if( oldCh && ch && oldCh !== ch ), 两个节点都有子节点,而且它们不一样,这样我们会调用updateChildren函数比较子节点,这是diff的核心,后边会讲到。

    4. else if (ch),只有新的节点有子节点,调用createEle(vnode)vnode.el已经引用了老的dom节点,createEle函数会在老dom节点上添加子节点。

    5. else if (oldCh),新节点没有子节点,老节点有子节点,直接删除老节点。

    updateChildren

    updateChildren (parentElm, oldCh, newCh) {
    let oldStartIdx = 0, newStartIdx = 0
    let oldEndIdx = oldCh.length - 1
    let oldStartVnode = oldCh[0]
    let oldEndVnode = oldCh[oldEndIdx]
    let newEndIdx = newCh.length - 1
    let newStartVnode = newCh[0]
    let newEndVnode = newCh[newEndIdx]
    let oldKeyToIdx
    let idxInOld
    let elmToMove
    let before
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (oldStartVnode == null) { //对于vnode.key的比较,会把oldVnode = null
    oldStartVnode = oldCh[++oldStartIdx]
    }else if (oldEndVnode == null) {
    oldEndVnode = oldCh[--oldEndIdx]
    }else if (newStartVnode == null) {
    newStartVnode = newCh[++newStartIdx]
    }else if (newEndVnode == null) {
    newEndVnode = newCh[--newEndIdx]
    }else if (sameVnode(oldStartVnode, newStartVnode)) {
    patchVnode(oldStartVnode, newStartVnode)
    oldStartVnode = oldCh[++oldStartIdx]
    newStartVnode = newCh[++newStartIdx]
    }else if (sameVnode(oldEndVnode, newEndVnode)) {
    patchVnode(oldEndVnode, newEndVnode)
    oldEndVnode = oldCh[--oldEndIdx]
    newEndVnode = newCh[--newEndIdx]
    }else if (sameVnode(oldStartVnode, newEndVnode)) {
    patchVnode(oldStartVnode, newEndVnode)
    api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el))
    oldStartVnode = oldCh[++oldStartIdx]
    newEndVnode = newCh[--newEndIdx]
    }else if (sameVnode(oldEndVnode, newStartVnode)) {
    patchVnode(oldEndVnode, newStartVnode)
    api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el)
    oldEndVnode = oldCh[--oldEndIdx]
    newStartVnode = newCh[++newStartIdx]
    }else {
    // 使用key时的比较
    if (oldKeyToIdx === undefined) {
    oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 有key生成index表
    }
    idxInOld = oldKeyToIdx[newStartVnode.key]
    if (!idxInOld) {
    api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
    newStartVnode = newCh[++newStartIdx]
    }
    else {
    elmToMove = oldCh[idxInOld]
    if (elmToMove.sel !== newStartVnode.sel) {
    api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
    }else {
    patchVnode(elmToMove, newStartVnode)
    oldCh[idxInOld] = null
    api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el)
    }
    newStartVnode = newCh[++newStartIdx]
    }
    }
    }
    if (oldStartIdx > oldEndIdx) {
    before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
    addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
    }else if (newStartIdx > newEndIdx) {
    removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
    }
    }

    代码很密集,为了形象的描述这个过程,可以看看这张图。

    image

    image

    过程可以概括为:oldChnewCh各有两个头尾的变量StartIdxEndIdx,它们的2个变量相互比较,一共有4种比较方式。如果4种比较都没匹配,如果设置了key,就会用key进行比较,在比较的过程中,变量会往中间靠,一旦StartIdx>EndIdx表明oldChnewCh至少有一个已经遍历完了,就会结束比较。

    作者简介:
    李晓伟
    9年前端工作经验,
    主要分享:Javascript、HTML5、CSS3、Vue、React、Angular、小程序、hybird、前后端开发协作、互联网、人工智能、用户体验等多方面知识
    公众号:前端之阶

    本文已经获得李晓伟老师授权转发,其他人若有兴趣转载,请直接联系作者授权。

    相关文章

      网友评论

          本文标题:VUE、React中虚拟DOM(virtual DOM)技术 V

          本文链接:https://www.haomeiwen.com/subject/saguwhtx.html