patch,从VNode到DOM

1,568 阅读5分钟

Vue 系列第五篇,前文详解 renderVNode 的过程,不记得的童鞋可以回到 [咖聊] 从 render 到 VNode 加深印象。我们知道, Vue 具有跨多端的能力,前提就是使用了 VNodeJavaScript 对象)。等于你有了“编译 🔨”,为所欲为自然不是事儿。本文将分析 VNode 生成 web 平台的 DOM 过程。阅读完本文,你将学习到:

  1. 普通节点的 patch (渲染)过程;
  2. 组件节点的 patch (渲染)过程;
  3. 一点点小技巧(藏匿文中 🤭🤭🤭);

普通节点的 patch

普通节点通过一个 div 🌰 去分析渲染过程:

<div id="app">
</div>
<script>
    new Vue({
        el: '#app',

        name: 'App',

        render (h) {
            return h('div', {
                id: 'foo'
            }, 'Hello, patch')
        }
    })
</script>

🌰 在经过 _render 后,得到的 VNode 如下图所示:

之后会调用 _update 去生成 DOM

updateComponent = function () {

    // vm._render()生成虚拟节点
    // 调用 vm._update 更新 DOM
    vm._update(vm._render(), hydrating);
};

vm._update 是在 initlifecycleMixin 时定义(这部分代码位于 src\core\instance\lifecycle.js):

Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    const vm: Component = this

    // 更新时的参数
    const prevEl = vm.$el
    const prevVnode = vm._vnode
    const prevActiveInstance = activeInstance
    activeInstance = vm

    // 将虚拟node缓存在_vnode
    vm._vnode = vnode
    // Vue.prototype.__patch__ is injected in entry points
    // based on the rendering backend used.
    // 首次渲染没有对比VNode,这里为null
    if (!prevVnode) {
      // initial render
      vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
    } else {
      // updates
      vm.$el = vm.__patch__(prevVnode, vnode)
    }
    activeInstance = prevActiveInstance
    // update __vue__ reference
    if (prevEl) {
      prevEl.__vue__ = null
    }
    if (vm.$el) {
      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.
  }

主逻辑是 vm.__patch__,这也是能够适配多端的核心!全局搜索 Vue.prototype.__patch__ ,我们能看到好几个定义:

本文只看 web 端的逻辑:

// src\platforms\web\runtime\index.js
// 判断是不是在浏览器环境,服务端渲染也不需要渲染成DOM,所以是空函数
Vue.prototype.__patch__ = inBrowser ? patch : noop

// src\platforms\web\runtime\patch.js
/**
 * @param {Object} - nodeOps 封装了一系列 DOM 操作的方法
 * @param {Object} - modules 定义了一些模块的钩子函数的实现
 */
export const patch: Function = createPatchFunction({ nodeOps, modules })

// src\core\vdom\patch.js
export function createPatchFunction (backend) {
 
  // ... 😎 带上墨镜,不看这几百行代码

  return function patch (oldVnode, vnode, hydrating, removeOnly) {
    // ... 
    return vnode.elm
  }
}

去掉上百行代码的情况下,我们能够非常清晰地理解 patch 的获取过程:

  • 不同客户端通过目录区分,webDOM 操作和钩子全部位于 src\platforms\webweex 的渲染函数和钩子全部位于 src\platforms\weex
  • 通过调用位于 src\core\vdom\patch.js 下的 createPatchFunction生成 patch 函数。我们的应用肯定是会重复调用渲染函数,通过柯里化的技巧将平台的差异一次磨平,后面每次调用 patch 不需要重复再去获取关于平台的操作函数(❗❗❗ 小技巧)。

获取到 patch 函数之后,接着看渲染过程:

// 首次渲染这里为空,当数据发生改变,重新渲染时,这里不为空
if (!prevVnode) {
    // initial render
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
    // updates
    vm.$el = vm.__patch__(prevVnode, vnode)
}

vm.__patch__ 就是 createPatchFunction 的返回值:

/**
 * 渲染函数
 * @param {VNode} oldVnode 旧的Vnode节点
 * @param {VNode} vnode 当前Vnode节点
 * @param {Boolean} hydrating 是否是服务端渲染
 * @param {Boolean} removeOnly transition-group组件用到的,这里不涉及
 */
return function patch (oldVnode, vnode, hydrating, removeOnly) {
    // _render 之后没有生成 vnode,旧节点如果有的话,执行销毁钩子
    if (isUndef(vnode)) {
        if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
        return
    }

    let isInitialPatch = false
    const insertedVnodeQueue = []

    // 初次渲染没有oldNode
    if (isUndef(oldVnode)) {
        // empty mount (likely as component), create new root element
        isInitialPatch = true
        createElm(vnode, insertedVnodeQueue)
    } else {
        const isRealElement = isDef(oldVnode.nodeType)

        // 组件更新时有了oldNode,所以执行sameVnode做对比
        if (!isRealElement && sameVnode(oldVnode, vnode)) {
            // patch existing root node
            patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
        } else {
            if (isRealElement) {
                // mounting to a real element
                // check if this is server-rendered content and if we can perform
                // a successful hydration.
                if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
                    oldVnode.removeAttribute(SSR_ATTR)
                    hydrating = true
                }
                // ...省去服务端渲染的逻辑
                // either not server-rendered, or hydration failed.
                // create an empty node and replace it
                // 把真实 DOM 转成 VNode
                // 🌰 中是 id = app 这个 DOM
                oldVnode = emptyNodeAt(oldVnode)
            }

            // replacing existing element
            // 挂载节点,🌰 中是 id = app 这个 DOM
            const oldElm = oldVnode.elm
            // 挂载节点的父节点,🌰 中是body
            const 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)
            )

            // ...

            // 销毁老节点,🌰 中 parentElm 是 body 元素
            if (isDef(parentElm)) {
                // oldVnode 是 id = app 的 div
                removeVnodes(parentElm, [oldVnode], 0, 0)
            } else if (isDef(oldVnode.tag)) {
                invokeDestroyHook(oldVnode)
            }
        }
    }

    // 执行插入钩子
    invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
    return vnode.elm
}

createElement 通过 VNode 去创建一个新的 DOM,然后插入到它的父节点中。是渲染的最主要过程:

// 通过虚拟节点创建真实的 DOM 并插入到它的父节点中
function createElm (
 vnode,
 insertedVnodeQueue,
 parentElm,
 refElm,
 nested,
 ownerArray,
 index
) {
    // ...
     
    // 🌰 中 {id: 'foo'}
    const data = vnode.data
    // 🌰 中是文本节点
    const children = vnode.children
    // 🌰 中是div
    const tag = vnode.tag

    // ... 省略判断标签是否合法

    // 调用平台 DOM 的操作去创建一个占位符元素
    vnode.elm = vnode.ns
       ? nodeOps.createElementNS(vnode.ns, tag)
       : nodeOps.createElement(tag, vnode)
       // scoped 样式处理,这里不涉及
       setScope(vnode)

        /* istanbul ignore if */
        if (__WEEX__) {
            // ... 省略 weex 代码
        } else {
            // 创建子节点
            createChildren(vnode, children, insertedVnodeQueue)
            if (isDef(data)) {

                // 执行所有的 create 的钩子并把 vnode push 到 insertedVnodeQueue 中
                invokeCreateHooks(vnode, insertedVnodeQueue)
            }

            // 把 DOM 插入到父节点中,因为是深度递归调用,插入顺序先子后父
            insert(parentElm, vnode.elm, refElm)
        }

        if (process.env.NODE_ENV !== 'production' && 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)
    }
}

通过 nodeOps.createElement 创建一个占位符元素:

export function createElement (tagName: string, vnode: VNode): Element {
  // 🌰 中创建一个 div 元素
  const elm = document.createElement(tagName)
  // 不是 select 直接返回 div
  if (tagName !== 'select') {
    return elm
  }
  // false or null will remove the attribute but undefined will not
  if (vnode.data && vnode.data.attrs && vnode.data.attrs.multiple !== undefined) {
    elm.setAttribute('multiple', 'multiple')
  }
  return elm
}

createChildren 深度遍历地递归去创建子节点:

function createChildren (vnode, children, insertedVnodeQueue) {
    if (Array.isArray(children)) {
        // 检查重复的key,有的话warn
        if (process.env.NODE_ENV !== 'production') {
            checkDuplicateKeys(children)
        }

        // 遍历子虚拟节点,递归调用 createElm
        for (let i = 0; i < children.length; ++i) {

            // 在遍历过程中会把 vnode.elm 作为父容器的 DOM 节点占位符
            createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i)
        }
    } else if (isPrimitive(vnode.text)) {
        nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text)))
    }
}

invokeCreateHooks 执行所有的 createXX 钩子:

// 执行所有的 create 的钩子并把 vnode push 到 insertedVnodeQueue 中
invokeCreateHooks(vnode, insertedVnodeQueue)

function invokeCreateHooks (vnode, insertedVnodeQueue) {
    // 这部分函数定义在 src\platforms\web\runtime\modules 下
    for (let i = 0; i < cbs.create.length; ++i) {
        cbs.create[i](emptyNode, vnode "i")
    }
    i = vnode.data.hook // Reuse variable
    if (isDef(i)) {
        // create 和 insert 的 hook
        if (isDef(i.create)) i.create(emptyNode, vnode)
        if (isDef(i.insert)) insertedVnodeQueue.push(vnode)
    }
}

最后调用 insert 函数插入到 DOM 中:

// 把 DOM 插入到父节点中,因为是深度递归调用,插入顺序先子后父
insert(parentElm, vnode.elm, refElm)

/**
   * dom插入函数
   * @param {*} parent - 父节点
   * @param {*} elm - 子节点
   * @param {*} ref
   */
function insert (parent, elm, ref) {
    if (isDef(parent)) {
        if (isDef(ref)) {
            if (ref.parentNode === parent) {
                // https://developer.mozilla.org/zh-CN/docs/Web/API/Node/insertBefore
                nodeOps.insertBefore(parent, elm, ref)
            }
        } else {
            // https://developer.mozilla.org/zh-CN/docs/Web/API/Node/appendChild
            nodeOps.appendChild(parent, elm)
        }
    }
}

执行完了 insert 之后,页面的 DOM 就会发生一次变化,🌰 中如下图所示:

patch 的最后就是要将占位符节点移除并执行销毁和插入钩子函数:

// 销毁老节点,🌰 中 parentElm 是 body 元素
if (isDef(parentElm)) {
    // oldVnode 是 id = app 的 div
    removeVnodes(parentElm, [oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
    invokeDestroyHook(oldVnode)
}

执行完上述逻辑,页面上的 <div id="app"> 被销毁,结果如下图所示:

小结

至此,普通节点的初次渲染过程也就分析完了。patch 过程通过创建节点、递归插入节点、最后销毁占位节点三步完成渲染。过程中的操作都是调用 DOM 原生 API 。组件节点其实也是一种占位符节点,下面我们就来分析组件的渲染过程。

组件的 patch

本节的 🌰 将上面的逻辑转移到 Child 组件:

<div id="app">
</div>
<script>
    const Child = {
        render(h) {
            return h('div', {
                id: 'foo',
                staticStyle: {
                    color: 'red',
                },
                style: [{
                    fontWeight: 600
                }]
            }, 'component patch')
        }
    }

    new Vue({
        el: '#app',

        components: {
            Child
        },

        name: 'App',

        render(h) {
            return h(Child)
        }
    })
</script>

组件渲染在执行到 createElm 以下逻辑时会返回 true

if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
    return
}

function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
    let i = vnode.data
    if (isDef(i)) {
        // keep-alive 组件
        const isReactivated = isDef(vnode.componentInstance) && i.keepAlive
        // 这里的i.hook是在上一节生成VNode中有一个 installComponentHooks 逻辑会生成组件VNode的hooks
        if (isDef(i = i.hook) && isDef(i = i.init)) {
            i(vnode, false /* hydrating */)
        }
        // after calling the init hook, if the vnode is a child component
        // it should've created a child instance and mounted it. the child
        // component also has set the placeholder vnode's elm.
        // in that case we can just return the element and be done.
        if (isDef(vnode.componentInstance)) {
            initComponent(vnode, insertedVnodeQueue)
            insert(parentElm, vnode.elm, refElm)
            if (isTrue(isReactivated)) {
                reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
            }
            return true
        }
    }
}

其中 i(vnode, false /* hydrating */) 会执行到 initHook

const componentVNodeHooks = {
  init (vnode: VNodeWithData, hydrating: boolean): ?boolean {
    // keepalive 的逻辑,本文不关注
    if (
      vnode.componentInstance &&
      !vnode.componentInstance._isDestroyed &&
      vnode.data.keepAlive
    ) {
      // kept-alive components, treat as a patch
      const mountedNode: any = vnode // work around flow
      componentVNodeHooks.prepatch(mountedNode, mountedNode)
    } else {
      // 创建一个 Vue 的实例
      const child = vnode.componentInstance = createComponentInstanceForVnode(
        vnode,
        activeInstance
      )
      // 调用 $mount 方法挂载子组件
      child.$mount(hydrating ? vnode.elm : undefined, hydrating)
    }
  },
  // ...
}

/**
 * 创建虚拟节点组件实例
 * @param {*} vnode 
 * @param {*} parent 
 */
export function createComponentInstanceForVnode (
  vnode: any, // we know it's MountedComponentVNode but flow doesn't
  parent: any, // activeInstance in lifecycle state
): Component {
  // 构造组件参数
  const options: InternalComponentOptions = {
    _isComponent: true,
    _parentVnode: vnode,
    parent
  }
  // check inline-template render functions
  const inlineTemplate = vnode.data.inlineTemplate
  if (isDef(inlineTemplate)) {
    options.render = inlineTemplate.render
    options.staticRenderFns = inlineTemplate.staticRenderFns
  }
  // 子组件的构造函数
  return new vnode.componentOptions.Ctor(options)
}

new vnode.componentOptions.Ctor(options) 会再执行到 _init 逻辑,不过此时不同的是以下逻辑会被执行:

if (options && options._isComponent) {
    // optimize internal component instantiation
    // since dynamic options merging is pretty slow, and none of the
    // internal component options needs special treatment.
    initInternalComponent(vm, options);
}

// src\core\instance\init.js
export function initInternalComponent (vm: Component, options: InternalComponentOptions) {

  // 这里的 vm.construction 就是子组件的构造函数 Sub,相当于 vm.$options = Object.create(Sub.options)
  const opts = vm.$options = Object.create(vm.constructor.options)
  // doing this because it's faster than dynamic enumeration.

  const parentVnode = options._parentVnode
  opts.parent = options.parent
  opts._parentVnode = parentVnode

  const vnodeComponentOptions = parentVnode.componentOptions
  opts.propsData = vnodeComponentOptions.propsData
  opts._parentListeners = vnodeComponentOptions.listeners
  opts._renderChildren = vnodeComponentOptions.children
  opts._componentTag = vnodeComponentOptions.tag

  if (options.render) {
    opts.render = options.render
    opts.staticRenderFns = options.staticRenderFns
  }
}

initInternalComponent 也比较简单,将参数都合并到 $options 中。然后会执行到 child.$mount(hydrating ? vnode.elm : undefined, hydrating) 挂载逻辑,这里 hydrating 因为不是服务端渲染所以是 false,相当于执行 child.$mount(undefined, false)。依次执行 mount -> mountComponent -> updateComponent -> vm._render-> vm._update。再执行到 vm._update 时,流程就跟上述普通节点的流程一样了。

本小节还有一点不同的是,给 Child 组件添加了 staticStylestyle。我们知道这些 style 最后都会渲染成 DOM 上的串儿。🌰 的渲染结果如下:

<div style="color: red; font-weight: 600;">component patch</div>

简单分析一下生成过程:在执行 invokeCreateHooks 时,会执行到 updateStyle 这个钩子:

function updateStyle (oldVnode: VNodeWithData, vnode: VNodeWithData) {
  const data = vnode.data
  const oldData = oldVnode.data

  if (isUndef(data.staticStyle) && isUndef(data.style) &&
    isUndef(oldData.staticStyle) && isUndef(oldData.style)
  ) {
    return
  }

  let cur, name
  const el: any = vnode.elm
    
  // 🌰 中首次渲染时,这些 oldXX 都是空对象
  const oldStaticStyle: any = oldData.staticStyle
  const oldStyleBinding: any = oldData.normalizedStyle || oldData.style || {}

  // 如果静态样式存在,则在执行 normalizeStyleData 时样式绑定已经合并到其中
  const oldStyle = oldStaticStyle || oldStyleBinding

  // 规范化动态样式,都转成 {key: value} 的格式
  const style = normalizeStyleBinding(vnode.data.style) || {}

  // store normalized style under a different key for next diff
  // make sure to clone it if it's reactive, since the user likely wants
  // to mutate it.
  // 将style结果缓存到data.normalizedStyle上
  vnode.data.normalizedStyle = isDef(style.__ob__)
    ? extend({}, style)
    : style

  // 拼接全部的样式属性,包括staticStyle和style
  const newStyle = getStyle(vnode, true)

  for (name in oldStyle) {
    if (isUndef(newStyle[name])) {
      setProp(el, name, '')
    }
  }
    
  // 将数据结构差异磨平之后,遍历set到DOM上
  for (name in newStyle) {
    cur = newStyle[name]
    if (cur !== oldStyle[name]) {
      // ie9 setting to null has no effect, must use empty string
      setProp(el, name, cur == null ? '' : cur)
    }
  }
}

给一个 DOM 设置 stylesetProperty 和 style[property] 方式。我们写组件时,有很多种写法,数组对象、字符串、对象都是支持的!这里涉及了一个框架设计概念:应用层的设计。在设计一个工具或者组件时,要从顶向下设计——先考虑怎么易用,也就是先考虑应用层的设计,其次再考虑怎么跟底层衔接(🌰 中 style.setPropertystyle.cssPropertyName)。如果是底层(固定、局限的特点)出发,往往会限制想象力,从而削弱框架的易用性。(❗❗❗ 小技巧)

小结

本节分析了组件的 patch 过程:跟普通节点不同的是组件在执行到 createElmcreateComponent 时,会返回 true。在 createComponent 中会重新执行 _init,然后执行组件的挂载逻辑(mount -> mountComponent -> updateComponent -> vm._render-> vm._update) 。

总结

最后用一张图总结本文:

通过 vm._render 获取到 VNode,就会执行 vm._update 开始渲染,不同客户端的 patch 不一样。通过 createPatchFunction 中柯里化技巧把差异一次性磨平。对于普通元素,会创建一个元素,然后执行 invokeCreateHooks 处理 style、class、attrs 等属性。这部分又涉及到框架设计思想——分层,从应用层出发,让框架更易用。最后移除占位符节点执行插入钩子。对于组件节点,在执行到 createElmcreateComponent 会返回 true,并且会执行到在创建组件 VNode 时安装的 init hooks,从而执行组件的构造函数,又会执行 init -> $mount -> mountComponent -> updateComponent -> vm._render -> vm._update 流,从而完成组件的首次渲染。

当数据发生变化,触发 VNode 更新,执行 VNode diff 后重新渲染将在下一篇文章中分析。关注码农小余,一起耕耘,一起收获!