前端布局动画的实现与难点

最近开发 Kiwi 的时候遇到前端布局动画的一些问题,深入研究起来还是挺有意思的。 下面聊两个点,都是跟布局改变时进行的动画过渡有关。

首先是在一条内容栏内部展示的条目发生改变的时候,新增时只要把新条目放到末尾,但是如果有之前的条目被移除,下方的条目就要向上滑动,如下图:

这种效果要如何实现呢? 最直接的方式就是在每次有条目被删除的时候,遍历所有它下方的条目,并为它们添加一个动画,从下方移动到上方应该去的位置。 但是这样操作非常的不优雅,跨越了组件的边界,带来了额外的复杂度。 那么是否能用比较简单的方式,让一个组件在自己的位置发生变化的时候,自动触发动画过渡到新的位置呢?

FLIP 操作

这个需求的难点在于, HTML 没有提供内置的元素移动时触发的事件。 如果想要直接检查元素是否移动,我们需要每隔一段时间轮询一个元素,并记忆它的上一个位置来判断它的位置是否发生了改变。 这样有两个致命缺点,首先是动画更新不及时,会出现元素已经移到新位置,却突然闪回原来的位置并播放动画的现象;其次是性能堪忧,每隔一段时间都要检查所有元素的位置。

轮询的方式行不通,我们要从造成布局改变的源头来找找触发器,如果是 JS 直接操作 DOM ,那么我们在所有可能造成元素改变的时候检查一下新的位置就好了。 举个例子,像是上面图中的 1 2 两个元素,假设它们的位置是 A B , 1 被删除, 2 的位置从 A 移动到 B ,那么每当有元素被删除的时候,元素们就主动检查一下自己的新位置, 2 发现自己的位置是 A 了,和上一次的位置 B 不同,那么就播放一个动画,使得自己的位置从 B 变到 A ,也就是 CSS transition 属性从 $-(A-B)$ 到 $0$ 。

这个技巧名叫 FLIP ,最早是在这篇博客被提出,主要步骤如下:

  1. First: the initial state of the element(s) involved in the transition.
  2. Last: the final state of the element(s).
  3. Invert: here’s the fun bit. You figure out from the first and last how the element has changed, so – say – its width, height, opacity. Next you apply transforms and opacity changes to reverse, or invert, them. If the element has moved 90px down between First and Last, you would apply a transform of -90px in Y. This makes the elements appear as though they’re still in the First position but, crucially, they’re not.
  4. Play: switch on transitions for any of the properties you changed, and then remove the inversion changes. Because the element or elements are in their final position removing the transforms and opacities will ease them from their faux First position, out to the Last position.

这里其实有一个问题,既然 DOM 已经发生了改变,我们实际上还是先读取到新位置,然后才执行的动画,为什么不会看到元素先跳到新位置,然后闪回去播放动画呢? 这是因为位置的更新是发生在我们用户脚本执行过程之中的。 在我们删除元素 1 之后,如果尝试通过 getBoundingClientRect 这类 API 来获取元素 2 的位置,那么会触发一次浏览器的 reflow ,重新计算所有 DOM 元素的位置并返回,这样直到我们的同步脚本执行结束都不会进行渲染。 因此并不会出现元素来回跳动的情况。

在 React 中如何判断一个元素删除呢? 使用在 DOM 操作结束时同步触发的 useLayoutEffect Hook 就够了。 毕竟我们不在乎多检查几次,只要位置发生改变的时候去布置动画就好了。 这样,我们不需要在元素变动的时候向其它组件传递任何信号,只是在每个组件内部就能实现布局的平滑移动,极大的减少了代码复杂度,而且更为通用。

Reparenting

上面的解决方案在一条内容栏的布局下工作正常,但是在多条内容栏(瀑布流)的布局下就出现了新的问题,这个问题和 React 有关。

首先讲一下多栏布局如何工作。 最懒的实现方式自然是新条目来的时候放入长度最短的流,后续不再做其它操作,但是这样经过几次关闭条目的操作后会让几个流的长度非常不均衡。 因此每次有条目的改变,都会按照下面的流程重新计算流的布局:

  1. 维护一个打开条目的列表,添加条目时push,移除条目时splice
  2. 为每个内容流分配一个列表
  3. 遍历条目列表,每次把当前条目放入长度最短的流
  4. 更新这个流的长度

渲染时,每个流都会对应一个div,这个div中包含当前流的所有元素。

举个例子,比如上图是某个时刻瀑布流的内容,如果条目6被删除,且通过上述流程重新计算得出的布局如下:

此时,预期的动画方向如下:

然而实际并没有播放出,这是因为代码结构是每个流一个 React 组件,流内部是一个条目的列表,我们重新渲染之后, React 的虚拟 DOM diff 算法并不能识别出上图的内部元素移动过程,因此会销毁之前的条目组件,重新构造一个。 这导致条目7记忆的 “上一个位置” 被清空,无法检测到位置变更并播放动画。 这个问题在 React 仓库中有一个 issue 讨论,在创建组件开销大或是需要记忆状态的时候会造成严重问题。 对这个问题目前没有官方的解决方案,好在有人实现了一个库 react-reparenting 来解决这个问题。

我们先脱离这个库想想解决方案。 可以通过 Portal 来做,把所有条目渲染到一个 Portal 的列表里,然后放入对应的流,这样 Portal 只会在元素真正创建关闭的时候进行构造和析构。 但是这种解决方案与 React 本身结构化的设计是相悖的。 另一种方案就是在渲染之前手动调整 React 内部的虚拟 DOM 节点,欺骗 React ,让它认为这个元素并没有发生变化,自然就不会进行二次构造了。 这需要对 React 的内部数据结构进行变更,但是是概念上更为完整的一个实现,对现有代码的影响最小,也是上文提到的 react-reparenting 采用的实现。 坏处在于它属于内部的实现,如果 React 对这个结构做了修改,并不需要更新 React 的 semver 版本,因此有某次只是更新 React 小版本(不会引入 API 变更)就导致代码停止工作的可能。 因此最好的方案是 React 官方纳入这个实现。

我们具体需要做什么呢? 在代码导致有组件从一个 parent 转移到另一个 parent 的时候,我们要手动告知 react-reparenting 对虚拟 DOM (代号 fiber) 进行对应的转移操作,这样可以在下次渲染的时候不被 diff 算法察觉。 以上图为例,就是

  • 流 2 / 条目 7 -> 流 3 第二个位置
  • 流 3 / 条目 8 -> 流 2 第三个位置

至于被删除的条目 6 ,会被 diff 算法发现并析构的。

这样,布局的问题就被完整解决了。