Flip动画

前言

最近在做复图标库功能时,感觉这个功能在使用上有些“生硬”。如随机删除一个图标,后面的元素在视觉上是“瞬间移动”过来补位的。想着做个小优化,简单加个动画效果吧。

在这里插入图片描述

看起来确实“花里胡哨”了,实现也很简单,

<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后立即获得最新的布局信息。这时我们可以使用强制同步布局的方法来实现。强制同步布局的方式往往是通过触发获取某些属性值的操作,例如读取元素的位置、大小、滚动等属性,或者通过访问offsetTopoffsetWidthoffsetHeight属性来实现。这样会迫使浏览器立即执行布局阶段,以确保获取的属性值是最新的。

当然,单个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可以更简单实现一个动画

参考文档

让你的网页更丝滑

性能优化之关于像素管道及优化

前端动画必知必会

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值