likes
comments
collection
share

Vuejs - 虚拟DOM

作者站长头像
站长
· 阅读数 16

虚拟DOM

1. 什么是虚拟DOM?为什么要有虚拟DOM?

虚拟DOM 是用于描述 DOM 节点的 JS 对象。

操作真实DOM非常 耗费性能 。尽可能 减少DOM 的操作,通过牺牲 JS 的计算性能来换取操作 DOM 所消耗的性能。

2. VNode类

省略了部分属性

/** src/core/vdom/vnode.ts **/

export default class VNode {
    tag;                 // 当前节点的标签名
    data;                // 当前节点对应的对象,包含了一些具体数据信息
    children;            // 当前节点的子节点数组
    text;                // 当前节点的文本
    elm;                 // 当前节点对应的真实DOM节点
    context;             // 当前节点的上下文(Vue实例)
    componentInstance;   // 当前节点对应的组件实例
    parent;              // 当前节点对应的真实的父DOM节点
    
    // diff 优化的属性
    isStatic;            // 是否静态节点,是则跳过 diff
    
    constructor(
        tag?,
        data?,
        children?,
        text?,
        elm?,
        context?
    ) {
        this.tag = tag;
        this.data = data;
        this.children = children;
        this.text = text;
        this.elm = elm;
        this.context = context;
        this.componentInstance = undefined;
        this.parent = undefined;
        
        this.isStatic = false;
    }
}

3. VNode的类型

3.1 注释节点

注释节点的描述非常简单: vnode.text 表示注释内容, vnode.isComment 表示是一个注释节点。

/** src/core/vdom/vnode.ts **/

export const createEmptyVNode = (text) => {
    const node = new VNode();
    node.text = text;
    node.isComment = true;
    return node;
};

3.2 文本节点

文本节点 的描述比 注释节点 更简单,只需要一个 text 属性。

/** src/core/vdom/vnode.ts **/

export function createTextVNode(val) {
    return VNode(undefined, undefined, undefined, String(val));
};

3.3 克隆节点

克隆节点是复制一个已存在的节点,主要是为了做 模版编译优化 时使用。

克隆时会新建一个 VNode实例 ,然后将需要复制的节点信息 浅拷贝 到新的节点上,并通过 vnode.isCloned 标识该节点是克隆节点。

/** src/core/vdom/vnode.ts **/

export function cloneVNode(vnode: VNode): VNode {
  const cloned = new VNode(
    vnode.tag,
    vnode.data,
    vnode.children && vnode.children.slice(),
    vnode.text,
    vnode.elm,
    vnode.context,
    vnode.componentOptions,
    vnode.asyncFactory
  );
  cloned.ns = vnode.ns;
  cloned.isStatic = vnode.isStatic;
  cloned.key = vnode.key;
  cloned.isComment = vnode.isComment;
  cloned.fnContext = vnode.fnContext;
  cloned.fnOptions = vnode.fnOptions;
  cloned.fnScopeId = vnode.fnScopeId;
  cloned.asyncMeta = vnode.asyncMeta;
  
  cloned.isCloned = true;
  return cloned;
};

3.4 元素节点

/** src/core/vdom/create-element.ts **/

export function _createElement(
    context,
    tag?,
    data?,
    children?
) {
    // 如果 data 存在且已转成可观测对象,则返回一个注释节点
    if (isDef(data) && isDef(data.__ob__)) {
        return createEmptyVNode();
    }
    
    // 根据 data.is 重新给 tag 赋值
    // :is 的实现原理
    if (isDef(data) && isDef(data.is)) {
        tag = data.is;
    }
    
    // 如果不存在 tag ,则返回一个注释节点
    if (!tag) {
        return createEmptyVNode();
    }
    
    let vnode;
    if (typeof tag === 'string') {
        let Ctor;
        if ((!data || !data.pre) && isDef((Ctor = resolveAsset(context.$options, 'components', tag)))) {
            // 根据 tag 从 options.components 中获取要创建的组件节点
            vnode = createComponent(Ctor, data, context, children);
        } else {
            // 创建普通的 vnode 节点
            vnode = new VNode(tag, data, children, undefined, undefined, context);
        }
    } else {
        // 创建组件节点
        vnode = createComponent(tag, data, context, children);
    }
    
    return vnode;
}

3.5 组件节点

组件节点除了元素节点具有的属性外,还有两个特有属性:

  • componentOptions: 组件的 option选项,如组件的 props
  • componentInstance: 组件节点对应的 Vue实例

3.6 函数式组件节点

函数式组件节点相较于组件节点又有两个特有属性:

  • fnContext: 函数式组件对应的 Vue实例
  • fnOptions: 组件的 option选项

4. 总结

在视图渲染之前,将写好的 template模版 编译成 vnode 缓存下来。等到 数据发生变化 页面需要 重新渲染 时,将数据发生变化后生成的 vnode 与前一次缓存的 vnode 进行对比,找出差异,根据 有差异的vnode 创建 真实DOM节点再插入到视图中,完成试图更新。

Diff

1. 创建节点

为了避免直接修改 vnode 而引起 状态混乱 问题,创建节点时若 vnode 已被之前的渲染使用,则 克隆该节点 ,修改克隆的 vnode 的属性。

创建节点时,会根据当前 宿主环境 调用封装好的 nodeOps.createElement() 方法,在 web端 等同于 document.createElement()

/** src/core/vdom/patch.ts **/

function createElm(
    vnode,
    insertedVnodeQueue,
    parentElm,
    refElm,
    nested,
    ownerArray,
    index
) {
    // vnode 有真实DOM节点时,克隆生成新的 vnode
    if (isDef(vnode.elm) && isDef(ownerArray)) {
        vnode = ownerArray[index] = cloneVNode(vnode);
    }
    
    // 是否是组件的根节点
    vnode.isRootInsert = !nested;
    
    const data = vnode.data;
    const children = vnode.children;
    const tag = vnode.tag;
    if (isDef(tag)) {
        // tag 不为 null/undefined 时
        
        // 创建新的真实DOM节点
        vnode.elm = nodeOps.createElement(tag, vnode);
        // 创建子节点
        createChildren(vnode, children, insertedVnodeQueue);
        // 插入节点
        insert(parentElm, vnode.elm, refElm);
    } 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);
    }
}

2. 删除节点

删除节点的逻辑非常简单,找到 父节点 再移除 子节点

/** src/core/vdom/patch.ts **/

function removeNode(el) {
    const parent = nodeOps.parentNode(el);
    nodeOps.removeChild(parent, el);
}

3. 更新节点

更新节点比较复杂:

  1. 判断新/旧节点是否相同,若 新/旧节点相同 , 则 结束更新流程

  2. 克隆节点

  3. 新旧节点是否为 静态节点 ,新/旧节点的 key 是否相同,新节点是否为 克隆节点 或新节点是否只创建一次。若 新/旧节点都为静态节点,新旧节点的 key相同 ,新节点为克隆节点或新节点只能被创建一次 ,则更新 vnode.componentInstance结束更新流程

  4. 新节点是否包含文本

  • 新节点不包含文本

    • 新/旧节点都包含子节点,且子节点不同 ,则 更新子节点

    • 只有新节点包含子节点 ,则 清空DOM中的文本内容,并更新子节点

    • 只有旧节点包含子节点 ,则 移除子节点

    • 新/旧节点都不包含子节点,且旧节点包含文本 ,则 清空DOM中的文本内容

  • 新节点包含文本,且新/旧节点的文本不同 ,则 更新DOM中的文本内容

/** src/core/vdom/patch.ts **/

function patchVnode(
    oldVnode,
    vnode,
    insertedVnodeQueue,
    ownerArray,
    index,
    removeOnly?
) {
    // 新、旧节点相同,直接返回
    if (oldVnode === vnode) {
        return;
    }
    
    // 克隆节点,为什么需要克隆节点的原因不做赘述
    if (isDef(vnode.elm) && isDef(ownerArray)) {
        vnode = ownerArray[index] = cloneVNode(vnode);
    }
    
    // 重新对克隆节点的真实DOM赋值
    const elm = (vnode.elm = oldVnode.elm);
    
    // vnode 与 oldVnode 都是静态节点,且 key 相同,直接返回
    if (
        isTrue(vnode.isStatic) &&
        isTrue(oldVnode.isStatic) &&
        vnode.key === oldVnode.key &&
        (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
    ) {
        vnode.componentInstance = oldVnode.componentInstance;
        return;
    }
    
    const oldCh = oldVnode.children;
    const ch = vnode.children;
    
    // vnode 没有文本属性
    if (isUndef(vnode.text)) {
        if (isDef(oldCh) && isDef(ch)) {
            // 若存在 oldCh 和 ch ,且二者不同
            // 则更新子节点
            if (oldCh !== ch) {
                updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly);
            }
        } else if (isDef(ch)) {
            // 若只存在 ch 
            // 则清空 DOM 中的文本,再添加子节点
            if (isDef(oldVnode.text)) {
                nodeOps.setTextContent(elm, '');
            }

            addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
        } else if (isDef(oldCh)) {
            // 若只存在 oldCh
            // 则移除子节点
            removeVnodes(oldCh, 0, oldCh.length - 1);
        } else if (isDef(oldVnode.text)) {
            // 若都没有子节点,且 oldVnode 有文本属性
            // 则清空 DOM 中的文本
            nodeOps.setTextContent(elm, '');
        }
    } else if (oldVnode.text !== vnode.text) {
        // vnode 有文本属性则与 oldVnode 的文本属性比较
        // 若有差异则更新为新的文本
        nodeOps.setTextContent(elm, vnode.text);
    }
}

4. 总结

Vuediff算法(patch) 的过程干了三件事: 创建节点,删除节点,更新节点。

更新子节点

Vue 中通过 updateChildren()方法 更新子节点。其思想就是循环新/旧子节点,然后对比。这部分代码较长,按照代码结构逐步分析。

对源码中的一些属性的中文名作以下约定:

  • 新子节点数组中第一个未处理的子节点: newStartVnode 新前
  • 新子节点数组中最后一个未处理的子节点:newEndVnode 新后
  • 旧子节点数组中第一个未处理的子节点: oldStartVnode 旧前
  • 旧子节点数组中最后一个未处理的子节点:oldEndVnode 旧后

1. 新前与旧前相同

/** src/core/vdom/patch.ts **/

else if (sameVnode(oldStartVnode, newStartVnode)) {
   // 进入 patch 流程,对比新旧 vnode 更新节点
   patchVnode(
       oldStartVnode,
       newStartVnode,
       insertedVnodeQueue,
       newCh,
       newStartIdx
   );
   
   // 从前向后切换待处理子节点
   oldStartVnode = oldCh[++oldStartIdx];
   newStartVnode = newCh[++newStartIdx];
}

2. 新后与旧后相同

/** src/core/vdom/patch.ts **/

else if (sameVnode(oldEndVnode, newEndVnode)) {
   // 进入 patch 流程,对比新旧 vnode 更新节点
   patchVnode(
       oldEndVnode,
       newEndVnode,
       insertedVnodeQueue,
       newCh,
       newEndIdx
   );
   
   // 从后向前切换待处理子节点
   oldEndVnode = oldCh[--oldEndIdx];
   newEndVnode = newCh[--newEndIdx];
}

3. 新后与旧前相同

/** src/core/vdom/patch.ts **/

else if (sameVnode(oldStartVnode, newEndVnode)) {
    // 进入 patch 流程,更新节点
    // 省略调用 patchVnode 的代码
    // ...
    
    // 将旧前节点插到旧后节点后面
    nodeOps.insertBefore(
        parentElm,
        oldStartVnode.elm,
        nodeOps.nextSibling(oldEndVnode.elm)
    );
    
    // 切换待处理子节点
    oldStartVnode = oldCh[++oldStartIdx];
    newEndVnode = newCh[--newEndIdx];
}

4. 新前与旧后相同

/** src/core/vdom/patch.ts **/

else if (sameVnode(oldEndVnode, newStartVnode)) {
    // 进入 patch 流程,更新节点
    // 省略调用 patchVnode 的代码
    // ...
    
    // 将旧后节点插到旧前前面
    nodeOps.insertBefore(
        parentElm,
        oldEndVnode.elm,
        oldStartVnode.elm
    );
    
    // 切换待处理子节点
    oldEndVnode = oldCh[--oldEndIdx];
    newStartVnode = newCh[++newStartIdx];
}

5. 不满足以上4种情况

/** src/core/vdom/patch.ts **/

else {
    // idxInOld 有两种取值方法
    // 1. 获取 旧节点数组中 与 新节点的 key 相同的 vnode 的索引
    // 2. 旧节点数组中 与 新节点相同的 vnode 的索引
    if (isUndef(idxInOld)) {
        // 旧子节点数组 中不存在 新前
        // 创建新元素
        createElm(
            newStartVnode,
            insertedVnodeQueue,
            parentElm,
            oldStartVnode.elm,
            false,
            newCh,
            newStartIdx
        );
    } else {
        vnodeToMove = oldCh[idxInOld];
        if (sameVnode(vnodeToMove, newStartVnode)) {
            // 进入 patch 流程,更新节点
            // 省略调用 patchVnode 的代码
            // ...
            
            oldCh[idxInOld] = undefined;
            
            // 将节点移动到 旧前节点 前面
            nodeOps.insertBefore(
                parentElm,
                vnodeToMove.elm,
                oldStartVnode.elm
            );
        } else {
            // vnode 不同,则创建新元素
            createElm(
                newStartVnode,
                insertedVnodeQueue,
                parentElm,
                oldStartVnode.elm,
                false,
                newCh,
                newStartIdx
            );
        }
    }
}

6. 结束while循环中的逻辑后

if (oldStartIdx > oldEndIdx) {
    refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
    
    // 插入新节点
    addVnodes(
        parentElm,
        refElm,
        newCh,
        newStartIdx,
        newEndIdx,
        insertedVnodeQueue
    );
} else if (newStartIdx > newEndIdx) {
    // 移除旧子节点数组中剩余未处理的节点
    removeNodes(oldCh, oldStartIdx, oldEndIdx);
}