Vue 2.x 源码阅读记录(四):组件更新与 Props
组件更新
文章为阅读笔记向,需 clone 下来 Vue 源码并加以调试服用~~
通过上面的分析可以知道,Vue 更新的关键点就在于 _render()
函数中对数据的访问触发数据的 getter
,在得到更新后的 vnode
时,会执行 _update()
方法重新 patch DOM,_update()
在更新时会进入 prevNode === true
的逻辑,就是下面的 else
分支,传给 __patch__
的时更新前的 vnode
与新的 vnode
:
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
const vm: Component = this
const prevEl = vm.$el
const prevVnode = vm._vnode // 更新前的 vnode
const restoreActiveInstance = setActiveInstance(vm)
// ...
vm._vnode = vnode // 保存更新前的 vnode
if (!prevVnode) {
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}
// ...
}
在 patch
方法中,有 新旧节点相同 与 新旧节点不同 两种逻辑,通过 sameVnode
方法判断:
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// 新旧节点相同
} else {
// 新旧节点不同
}
sameVnode
方法就是将新旧 vnode
中的一些属性进行对比,以及对异步组件的属性进行对比:
function sameVnode (a, b) {
return (
a.key === b.key && (
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
) || (
isTrue(a.isAsyncPlaceholder) &&
a.asyncFactory === b.asyncFactory &&
isUndef(b.asyncFactory.error)
)
)
)
}
新旧节点不同
对于新旧节点不同的逻辑,与初次 patch #app
过程相似,分为创建新节点、更新占位符节点、删除旧节点三个过程。这里先看一个新旧节点不同的例子,例子中,Comp
组件内就是新旧节点不同的情况,当调用 changeMsg
的时候,会走到 Comp
组件内部元素的 patch:
const Comp = Vue.extend({
template: `
<div class="comp" v-if="msg === 'Hello'">
<h1>{{msg}} from Comp</h1>
</div>
<ul v-else>
<li>{{msg}}</li>
</ul>
`,
props: {
msg: String
},
});
const App = Vue.component('App', {
template: `
<div id="my-app">
<Comp :msg="msg" />
<h2>{{msg}} from App</h2>
<button @click="changeMsg">Change msg</button>
</div>
`,
data() {
return {
msg: 'Hello',
};
},
methods: {
changeMsg() {
this.msg = 'World';
}
},
components: {
Comp
}
});
const vm = new Vue({
el: '#app',
render: (h) => h(App)
});
- 创建新节点,根据旧
vnode.elm
获取旧的 DOM 节点并获取它的父元素,调用createElm
进行 DOM 挂载:
const oldElm = oldVnode.elm
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)
)
- 更新占位符节点(上面例子中的
<Comp :msg="msg" />
),找到当前vnode.parent
对应的占位符节点,递归地调用destory
、create
等钩子函数:
// update parent placeholder node element, recursively
if (isDef(vnode.parent)) {
let ancestor = vnode.parent
const patchable = isPatchable(vnode)
while (ancestor) {
for (let i = 0; i < cbs.destroy.length; ++i) {
cbs.destroy[i](ancestor)
}
ancestor.elm = vnode.elm
if (patchable) {
for (let i = 0; i < cbs.create.length; ++i) {
cbs.create[i](emptyNode, ancestor)
}
// #6513
// invoke insert hooks that may have been merged by create hooks.
// e.g. for directives that uses the "inserted" hook.
const insert = ancestor.data.hook.insert
if (insert.merged) {
// start at index 1 to avoid re-invoking component mounted hook
for (let i = 1; i < insert.fns.length; i++) {
insert.fns[i]()
}
}
} else {
registerRef(ancestor)
}
ancestor = ancestor.parent
}
}
- 删除旧节点,其中会将旧的
vnode
与旧的 DOM 节点删除、执行一些销毁节点的钩子函数等:
// destroy old node
if (isDef(parentElm)) {
removeVnodes([oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}
新旧节点相同
这里先留一个问题,为什么用户调用 changeMsg
的时候,修改的只是 App
组件下的数据,理应只对当前 App
组件的 vnode
进行 patch
,但通过单步调试发现,它也会将 <Comp :msg="msg" />
组件内部进行 patch?
<div id="my-app">
<Comp :msg="msg" />
<h2>{{msg}} from App</h2>
<button @click="changeMsg">Change msg</button>
</div>
新旧节点相同的下,会调用 patchVnode
方法,将新的 vnode
patch 到旧的 vnode
上,主要逻辑可以拆成四个步骤:
-
执行组件
prepatch
钩子函数。当 patch 一个组件vnode
时,会执行createComponent
时定义的prepatch
钩子:let i const data = vnode.data if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) { // debugger i(oldVnode, vnode) }
prepatch
钩子获取组件的options
和组件实例,调用updateChildComponent
方法,updateChildComponent
方法会将组件上的props
、slots
、listeners
等属性更新:prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) { const options = vnode.componentOptions const child = vnode.componentInstance = oldVnode.componentInstance updateChildComponent( child, options.propsData, // updated props options.listeners, // updated listeners vnode, // new parent vnode options.children // new children ) }
-
执行
update
钩子函数。 在执行完新的vnode
的prepatch
钩子函数,会执行所有module
的update
钩子函数以及用户自定义update
钩子函数,对于module
的钩子函数,之后会有具体的章节针对一些具体的 case 分析:if (isDef(data) && isPatchable(vnode)) { for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode) if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode) }
-
完成
patch
过程。如果vnode
是一个文本节点则直接将替换元素的文本内容,不是文本节点的时候,分为几种情况:- 新旧节点都有
children
子节点并且不相等的情况,调用updateChildren
patch 所有子节点。 - 只有新节点有
children
, 表示旧节点不需要了 。如果旧节点是文本节点,将其文本置空,调用addVnodes
将新节点children
添加到elm
中。addVnodes
方法中循环调用了createElm
方法 将vnode
patch 到 DOM。 - 只有旧节点有
children
,表示更新的是空节点(children
为空或undefined
的情况,也不会是文本节点,因为第一个判断已经处理了),则需要将旧的节点通过removeVnodes
全部清除。 - 旧节点是文本节点,清除其文本内容。
if (isUndef(vnode.text)) { if (isDef(oldCh) && isDef(ch)) { // 1 if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly) } else if (isDef(ch)) { // 2 if (process.env.NODE_ENV !== 'production') { checkDuplicateKeys(ch) } if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '') addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue) } else if (isDef(oldCh)) { // 3 removeVnodes(oldCh, 0, oldCh.length - 1) } else if (isDef(oldVnode.text)) { nodeOps.setTextContent(elm, '') } } else if (oldVnode.text !== vnode.text) { nodeOps.setTextContent(elm, vnode.text) }
- 新旧节点都有
-
执行
postpatch
方法。它是组件自定义的钩子函数,有则执行:if (isDef(data)) { if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode) }
解答新旧节点相同疑问
-
首先需要确定一个情况,当用户调用
changeMsg
时新生成的vnode
直接是App
组件的根元素对应的vnode
,而不是App
组件的占位符vnode
,因为在初始化 patch 时已经将旧的options.el
模板删除了,App
组件的根元素就是全局的根元素。 -
通过上面的例子和分析可以知道,App 组件进入了相同
vnode
的 patch 过程,而App
的根元素此时不是一个组件,则直接走到上面完成patch
过程,此时App
的children
下有 5 个 vnode 节点,然后回进入updateChildren
分支。 -
updateChildren
函数内会再次调用patchVnode
方法(后面会分析),由于App.children
的第一位是<Comp />
的占位符vnode
,这时就进入了它的patchVnode
过程。 -
此时的
vnode
是组件占位符vnode
,就会执行到上面的执行组件prepatch
钩子函数流程,prepatch
函数调用了updateChildComponent
,在updateChildComponent
中有一段更新props
的逻辑:// update props if (propsData && vm.$options.props) { toggleObserving(false); var props = vm._props; var propKeys = vm.$options._propKeys || []; for (var i = 0; i < propKeys.length; i++) { var key = propKeys[i]; var propOptions = vm.$options.props; // wtf flow? // 更新组件的 props props[key] = validateProp(key, propOptions, propsData, vm); } toggleObserving(true); // keep a copy of raw propsData vm.$options.propsData = propsData; }
更新
props
时,实际上会触发props
属性的setter
。根据之前的分析知道的,组件收集的是组件执行mountComponent
时订阅的渲染Watcher
,派发更新过程会将Watcher.getter
的执行推入异步队列。再回到updateChildComponent
,执行完后会接着将App
剩下的子节点都 patch 完,页面渲染完毕:set: function reactiveSetter (newVal) { // debugger const value = getter ? getter.call(obj) : val /* eslint-disable no-self-compare */ if (newVal === value || (newVal !== newVal && value !== value)) { return } /* eslint-enable no-self-compare */ if (process.env.NODE_ENV !== 'production' && customSetter) { customSetter() } // #7981: for accessor properties without setter if (getter && !setter) return if (setter) { setter.call(obj, newVal) } else { val = newVal } childOb = !shallow && observe(newVal) // notify 之前,已经将 data[val] 更新了 dep.notify() }
页面渲染完毕就是同步任务执行完毕了,此时会开始执行异步队列中保存的
Watcher.getter
,而此时就会开始进入进入<Comp />
组件的 patch 过程了。
updateChildren
前面提到新旧节点都有 children
子节点并且不相等的情况,调用 updateChildren
patch 所有子节点。这其实是一个递归的过程,这里通过一个例子来分析:
<div id="app">
<ul>
<li v-for="item in list" :key="item.value">{{item.text}}</li>
</ul>
<button @click="reverseList">Reverse</button>
</div>
new Vue({
el: '#app',
data: {
list: [{
text: 'A',
value: '1'
}, {
text: 'B',
value: '2'
}, {
text: 'C',
value: '3'
}, {
text: 'D',
value: '4'
}]
},
methods: {
reverseList() {
this.list = this.list.reverse();
}
}
});
当 patch 到 ul
的 vnode
时,会进入 updateChildren
流程。updateChildren
的流程有点复杂,这里就结合上面例子大致理解一下。首先定义了一些遍历来作为当前 child vnode
的索引与新旧的头尾 vnode
,然后开启一个循环,对比 vnode
的各种相等条件,然后继续调用 patchVnode
、addVnodes
、createElm
、removeVnodes
完成对 vnode
的 patch
过程。递归调用完成 li vnode
以及其内部的 text vnode
的 patch 之后,则回到 ul vnode
的 patch 过程继续往下走:
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
debugger
let oldStartIdx = 0
let newStartIdx = 0
let oldEndIdx = oldCh.length - 1
let oldStartVnode = oldCh[0]
let oldEndVnode = oldCh[oldEndIdx]
let newEndIdx = newCh.length - 1
let newStartVnode = newCh[0]
let newEndVnode = newCh[newEndIdx]
let oldKeyToIdx, idxInOld, vnodeToMove, refElm
// removeOnly is a special flag used only by <transition-group>
// to ensure removed elements stay in correct relative positions
// during leaving transitions
const canMove = !removeOnly
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(newCh)
}
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
debugger
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
if (isUndef(idxInOld)) { // New element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
vnodeToMove = oldCh[idxInOld]
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldCh[idxInOld] = undefined
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// same key but different element. treat as new element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
newStartVnode = newCh[++newStartIdx]
}
}
if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else if (newStartIdx > newEndIdx) {
removeVnodes(oldCh, oldStartIdx, oldEndIdx)
}
}
针对例子的 patch 流程图可以参考文档:ustbhuangyi.github.io/vue-analysi…
总结
组件更新就是对其新旧 vnode 的 patch 过程,也可以称之为 diff
。又分为新旧节点相同与不同的情况:
- 新旧节点相同:创建新的节点 (createElm)--> 更新占位符节点(如果有) --> 删除旧节点(removeVnodes)
- 新旧节点不同:组件执行更新的钩子函数(
prepatch
、update
) --> 更新所有 children(updateChildren
)
Props
规范化
在组件初始化 mergeOptions
的过程中,会对用户定义的 props
选项进行规范化。它在 mergeOptions
中调用了 normalizeProps
方法。props
支持数组与对象写法,数组时,会将其每个值作为 res
的 key
存入,并默认 type: null
;对象时,将对象直接存入 res
:
function normalizeProps (options: Object, vm: ?Component) {
const props = options.props
if (!props) return
const res = {}
let i, val, name
if (Array.isArray(props)) {
i = props.length
while (i--) {
val = props[i]
if (typeof val === 'string') {
// 转驼峰命名
name = camelize(val)
// 添加到 res
res[name] = { type: null }
} else if (process.env.NODE_ENV !== 'production') {
warn('props must be strings when using array syntax.')
}
}
} else if (isPlainObject(props)) {
for (const key in props) {
val = props[key]
name = camelize(key)
// msg: String --> msg: { type: String }
res[name] = isPlainObject(val)
? val
: { type: val }
}
} else if (process.env.NODE_ENV !== 'production') {
warn(
`Invalid value for option "props": expected an Array or an Object, ` +
`but got ${toRawType(props)}.`,
vm
)
}
options.props = res
}
下面是一个例子:
// 数组
props: ['msg']
// 转换成
props: {
msg: {
type: null
}
}
// 对象
props: {
msg: String,
msg: {
type: String
}
}
// 统一转换成
props: {
msg: {
type: String
}
}
初始化
在初始化的 initState
中,会调用 initProps
进行初始化。初始化存在三个步骤,遍历地为每个 prop
属性执行:
-
调用
validateProp
拿到父组件传入的prop
对应的值。 -
调用
defineReactive
给prop
值添加getter
和setter
将其变成响应式。注意在调用时传入了一个自定义setter
,组件内部修改props
时会报错。 -
调用
proxy
将其this
访问代理到_props。
function initProps (vm, propsOptions) { debugger var propsData = vm.$options.propsData || {}; var props = vm._props = {}; // cache prop keys so that future props updates can iterate using Array // instead of dynamic object key enumeration. var keys = vm.$options._propKeys = []; var isRoot = !vm.$parent; // root instance props should be converted if (!isRoot) { toggleObserving(false); } var loop = function ( key ) { keys.push(key); var value = validateProp(key, propsOptions, propsData, vm); /* istanbul ignore else */ { // 将 key 转成小写 var hyphenatedKey = hyphenate(key); if (isReservedAttribute(hyphenatedKey) || config.isReservedAttr(hyphenatedKey)) { // 如果为保留属性则报出警告 warn( ("\"" + hyphenatedKey + "\" is a reserved attribute and cannot be used as component prop."), vm ); } defineReactive(props, key, value, function () { if (!isRoot && !isUpdatingChildComponent) { warn( "Avoid mutating a prop directly since the value will be " + "overwritten whenever the parent component re-renders. " + "Instead, use a data or computed property based on the prop's " + "value. Prop being mutated: \"" + key + "\"", vm ); } }); } // static props are already proxied on the component's prototype // during Vue.extend(). We only need to proxy props defined at // instantiation here. if (!(key in vm)) { proxy(vm, "_props", key); } }; for (var key in propsOptions) loop( key ); toggleObserving(true); }
对于组件的
props
,会在extend
生成组件构造函数时执行props
代理,这样在实例初始化initProps
就不会再为props
执行proxy
。这样做的好处是不用在每个组件实例创建时执行代理,因为每次执行代理都会触发响应式数据的getter
:// For props and computed properties, we define the proxy getters on // the Vue instances at extension time, on the extended prototype. This // avoids Object.defineProperty calls for each instance created. if (Sub.options.props) { initProps(Sub) } function initProps (Comp) { const props = Comp.options.props for (const key in props) { proxy(Comp.prototype, `_props`, key) } }
子组件更新
对于 props
的更改会使子组件重新渲染这块逻辑,在之前组件更新的 updateChildren
提到过。props
内部还有一些对响应式的优化,这个以后再回过来看。