深入浅出--虚拟DOM

虚拟DOM

Vue.js2.0引入了虚拟dom,比1.0的初始渲染速度提升了2-4倍,并大大降低了内存消耗。
dom操作越来越频繁,代码中多数都在操作dom,状态难以管理 => 代码应该集中于状态管理,dom操作交给框架去实现 => 状态改变,重新渲染,只更新与这个状态相关的dom节点,angular脏检查-react虚拟dom-vue1.0细粒度绑定。

虚拟dom的解决方式是通过状态生成一个虚拟节点树, 渲染前会用新的虚拟dom树和上一次的进行比对,只渲染不同的部分。

vue1.0通过细粒度绑定来更新,每一个绑定都有一个对应的watcher来观察状态的变化,产生内存开销和依赖追踪的开销,对大型项目来说,这个开销是非常大的。
vue2.0开始选择中等粒度的解决方案,引入虚拟dom,组件级别是一个watcher实例。即便一个组件中有10个节点使用了某个状态,但其实也只有一个watcher,状态发生变化时,通知到组件,然后组件内部再通过虚拟dom去进行对比和渲染。** vue能够随意调整绑定的粒度,本质上还要归功于变化侦测。**

模板 -> 渲染函数 -> vnode -> patch -> 视图
patch为新旧节点树比对,判断出哪些节点发生了变化,从而只对变化的节点进行更新操作。

VNode

VNode类可以实例化不同类型的vnode实例,表示不同类型的dom元素。使用vnode创建真实dom,再插入到页面中渲染视图。

    export default class VNode {
        constructor (tag, data, children, text, elm, context, componentOptions, asynFactory) {
            this.tag = tag
            this.data = data
            this.children = children
            this.text = text
            this.elm = elm
            this.ns = undefined
            this.context = context
             ...
        }
    }

作用在于,可以将上一次渲染视图时的vnode缓存起来,之后每当需要重新渲染视图时,将新创建的vnode和上一次缓存的vnode进行比对。

注释节点

    export const createEmptyVNode = text => {
        const node = new VNode()
        node.text = text
        node.isComment = true
        // 其余属性全是默认的undefined或者false
    }

文本节点

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

克隆节点

将现有节点的属性复制到新节点中,优化静态节点和插槽节点。
eg: 组件因状态变化需要重新渲染时,静态节点不会改变,所以除了首次渲染需要执行渲染函数获取vnode之外,后续都创建克隆节点进行渲染,不用重新执行渲染函数生成新的vnode。
克隆节点的isCloned属性值为true,被克隆的原始节点isCloned为false。

    export function cloneVNode (vnode, deep) {
        const cloned = new VNode(
            vnode.tag,
            vnode.data,
            vnode.children,
            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.isCloned = true
        if (deep && vnode.children) {
            cloned.children = cloneVNodes(vnode.children)
        }
        return cloned
    }

元素节点

    {
        // 通常存在4种有效属性
        children: [VNode, VNode],
        context: {...},
        data: {...},
        tag: "p",
        ...
    }

组件节点

    {
        componentInstance: {...},
        componentOptions: {...},
        context: {...},
        data: {...},
        tag: "vue-component-1-child"
    }

函数式组件

    {
        functionalContext: {...},
        functionalOptions: {...},
        context: {...},
        data: {...},
        tag: "div"
    }

patch

虚拟dom最核心的部分是patch,可以将vnode渲染成真实的dom。渲染时,不是直接暴力覆盖原有dom,而是通过比对新旧虚拟节点树,找出需要更新的节点操作dom更新。用js运算成本来替换dom操作的成本。

patch的目的是修改dom节点渲染视图,对比新旧vnode差异只是手段。

oldNode是否存在? -> 不存在,使用vnode创建节点并插入视图
-> 存在 -> oldNode和vnode是否是同一个节点
-> 是,使用patchVnode进行详细的对比和更新操作
否,使用vnode创建真实节点并插入到视图中旧节点旁边,删除视图中旧节点

创建节点

对于每一个节点,先判断节点类型,如果有tag属性,则是元素节点,调用当前环境下的createElement方法创建;如果isComment为true,则是注释节点,调用createComment方法创建;如果为false,则为文本节点,调用createTextNode创建。如果其有子节点,则需要递归创建子节点,等到所有子节点都创建完,再将其用appendChild方法插入到其父节点中。

删除节点

    // 删除一组指定节点
    function removeVnodes (vnodes, startIdx, endIdx) {
        for (; startIdx <= endIdx; ++startIdx) {
            const ch = vnodes[startIdx]
            if (isDef(ch)) {
                removeNode(ch.elm)
            }
        }
    }
    // 删除单个节点
    function removeNode(el) {
        const parent = nodeOps.parentNode(el)
        if (isDef(parent)) {
            nodeOps.removeChild(parent, el)    
        }
    }
    // 把removeChild封在nodeOps中,是为了把框架更新dom节点的操作封装,实现**跨平台渲染**,不用平台下调用节点操作。
    const nodeOps = {
          removeChild(node, child) {
              node.removeChild(child)
          }
    }

更新节点

以vnode为准来更新视图

静态节点

如果新旧节点都为静态节点,无论状态怎么变化都不会该改变,则不需要重新渲染,跳过更新节点的过程。

vnode有文本属性

如果oldVnode也有文本属性,且与vnode一致,则不要改动。其余情况,无论oldVNode怎样,都调用setTextContent方法,更新视图中dom内容为text属性值。

vnode无文本属性

  1. 有children
    如果oldVNode有children,则需要进一步详细比较其区别;
    如果oldVNode没有children,那不是空节点就是文本节点,如果是文本节点,就清空文本,然后遍历vnode的children创建真实子dom,并插入到视图中。
  2. 无children
    说明vnode是一个空节点,所以oldVNode中有什么就删什么,使真实dom变成一个空节点。

更新子节点

当vnode和oldVNode的子节点都存在且不同时,需要更新子节点。大概分为4种操作: 更新节点,新增节点,删除节点和移动节点。对比两个子节点列表,需要遍历newChildren,对于每一个child,遍历oldChildren查找,如果找不到,说明是新增节点,如果找到了,就更新节点,如果找到的oldChild和newChild位置不同,则需要移动节点。

更新策略

1.新增节点:创建新节点,并插入到oldChildren中所有未处理节点的前面。
为啥是所有未处理节点的前面?
因为我们是用新旧节点树进行对比,而不是真实dom树。如果,新旧节点树都已经处理了两个节点,新树的第三个节点,旧树中没有,此时需要在真实dom树中新增这个节点,而旧树中只有两个已处理和两个未处理。当比对第四个节点也需要新增时,如果添加在所有已处理的后面,将添加在第3位,位置错误。
2.更新子节点:当一个节点同时存在于newChildren和oldChildren中,且位置相同,只需要更新children和文本即可。
3.移动子节点:当一个节点同时存在于newChildren和oldChildren中,但位置不同,需要在视图中将此节点移动到所有未处理节点的最前面。节点更新并且完成移动后,开始进行下一轮循环,处理下一个未处理的节点。
4.删除子节点:当遍历完newChildren中所有节点时,oldChildren中剩余的未处理节点就是被废弃需要删除的节点。

优化策略

利用四种快捷查找方式,如果找到则直接更新和移动位置,如果没有找到,再进行遍历查找。
新前: newChildren中所有未处理的第一个节点
新后: newChildren中所有未处理的最后一个节点
旧前: oldChildren中所有未处理的第一个节点
旧后: oldChildren中所有未处理的最后一个节点

  1. 新前与旧前
    比对新前与旧前两个节点,如果是同一个节点,则直接更新节点并更新视图;如果不是,则尝试比对新后与旧后。
  2. 新后与旧后
    比对新后与旧后两个节点,如果是同一个节点,则直接更新节点并更新视图;如果不是,则尝试比对新后与旧前。
  3. 新后与旧前
    比对新后与旧前两个节点,如果是同一个节点,则更新节点,然后将此节点移动到oldChildren中所有未处理节点的最后面;如果不是,则尝试比对新前与旧后。
  4. 新前与旧后
    比对新前与旧后两个节点,如果是同一个节点,则更新节点,然后将此节点移动到oldChildren中所有未处理节点的最前面;如果不是,则遍历oldChildren查找。

未处理节点

被遍历过的都是处理过的节点,还没遍历到的就是未处理节点。但是由于优化策略,节点可能会有从后面比对的,所以不再只是处理所有未处理节点的第一个也可能是最后一个,所以遍历应该从两头向中间。
oldStartIdx、oldEndIdx、newStartIdx、newEndIdx。start被处理过,就后移一位;end被处理过,就前移一位;old和new一起移动节点。

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 4种快速查找
    // 遍历查找
}
while (oldStartIdx <= oldEndIdx) {
    // oldChildren中还有剩余节点,是需要被废弃的,可以遍历删除
}
while (newStartIdx <= newEndIdx) {
    // newChildren中还有剩余节点,是新增节点,需要遍历添加
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容