前言
最近在做复图标库功能时,感觉这个功能在使用上有些“生硬”。如随机删除一个图标,后面的元素在视觉上是“瞬间移动”过来补位的。想着做个小优化,简单加个动画效果吧。
看起来确实“花里胡哨”了,实现也很简单,
<ul>
<transition-group appear tag="ul">
<!--图片循环-->
</transition-group>
</ul>
为什么简单的设置几个样式规则,元素就可以平滑的移动到对应的位置?如果我们手写这个功能,应该如何考量和设计?
插播
先简单回顾几个基本知识点(更细节的内容这里不展开讨论)。
浏览器的渲染流程
虽然不同的浏览器内核在渲染流程上稍有不同,但大体上是一致的,主要步骤如下:
- DOM树构建:解析HTML,生成DOM树
- CSSOM树构建:解析CSS,生成CSSOM树
- 渲染树构建:将DOM树和CSSOM树结合,生成渲染树(Render Tree)
- 布局:根据渲染树,计算元素的的几何信息(位置,大小)
- 绘制:根据布局信息,把每一个图层转换为像素,渲染在屏幕上
简单示意图如下
由图可知:
- CSS解析不会阻塞DOM解析,但会阻塞DOM渲染
- JavaScript会阻塞DOM解析,进而阻塞DOM渲染
- 浏览器碰到script标签(没有defer/async属性时)就会触发页面渲染。如果前面的CSS资源尚未加载完毕,浏览器会等待它加载完毕后再执行脚本
FPS
简单来说,FPS是浏览器的每秒的渲染帧数,大多数设备的刷新率是60FPS,一般来说FPS越低页面就会越卡顿。
像素管道
标准上每一帧约为16.7ms。但浏览器需要花费时间将新帧绘制到屏幕上,大约只有10ms执行代码。如果无法满足这个要求,帧率会降低,出现卡顿。
先看一张经典图:
- JavaScript(代码执行)。一般的纯前端阻塞都是来自JS,JS线程的运行本身就会阻塞UI线程(除WebWorker外)。所以执行长时间的同步代码会占用每帧的渲染时间。
- Style(样式计算)。利用CSS匹配器计算元素的最终样式。
- Layout(布局)。当样式规则应用后,浏览器开始计算元素在屏幕上显示的大小及位置,这个过程中一个元素的变动可能会影响到另一个元素,从而引起回流。
- Paint(绘制)。绘制就是简单的像素填充,包括文本、颜色、图片、边框、阴影等可视部分。因为网页样式是层级结构,所以绘制操作会在每一层进行。
- Composite(合成)。合成操作会按照正确的层级顺序绘制到屏幕上,以保证渲染的正确性。层级错误的话会导致样式错乱,如底层的元素显示到上层等。
上述过程为理论标准过程,但实际上并非每一帧都会完整执行这五个步骤,不管我们通过JS或者css动画去完成一些动作,本质上都与【回流】(重排)和【重绘】两个概念相关。所以通常对于指定帧有3种运行方式。
-
修改元素的"layout"属性(几何属性,如宽、高、位置等),浏览器会自动重排页面,受到影响的元素都需要重新绘制,且最终绘制的元素需要进行合成。重排经过了管道的每一步,对性能影响比较大。
-
修改元素的"paint only"属性(外观属性,,如颜色、阴影等),不影响页面布局,此时浏览器会跳过布局,但仍执行绘制。
-
修改元素的一个不需要布局和重绘的属性(如透明度、transform变形等),浏览器只执行合成,性能较好。
由上可知,JavaScript、Style和Composite三个阶段是无法避开的。而执行的阶段越少,耗时就越短,每秒渲染的帧数就越高。
简单优化
布局的过程实际上就是回流的过程,这一步几乎会对整个页面重新计算排版,性能开销较大。而绘制是像素填充的过程,是管道中运行时间最长的任务。所以针对JS动画,通常我们可以采用如下优化方法
- 使用requestAnimationFrame来代替定时器
- 大量计算任务可以使用Web Worker执行
- 更改DOM时使用微任务
- 降低CSS选择器的复杂度
- 避免强制同步布局
- 合理设计z-index层数
- 频繁修改属性的元素,可使其脱离文档流
- 渲染层提升为合成层,利用GPU加速绘制
下面简单介绍其中的两点(后面会用到)
requestAnimationFrame
上面提到了,每一帧必须保证JS运行时间小于10ms,才能给样式计算、布局、绘制留出充足的时间。那么,是否我们满足了这个条件,且保证每一帧耗时都在16.7ms之内,就能保证不丢帧呢?
其实不必然,这取决于JS执行方式,如使用定时器(setTimeout/setInterval)来实现。因为定时器无法保证回调函数的真正执行时机,它可能在某一帧的开始、中间、结束时执行,有可能导致丢帧。
使用requestAnimationFrame,会使浏览器在下一次重绘之前调用传入的动画函数,回调函数执行次数通常与浏览器屏幕刷新次数相匹配。
如下图所示,我们分别使用setTimeout和requestAnimationFrame把元素平移800像素,差异还是很明显的。
避免强制同步布局(FSL)
在上面的浏览器的渲染流程中提到,浏览器中的页面的渲染过程可以分为计算布局和绘制两个阶段。布局是指浏览器根据DOM树、CSS样式和其他因素来确定每个元素在页面中的大小和位置等相关属性。绘制是指将计算好的布局信息转化为可视元素显示在屏幕上。
通常情况下,浏览器会对布局操作进行优化,例如使用异步方式进行布局(也称为增量布局或延迟布局)。这意味着当对DOM进行修改时,浏览器不会立即触发布局,而是等待一段时间,将多个连续的布局操作合并在一起进行。这样可以提高性能和响应速度。
然而,有些情况下,我们需要在修改DOM后立即获得最新的布局信息。这时我们可以使用强制同步布局的方法来实现。强制同步布局的方式往往是通过触发获取某些属性值的操作,例如读取元素的位置、大小、滚动等属性,或者通过访问offsetTop
、offsetWidth
、offsetHeight
属性来实现。这样会迫使浏览器立即执行布局阶段,以确保获取的属性值是最新的。
当然,单个FSL影响不大,如果触发了布局抖动,会导致严重的性能问题。举个简单的例子,批量修改元素宽度
// 把子元素的宽设置成与外部容器一样
const container = document.querySelector('.container');
const items = document.querySelectorAll('.item');
// 遍历所有子节点,重新设置width
for (var i = 0; i < items.length; i++) {
const width = container.offsetWidth;
items[i].style.width = width + 'px';
}
实际上,每次更改样式,都会导致刚刚执行的布局失效。因为更改了新的样式,下一次读取宽度时,浏览器需要重新布局,直到循环结束,循环期间的布局实际上都是“无效”的。
我们可以在谷歌性能在线测试网站(https://googlechrome.github.io/devtools-samples/jank/)进行测试。观察性能面板,可以看到给出了警告提示:强制回流可能是性能瓶颈。定位到相关代码可以看到正是获取元素位置信息造成的。
FLIP
进入正题。严格来说,FLIP并不是特定的代码实现或者框架,而是一种思路。FLIP技术以一种高效的方式来动态的改变DOM元素的位置和尺寸,而无需关注布局是如何计算或渲染的。在改变的过程中赋予一定的动效,从而达到动画的目的。
##核心思想
FLIP由四个单词组成:First, Last, Invert, Play。
First:
元素的初始状态。
Last
元素的最终状态。
Invert
反转。计算初始状态和最终状态的属性差异,如宽高、位置、透明度等,设置对应的规则进行反转,使其看起来还在初始状态(这点比较绕,下面有具体示例介绍)。
Play
执行。移除对应规则,使其平滑变化到最终状态。
具体实现
下面来看一个简单示例:把第一个元素移动到最后一个位置(原生写法)。
按照FLIP的设计原则,我们来看一下如何实现。
<!--html部分-->
<button class="button">修改第一个元素位置</button>
<ul class="list">
<li class="list-item active">元素1</li>
<li class="list-item">元素2</li>
<li class="list-item">元素3</li>
<li class="list-item">元素4</li>
<li class="list-item">元素5</li>
<li class="list-item">元素6</li>
</ul>
-
First:获取初始位置
// 元素 const btn = document.querySelector('button'); const list = document.querySelector('.list'); const firstItem = document.querySelector('.list-item:first-child'); // 获取位置方法(这个例子只有上下平移,只记录top值即可) function getLocation() { const rect = firstItem.getBoundingClientRect(); return rect.top; } // First:获取初始位置 const start = getLocation(); console.log('first:', start);
-
Last:获取最终位置
// 移动元素 btn.onclick = () => { list.insertBefore(firstItem, null); // Last:获取最终位置 const end = getLocation(); console.log('last:', end) }
效果如下:获取到了起始位置和最终位置
到了这里,大家可能会有疑问,这都变化完了,拿到的两个位置信息有什么用?别急,下面才是重点。
-
Invert:规则反转
这里大家可以暂停一下,思考一个问题,当我获取到最终位置的时候,看到的是变化前的页面还是变化后的页面?
其实看到的是变化前的页面,这个现象才是核心所在。这里大家可能会有疑问,明明看到元素动了啊,为什么还是变化前的页面?我们可以来验证一下:
// 移动元素 btn.onclick = () => { list.insertBefore(firstItem, null); // Last:获取最终位置 const end = getLocation(); console.log('last:', end) // 模拟执行js代码 const start = Date.now(); while (Date.now() - start < 2000) { console.log('模拟执行js代码') } }
可以看到当我们获取元素最终位置的时候,显示的还是未变化前的页面。为什么会这样?这就是我们上面提到的当获取元素布局信息的时候,会触发强制同步布局,浏览器立即执行布局,但还没有到绘制阶段。
接下来计算偏移值,设置变化规则即可。注意这里我们要用开始状态减去最终状态,做一个反转,使其看起来还在原来位置(因为动画是从开始位置到最终位置的)
// Invert:反转 const dis = start - end; firstItem.style.transform = `translateY(${dis}px)`; console.log('invert:', dis)
可以看到,DOM结构已经发生变化,元素也反转回到了初始位置。
-
Play:执行
这里我们只需要设置一下transition效果,并移除掉transform即可(这样元素就会回到它现在真实的位置)。这里我们使用requestAnimationFrame来实现。
// play回调 function raf(callback) { requestAnimationFrame(() => { requestAnimationFrame(callback); }) } // 移动元素 btn.onclick = () => { list.insertBefore(firstItem, null); // Last:获取最终位置 const end = getLocation(); console.log('last:', end) // Invert:反转 const dis = start - end; firstItem.style.transform = `translateY(${dis}px)`; console.log('invert:', dis) // Play:执行 raf(() => { firstItem.style.transition = 'transform 1s'; firstItem.style.removeProperty('transform'); console.log('play') }) }
到这里我们就实现了一个很简单的FLIP动画。当然这个例子只是对一个元素做了效果,我们同样可以对其它元素做相应的操作。
简单封装
以Vue为例,做如下封装,快速实现图片库的随机插入、删除、乱序等动画效果。
// 记录位置
recordPosition(nodes) {
return nodes.reduce((prev, node) => {
const rect = node.getBoundingClientRect();
const { left, top } = rect;
if (node.attributes.card.value) {
prev[node.attributes.card.value] = { left, top, node };
}
return prev;
}, []);
},
// 设置动画
async scheduleAnimation(update) {
// 获取子节点(nodes:参与动画的节点)
const prev = Array.from(nodes);
// 记录子节点初始位置
const prevRectMap = this.recordPosition(prev);
// 执行数据变化:如增删改等操作
update()
await this.$nextTick();
// 记录子节点现在位置
const currentRectMap = this.recordPosition(prev);
// 遍历对比
Object.keys(prevRectMap).forEach((node) => {
const currentRect = currentRectMap[node];
const prevRect = prevRectMap[node];
// 计算反转值
const invert = {
left: prevRect.left - currentRect.left,
top: prevRect.top - currentRect.top,
};
// 设置动画
const keyframes = [
{
transform: `translate(${invert.left}px, ${invert.top}px)`,
},
{ transform: "translate(0, 0)" },
];
const options = {
duration: 300,
easing: "linear",
};
// 执行动画
currentRect.node?.animate(keyframes, options);
})
}
// 调用
this.scheduleAnimation(()=>{
// TODO... 对元素增删改
})
图片库的相关实现
列表的相关实现
transtion-group
当然在Vue中已经内置了相关功能,查看源码(src/platforms/web/runtime/components/transition-group.ts。可以看到在初始render时记录原始位置,在updated中记录最新位置,并计算偏移参数,设置动画效果,执行完成之后,移除相关属性。
React可以参考react-transition-group或者react-flip-toolkit等插件。
为什么要用FLIP
对于明确知道元素的起止状态的动画,如从坐标(0,0)移动到(100,100),或透明度从0变化到1等,直接设置相应的规则即可。而对于一些无法明确起止状态的动画,使用FLIP就简单多了,避免了我们手动计算维护元素的状态。
后记
- 我们可能在写一些过渡效果的时候,无意中用到了FLIP动画,但更需要了解相关原理
- 合理使用动效让平台的操作更加平滑(就如同使用loading让用户感知网站确实在响应)
- 要确保多个连续的FLIP动画之间互不影响,或者说要预留出一定的时间给到相关的计算过程
- 实现动画当然有多种方式,结合项目实际选择合适的技术(如通过纯CSS实现瀑布流而非JS的形式)
- 使用Web Animations API可以更简单实现一个动画