!important导致TransitionGroup失效

大家如果曾经接触过 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的代码是这样的：

Copy
const movedChildren = prevChildren.filter(applyTranslation)

Vue 使用 applyTranslation 方法过滤出了需要移动的子元素数组 movedChildren。通过观察函数和变量的命名，我们几乎可以肯定地说，在过滤的同时，Vue 还对元素进行了一些操作，而实际上也确实如此。

Copy
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)

Copy
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