Vue
系列第五篇,前文详解 render
到 VNode
的过程,不记得的童鞋可以回到 [咖聊] 从 render 到 VNode 加深印象。我们知道, Vue
具有跨多端的能力,前提就是使用了 VNode
(JavaScript
对象)。等于你有了“编译 🔨”,为所欲为自然不是事儿。本文将分析 VNode
生成 web
平台的 DOM
过程。阅读完本文,你将学习到:
patch
(渲染)过程;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
是在 init
的 lifecycleMixin
时定义(这部分代码位于 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
的获取过程:
web
的 DOM
操作和钩子全部位于 src\platforms\web
,weex
的渲染函数和钩子全部位于 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
。组件节点其实也是一种占位符节点,下面我们就来分析组件的渲染过程。
本节的 🌰 将上面的逻辑转移到 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.
本小节还有一点不同的是,给 Child
组件添加了 staticStyle
和 style
。我们知道这些 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
设置 style
有setProperty 和 style[property] 方式[1]。我们写组件时,有很多种写法,数组对象、字符串、对象都是支持的!这里涉及了一个框架设计概念:应用层的设计。在设计一个工具或者组件时,要从顶向下设计——先考虑怎么易用,也就是先考虑应用层的设计,其次再考虑怎么跟底层衔接(🌰 中 style.setProperty
和 style.cssPropertyName
)。如果是底层(固定、局限的特点)出发,往往会限制想象力,从而削弱框架的易用性。(❗❗❗ 小技巧)
本节分析了组件的 patch
过程:跟普通节点不同的是组件在执行到 createElm
的 createComponent
时,会返回 true
。在 createComponent
中会重新执行 _init
,然后执行组件的挂载逻辑(mount
-> mountComponent
-> updateComponent
-> vm._render
-> vm._update
) 。
最后用一张图总结本文:
通过 vm._render
获取到 VNode
,就会执行 vm._update
开始渲染,不同客户端的 patch
不一样。通过 createPatchFunction
中柯里化技巧把差异一次性磨平。对于普通元素,会创建一个元素,然后执行 invokeCreateHooks
处理 style、class、attrs
等属性。这部分又涉及到框架设计思想——分层,从应用层出发,让框架更易用。最后移除占位符节点执行插入钩子。对于组件节点,在执行到 createElm
的 createComponent
会返回 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