大家如果曾经接触过 Vue, 那么大抵会对其自带的组件 TransitionGroup 有所了解。这篇文章便记录了 TransitionGroup 中「移动动画」的一些使用细节。
或许你对 TransitionGroup 的「移动动画」还不太了解,那么我在这里浅浅地介绍一下。正常使用时,你需要为 .[name]-move
类提供一个过渡样式,例如 transition: all 0.5s ease;
,这样,当 TransitionGroup 内的元素位置变更后, Vue 会尝试让变动了位置的元素从老位置平滑过渡到新位置。当然,Vue 也支持新增元素和删除元素的过渡效果,只需要为 [name]-enter-from
和 [name]-leave-to
类名提供样式,这不是本文的重点,故不再详细介绍。
曾经的我,也像大部分人那样按部就班把样式写完,没出过问题。
直到群里有人告诉我:「试试给元素增加一个常驻的、带有 !important
的 transition
样式,会让过渡失效」
我当场愣住😧,这在当时的我看来是一件很难理解的事情:本身过渡时 Vue 就会通过 [name]-move
为元素加上 transition
属性,为什么提前给元素加上一个优先级最高的 transition
属性,过渡反而没法生效了呢?
从源码入手#
我们可以在 TransitionGroup.ts 阅读与 TransitionGroup 相关的代码内容
初始化阶段#
不难发现,Vue 在渲染函数内,将子元素数组 children
赋值给 setup
函数作用域下的变量 prevChildren
(L113)。同时,通过一个 for 循环遍历 prevChildren
(L135),将每个子元素的位置信息储存于 positionMap
中 (旧位置)。
该阶段中与我们讨论内容相关的,便是这两处内容。prevChildren
的赋值,使得 Vue 能够在之后的 updated 生命周期中,得以取得子元素引用,方便进行相关操作。而 positionMap
,让 Vue 有能力在之后的操作中,得到元素的原始位置。
此处的 positionMap
是一个 WeakMap,Vue 使用元素对象作为 key 值,能够保证在元素被销毁后,positionMap
中对应元素的位置信息被适时自动回收。
Updated 生命周期#
当 TransitionGroup 内的子元素发生变动后,会调用 onUpdated
注册的回调函数,同时也是在这里,Vue 完成了过渡所需要的大部分操作。
首先,Vue 通过一个 forEach,再次遍历获取了各个元素的位置信息,储存到 newPositionMap
中 (L72)。此时,一个元素的新旧位置分别储存在 newPositionMap
和 positionMap
中,我们需要做的,就是让元素从旧位置平滑过渡到新位置。
接下来就是关键所在:既然是 updated 生命周期,此时元素们应该已经在新位置呆着了,又谈何过渡?所以,我们要做的,并不是单纯让元素从旧位置过渡到新位置。而是将已经位于新位置的元素,重新放回旧位置,再让其平滑返回到新位置,完成整个过渡过程。那么 Vue 是怎么完成的呢?
L73的代码是这样的:
const movedChildren = prevChildren.filter(applyTranslation)
Vue 使用 applyTranslation
方法过滤出了需要移动的子元素数组 movedChildren
。通过观察函数和变量的命名,我们几乎可以肯定地说,在过滤的同时,Vue 还对元素进行了一些操作,而实际上也确实如此。
function applyTranslation(c: VNode): VNode | undefined {
const oldPos = positionMap.get(c)!
const newPos = newPositionMap.get(c)!
const dx = oldPos.left - newPos.left
const dy = oldPos.top - newPos.top
if (dx || dy) {
const s = (c.el as HTMLElement).style
s.transform = s.webkitTransform = `translate(${dx}px,${dy}px)`
s.transitionDuration = '0s'
return c
}
}
对于需要移动的元素,Vue 计算了新旧位置差值,使用 css 属性 Transform
,将元素放回旧位置。特别关注一下 s.transitionDuration = '0s'
,~ 之后要考~。
但是实际上,此时的元素仍然没有回到旧位置。浏览器会将样式变动加入渲染队列中,而不是立刻渲染。这里涉及浏览器重排 (reflow) 的相关知识,可以搜索相关文章来进行阅读。
为了保证元素立刻被放置到旧位置,在 L73 得到 movedPosition
后,Vue 执行了 forceReflow
方法 (L76),强制触发重排。而 forceReflow
方法内容也很简单 (Transition.ts#L461)
function forceReflow() {
return document.body.offsetHeight
}
这里也是个前端小知识🤯,读取文档的 offsetHeight
或 offsetWidth
,也能触发文档重排。可以这么理解:渲染队列中存在改动而不进行重排直接获取文档宽度或高度,会导致拿到的元素宽高是过时的,所以浏览器在读取前对文档进行了重排。
之后的工作就很简单了,只需要给元素加上 [name]-move
类名 (L81),然后去除之前添加的 transitionDuration
和 Transform
属性,元素自然就能平滑返回到新位置啦~监听 transitionend
事件 (L83-L93),做一些收尾工作(去除过渡相关类名等)
读到这里,我们已经能够解决文章开头的那个问题了。实现过渡效果,需要确保元素正位于旧位置。在 Vue 中,为了确保文档重排后元素通过 Transform
放到了旧位置,Vue 将元素的过渡时间设置为 0s 并进行了一次强制重排。但是人为添加的高优先级 transition
属性导致重排时元素没法第一时间回到旧位置,也就没有过渡效果了。
我也写了一个小 demo,简化了 TransitionGroup 中的无关代码,感兴趣可以看看 https://codepen.io/DaiDR/pen/VwdMRxa