前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >首次 patch,从 VNode 到 DOM

首次 patch,从 VNode 到 DOM

作者头像
码农小余
发布2022-06-16 16:40:32
1.1K0
发布2022-06-16 16:40:32
举报
文章被收录于专栏:码农小余

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

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

普通节点的 patch

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

代码语言:javascript
复制
<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

代码语言:javascript
复制
updateComponent = function () {

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

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

代码语言:javascript
复制
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 端的逻辑:

代码语言:javascript
复制
// 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 函数之后,接着看渲染过程:

代码语言:javascript
复制
// 首次渲染这里为空,当数据发生改变,重新渲染时,这里不为空
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 的返回值:

代码语言:javascript
复制
/**
 * 渲染函数
 * @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,然后插入到它的父节点中。是渲染的最主要过程:

代码语言:javascript
复制
// 通过虚拟节点创建真实的 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 创建一个占位符元素:

代码语言:javascript
复制
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 深度遍历地递归去创建子节点:

代码语言:javascript
复制
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 钩子:

代码语言:javascript
复制
// 执行所有的 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 中:

代码语言:javascript
复制
// 把 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 的最后就是要将占位符节点移除并执行销毁和插入钩子函数:

代码语言:javascript
复制
// 销毁老节点,🌰 中 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 组件:

代码语言:javascript
复制
<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

代码语言:javascript
复制
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

代码语言:javascript
复制
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 逻辑,不过此时不同的是以下逻辑会被执行:

代码语言:javascript
复制
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.

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

代码语言:javascript
复制
<div style="color: red; font-weight: 600;">component patch</div>

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

代码语言:javascript
复制
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] 方式[1]。我们写组件时,有很多种写法,数组对象、字符串、对象都是支持的!这里涉及了一个框架设计概念:应用层的设计。在设计一个工具或者组件时,要从顶向下设计——先考虑怎么易用,也就是先考虑应用层的设计,其次再考虑怎么跟底层衔接(🌰 中 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 后重新渲染将在下一篇文章中分析。关注码农小余,一起耕耘,一起收获!

参考资料

[1]

setProperty 和 style[property] 方式: https://developer.mozilla.org/zh-CN/docs/Web/API/CSSStyleDeclaration/setProperty

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-08-16,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 码农小余 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 普通节点的 patch
    • 小结
    • 组件的 patch
      • 小结
      • 总结
        • 参考资料
        相关产品与服务
        容器服务
        腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
        领券
        问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档