前端开发经常会遇到列表的需求,对列表元素进行添加和删除,有时候也希望有过渡效果,Vue.js 提供了
例子
let vm = new Vue({el: '#app',template: '<div id="list-complete-demo" class="demo">' +'<button v-on:click="add">Add</button>' +'<button v-on:click="remove">Remove</button>' +'<transition-group name="list-complete" tag="p">' +'<span v-for="item in items" v-bind:key="item" class="list-complete-item">' +'{{ item }}' +'</span>' +'</transition-group>' +'</div>',data: {items: [1, 2, 3, 4, 5, 6, 7, 8, 9],nextNum: 10},methods: {randomIndex: function () {return Math.floor(Math.random() * this.items.length)},add: function () {this.items.splice(this.randomIndex(), 0, this.nextNum++)},remove: function () {this.items.splice(this.randomIndex(), 1)}}})
.list-complete-item {display: inline-block;margin-right: 10px;}.list-complete-move {transition: all 1s;}.list-complete-enter, .list-complete-leave-to {opacity: 0;transform: translateY(30px);}.list-complete-enter-active {transition: all 1s;}.list-complete-leave-active {transition: all 1s;position: absolute;}
初始会展现 1-9 十个数字,当点击 Add 按钮时,会生成 nextNum 并随机在当前数列表中插入;当点击 Remove 按钮时,会随机删除掉一个数。会发现在数添加删除的过程中在列表中会有过渡动画,这就是
const props = extend({tag: String,moveClass: String}, transitionProps)delete props.modeexport default {props,beforeMount () {const update = this._updatethis._update = (vnode, hydrating) => {const restoreActiveInstance = setActiveInstance(this)// force removing passthis.__patch__(this._vnode,this.kept,false, // hydratingtrue // removeOnly (!important, avoids unnecessary moves) 设置为true,这样在updateChildren阶段是不会移动vnode节点的)this._vnode = this.keptrestoreActiveInstance()update.call(this, vnode, hydrating)}},// <transition-group> 组件也是由 render 函数渲染生成 vnoderender (h: Function) {// 1.定义一些变量// 会渲染成一个真实元素,默认tag是spanconst tag: string = this.tag || this.$vnode.data.tag || 'span'const map: Object = Object.create(null)// 储存上一次的子节点const prevChildren: Array<VNode> = this.prevChildren = this.children// <transition-group>包裹的原始子节点const rawChildren: Array<VNode> = this.$slots.default || []// 储存当前的子节点const children: Array<VNode> = this.children = []// 从<transition-group>组件上提取出来的一些渲染数据const transitionData: Object = extractTransitionData(this)// 2.遍历rawChildren,初始化childrenfor (let i = 0; i < rawChildren.length; i++) {// 拿到每个vnodeconst c: VNode = rawChildren[i]if (c.tag) {// 判断是否设置了key,这个是<transition-group>对列表元素的要求if (c.key != null && String(c.key).indexOf('__vlist') !== 0) {// 把vnode添加到childrenchildren.push(c)map[c.key] = c// 把刚刚提取的过渡数据transitionData添加到vnode.data.transition中;(c.data || (c.data = {})).transition = transitionData} else if (process.env.NODE_ENV !== 'production') {const opts: ?VNodeComponentOptions = c.componentOptionsconst name: string = opts ? (opts.Ctor.options.name || opts.tag || '') : c.tagwarn(`<transition-group> children must be keyed: <${name}>`)}}}// 3.处理prevChildrenif (prevChildren) {const kept: Array<VNode> = []const removed: Array<VNode> = []// 遍历for (let i = 0; i < prevChildren.length; i++) {// 获取到每个vnodeconst c: VNode = prevChildren[i]// 把transitionData赋值到vnode.data.transition - 是为了当它在enter和leave的钩子函数中有过渡动画c.data.transition = transitionData// 调用原生DOM方法getBoundingClientRect获取到原生DOM的位置信息,记录到vnode.data.posc.data.pos = c.elm.getBoundingClientRect()// 判断vnode.key是否在map中if (map[c.key]) {// 在则放入kept中kept.push(c)} else {// 否则表示该节点已被删除,放入removed中removed.push(c)}}// 执行h(tag, null, kept)渲染后放入this,kept中this.kept = h(tag, null, kept)// 把removed用this.removed保存this.removed = removed}// h(tag, null, children)生成渲染vnodereturn h(tag, null, children)},updated () {// 1.判断子元素是否定义move相关样式const children: Array<VNode> = this.prevChildrenconst moveClass: string = this.moveClass || ((this.name || 'v') + '-move')if (!children.length || !this.hasMove(children[0].elm, moveClass)) {return}// 2.子节点预处理// 堆children做了三轮循环// 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)// 3.遍历子元素实现move过渡// 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遍历children.forEach((c: VNode) => {if (c.data.moved) {const el: any = c.elmconst s: any = el.style// 先给子节点添加moveClassaddTransitionClass(el, moveClass)// 把子节点的style.transform设置为空 - 例子中moveClass定义了transition: all 1s;缓动,由于前面把这些节点偏移到之前的旧位置,所以它就会从旧位置按照 1s 的缓动时间过渡偏移到它的当前目标位置,这样就实现了 move 的过渡动画s.transform = s.WebkitTransform = s.transitionDuration = ''// 监听transitionEndEvent过渡结束的事件,做一些清理操作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 = nullremoveTransitionClass(el, moveClass)}})}})},methods: {hasMove (el: any, moveClass: string): boolean {/* istanbul ignore if */if (!hasTransition) {return false}/* istanbul ignore if */if (this._hasMove) {return this._hasMove}// Detect whether an element with the move class applied has// CSS transitions. Since the element may be inside an entering// transition at this very moment, we make a clone of it and remove// all other transition classes applied to ensure only the move class// is applied.const clone: HTMLElement = el.cloneNode()if (el._transitionClasses) {el._transitionClasses.forEach((cls: string) => { removeClass(clone, cls) })}addClass(clone, moveClass)clone.style.display = 'none'this.$el.appendChild(clone)const info: Object = getTransitionInfo(clone)this.$el.removeChild(clone)return (this._hasMove = info.hasTransform)}}}
render 函数
如果 transition-group 只实现了这个 render 函数,那么每次插入和删除的元素的缓动动画是可以实现的,在例子中,当新增一个元素,它的插入的过渡动画是有的,但是剩余元素平移的过渡效果是出不来的
分析
move过渡实现
在实现元素的插入和删除,无非就是操作数据,控制它们的添加和删除。比如新增数据的时候,会添加一条数据,除了重新执行 render 函数渲染新的节点外,还要触发 updated 钩子函数
接着就来分析 updated 钩子函数的实现
hasMove
hasMove (el: any, moveClass: string): boolean {/* istanbul ignore if */if (!hasTransition) {return false}/* istanbul ignore if */if (this._hasMove) {return this._hasMove}// Detect whether an element with the move class applied has// CSS transitions. Since the element may be inside an entering// transition at this very moment, we make a clone of it and remove// all other transition classes applied to ensure only the move class// is applied.// 克隆一个DOM节点const clone: HTMLElement = el.cloneNode()// 移除它的所有其它的过渡Classif (el._transitionClasses) {el._transitionClasses.forEach((cls: string) => { removeClass(clone, cls) })}// 添加moveClass样式addClass(clone, moveClass)// 设置display为noneclone.style.display = 'none'// 添加到根节点上this.$el.appendChild(clone)// 通过getTransitionInfo获取它的一些缓动相关的信息const info: Object = getTransitionInfo(clone)// 从组件根节点上删除这个克隆节点this.$el.removeChild(clone)// 通过判断info.hasTransform来判断hasMovereturn (this._hasMove = info.hasTransform)}
callPendingCbs、recordPosition 、applyTranslation
// 在前一个过渡动画没执行完又再次执行到该方法时会提前执行_moveCb和_enterCbfunction callPendingCbs (c: VNode) {/* istanbul ignore if */if (c.elm._moveCb) {c.elm._moveCb()}/* istanbul ignore if */if (c.elm._enterCb) {c.elm._enterCb()}}// 记录节点的新位置function recordPosition (c: VNode) {c.data.newPos = c.elm.getBoundingClientRect()}// 先计算节点新位置和旧位置的差值// 如果差值不为 0,则说明这些节点是需要移动的,所以记录 vnode.data.moved 为 true,并且通过设置 transform 把需要移动的节点的位置又偏移到之前的旧位置,目的是为了做 move 缓动做准备function applyTranslation (c: VNode) {const oldPos = c.data.posconst newPos = c.data.newPosconst dx = oldPos.left - newPos.leftconst dy = oldPos.top - newPos.topif (dx || dy) {c.data.moved = trueconst s = c.elm.styles.transform = s.WebkitTransform = `translate(${dx}px,${dy}px)`s.transitionDuration = '0s'}}
由于虚拟 DOM 的子元素更新算法是不稳定的,它不能保证被移除元素的相对位置,所以强制
第一步移除需要移除的 vnode,同时触发它们的 leaving 过渡
第二步把插入和移动的节点达到它们的最终态,同时还要保证移除的节点保留在应该的位置,而这个是通过 beforeMount 钩子函数来实现的
