Vue源码分析之虚拟DOM

虚拟Dom

关于虚拟Dom的概念可以从一个简单的小例子出发,如下代码所示:

let div = document.querySelector('#container');
let s = '';
for (let k in div){s += k + ','}

运行后结果如下

可见创建一个Dom元素开销的有多大,一般对数据进行操作而改变后渲染在页面,做法就是直接删除所有旧的Dom,渲染出新的Dom

因为dom元素无法跟踪和感知数据的变化,以及上一次的数据是什么。从而采用重渲染笨重的方式来改变试图。

而虚拟dom解决的就是数据跟踪的问题,将dom所需要渲染的数据单独提取放在js对象 vnode 当中,对数据修改时,通过比较新旧虚拟

节点的差异,来决定如何以最高效的方式修改已存在的Dom元素。

  • vnode数据结构
interface VNode {
    sel: string | undefined;
    data: VNodeData | undefined;
    children: Array<VNode | string> | undefined;
    elm: Node | undefined;
    text: string | undefined;
    key: Key | undefined;
}


interface VNodeData {
    props?: Props;
    attrs?: Attrs;
    class?: Classes;
    style?: VNodeStyle;
    dataset?: Dataset;
    on?: On;
    hero?: Hero;
    attachData?: AttachData;
    hook?: Hooks;
    key?: Key;
    ns?: string;
    fn?: () => VNode;
    args?: Array<any>;
    [key: string]: any;
}

| 字段 | 描述 |
| ---- | ---- | ---- |
| sel | 选择器字符串 |
| data | 描述对象属性,类似vue的 @click :props等 |
| children | 孩子虚拟节点数组 |
| elm | 存放真实Dom |
| text | 文本节点与children不可共存 |
| key | 区分不同vnode,类似vue的 v-for=... :key="idx" 中的key |

patchVnode

概述

对两个不同的vnode进行打补丁,使用diff算法比较新旧Dom元素的差异,并只对差异部分进行更新,原本使用的是直接全删除重新创建新的Dom

为方便描述, vnode 代表新节点, oldVnode 代表旧节点, ch 代表孩子节点数组, text代表文本节点

流程描述

  • 初始化
  1. 因为用户仅对 vnode 虚拟节点进行操作,而非真实的Dom,因此对于 vnode.elm 还未赋予真实的Dom
  2. 通过sameVnode比较后,可知新旧的虚拟节点是对同一Dom进行修改,因此直接将旧dom赋予到新dom方便操作
const elm = vnode.elm = (oldVnode.elm as Node);
  • hook钩子

流程最初和最末尾会触发用户传递的 prepatchpostpatch 两个不同的钩子函数,分别代表补丁前,和补丁后的生命周期

  • 比较虚拟dom

直接对比新旧vnode对象指针地址来判断是否为同一个对象,若对象相同,无需补丁,直接返回

  • 触发update钩子

如果 vnode.data != undefined 触发模块和用户的update钩子

  • 对比算法

根据流程图,对比新旧节点的 textch 来分以下5种情况操作, 注意同节点下 textch 不可共存。

情况1. vnode.text != undefined

新节点存在 text 文本节点,若 oldCh 存在则移除 oldCh,最终

api.setTextContent(elm, vnode.text as string);

情况2. ch && oldCh

updateChildren 后面介绍

if (oldCh !== ch) updateChildren(elm, oldCh as Array<VNode>, ch as Array<VNode>, insertedVnodeQueue);

情况3. isDef(ch)

oldText 可能有也可能没有,无论如何执行 addVnodes,添加虚拟节点和Dom节点

if (isDef(oldVnode.text)) api.setTextContent(elm, '');
addVnodes(elm, null, ch as Array<VNode>, 0, (ch as Array<VNode>).length - 1, insertedVnodeQueue);

情况4. isDef(oldCh)

存在 oldCh ,移除虚拟节点和Dom节点

removeVnodes(elm, oldCh as Array<VNode>, 0, (oldCh as Array<VNode>).length - 1);

情况5. isDef(oldVnode.text)

新节点既没有text也没有ch,若存在oldText,清空文本即可

createElm

/****
 * @params vnode 需要生成Dom的虚拟节点
 * @params insertedVnodeQueue 用于用户传递 insert 钩子回调,这里不关注
 * @return Dom
 */

function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
    let i: any, data = vnode.data;
    //触发用户传递 init 钩子
    if (data !== undefined) {
      if (isDef(i = data.hook) && isDef(i = i.init)) {
        i(vnode);
        data = vnode.data;
      }
    }
    let children = vnode.children, sel = vnode.sel;

    //选择器为 ! 时 创建注释节点
    if (sel === '!') {
      if (isUndef(vnode.text)) {
        vnode.text = '';
      }
      vnode.elm = api.createComment(vnode.text as string);
    } 

    //选择器不为空时
    else if (sel !== undefined) {
      // 解析和提取选择器中 tag 和 #id 和 .class 
      const hashIdx = sel.indexOf('#');
      const dotIdx = sel.indexOf('.', hashIdx);

      // 这里不考虑0是因为用法不允许省略标签名,如 h('#app', 'no div tags');
      const hash = hashIdx > 0 ? hashIdx : sel.length;

      // 如果不存在 #id 或 .class,则赋予长度,处理选择器出现 #id 和 .class 顺序不同的情况
      const dot = dotIdx > 0 ? dotIdx : sel.length;
      const tag = hashIdx !== -1 || dotIdx !== -1 ? sel.slice(0, Math.min(hash, dot)) : sel;

      // 生成Dom,ns为域名空间,针对svg标签情况,一般都是调用 api.createElement(tag) 情况,
      const elm = vnode.elm = isDef(data) && isDef(i = (data as VNodeData).ns) ? api.createElementNS(i, tag)
                                                                               : api.createElement(tag);

      // 设置 id 和 class
      if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot));
      if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/\./g, ' '));

      // 触发所有模块 create钩子,这些模块可以辅助添加:样式,类名,属性,事件等
      for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode);

      // 递归创建孩子节点,并追加到 children[] 中
      if (is.array(children)) {
        for (i = 0; i < children.length; ++i) {
          const ch = children[i];
          if (ch != null) {
            api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue));
          }
        }
      } 
      
      //文本节点则追加文本节点
      else if (is.primitive(vnode.text)) {
        api.appendChild(elm, api.createTextNode(vnode.text));
      }
      i = (vnode.data as VNodeData).hook; // Reuse variable

      //触发用户传递的 create hook 并未 insert钩子做数据铺垫
      if (isDef(i)) {
        if (i.create) i.create(emptyNode, vnode);
        if (i.insert) insertedVnodeQueue.push(vnode);
      }
    } else {
      // sel 选择器不传递的情况,直接创建文本节点
      vnode.elm = api.createTextNode(vnode.text as string);
    }
    return vnode.elm;
  }

removeVnodes

这里强调一下 invokeDestroyHook 和 createRmCb 的作用,一是防止过河拆桥,而是防止重复remove

invokeDestroyHook 用于会触发用户和模块中 destroy钩子函数,该函数会深度优先递归子节点。

createRmCb 闭包,因为要求模块的 remove钩子执行到最后一个模块,才对element进行删除,否则会出现过河拆桥的情况

listeners = cbs.remove.length + 1;   //listeners 记录有几个需要执行remove的模块
rm = createRmCb(ch.elm as Node, listeners);
for (i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm);  //每执行一次模块的remove,rm就调用一次,listeners--
  function removeVnodes(parentElm: Node,
                        vnodes: Array<VNode>,
                        startIdx: number,
                        endIdx: number): void {
    for (; startIdx <= endIdx; ++startIdx) {
      let i: any, listeners: number, rm: () => void, ch = vnodes[startIdx];
      if (ch != null) {
        if (isDef(ch.sel)) {
          invokeDestroyHook(ch);
          listeners = cbs.remove.length + 1;
          rm = createRmCb(ch.elm as Node, listeners);
          for (i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm);
          if (isDef(i = ch.data) && isDef(i = i.hook) && isDef(i = i.remove)) {
            i(ch, rm);
          } else {
            rm();
          }
        } else { // Text node
          api.removeChild(parentElm, ch.elm as Node);
        }
      }
    }
  }

  function invokeDestroyHook(vnode: VNode) {
    let i: any, j: number, data = vnode.data;
    if (data !== undefined) {
      if (isDef(i = data.hook) && isDef(i = i.destroy)) i(vnode);
      for (i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode);
      if (vnode.children !== undefined) {
        for (j = 0; j < vnode.children.length; ++j) {
          i = vnode.children[j];
          if (i != null && typeof i !== "string") {
            invokeDestroyHook(i);
          }
        }
      }
    }
  }

  function createRmCb(childElm: Node, listeners: number) {
    return function rmCb() {
      if (--listeners === 0) {
        const parent = api.parentNode(childElm);
        api.removeChild(parent, childElm);
      }
    };
  }
posted @ 2020-08-20 18:04  Khazix  阅读(275)  评论(0编辑  收藏  举报