一、前情回顾 & 背景
上一篇小作文深入讨论了渲染 watcher
求值调用 updateComponent
方法中对 vm._render
和 vm._update
的调用:
-
vm._render
即Vue.prototype._render
是用于调用前面parse & generate
后得到的渲染函数,即vm.$options.render
得到VNode
,所谓VNode
就是传说中的虚拟DOM
树,描述节点间的关系; -
vm._update
即Vue.prototype._update
接收上一步得到的虚拟 DOM
,将其渲染到页面,变成真实 DOM
,也就是vm.__patch__
方法的工作; -
紧接着我们溯源了
vm.__patch__
即Vue.prototype.__patch__
方法的过程,它是由createPatchFunction
这个工厂返回的方法;vm.__patch__
负责初次渲染和响应式数据更新后的更新渲染工作;
那么本篇小作文的重点将放在 patch
函数在初次渲染
时所做的工作,之所以称之为初次渲染
,是为了区别当响应式数据更新后触发的再次渲染
更新视图的过程;
从标题可以看出,我们现在还处在挂载阶段
,并没有进入到响应式更新后的 DOM diff + patch
的更新阶段,当然这是后面的内容。
二、patch 初次渲染的调用
先回顾 patch
在初次渲染时的调用过程
声明 updateComponent = () => vm._update(vm_render(), ...)
-> new Wathcer 构造函数执行 updateComponent
-> vm._update 执行,即 Vue.prototype._update 执行
-> if (!preVnode) vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false)
三、简化 createPatchFunction 逻辑
createPatchFunction
内部代码量非常大,首先内部方法奇多,为了便于大家的理解,根据当前是初次渲染阶段,我们只把初次渲染相关的代码留下,其余的都删除,如此依赖这个代码看着就没有这么吓人了。
export function createPatchFunction (backend) {
// ....
return function patch (oldVnode, vnode, hydrating, removeOnly) {
// ...
let isInitialPatch = false
const insertedVnodeQueue = []
if (isUndef(oldVnode)) {
// 新的 VNode 存在,旧的 VNode 不存在,
// 说明这种情况下是一个【组件】初次渲染,比如:
// <div id='app'> <some-com></some-com> </div>
// 中的 some-com 的初次渲染走这里
} else {
// 根实例的 patch,从顶层 div#app 的初次渲染在这里
// 上面的 if 是这个 else 的后面渲染到自定义组件后的一个分支流程
}
return vnode.elm
}
}
四、patch 函数
方法位置:上面 createPatchFunction
返回值
方法参数:
oldVnode
, 旧的vnode
vnode
,新的vnode
;hydrating
,是否合成,忽略这个参数removeOnly
,仅移除
方法作用:patch
函数用于将 VNode
变成真正的 DOM
,渲染到页面上,这个过程涵盖了两种情况,第一种就是初次渲染,另一种就是响应式数据发生变化视图随之更新;这里我们讨论的是初次渲染的代码;
return function patch (oldVnode, vnode, hydrating, removeOnly) {
// 如果新节点不存在,老节点存在,调用 destroy 销毁老节点
if (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}
let isInitialPatch = false
const insertedVnodeQueue = []
if (isUndef(oldVnode)) {
// 组件初次渲染
} else {
// 判断 oldVnode 是否是真实元素,
// 初次渲染时,oldVnode 是传递的 div#app 这个真是的 DOM 元素
const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// 不是真实元素,但是旧节点和新节点是同一个节点,
// 说明是更新阶段,执行 patchVnode 进行更新
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
} else {
// 是真实节点,则表示初次渲染
if (isRealElement) {
// 挂载到真实元素以及处理服务端渲染情况(忽略服务端渲染的代码)
// 走到这里说明不是服务端渲染,则根据 oldVnode 创建一个空 vnode 节点
// 执行过这一行后,oldVnode 不再是 div#app 这个真是的 DOM 了,而是一个空的 VNode 了
oldVnode = emptyNodeAt(oldVnode)
}
// 替换掉旧节点的真实元素
const oldElm = oldVnode.elm
// 获取旧节点的父元素,即 body
const parentElm = nodeOps.parentNode(oldElm)
// 用新 vnode 创建整棵 DOM 树并插入到 body 元素
createElm(
vnode,
insertedVnodeQueue,
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)
// 递归更新父占位符元素节点
// 所谓占位符元素节点指的是:
// 自定义组件创建出来的以 <vue-component-cid-自定义组件名字 /> 的 VNode
// 比如我们的自定义组件 <some-com />,
// 它的占位符节点是 <vue-component-1-some-com /> 这个 vnode
// 初次渲染时 vnode.parent 为 undefined 忽略这部分
if (isDef(vnode.parent)) {
// ....
}
// 移除旧节点,所谓旧节点就是我们在 test.html 中的模板语法,即
// <div id="app"> <some-com></some-com> <div>
// 这个 html 在渲染后就被 Vue 真实的 DOM 替换掉了,所以需要这一段模板代码要移除掉
if (isDef(parentElm)) {
removeVnodes([oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}
}
}
// 触发 insertHook
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
return vnode.elm
}
4.1 createElm
function createElm (
vnode,
insertedVnodeQueue,
parentElm,
refElm,
nested,
ownerArray,
index
) {
// 为 transition 进入检查时用
vnode.isRootInsert = !nested
// 重点来啦:
// 这个 createComponent 负责处理 vnode 是自定义组件的情况
// 如果是 vnode 是一个普通元素,createComponent 调用后返回 false
if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
// 如果 vnode 是自定义组件,createComponent 执行后返回 true,到这里就终止了
return
}
// 能走到这里说明 vnode 是个普通的元素
// 获取 data 对象
const data = vnode.data
// 获取子节点列表
const children = vnode.children
// vnode 的标签名
const tag = vnode.tag
if (isDef(tag)) {
// 未知标签
if (process.env.NODE_ENV !== 'production') {
if (data && data.pre) {
creatingElmInVPre++
}
if (isUnknownElement(vnode, creatingElmInVPre)) {
// 不知名的标签警告
}
}
// 创建新节点,并挂载到 vnode 对象上,
// vnode.elm 是个真实的 DOM 元素
vnode.elm = vnode.ns // ns 是命名空间,忽略他
? nodeOps.createElementNS(vnode.ns, tag)
: nodeOps.createElement(tag, vnode) // 咱们研究这种情况
// 设置 css scoped id 属性
// 这种实现作为一种特殊 case 用以避免 patch 处理时遍历常规属性的开销
setScope(vnode)
if (__WEEX__) {
// weex 处理,忽略
} else {
// 递归创建所有子节点(普通元素,组件)
createChildren(vnode, children, insertedVnodeQueue)
if (isDef(data)) {
// 调用 createHooks
invokeCreateHooks(vnode, insertedVnodeQueue)
}
// 初次渲染时将节点插入父节点,这是至关重要的一步了,
// vnode.elm 是创建出来的真实元素,到了这里包含所有模板内容的一整棵 DOM 树,
// parentElm 是 body 元素
// 把 DOM 元素插入到 body,实现渲染
insert(parentElm, vnode.elm, refElm)
}
} else if (isTrue(vnode.isComment)) {
// vnode.tag 属性不存在,即不是元素或者自定义组件
// 到这里就是注释节点,创建注释节点并插入父节点
vnode.elm = nodeOps.createComment(vnode.text)
insert(parentElm, vnode.elm, refElm)
} else {
// 不是注释、也不是元素,就当文本处理了
// 文本节点,创建文本节点并插入父节点
vnode.elm = nodeOps.createTextNode(vnode.text)
insert(parentElm, vnode.elm, refElm)
}
}
我们在这里没有展开讲 createElm
方法,因为涉及到了自定义组件的渲染过程,所以决定将这一部分单独抽离成一篇,可以更加专注、也便于大家的理解;
4.2 removeVnodes
方法位置:`src/core/vdom/patch.js -> function createPatchFunction 内部方法
方法参数:
vnodes
: 集合对象stardIdx
: 开始索引endIdx
:结束索引
方法作用:从 vnodes
列表中,从移除索引位于 [startIdx, endIdx]
这个闭区间内的所有节点;从上面的 patch
初次渲染调用这个方法的作用就是从 test.html
中移除我们写的 div#app
这个写这 Vue
模板语法的 HTML
元素;
function removeVnodes (vnodes, startIdx, endIdx) {
// 从 startIdx 到 endIdx 内
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx]
if (isDef(ch)) {
if (isDef(ch.tag)) {
// 如果有标签名,说明是元素
// 在移除前会调用 createPatchFunction 时接收到的 modules
// 中的 remove 和 destroy 钩子方法处理各个功能模块对应的 remove 和 destroy 逻辑
// 比如说 $ref 是需要在节点移除的时候移除调用该节点的 ref 引用,
// 所以 ref 模块就导出了一个 destroy 方法
removeAndInvokeRemoveHook(ch)
invokeDestroyHook(ch)
} else {
// 移除文本节点
removeNode(ch.elm)
}
}
}
}
4.3 invokeInsertHook
方法位置:src/core/vdom/patch.js -> function createPatchFunction 的内部方法
方法参数:
vnode
,vnode
节点对象queue
, 接收的insertedVnodeQueue
队列initial
, 是否初次渲染
方法作用:调用组件的 data.hook
上的 insert
钩子。data.hook
是前面创建组件的 vnode
的时候执行 installComponentHooks
方法为 data.hook
上添加的四个钩子:init、prepatch、insert、destroy
;
这里就是调用 insert
钩子了
function invokeInsertHook (vnode, queue, initial) {
// 对于组件根节点,推迟它的 insert 钩子调用,当他们被插入到文档中之后再调用
if (isTrue(initial) && isDef(vnode.parent)) {
vnode.parent.data.pendingInsert = queue
} else {
for (let i = 0; i < queue.length; ++i) {
queue[i].data.hook.insert(queue[i])
}
}
}
五、总结
本篇小作文开始介绍 patch 函数
,patch
函数的作用——把 vnode
通过 DOM API
转成真实 DOM
并插入到页面。这个过程既包括初次渲染,也包括因响应式数据发生变化而进行的更新渲染。
今天我们重点讨论的是进行初次渲染的过程,我们把 createPatchFunction
和它返回的 patch 函数
进行了简化,只留下能够表达初次渲染过程的代码,具体如下:
patch
判断没有oldVnode
即旧节点
不存在,说明这是个自定义组件
的初次渲染,这其实是初次渲染时渲染到自定义组件的一个分支流程
;- 如果
oldVnode 存在
,那么判断oldVnode
是否是真实的元素节点,如果是就是根实例挂载时触发的首次渲染;这里的oldVnode
是页面中的真实元素div#app
,也就是我们写在test.html
中的模板部分; - 根据
oldVnode
创建一个新的空节点,这个空节点的作用相当于再造一个div#app
,oldVnode.elm
表示当前Vnode
对应的真实元素; - 获取
oldVnode
的父元素,即div#app
的父元素body
元素; - 调用
createElm
方法将vnode
节点树变成真实DOM
树并插入到body
。createElm
中会创建原生的HTML
元素和自定义组件
,因涉及了自定义组件的渲染是一个大篇幅的工作,下一篇单独开篇聊; - 移除旧节点即
div#app
,这也就解释了为啥Vue
渲染完成后我们写的那些带指令的模板比如{{}}、v-bind
在浏览器中就没有了,是因为虚拟 DOM
得到的真实 DOM
替代了模板 DOM
; - 触发
insertHook
,如果节点是组件的根节点则要等他插入到父节点以后再触发;