vue.js

关注公众号 jb51net

关闭
首页 > 网络编程 > JavaScript > javascript类库 > vue.js > Vue 虚拟DOM

Vue中虚拟DOM的简单实现

作者:刘狗蛋O_o

Vue中的虚拟DOM是通过JavaScript对象来描述真实DOM结构的一种方式,本文将介绍Vue中虚拟DOM的简单实现,具有一定的参考价值,感兴趣的可以了解一下

虚拟DOM

1. 什么是虚拟DOM?为什么要有虚拟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 组件节点

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

3.6 函数式组件节点

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

  1. fnContext: 函数式组件对应的 Vue实例
  2. 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. 更新节点

更新节点比较复杂:

/** 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. 总结

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

更新子节点

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

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

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);
}

到此这篇关于Vue中虚拟DOM的简单实现的文章就介绍到这了,更多相关Vue 虚拟DOM内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文