在CSS3过渡、@keyframe动画和奇妙的新技术(如即将推出的Web Animations API)之间,当涉及到在网络上构建动画时,我们从未有更多的控制。
不过,仍有一件事是这些技术都无法处理的:动画列表的重新排序。

识别问题
比方说,你有这个组件。
class ArticleList extends Component {
render() {
return (
<div id="article-list">
{this.props.articles.map(article => (
<Article key={article.id} {...article} />
))}
</div>
);
}
}
我们有一个父级的ArticleList 组件,它接受一个文章列表作为其道具。它按顺序映射它们,并渲染它们。
如果列表的顺序发生了变化(例如:用户切换了一个设置,改变了排序,一个项目得到了支持并改变了位置,新的数据从服务器上传来......),React会协调这两种状态,并更新DOM,创建新的节点,移动现有的节点,或销毁节点。
如果一个项目被从原来的位置移走,并在向下200px的新位置重新插入,它不知道这种更新对该元素在屏幕上的位置意味着什么。
因为该元素的CSS属性没有变化,所以没有办法使用CSS过渡来模拟这种变化。
我们怎样才能让浏览器表现得好像这些元素已经移动了?这个问题的解决方案将带领我们经历低级别的DOM操作、React生命周期方法和硬件加速的CSS实践。甚至还会有一些基本的数学知识。
解决方案
TL:DR - 我做了一个React模块来做这个。
为了解决这个问题,有几条信息是我们需要的,而且我们在一个非常具体的时间点上需要它们。让我们暂时放弃获取这些信息的复杂性,并根据以下假设进行操作。
-
我们知道React刚刚重新渲染,DOM节点已经被重新排列。
-
浏览器还没有绘制。即使DOM节点在新的位置上,屏幕上的元素还没有被更新。
-
我们知道这些元素在屏幕上的位置。
-
我们知道这些元素即将被重新涂抹的位置。
下面是我们可能遇到的情况。我们有一个3个项目的列表,它们刚刚被颠倒过来。我们知道他们的原始位置(左边),我们知道他们要移动到哪里(右边)。
请无视我的艺术能力不足
操作顺序
一个简短的旁白:你可能会惊讶地发现,在一个项目被画到屏幕上之前,存在一个我们可以知道它将在哪里的时刻。
当你想一想,这是有道理的;浏览器在知道新像素的确切位置之前,怎么能把它们画到屏幕上?
值得庆幸的是,这并不是一个黑匣子;浏览器以不同的步骤进行更新,而且有可能在计算布局和向屏幕绘制之间执行逻辑。
但我们如何访问计算出来的布局呢?
来救场!
DOM节点有一个非常有用的本地方法,getBoundingClientRect。它为我们提供了目标元素相对于视口的大小和位置。如果我们在计算新的布局之前,在顶部的蓝色矩形上调用这个方法,它可能会给我们带来什么。
blueItem.getBoundingClientRect();
// {
// top: 0,
// bottom: 600,
// left: 0,
// right: 500,
// height: 60,
// width: 400
// }
还有,在计算了新的布局之后。
blueItem.getBoundingClientRect();
// {
// top: 136,
// bottom: 464,
// left: 0,
// right: 500,
// height: 60,
// width: 400
// }
getBoundingClientRect很聪明,它可以计算出一个元素的新布局位置,同时考虑到它的高度、边距和其他会影响它在视口中位置的变量。
有了这两个数据,我们就可以计算出元素位置的变化;它的delta。
Δy = finalTop - initialTop = 132 - 0 = 132
所以,我们知道这个元素已经向下移动了132px。同样地,我们知道中间的元素根本没有移动(Δy = 0px),而最后一个元素向上移动了132px(Δy = -132px)。
问题是,当我们知道所有这些事实的时候,DOM即将更新;在一眨眼的时间里,这些盒子就会立刻出现在它们的新位置上!这就是我们的下一个工具。
这就是我们武器库中的下一个工具的作用:requestAnimationFrame。
这是窗口对象上的一个方法,它告诉浏览器:"嘿,在你对屏幕做任何改变之前,你能先运行这段代码吗?"。这是一种在元素被更新之前快速做出任何调整的方法。
如果在浏览器画图之前,我们应用反向的变化,会怎么样?想象一下这个CSS。
.blue-item {
top: -132px;
}
.purple-item {
top: 0;
}
.fuscia-item {
top: 132px;
}
浏览器会涂抹这个更新,但涂抹后不会有任何变化;DOM节点已经改变了位置,但我们已经用CSS抵消了这个变化。
这是很棘手的事情,所以让我们对刚刚发生的事情做一个高层次的概述。
-
React渲染了我们的初始状态,蓝色项目在上面。我们使用getBoundingClientRect来确定项目的位置。
-
React收到了新的道具:项目被颠倒了!现在蓝色项目在底部。现在蓝色项目在底部。
-
我们使用getBoundingClientRect来计算出项目现在的位置,并计算出位置的变化。
-
我们使用requestAnimationFrame来告诉DOM应用一些CSS来撤销这个新的变化;如果元素的新位置低了100px,我们就应用CSS来使它高100px。
动画时间到了
好了,我们在这里肯定已经完成了一些事情;我们已经使DOM的变化对用户来说是完全不可见的。这可能是一个整洁的聚会技巧,但可能仍然不清楚这对我们有什么帮助。
问题是,我们已经使它处于一种常规的CSS转换可以再次工作的情况。为了将这些元素动画化到它们的新位置,我们可以添加一个过渡,并撤消人为的位置变化。
继续我们上面的例子。我们的蓝色项目实际上是最后一个项目,但它看起来是第一个项目。它的CSS看起来像这样。
.blue-item {
top: -132px;
}
现在,让我们更新一下CSS,使它看起来像这样。
.blue-item {
transition: top 500ms;
top: 0;
}
蓝色项目现在将向下滑动,超过半秒,从顶部位置到底部位置。欢呼吧!我们已经做了一些动画。
这种技术是由谷歌的保罗-刘易斯(Paul Lewis)推广的,他把它称为FLIP技术。FLIP是First,Last,Inverse,Play的缩写。

-
计算第一个位置。
-
计算最后的位置。
-
反转这些位置
-
播放动画
我们的版本有点不同,但原理是一样的。
对DOM的简单探索
在学习这种技术和编写我的模块时,我学到了不少关于DOM渲染的知识。虽然我学到的大部分内容都不在本文的讨论范围内,但有一个花絮我们应该快速浏览一下:绘画和合成的区别,以及它对选择硬件加速的CSS属性的影响。
最初,浏览器用CPU做一切事情。近年来,一些非常聪明的人发现,某些任务可以委托给GPU,以获得巨大的性能提升;特别是当静态内容的 "纹理 "没有变化时。
其主要目的是为了加快滚动速度;当你向下滚动一个页面时,没有任何元素发生变化,它们只是向上滑动。浏览器的人很好,他们也允许某些CSS属性以同样的方式工作。
通过使用CSS属性的转换套件--平移、缩放、旋转、倾斜等--和不透明度,我们并没有改变一个元素的质地。如果纹理不改变,它就不必在每一帧上重新绘制;它可以由GPU进行合成。这就是实现60+fps动画的方法。
如果你想了解更多关于浏览器的渲染过程(你应该了解!它既迷人又实用),我在下面提供了一些链接。
不过,在我们的案例中,这意味着我们应该使用transform而不是top。
.blue-item {
transition: transform 500ms;
transform: translateY(0px);
}
缺少的部分:React
注意:这篇文章最初是在很久以前写的,特别是这一部分的代码已经不是很成熟了。所用的生命周期方法已经被废弃,ReactDOM.findDOMNode也被极力劝阻了。本节中的想法是可靠的,但请不要试图重复使用所提供的代码
React是如何融入这一切的?令人高兴的是,事实证明,React与这种技术配合得非常好。
每个孩子都需要两个重要的东西来实现这个功能。
-
每个孩子都需要一个独特的 "key "属性。这就是我们用来区分它们的东西。
-
每个孩子都需要一个ref,这样我们就能查找DOM节点并计算其边界框。
获取第一位置
每当组件收到新的道具时,我们需要检查是否有必要做动画。最早的机会是在componentWillReceiveProps生命周期方法中进行。
class ArticleList extends Component {
componentWillReceiveProps() {
this.props.children.forEach(child => {
// Find the ref for this specific child.
const ref = this.refs[child.key];
// Look up the DOM node
const domNode = ReactDOM.findDOMNode(ref);
// Calculate the bounding box
const boundingBox = domNode.getBoundingClientRect();
// Store that box in the state, by its key.
this.setState({
[child.key]: boundingBox,
});
});
}
}
在这个生命周期方法结束时,我们的状态将充满DOMRect对象,准确地勾勒出页面上每个孩子的位置。
获得最后的位置
下一个任务是弄清楚事物的位置。
这里要做的非常重要的区别是,**React的渲染方法并不立即画到屏幕上。**我对低级别的细节有点模糊,但这个过程看起来有点像这样。
-
render返回一个它希望DOM是什么样子的表示。
-
React将这个表示与DOM的实际状态相协调,并应用其中的差异。
-
浏览器注意到有些东西已经改变,并计算新的布局。
-
React的componentDidUpdate生命周期方法启动了。
-
浏览器将这些变化显示在屏幕上。
这个过程的美妙之处在于,我们有机会在DOM的布局被计算出来之后,但在屏幕被更新之前,勾住DOM的状态。
下面是它的样子。
componentDidUpdate(previousProps) {
previousProps.children.forEach(child => {
let domNode = ReactDOM.findDOMNode(this.refs[child.key]);
const newBox = domNode.getBoundingClientRect();
// ...more to come
});
}
倒立
我们现在知道了第一个和最后一个位置,而且没有一毫秒的空闲!DOM就要更新了!"。DOM就要更新了!
我们将使用requestAnimationFrame来确保我们的变化在那一帧之前完成。
让我们继续编写componentDidUpdate方法。
componentDidUpdate(previousProps) {
previousProps.children.forEach(child => {
let domNode = ReactDOM.findDOMNode(this.refs[child.key]);
const newBox = domNode.getBoundingClientRect();
const oldBox = this.state[key];
const deltaX = oldBox.left - newBox.left;
const deltaY = oldBox.top - newBox.top;
requestAnimationFrame(() => {
// Before the DOM paints, Invert it to its old position
domNode.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
// Ensure it inverts it immediately
domNode.style.transition = 'transform 0s';
});
});
}
此时,在这个方法运行后,我们的DOM节点将被重新排列,但它们在屏幕上的位置将保持不变。爽啊!只剩下一个步骤了...
播放
componentDidUpdate(previousProps) {
previousProps.children.forEach(child => {
let domNode = ReactDOM.findDOMNode(this.refs[child.key]);
const newBox = domNode.getBoundingClientRect();
const oldBox = this.state[key];
const deltaX = oldBox.left - newBox.left;
const deltaY = oldBox.top - newBox.top;
requestAnimationFrame(() => {
domNode.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
domNode.style.transition = 'transform 0s';
哈!我们已经做到了;我们已经使非动画化了。
