Vue 中虚拟dom diff 算法详解

Vue 在 2.x 版本开始,引入了一个非常核心的机制 – 虚拟dom。本文将简单的就下面几点进行分析:

  1. 什么是虚拟dom?
  2. Vue数据变动的时候,diff算法是如何运转的?
  3. 是否设置key对dom更新有什么区别?

一:什么是虚拟dom?
虚拟dom(vnode)是真实dom在js内存上的一种结构映射。比如我们在页面有这样一个真实的dom

<div id="a">
    <p>test</p>
</div>

那在vue中,就会有这样一个结构和它对应(vue通过createElement来创建Vnode对象)

// @returns {VNode}
createElement(
  'div',
  {
    id:"a"
  },
  [
    createElement('p', 'test'),
  ]
)

Vnode的结构如下
vnode 结构
我们可以看到一个多层嵌套的js object对象,里面包含了描述dom的完整信息。虚拟dom就是通过这样的结构来实现在数据发生改变的时候,通过在js运行内存中比对新旧虚拟dom,来精确的知道需要改变的是哪些节点,然后进行批量的dom更改。这样的机制带来了几个好处:
1.在复杂应用中,无需手动去一个个操作dom,简化了开发逻辑。用户的关注重点在数据层面。
2.精准的找到需要更改的点,避免了不必要的改动。通过一些复用机制避免在内存在重复的创建virtual dom和更新dom。
3.抽象的virtual dom可以兼容其他的ui平台,比如将virtual dom固化成类似于原生应用,比如weex。

二:Vue数据变动的时候,diff算法是如何运转的?
首先界面是响应数据更新,对于响应式不熟悉的可以看Vue 响应式原理

// 在dom第一次挂载的时候,会创建一个watcher方法,watcher的get函数是一个update操作

   // 省略N行代码
    } else {
      updateComponent = function () {
        vm._update(vm._render(), hydrating); // update操作在这里
      };
    }

    // 创建一个watcher实例,第二个参数是对应watcher的expOrFn 这里是传入function,即watcher的getter对应该fuction,参考Watcher构造函数的实现。
    new Watcher(vm, updateComponent, noop, {
      before: function before () {
        if (vm._isMounted && !vm._isDestroyed) {
          callHook(vm, 'beforeUpdate');
        }
      }
    }, true /* isRenderWatcher */);

当数据发生变化:

// vue响应式的核心代码
// 当我们改变数据的时候触发set,
// set会触发观察者的notify方法
Object.defineProperty(obj, key, {
      get: function reactiveGetter () { // 省略N行 },
      set: function reactiveSetter (newVal) {
        // 省略N行
        dep.notify(); // 通知更新
      }
    });
  }

notify实现

// 更新
Dep.prototype.notify = function notify () {
    // stabilize the subscriber list first
    var subs = this.subs.slice();
    if (!config.async) {
      // subs aren't sorted in scheduler if not running async
      // we need to sort them now to make sure they fire in correct
      // order
      subs.sort(function (a, b) { return a.id - b.id; });
    }
    for (var i = 0, l = subs.length; i < l; i++) {
      // subs里是观察者watcher实例的集合
      subs[i].update(); 
    }
  };

vue实例update方法

// 触发run或者queueWatcher方法
Watcher.prototype.update = function update () {
    /* istanbul ignore else */
    if (this.lazy) {
      this.dirty = true;
    } else if (this.sync) { // 判断是同步还是异步更新dom
      this.run();
    } else {
      queueWatcher(this);
    }
  };

// 不管是同步更新还是异步更新,最终总会触发watcher的run方法
  Watcher.prototype.run = function run () {
    if (this.active) {
      var value = this.get();// 触发更新
      if (
        value !== this.value ||
        // Deep watchers and watchers on Object/Arrays should fire even
        // when the value is the same, because the value may
        // have mutated.
        isObject(value) ||
        this.deep
      ) {
        // set new value
        var oldValue = this.value;
        this.value = value;
        if (this.user) {
          try {
            this.cb.call(this.vm, value, oldValue);
          } catch (e) {
            handleError(e, this.vm, ("callback for watcher \"" + (this.expression) + "\""));
          }
        } else {
          this.cb.call(this.vm, value, oldValue);
        }
      }
    }
  };
// watcher 的 get方法最终会调用之前设置的getter对应的function,参考文中第一个代码块
vm._update(vm._render(), hydrating); // update操作在这里
// 这是vue实例的更新方法
Vue.prototype._update = function (vnode, hydrating) {
      var vm = this;
      var prevEl = vm.$el;
      var prevVnode = vm._vnode; // 获取更新前的旧vnode
      var restoreActiveInstance = setActiveInstance(vm);
      vm._vnode = vnode;
      // Vue.prototype.__patch__ is injected in entry points
      // based on the rendering backend used.
      if (!prevVnode) {
        // 如果没有旧node,直接取之前的dom,初始化页面时的root的el
        vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */);
      } else {
        // 更新旧节点
        vm.$el = vm.__patch__(prevVnode, vnode);
      }
      restoreActiveInstance();
      // update __vue__ reference
      if (prevEl) {
        prevEl.__vue__ = null;
      }
      if (vm.$el) {
        // 更新$el 对应的新的vue实例
        vm.$el.__vue__ = vm; 
      }
      // if parent is an HOC, update its $el as well
      if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
        vm.$parent.$el = vm.$el;
      }
      // updated hook is called by the scheduler to ensure that children are
      // updated in a parent's updated hook.
    };
//其中的关键方法__patch__
Vue.prototype.__patch__ = inBrowser ? patch : noop;
var patch = createPatchFunction({ nodeOps: nodeOps, modules: modules });
// 开始进入diff算法的核心逻辑createPatchFunction
// 该函数返回一个patch函数,入参为旧的vnode和新的vnode
// 其他流程看代码中的中文注释
 function createPatchFunction (backend) {
   return function patch (oldVnode, vnode, hydrating, removeOnly) {
      if (isUndef(vnode)) {
        // 新dom是undefined 旧dom是有内容的,直接触发销毁
        if (isDef(oldVnode)) { invokeDestroyHook(oldVnode); }
        return
      }
      var isInitialPatch = false;
      var insertedVnodeQueue = [];
      if (isUndef(oldVnode)) {
        // 如果旧dom不存在,直接用新的vdom创建新的dom
        isInitialPatch = true;
        createElm(vnode, insertedVnodeQueue);
      } else {
        var isRealElement = isDef(oldVnode.nodeType);
        if (!isRealElement && sameVnode(oldVnode, vnode)) {
         //如果不是真实dom,第二次数据更新,直接调用patchVnode
          patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly);
        } else {
          //第一次加载时候,挂载到真实的dom上
          if (isRealElement) {
          //服务端渲染的一些处理
            if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
              oldVnode.removeAttribute(SSR_ATTR);
              hydrating = true;
            }
            if (isTrue(hydrating)) {
              if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
                invokeInsertHook(vnode, insertedVnodeQueue, true);
                return oldVnode
              } else {
                warn(
                  'The client-side rendered virtual DOM tree is not matching ' +
                  'server-rendered content. This is likely caused by incorrect ' +
                  'HTML markup, for example nesting block-level elements inside ' +
                  '<p>, or missing <tbody>. Bailing hydration and performing ' +
                  'full client-side render.'
                );
              }
            }
            // 否则创建一个空节点
            oldVnode = emptyNodeAt(oldVnode);
          }

          // replacing existing element
          var oldElm = oldVnode.elm;
          var parentElm = nodeOps.parentNode(oldElm);

          // 第一次渲染直接创建节点
          createElm(
            vnode,
            insertedVnodeQueue,
            // extremely rare edge case: do not insert if old element is in a
            // leaving transition. Only happens when combining transition +
            // keep-alive + HOCs. (#4590)
            oldElm._leaveCb ? null : parentElm,
            nodeOps.nextSibling(oldElm)
          );
        // 省略N行代码
      return vnode.elm
    }
}

如果存在老节点,更新老节点

// 更新新老节点
function patchVnode (
      oldVnode,
      vnode,
      insertedVnodeQueue,
      ownerArray,
      index,
      removeOnly
    ) {
      如果没有变化,直接返回
      if (oldVnode === vnode) {
        return
      }

     // 省略N行代码
      var data = vnode.data;
      if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
        i(oldVnode, vnode);
      }

      var oldCh = oldVnode.children;
      var ch = vnode.children;
      if (isDef(data) && isPatchable(vnode)) {
        for (i = 0; i < cbs.update.length; ++i) { cbs.update[i](oldVnode, vnode); }
        if (isDef(i = data.hook) && isDef(i = i.update)) { i(oldVnode, vnode); }
      }
      if (isUndef(vnode.text)) {
        if (isDef(oldCh) && isDef(ch)) {
         // 更新子节点
          if (oldCh !== ch) { updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly); }
        } else if (isDef(ch)) {
          {
            checkDuplicateKeys(ch);
          }
          // 如果老的dom有文本内容,晴空文本内容
          if (isDef(oldVnode.text)) { nodeOps.setTextContent(elm, ''); }
          //如果新node有子节点,老的node没有,直接添加所有新的子节点
          addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
        } else if (isDef(oldCh)) {
         // 如何老node有子节点,新node没有,直接删除所有老的子节点。
          removeVnodes(elm, oldCh, 0, oldCh.length - 1);
        } else if (isDef(oldVnode.text)) {
          nodeOps.setTextContent(elm, '');
        }
      } else if (oldVnode.text !== vnode.text) {
        // 更新文本
        nodeOps.setTextContent(elm, vnode.text);
      }
      if (isDef(data)) {
        if (isDef(i = data.hook) && isDef(i = i.postpatch)) { i(oldVnode, vnode); }
      }
    }

如果新的和老的vdom 都有有子节点,更新子节点。进入diff处理流程

// 更新子节点
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
      var oldStartIdx = 0;
      var newStartIdx = 0;
      var oldEndIdx = oldCh.length - 1;
      var oldStartVnode = oldCh[0];
      var oldEndVnode = oldCh[oldEndIdx];
      var newEndIdx = newCh.length - 1;
      var newStartVnode = newCh[0];
      var newEndVnode = newCh[newEndIdx];
      var oldKeyToIdx, idxInOld, vnodeToMove, refElm;

      // removeOnly is a special flag used only by <transition-group>
      // to ensure removed elements stay in correct relative positions
      // during leaving transitions
      var canMove = !removeOnly;

      {
        checkDuplicateKeys(newCh);
      }
	  // 快慢指针算法
      while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (isUndef(oldStartVnode)) {
          oldStartVnode = oldCh[++oldStartIdx]; // Vnode has been moved left
        } else if (isUndef(oldEndVnode)) {
          oldEndVnode = oldCh[--oldEndIdx];
        } else if (sameVnode(oldStartVnode, newStartVnode)) {
         //  见下面表格1
          patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
          oldStartVnode = oldCh[++oldStartIdx];
          newStartVnode = newCh[++newStartIdx];
        } else if (sameVnode(oldEndVnode, newEndVnode)) {
         //  见下面表格2
          patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx);
          oldEndVnode = oldCh[--oldEndIdx];
          newEndVnode = newCh[--newEndIdx];
        } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
          //  见下面表格3
          patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx);
          canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm));
          oldStartVnode = oldCh[++oldStartIdx];
          newEndVnode = newCh[--newEndIdx];
        } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
          //  见下面表格4
          patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
          canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
          oldEndVnode = oldCh[--oldEndIdx];
          newStartVnode = newCh[++newStartIdx];
        } else {
          // 如果上面的都不匹配,收集旧的key和索引的map关系
          if (isUndef(oldKeyToIdx)) { oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx); }
          // 通过上面的map来找到新vnode的key对应老的vnode的索引位置
          idxInOld = isDef(newStartVnode.key)
            ? oldKeyToIdx[newStartVnode.key]
            : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
            // 如果找不到老vnode的索引,说明是一个新dom,执行createElm
          if (isUndef(idxInOld)) { // New element
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx);
          } else {
            vnodeToMove = oldCh[idxInOld];
            // 找到当前新vnode key对应的老vnode
            // 判断newStartIds 对应的vnode是否可复用该老的vnode
            if (sameVnode(vnodeToMove, newStartVnode)) {
              patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
              oldCh[idxInOld] = undefined;
              //调换dom位置
              canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm);
            } else {
              // 不能复用旧的直接创建dom
              createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx);
            }
          }
          // newStartIdx 指针前移
          newStartVnode = newCh[++newStartIdx];
        }
      }
      // 如果oldStartIdx 大于oldEndIdx 说明旧节点遍历处理完毕,那么还未遍历到的新节点就是需要新增的节点
      if (oldStartIdx > oldEndIdx) {
        refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
        addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
      } else if (newStartIdx > newEndIdx) {
        // 如果newStartIdx 比newEndIdx 大,说明新节点已经遍历结束,那么老节点里还没有遍历到的旧是需要删除的节点
        removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
      }
    }

虚拟dom的diff算法核心逻辑就是上面updateChildren函数的内容,其中主要有四种diff判断,在此画图描述:假设有4个旧的节点(虚拟dom节点),新的节点也是4个,diff算法会对这四个虚拟节点进行比对。
oldStartIdx,oldEndIdx,newStartIdx,newEndIdx分别是四个指针,初始的时候分别指向老新节点列表的首部和尾部,表格dom行对应的是虚拟dom对应的实际dom。示例如下表格:
在这里插入图片描述
1.第一种情况,新的start指针对应节点和旧的start指针对应节点是可复用节点,通过新的n1到老的n1, 将dom旧d1更改为新d1,两个首部指针向后移动。见表格1:
在这里插入图片描述
2.第二种情况,新的newEndIdx指针对应节点和旧的oldEndIdx指针对应的节点是可复用节点,通过新的n4 和旧的n4,将旧的dom 旧d4更新为新的dom 新d4,两个尾部指针向前移动,见表格2:
在这里插入图片描述
3.第三种情况,旧的oldStartIdx指针对应的节点和新的newEndIdx指针对应的节点是可复用节点,将旧的oldStartIdx对应节点的dom移动到旧的oldEndIdx对应节点dom的后面,oldStartIdx向后移动一位,newEndIdx向前移动一位(为了演示清晰,移除前两种情况的改动),见表格3:
在这里插入图片描述

4.第四种情况,旧节点的oldEndIdx指针对应的节点和新节点newStartIdx指针对应的节点是可复用节点,需要将旧oldEndIdx指针对应节点dom移动到旧节点oldStartIdx指针对应节点的dom。oldEndIdx 向前移动,newStartIdx向后移动,见表格4:

在这里插入图片描述
还有需要注意的是,虚拟dom的diff比较永远是同层级比较。从root向下逐层进行处理。
在这里插入图片描述

如何判断新旧节点是否是可以直接复用更新的节点?
1.key相同 (都是undefined 也算相同)
2.tag名相同
3.isComment属性值相同
4.同时都有或者没有data属性
5.是否动态占位符,且两个动态函数是一样的,且没有error处理函数。
如果是input类型节点,还需要判断
1.是否同时都有或者没有attrs属性
2.是否都是表单的常见的几种type类型(text,number,password,search,email,tel,url)

// 
  function sameVnode (a, b) {
    return (
      a.key === b.key && (
        (
          a.tag === b.tag &&
          a.isComment === b.isComment &&
          isDef(a.data) === isDef(b.data) &&
          sameInputType(a, b)
        ) || (
          isTrue(a.isAsyncPlaceholder) &&
          a.asyncFactory === b.asyncFactory &&
          isUndef(b.asyncFactory.error)
        )
      )
    )
  }

  function sameInputType (a, b) {
    if (a.tag !== 'input') { return true }
    var i;
    var typeA = isDef(i = a.data) && isDef(i = i.attrs) && i.type;
    var typeB = isDef(i = b.data) && isDef(i = i.attrs) && i.type;
    return typeA === typeB || isTextInputType(typeA) && isTextInputType(typeB)
  }

三:是否设置key对dom更新有什么区别?
1.首先了解下key的用法, 我们可以像绑定一个普通的属性一样绑定key,我们经常会在v-for循环中来使用。从而提高性能。

 <div v-for="x in list" :key="x.a">{{x.a}}</div>

官方文档是这么说key的作用的:

  • key 的特殊属性主要用在 Vue 的虚拟 DOM 算法,在新旧 nodes 对比时辨识 VNodes。如果不使用 key,Vue 会使用一种最大限度减少动态元素并且尽可能的尝试修复/再利用相同类型元素的算法。使用 key,它会基于 key 的变化重新排列元素顺序,并且会移除 key 不存在的元素。

  • 有相同父元素的子元素必须有独特的 key。重复的 key 会造成渲染错误。

  • 它也可以用于强制替换元素/组件而不是重复使用它。当你遇到如下场景时它可能会很有用:

  • 完整地触发组件的生命周期钩子

  • 触发过渡

我们先分析第一种 如果不加key,vue的处理方式,以v-for的场景为例
1.vue会对子节点标准化

function getNormalizationType (
    children,
    maybeComponent
  ) {
    var res = 0;
    for (var i = 0; i < children.length; i++) {
      var el = children[i];
      if (el.type !== 1) {
        continue
      }
      if (needsNormalization(el) ||
          (el.ifConditions && el.ifConditions.some(function (c) { return needsNormalization(c.block); }))) {
        res = 2;
        break
      }
      if (maybeComponent(el) ||
          (el.ifConditions && el.ifConditions.some(function (c) { return maybeComponent(c.block); }))) {
        res = 1;
      }
    }
    return res
  }

通过这个函数判断标准化类型

  var SIMPLE_NORMALIZE = 1;
  var ALWAYS_NORMALIZE = 2;

简单的标准化只是对children进行了数组化

function simpleNormalizeChildren (children) {
    for (var i = 0; i < children.length; i++) {
      if (Array.isArray(children[i])) {
        return Array.prototype.concat.apply([], children)
      }
    }
    return children
  }

always标准化方法,在这个方法里为v-for里没有设置key的元素设置了默认规则的key

function normalizeChildren (children) {
    return isPrimitive(children)
      ? [createTextVNode(children)]
      : Array.isArray(children)
        ? normalizeArrayChildren(children)
        : undefined
  }

  function isTextNode (node) {
    return isDef(node) && isDef(node.text) && isFalse(node.isComment)
  }

  function normalizeArrayChildren (children, nestedIndex) {
    var res = [];
    var i, c, lastIndex, last;
    for (i = 0; i < children.length; i++) {
      c = children[i];
      if (isUndef(c) || typeof c === 'boolean') { continue }
      lastIndex = res.length - 1;
      last = res[lastIndex];
      //  nested
      if (Array.isArray(c)) {
        if (c.length > 0) {
          c = normalizeArrayChildren(c, ((nestedIndex || '') + "_" + i));
         // 忽略N行代码
          // default key for nested array children (likely generated by v-for)
          // 在这里判断是否是v-for list
          // 如果是按照循环顺序定义一个自定义的key的值
          if (isTrue(children._isVList) &&
            isDef(c.tag) &&
            isUndef(c.key) &&
            isDef(nestedIndex)) {
            c.key = "__vlist" + nestedIndex + "_" + i + "__"; // 拼接的key
          }
          res.push(c);
        }
      }
    }
    return res
  }

在这里插入图片描述
所以在用户未设置key的时候,vue会按照dom子元素的情况,判断是否需要为其添加默认生成的key。虽然有了这个默认的key,但是因为只是按照默认在数组的索引值来生成的,所以没有办法达到精准的新节点替换对应之前的旧节点,而是总是死板的按照索引位置来复用节点,造成很多不必要的复用。

如果最终没有添加默认规则的key,key是undefined,下面代码中的a.key === b.key 就默认成立。所以只要后面的条件成立,就会被复用。


  function sameVnode (a, b) {
    return (
      a.key === b.key && (
        (
          a.tag === b.tag &&
          a.isComment === b.isComment &&
          isDef(a.data) === isDef(b.data) &&
          sameInputType(a, b)
        ) || (
          isTrue(a.isAsyncPlaceholder) &&
          a.asyncFactory === b.asyncFactory &&
          isUndef(b.asyncFactory.error)
        )
      )
    )
  }

观察下面的场景

<input  type="text"  v-if="b"/>
<input  type="text"  v-else />

b的值初始为true,所以默认只会显示第一个input,当我们在第一个input里输入一些值,然后切换显示第二个input,上一个input里显示的值还会被保留。这个就是因为没有设置key,导致符合sameVnode的条件,vue就直接复用dom,只修改dom上显式定义的属性。value就被保留下来了。

需要注意:直接复用更新的dom是不会触发完整的钩子函数。如果你想默认触发钩子函数,一定要设置一个不同的key,保证更新的时候调用createElm创建新dom

function createElm (
      vnode,
      insertedVnodeQueue,
      parentElm,
      refElm,
      nested,
      ownerArray,
      index
    ) {
      if (isDef(vnode.elm) && isDef(ownerArray)) {
        // This vnode was used in a previous render!
        // now it's used as a new node, overwriting its elm would cause
        // potential patch errors down the road when it's used as an insertion
        // reference node. Instead, we clone the node on-demand before creating
        // associated DOM element for it.
        vnode = ownerArray[index] = cloneVNode(vnode);
      }

      vnode.isRootInsert = !nested; // for transition enter check
      // 创建新的组件子节点dom
      if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
        return
      } 

      var data = vnode.data;
      var children = vnode.children;
      var tag = vnode.tag;
      if (isDef(tag)) {
        {
          if (data && data.pre) {
            creatingElmInVPre++;
          }
          if (isUnknownElement$$1(vnode, creatingElmInVPre)) {
            warn(
              'Unknown custom element: <' + tag + '> - did you ' +
              'register the component correctly? For recursive components, ' +
              'make sure to provide the "name" option.',
              vnode.context
            );
          }
        }

        vnode.elm = vnode.ns
          ? nodeOps.createElementNS(vnode.ns, tag)
          : nodeOps.createElement(tag, vnode);
        setScope(vnode);

        /* istanbul ignore if */
        {
          createChildren(vnode, children, insertedVnodeQueue);
          if (isDef(data)) {
            invokeCreateHooks(vnode, insertedVnodeQueue);
          }
          insert(parentElm, vnode.elm, refElm);
        }

        if (data && data.pre) {
          creatingElmInVPre--;
        }
      } else if (isTrue(vnode.isComment)) {
        vnode.elm = nodeOps.createComment(vnode.text);
        insert(parentElm, vnode.elm, refElm);
      } else {
        vnode.elm = nodeOps.createTextNode(vnode.text);
        insert(parentElm, vnode.elm, refElm);
      }
    }
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

森哥的歌

一杯咖啡半包烟,助我熬过那长夜

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值