前面在vue2-render函数中梳理了组件挂载的过程,其中在分析 $createElement
的时候有遇到虚拟节点的创建及使用。今天就来好好分析下虚拟节点在节点创建及渲染中所扮演个什么样的角色以及实现。
为什么需要虚拟节点
网上关于虚拟节点的文章很多,关于为什么需要使用虚拟节点的原因也众说纷纭,下面我说说自己的理解
1.跨平台
我们知道 vue
其实是支持 weex
开发的,weex
我们可以理解为原生渲染引擎,它可以将按照协定的格式如 虚拟节点
将h5解析并渲染为原生APP。所以为了更好地兼容跨平台开发,有必要引入 DOM
管理机制,也就是由我们的虚拟节点
实现。
可以看到,引入的虚拟节点可以作为一个中间桥梁,我们的数据由VUE先转化为虚拟节点,再根据不同的平台调用不同的API去渲染节点
2.提升效率
我们知道使用vue开发是不需要也不建议我们手动修改DOM的,我们通常只是建立模板,再操作数据来更改DOM。考虑到有两个相邻互斥标签,它们除了里面的文本不同其它都相同。如果仅仅根据数据渲染DOM的做法,虽然页面同时只会挂载一个节点,但对于VUE来说可能需要管理两个DOM节点。
但是引入虚拟节点后,VUE就可以很轻松地知道,页面当前实际只有一个节点,当我们切换数据挂载不同节点时,并不需要个新建删除的过程,而是能很好的复用节点。
所以引入虚拟节点其实是为了更好地管理当前页面的节点信息,通过虚拟节点的对比来达到复用及其它高效更新的目的。
虚拟节点的实现
我们先来看看vue中虚拟节点 VNode
的实现代码
export default class VNode { constructor ( tag?: string, data?: VNodeData, children?: ?Array<VNode>, text?: string, elm?: Node, context?: Component, componentOptions?: VNodeComponentOptions, asyncFactory?: Function ) { this.tag = tag // 标签如div this.data = data // 数据包括style attrs class normalizedStyle staticClass staticStyle style等 this.children = children // 子节点[VNode] this.text = text // 文本节点文本 this.elm = elm // 关联的真实节点 this.ns = undefined this.context = context // context vm实例 this.fnContext = undefined this.fnOptions = undefined this.fnScopeId = undefined this.key = data && data.key // key this.componentOptions = componentOptions // 组件配置 this.componentInstance = undefined // 组件实例 this.parent = undefined this.raw = false this.isStatic = false this.isRootInsert = true this.isComment = false this.isCloned = false this.isOnce = false this.asyncFactory = asyncFactory this.asyncMeta = undefined this.isAsyncPlaceholder = false } // DEPRECATED: alias for componentInstance for backwards compat. /* istanbul ignore next */ get child (): Component | void { return this.componentInstance } } 复制代码
可以看到 VNode
的代码看起来比较简单,就是定义了一堆属性,通过那些属性来描述一个节点。当然,我们也可以说其很复杂,因为定义的属性实在太多了,我也没有逐一弄去清楚每个属性的作用。
值得注意的是 componentOptions
选项,组件其实也会被创建为一个 VNode
实例,这个我们在以后的文章再去分析。
虚拟节点的创建到渲染
我们结合之前的 render
来分析下 VNode
的使用,主要包括虚拟节点创建时机,虚拟节点如何渲染为真实节点等。
1. 虚拟节点的创建
从组件挂载开始,我们在前面有分析过,组件实例化的最后一步是挂载节点
if (vm.$options.el) { vm.$mount(vm.$options.el) } 复制代码
实际将调用 mountComponent
updateComponent = () => { vm._update(vm._render(), hydrating) } 复制代码
在 vm.render()
中将调用
const { render, _parentVnode } = vm.$options vnode = render.call(vm._renderProxy, vm.$createElement) vnode.parent = _parentVnode 复制代码
此处的 render
则是我们在初始化时配置的 render
函数,如果我们使用的是 template
模板的话,在编译阶段会将 template
编译为 render
我们再来看看 $createElement
的逻辑 vm.$createElement
实际调用的是 createElement
export function createElement ( context: Component, tag: any, data: any, children: any, normalizationType: any, alwaysNormalize: boolean ): VNode | Array<VNode> { if (Array.isArray(data) || isPrimitive(data)) { normalizationType = children children = data data = undefined } if (isTrue(alwaysNormalize)) { normalizationType = ALWAYS_NORMALIZE } return _createElement(context, tag, data, children, normalizationType) } 复制代码
我们再来看看 _createElement
export function _createElement ( context: Component, tag?: string | Class<Component> | Function | Object, data?: VNodeData, children?: any, normalizationType?: number ): VNode | Array<VNode> { // ... // 扁平化子节点 if (normalizationType === ALWAYS_NORMALIZE) { children = normalizeChildren(children) } else if (normalizationType === SIMPLE_NORMALIZE) { children = simpleNormalizeChildren(children) } let vnode, ns if (typeof tag === 'string') { let Ctor ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag) // 保留标签如div span if (config.isReservedTag(tag)) { // platform built-in elements if (process.env.NODE_ENV !== 'production' && isDef(data) && isDef(data.nativeOn) && data.tag !== 'component') { warn( `The .native modifier for v-on is only valid on components but it was used on <${tag}>.`, context ) } // 在这创建VNode // 关于节点的主要数据其实都保存在data中 vnode = new VNode( config.parsePlatformTagName(tag), data, children, undefined, undefined, context ) } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) { // 组件类型暂不分析 // component vnode = createComponent(Ctor, data, context, children, tag) } else { // unknown or unlisted namespaced elements // check at runtime because it may get assigned a namespace when its // parent normalizes children vnode = new VNode( tag, data, children, undefined, undefined, context ) } } else { // direct component options / constructor vnode = createComponent(tag, data, context, children) } if (Array.isArray(vnode)) { return vnode } else if (isDef(vnode)) { if (isDef(ns)) applyNS(vnode, ns) if (isDef(data)) registerDeepBindings(data) return vnode } else { return createEmptyVNode() } } 复制代码
到这,我们其实已经梳理完了 VNode
的创建过程,主要创建逻辑在 _createElement
2. 虚拟节点渲染为DOM
前面我们分析了虚拟节点的创建过程,现在我们再来看看虚拟节点是如何被渲染为真实节点的
回到前面,我们在创建过程分析了 vm._render()
的实现,最后将返回 VNode
节点
updateComponent = () => { vm._update(vm._render(), hydrating) } 复制代码
接下来我们再看看 _update
是如何将 VNode
渲染为DOM的
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) { const vm: Component = this const prevEl = vm.$el const prevVnode = vm._vnode const restoreActiveInstance = setActiveInstance(vm) vm._vnode = vnode if (!prevVnode) { // initial render vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */) } else { // updates vm.$el = vm.__patch__(prevVnode, vnode) } restoreActiveInstance() // ... } 复制代码
可以看到 _update
的主要逻辑就是调用 vm.__patch__
,我们先来分析下首次调用时执行 vm.__patch__(vm.$el, vnode)
__patch
的定义来自 platform/web/runtime/patch.js
import * as nodeOps from 'web/runtime/node-ops' import { createPatchFunction } from 'core/vdom/patch' import baseModules from 'core/vdom/modules/index' import platformModules from 'web/runtime/modules/index' // the directive module should be applied last, after all // built-in modules have been applied. const modules = platformModules.concat(baseModules) export const patch: Function = createPatchFunction({ nodeOps, modules }) 复制代码
这边最终调用了 createPatchFunction
同时传入两个参数
- nodeOps DOM操作的相关API
- modules DOM属性更新的相关函数
可以发现 nodeOps
和 modules
都是和平台相关的参数,例如浏览器的DOM API,所以这边通过以参数的方式传递下去,进一步解耦,在 createPatchFunction
就可以专心处理 patch
相关事务了。
const hooks = ['create', 'activate', 'update', 'remove', 'destroy'] export function createPatchFunction (backend) { let i, j const cbs = {} // hooks定义了一个节点的生命周期如create创建update更新 // modules定义了属性创建更新函数包括attr class event style props等 const { modules, nodeOps } = backend for (i = 0; i < hooks.length; ++i) { cbs[hooks[i]] = [] for (j = 0; j < modules.length; ++j) { if (isDef(modules[j][hooks[i]])) { cbs[hooks[i]].push(modules[j][hooks[i]]) } } } let creatingElmInVPre = 0 let hydrationBailed = false return function patch (oldVnode, vnode, hydrating, removeOnly) { if (isUndef(vnode)) { if (isDef(oldVnode)) invokeDestroyHook(oldVnode) return } let isInitialPatch = false const insertedVnodeQueue = [] // 没有老节点 直接创建新节点 if (isUndef(oldVnode)) { // empty mount (likely as component), create new root element isInitialPatch = true createElm(vnode, insertedVnodeQueue) } else { // 通过nodeType判断真实节点 const isRealElement = isDef(oldVnode.nodeType) if (!isRealElement && sameVnode(oldVnode, vnode)) { // patch existing root node patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly) } else { if (isRealElement) { // 创建空节点先替代旧节点 oldVnode = emptyNodeAt(oldVnode) } // replacing existing element const oldElm = oldVnode.elm const parentElm = nodeOps.parentNode(oldElm) // create new node 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) ) // destroy old node if (isDef(parentElm)) { removeVnodes([oldVnode], 0, 0) } else if (isDef(oldVnode.tag)) { invokeDestroyHook(oldVnode) } } } invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch) return vnode.elm } } 复制代码
createPatchFunction
我们分析的比较粗放一些,主要了解其定义了 cbs
并将DOM更新相关的函数添加进里面。还有就是调用了 createElm
来创建真实节点。
我们再来简单看看 createElm
function createElm ( vnode, insertedVnodeQueue, parentElm, refElm, nested, ownerArray, index ) { // vnode是组件的情况 vnode.isRootInsert = !nested // for transition enter check if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) { return } const data = vnode.data const children = vnode.children const tag = vnode.tag if (isDef(tag)) { vnode.elm = vnode.ns ? nodeOps.createElementNS(vnode.ns, tag) // 调用nodeOps.createElement创建节点 : nodeOps.createElement(tag, vnode) // scoped 相关实现 setScope(vnode) if (__WEEX__) { // ... } else { // 这边会遍历子vnode然后调用createElm形成递归 createChildren(vnode, children, insertedVnodeQueue) if (isDef(data)) { // 在存在data的情况则调用invokeCreateHooks // invokeCreateHooks实际就会调用我们之前定义的cbs中的create数组 invokeCreateHooks(vnode, insertedVnodeQueue) } // 插入节点到DOM中完成渲染 insert(parentElm, vnode.elm, refElm) } } } 复制代码
在 createElm
中我们可以很清楚地梳理出节点的创建,样式更新及插入了
nodeOps.createElement
就是直接调用了 DOM API
,可以看看
export function createElement (tagName: string, vnode: VNode): Element { const elm = document.createElement(tagName) 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 } 复制代码
同理,插入就是调用了 parentNode.insertBefore
,可能稍微需要说明一下的就是 invokeCreateHooks
function invokeCreateHooks (vnode, insertedVnodeQueue) { // 如前面所说就是调用create数组中的函数 for (let i = 0; i < cbs.create.length; ++i) { cbs.create[i](emptyNode, vnode) } // 组件生命周期钩子相关 i = vnode.data.hook if (isDef(i)) { if (isDef(i.create)) i.create(emptyNode, vnode) if (isDef(i.insert)) insertedVnodeQueue.push(vnode) } } 复制代码
我们前面有说过 cbs.create
其实来自于 modules
,所以我们看看 modules
,src/core/vdom/modules/index.js
import attrs from './attrs' import klass from './class' import events from './events' import domProps from './dom-props' import style from './style' import transition from './transition' export default [ attrs, klass, events, domProps, style, transition ] 复制代码
可以很清楚地看到和节点属性样式相关的函数名,以 style
为例,我们看看部分代码
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 const oldStaticStyle: any = oldData.staticStyle const oldStyleBinding: any = oldData.normalizedStyle || oldData.style || {} // if static style exists, stylebinding already merged into it when doing normalizeStyleData const oldStyle = oldStaticStyle || oldStyleBinding 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. vnode.data.normalizedStyle = isDef(style.__ob__) ? extend({}, style) : style const newStyle = getStyle(vnode, true) for (name in oldStyle) { if (isUndef(newStyle[name])) { setProp(el, name, '') } } 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) } } } export default { create: updateStyle, update: updateStyle } 复制代码
可以发现 style.js
暴露了含有 create
属性的对象,其值为 updateStyle
,而 updateStyle
最终将 push
到 cbs.create
中,所以当我们遍历 cbs.create
中函数时就会调用 updateStyle
并传入节点数据 vnode
。updateStyle
则会取到 vnode.data
进行节点样式更新。
3. vnode创建到渲染流程梳理
到此,vnode
如何创建并渲染为真实节点的过程我们就梳理完了。过程比较复杂,所以我们来梳理一下。
- 组件创建或更新调用
updateComponent
updateComponent = () => { vm._update(vm._render(), hydrating) } 复制代码
vm._render()
生成组件的虚拟节点vnode
,其中vnode
的children
属性包含了整个组件的虚拟节点。render
中实际调用了createElement
创建vnode
- 调用
vm._update
进行__patch__
,__patch__
中的vnode
是以整个组件为单元的,并非单个节点的vnode
if (!prevVnode) { // initial render vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */) } else { // updates vm.$el = vm.__patch__(prevVnode, vnode) } 复制代码
__patch__
的实际定义来自createPatchFunction
,并传入了节点操作APInodeOps
及属性样式包括事件的更新处理函数modules
export const patch: Function = createPatchFunction({ nodeOps, modules }) 复制代码
- 在
createPatchFunction
中会先将modules
中的更新函数推入cbs
再返回真正执行的patch
let i, j const cbs = {} const { modules, nodeOps } = backend for (i = 0; i < hooks.length; ++i) { cbs[hooks[i]] = [] for (j = 0; j < modules.length; ++j) { if (isDef(modules[j][hooks[i]])) { cbs[hooks[i]].push(modules[j][hooks[i]]) } } } return function patch() {} 复制代码
- 所以我们在
__patch__
中实际调用patch
,在patch
函数中调用createElm
进行创建节点
// create new node 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) ) 复制代码
- 在
createElm
中调用nodeOps.createElement
创建真实节点,并通过createChildren
遍历虚拟子节点递归调用createElm
进行创建真实子节点。之后再调用invokeCreateHooks
更新节点属性及样式。注意此时节点还未插入页面中,但是真实节点已经创建好了,其中包括整个组件的根节点及子节点。所以我们最后再去调用insert
将其插入页面中。
vnode.elm = vnode.ns ? nodeOps.createElementNS(vnode.ns, tag) : nodeOps.createElement(tag, vnode) createChildren(vnode, children, insertedVnodeQueue) if (isDef(data)) { invokeCreateHooks(vnode, insertedVnodeQueue) } insert(parentElm, vnode.elm, refElm) 复制代码
- 在
createElm
中再调用invokeCreateHooks
更新节点属性及样式。invokeCreateHooks
实际将调用到modules
中的各类型属性及样式的更新函数
if (isDef(data)) { invokeCreateHooks(vnode, insertedVnodeQueue) } 复制代码
- 至此我们已经将虚拟节点创建了真实的节点,并且通过更新函数更新了所以样式属性及事件,这时再通过
insert
将其插入到页面中,完成渲染流程。
insert(parentElm, vnode.elm, refElm) 复制代码
我们再次强调下,
patch
函数是针对整个组件级别的。我们在组件的render
函数中会生成整个组件的虚拟节点对象vnode
。在patch
中又会在createElm
递归子节点实现子节点,孙子节点的创建及样式属性更新。所以最后再插入页面的真实节点是组件的根节点,但是我们已经为其创建好了其所有子节点及孙子节点。这时候我们其实就完成了一整个组件的渲染。
总结
本篇文章分析了 vnode
在 vue
中的运用,包括 vnode
的 创建 -> 映射为真实节点 -> 渲染
的完整流程。当然篇幅有限,之前在 render 中分析的 createElement
及 patch
的细节及 modules
中的更新函数我们没有逐个分析。所以我们后面会将 patch
和 modules
放到后面单独分析梳理。goods goods staduy day day up