有一个Vue常考面试题是:v-for为什么要加key?
官方文档告诉我们:key在触发过渡时很有效
设想一个场景:要对一个简单的列表进行重新排序,排序过程中要有动画效果。那么,要如何来实现这个效果呢?为什么必须使用key呢?
diff算法
key是在diff算法中要用到的,其实只要明白diff算法的过程,就可以理解为什么触发过渡必须要用key
当我们更改列表排序后,会生成新vnode,并使用diff算法比对新旧vnode。假如是同一个vnode,且都有子节点时,要开启while循环对子节点进行对比,这也是diff算法核心,大概过程就是依次对比:
- 对比新旧开始节点。如果是同一节点,更新内部差异(本例中就是更新li内部的文本节点),然后继续下一轮比对
- 对比新旧结束节点。如果是同一节点,更新内部差异,然后继续下一轮比对
- 对比旧开始节点和新结束节点。如果是同一个节点,更新内部差异,并利用insertBefore方法更换位置,继续下一轮比对
- 对比旧结束节点和新开始节点。如果是同一个节点,更新内部差异,并利用insertBefore方法更换位置,继续下一轮比对
判断是否为同一vnode主要就是根据标签名和key值是否相等。要是没有加key,那么每一轮对比新旧开始节点,都被判断为同一节点,只会更新li内部的文本,那么li自然就不能拥有过渡效果;如果加了key,就能追踪到节点位置,对于改变了顺序的节点,会用到insertBefore方法更换位置
使用transition-group
官方文档中有写用transition-group来实现列表排序过渡效果,接下来我就通过调试来分析一下这个内置组件的实现原理
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>compile</title>
<style>
.flip-list-move {
transition: transform 10s;
}
</style>
</head>
<body>
<div id="flip-list-demo" class="demo">
<button v-on:click="shuffle">Shuffle</button>
<transition-group name="flip-list" tag="ul">
<li v-for="item in items" v-bind:key="item">{{ item }}</li>
</transition-group>
</div>
<script src="../../dist/vue.js"></script>
<script>
const vm = new Vue({
el: '#flip-list-demo',
data: {
items: [10,9,8,7,6,5,4,3,2,1]
},
methods: {
shuffle: function () {
this.items.sort((a,b)=>{
return a - b
})
}
}
})
</script>
</body>
</html>
源码调试
demo中点击按钮后倒叙排列,根据diff算法的逻辑,很容易推测代码会走到什么地方,在updateChildren方法内部打如下断点:
可以看到,diff完毕后,视图上节点的位置已经更新,但并没有动画效果
继续调试,找到动画效果实现的位置,是在transition-group组件的updated方法中
既然已经找到了核心位置,那接下来具体看一下applyTranslation方法
可以看到,每个vnode.data都存储了新旧位置信息。applyTranslation方法内部,直接更改真实dom节点的transform:translate样式,使节点移动到旧位置
接着来看addTransitionClass,它的作用主要就是给li节点加class。回顾在demo中,我们给.flip-list-move设置了transition的样式
接着往下走,实现动画过渡效果
小结
-
更改排序,diff算法会将节点放到最新的位置
-
dom更新完毕后,调用组件updated生命周期钩子,updated内部:
-
会利用vnode.data中存储的新旧节点位置信息,更改真实dom的样式,通过transform让节点移动到旧位置
-
给节点添加类名(demo中该类设置了transition),再清空transform样式,实现节点动效移动到新位置
-
问题:vnode.data什么时候被存储的新旧位置信息?
源码分析
上面调试只关注了过渡效果的核心实现,接下来从数据变更开始捋捋整个流程
sort更新数组后发生的事情
-
调用根组件的watcher.update()
-
异步执行flushSchedulerQueue方法,内部遍历queue队列,此时队列中只有根watcher
-
遍历过程中,执行根组件watcher.run()重新渲染根组件
-
在patch过程中会强制调用子组件的watcher.update
-
调用queueWatcher,内部只做了一件事,就是给queue里加了个watcher
-
-
此时queue中就多了子watcher,继续遍历,会调用子watcher.run()重新渲染子组件
-
我们知道重新渲染核心代码就是调用vm._update(vm._render(), hydrating)
-
render重新生成vnode的过程中,给旧vnode加了位置信息
-
patch过程使用diff算法,更换了真实dom顺序
-
-
queue队列遍历完毕后,会调用upated生命周期钩子函数,在这里完成了动效过渡
transition-group组件的render
组件实例有一个prevChildren属性,它是一个数组,里面记录了列表节点的vnode
// src\platforms\web\runtime\components\transition-group.js
render (h: Function) {
// tag: "ul"
const tag: string = this.tag || this.$vnode.data.tag || 'span'
// map: {}
const map: Object = Object.create(null)
// prevChildren: 重新排序前的数组,数组中的每一项都是li的vnode
// 如果是初始渲染,这个值为undefined
const prevChildren: Array<VNode> = this.prevChildren = this.children
// rawChildren:重新排序后未经处理的数组,数组中的每一项都是li的vnode
const rawChildren: Array<VNode> = this.$slots.default || []
// 每次调用render,都将this.children赋值为空数组,后面会给它加数据
const children: Array<VNode> = this.children = []
// transitionData: {tag: 'ul', name: 'flip-list'}
const transitionData: Object = extractTransitionData(this)
for (let i = 0; i < rawChildren.length; i++) {
const c: VNode = rawChildren[i]
if (c.tag) {
if (c.key != null && String(c.key).indexOf('__vlist') !== 0) {
children.push(c)
map[c.key] = c
;(c.data || (c.data = {})).transition = transitionData
} else if (process.env.NODE_ENV !== 'production') {
const opts: ?VNodeComponentOptions = c.componentOptions
const name: string = opts ? (opts.Ctor.options.name || opts.tag || '') : c.tag
warn(`<transition-group> children must be keyed: <${name}>`)
}
}
}
// 给prevChildren中的每一项添加pos位置信息
if (prevChildren) {
const kept: Array<VNode> = []
const removed: Array<VNode> = []
for (let i = 0; i < prevChildren.length; i++) {
const c: VNode = prevChildren[i]
c.data.transition = transitionData
// 核心就是调用真实dom的getBoundingClientRect
c.data.pos = c.elm.getBoundingClientRect()
if (map[c.key]) {
kept.push(c)
} else {
removed.push(c)
}
}
this.kept = h(tag, null, kept)
this.removed = removed
}
return h(tag, null, children)
},
transition-group组件的_update
// src\platforms\web\runtime\components\transition-group.js
beforeMount () {
// 初始渲染会将Vue的实例方法Vue.prototype._update存储到update中
const update = this._update
// 重写组件实例的_update方法
this._update = (vnode, hydrating) => {
const restoreActiveInstance = setActiveInstance(this)
// force removing pass
this.__patch__(
this._vnode,
this.kept,
false, // hydrating
true // removeOnly (!important, avoids unnecessary moves)
)
this._vnode = this.kept
restoreActiveInstance()
// 真实dom更换位置在这里发生
update.call(this, vnode, hydrating)
}
},
transition-group组件的updated钩子函数
updated () {
const children: Array<VNode> = this.prevChildren
const moveClass: string = this.moveClass || ((this.name || 'v') + '-move')
if (!children.length || !this.hasMove(children[0].elm, moveClass)) {
return
}
// we divide the work into three loops to avoid mixing DOM reads and writes
// in each iteration - which helps prevent layout thrashing.
children.forEach(callPendingCbs)
// 记录位置信息
children.forEach(recordPosition)
// 调试已经看过了,改变节点样式,让它们移动到原来的位置
children.forEach(applyTranslation)
// force reflow to put everything in position
// assign to this to avoid being removed in tree-shaking
// $flow-disable-line
this._reflow = document.body.offsetHeight
children.forEach((c: VNode) => {
if (c.data.moved) {
const el: any = c.elm
const s: any = el.style
// 调试已经看过了,给节点添加class,我们在demo中已经为该class添加了transition样式
addTransitionClass(el, moveClass)
// 清空节点transform样式,于是节点回到该在的位置并且有过渡动效
s.transform = s.WebkitTransform = s.transitionDuration = ''
el.addEventListener(transitionEndEvent, el._moveCb = function cb (e) {
if (e && e.target !== el) {
return
}
if (!e || /transform$/.test(e.propertyName)) {
el.removeEventListener(transitionEndEvent, cb)
el._moveCb = null
removeTransitionClass(el, moveClass)
}
})
}
})
}
function recordPosition (c: VNode) {
c.data.newPos = c.elm.getBoundingClientRect()
}