1. 写在前面
Vue官网上过渡与动画一章有一个非常炫酷的洗牌动画,这里也是用的FLIP思想来做的过渡动画,Vue过渡与动画文档。
2. 什么是FLIP?
- F:代表fisrt,表示所有过渡元素的初始状态
- L:代表last,表示所有过渡元素的结束状态
- I:代表Invert,反转,这是FlIP思想的核心。表示把过渡元素的状态(top、left、width、height、opacity等)回归到之前的状态,具体怎样回归呢?举个例子:某元素从初始位置0px到结束向右偏移了100px,这个时候进行反转设置,给这个元素设置
transform: translateX(0-100px)
,让这个元素看起来好像还停留在初始的位置。 - P:代表play,播放。就是删除上面Invert的设置,并且为其css设置
tansition: all 1s
过渡,这时元素就会从起点到终点做路径动画。
3. 基于Vue实现洗牌动画
Vue中要获取元素最终的状态要在$nextTick中执行,才能确保元素最终的状态。Vue源码详解之nextTick:MutationObserver只是浮云,microtask才是核心!
- html
<div id="root">
<button @click="handleShuffle">shuffle</button>
<div class="container">
<div class="item" v-for="item in arr" :key="item" ref="box">
{{item}}
</div>
</div>
</div>
- css
.container {
margin: 100px auto 0;
width: 450px;
height: 450px;
display: flex;
flex-wrap: wrap;
}
.item {
width: 50px;
height: 50px;
border: 1px solid #333;
display: flex;
justify-content: center;
align-items: center;
box-sizing: border-box;
}
- js
new Vue({
el: '#root',
data() {
return {
arr: [...Array(81).keys()]
}
},
methods: {
handleShuffle() {
// 第一步:First
[...this.$refs.box].forEach(item => {
const { left, top } = item.getBoundingClientRect();
item.dataset.left = left;
item.dataset.top = top;
item.style.transition = 'unset';
});
// 数组乱序操作
this.arr.sort((a, b) => Math.random() - 0.5);
// 在nextTick中获取元素的最终位置
this.$nextTick(() => {
[...this.$refs.box].forEach(item => {
// 第二步:Last
const { left: currentLeft, top: currentTop } = item.getBoundingClientRect();
const { left: oldLeft, top: oldTop } = item.dataset;
// 第三步:Invert
item.style.transform = `translate3d(${oldLeft - currentLeft}px, ${oldTop - currentTop}px, 0)`;
})
});
// 第四步:Play
requestAnimationFrame(() => {
[...this.$refs.box].forEach(item => {
item.style.transform = 'translate3d(0, 0, 0)';
item.style.transition = 'all 1s';
})
});
}
}
})
4. 为什么是在requestAnimationFrame中执行play?
先看这段代码:
for (var i = 0; i < 100; i++) {
document.body.style.background = 'red';
document.body.style.background = 'blue';
}
- 这里面有一个重要的浏览器渲染知识,并不是每执行一次代码,浏览器就执行一次渲染。通过浏览器Performance面板里看到:
- 浏览器并没有根据我们常规思维去执行渲染,而是执行了最后一次状态位的渲染。浏览器会将js的操作推入到一个渲染队列里,在js执行完后会将渲染队列任务取出然后统一进行渲染。
- 而requestAnimationFrame会在页面重新渲染前调用,从渲染前调用到下一帧的开始渲染,大概有100ms的空闲时间,我们利用这100ms,用Vue.$nextTick获取到了元素的最终位置,并且对元素做了Invert回归操作。等到渲染的时候,让元素的位置又变回去,即
transform: translate3d(0, 0, 0)
,并且给元素添加了tansition: all 1s
过渡,这样元素就按着位置改变进行动画,这里强烈推荐大佬的深入解析你不知道的 EventLoop 和浏览器渲染、帧动画、空闲回调(动图演示)这篇文章,里面详细写了requestAnimationFrame 和requestIdleCallback。 - 另外这里也可以用web animation来进行动画,做如下修改:
this.$nextTick(() => {
[...this.$refs.box].forEach(el => {
const { left: currentLeft, top: currentTop } = el.getBoundingClientRect();
const { left: oldLeft, top: oldTop } = el.dataset;
var player = el.animate([
{ transform: `translate3d(${oldLeft - currentLeft}px, ${oldTop - currentTop}px, 0)` },
{ transform: '`translate3d(0, 0, 0)' }
], {
duration: 500,
easing: 'cubic-bezier(0,0,0.32,1)',
});
})
});
对于web animation兼容性: 我们可以用官方提供的polyfill。
5. 使用React Hooks实现洗牌动画
css还是用上面的,这里要注意useEffect和useLayoutEffect的区别,useLayoutEffect里面的callback函数会在DOM更新完成后立即执行,但会在浏览器进行任何绘制之前运行完成,我们在这里面获取到元素的最终状态,类似于Vue里的$nextTick。
import { useState, useRef, useLayoutEffect, useEffect } from "react";
import './App.css';
// 打乱数组顺序-洗牌算法
// 每次从未处理的数组中随机取一个元素,然后把该元素放到数组的尾部
// 即数组的尾部放的就是已经处理过的元素,以此循环处理
// https://github.com/ccforward/cc/issues/44
function shuffleArr(arr) {
let len = arr.length;
while (len) {
const random = Math.floor(Math.random() * len);
len--;
// const lastNum = arr[len];
// const randomNum = arr[random];
// arr[len] = randomNum;
// arr[random] = lastNum;
// es6变量互换
[arr[len], arr[random]] = [arr[random], arr[len]];
}
return arr;
}
function App() {
const [arr, setArr] = useState([...Array(81).keys()]);
const containerRef = useRef(null);
const handleClick = () => {
const itemList = containerRef.current.querySelectorAll(".item");
// 第一步:First
[...itemList].forEach((item) => {
const { left, top } = item.getBoundingClientRect();
item.dataset.left = left;
item.dataset.top = top;
item.style.transition = "unset";
});
// 对数组进行乱序
const newArr = shuffleArr(arr);
setArr([...newArr]);
}
useLayoutEffect(() => {
const itemList = containerRef.current.querySelectorAll(".item");
[...itemList].forEach((item) => {
// 第二步:Last
const { left: currentLeft, top: currentTop } = item.getBoundingClientRect();
const { left: oldLeft, top: oldTop } = item.dataset;
// 第三步:Invert
item.style.transform = `translate3d(${oldLeft - currentLeft}px, ${oldTop - currentTop}px, 0)`;
});
}, [arr]);
useEffect(() => {
const itemList = containerRef.current.querySelectorAll(".item");
// 第四步:Play
requestAnimationFrame(() => {
[...itemList].forEach((item) => {
item.style.transform = `translate3d(0, 0, 0)`;
item.style.transition = "all 1s";
});
});
}, [arr]);
return (
<div>
<button onClick={handleClick}>btn</button>
<div className="container" ref={containerRef}>
{arr.map((item) => (
<div className="item" key={item}>
{item}
</div>
))}
</div>
</div>
);
}
export default App;