一文解释Vue官网的动画内幕

568 阅读2分钟

俾众周知,Vue对于动画的支持专门内置了原生组件 -- transition

读过Vue官网动画篇的同学们应该都看过以下这个动画效果, shuffle类似于洗牌的效果, 那么这看似复杂的动画是怎么实现的呢? 其实Vue官网也给了提示 - FLIP

FLIP思想

FLIP全程为First-Last-Invert-Play, 下面我们来拆读看下

  • F: First顾名思义就是开始的位置,我们需要在动画开始前记录下节点的开始位置
  • L: Last代表结束的位置, 我们同样需要记录下结束的位置,这样我们的运行轨迹就可以通过终始点的位置计算出来
  • I: Invert表示回归, 我们将当前节点的位置回归到最初的位置,这样看起来就像是从起点开始运动到终点
  • P: Play表示播放,也就是开始从起点往终点运行

Vue官网动画解析

我们通过这个FLIP思想去分析Vue官网动画到底是怎么实现的


在解析动画之前,我们要先清楚一个概念

  • DOM属性的获取不是从RenderTree上获取,而是从DomTree上获取的, 什么意思呢?
  this.$nextTick(() => {
    console.log(最新的DOM)
  })

我们知道获取更新后的DOM通常是放在nextTick里去获取的(这里要注意, nextTick回调函数需要放置在依赖更新代码之后才能获取到更新后的Dom), 如果不清楚nextTick内部原理的请看你不知道的nextTick

明白了以上概念后,我们可以开始进入正题,请看下面的代码

    new Vue({
      el: '#root',
      template: `<div id="root"><div class="container">
        <div class="c-random__brand" v-for="brand in brands" :key="brand" ref="brands">{{brand}}</div>  
      </div>
      <button @click="shuffle()">shuffle</button></div>`,
      data() {
        return {
          brands: [...Array(81).keys()]
        }
      },
      methods: {
        shuffle() {
          this.$refs.brands.forEach(brand => {
            const { left, top } = brand.getBoundingClientRect()
            brand.dataset.left = left
            brand.dataset.top = top
            brand.style.transition = 'unset'
          })
          this.brands.sort((a, b) => Math.random() > .5 ? -1 : 1)
          this.$nextTick().then(() => {
            this.$refs.brands.forEach(brand => {
              const { left, top } = brand.getBoundingClientRect()
              const { left: oldLeft, top: oldTop } = brand.dataset
              brand.style.transform = `translate3d(${oldLeft - left}px, ${oldTop - top}px, 0)`
            })
          })
          requestAnimationFrame(() => {
            this.$refs.brands.forEach(brand => {
              const { left, top } = brand.getBoundingClientRect()
              brand.style.transform = `translate3d(0, 0, 0)`
              brand.style.transition = 'all .3s'
            })
          })
        }
      }
    })

效果展示


  • 我们可以看到首先定义了一个 9 * 9的方格数据
  • 随后当我们点击button时会对源数据进行不规则排序
  • 下面就是重点, 我们首先获取了排序之前每个元素的left与top值(FLIP中的F),随后我们在$nextTick(微任务)里获取更新后的DOM位置(FILP中的L), 再之后我们将DOM的位置回归到最初始还未更改的位置(FLIP中的I), 此时的位置其实是translate3d(0, 0, 0), 我们只需要把最后的状态变更成0,0,0就可以实现从起点往终点位置运动的效果(FLIP中的P),但其实在最后一步有一个重要的渲染基础知识,我们可以看到play步骤是在requestAnimationFrame里执行的, 所以我们再着重的讲解下为什么需要在该函数里去执行

浏览器渲染优化

我们看下下面这段代码

  for (var i = 0; i < 100; i++) {
      document.body.style.background = 'red'
  }

以上的代码其实并不会导致渲染100次, 我们知道浏览器的一帧里包含了以下几个步骤


  • JavaScript:JavaScript 实现动画效果,DOM 元素操作等。
  • Style(计算样式):确定每个 DOM 元素应该应用什么 CSS 规则。
  • Layout(布局):计算每个 DOM 元素在最终屏幕上显示的大小和位置。由于 web 页面的元素布局是相对的,所以其中任意一个元素的位置发生变化,都会联动的引起其他元素发生变化,这个过程叫 reflow。(每个DOM对应一个渲染层))
  • Paint(绘制):在多个层上绘制 DOM 元素的的文字、颜色、图像、边框和阴影等, 这个过程叫做repaint。
  • Composite(渲染层合并):当每个DOM处于同一层的时候,RenderLayers将按照合理的顺序合并图层然后显示到屏幕上。

我们循环一百次的操作其实对于浏览器来说它只需要最后一次的状态位,浏览器会将javascript的操作推入到一个渲染队列里,在它js执行完后会将渲染队列任务取出然后进行渲染,requestAnimationFrame里的代码会在layout之前触发,不会进入渲染队列里, 此时浏览器渲染是从渲染队列里最后一个状态位作为起始点渲染, 此时requestAnimationFrame里操作了布局,将会触发第二次渲染,相当于是浏览器第二次渲染的一个状态位(前面的渲染队列可以视为第一次渲染),此时的效果就是第一次渲染的最后一次状态往requestAnimationFrame更改的状态位更改,这个需要细品

当然其中不只requestAnimationFrame可以达到这个效果,我们知道本质上其实就是第一次渲染的最后一个状态往第二次渲染的状态进行过度,那么我们只需要将渲染队列里的更新立即触发渲染,那么下一次的渲染操作就是第二次渲染的状态, 触发立即渲染的操作如下:

  • offsetTop、offsetLeft、offsetWidth、offsetHeight
  • scrollTop、scrollLeft、scrollWidth、scrollHeight
  • clientTop、clientLeft、clientWidth、clientHeight
  • getComputedStyle()
  • getBoundingClientRect

附上非Vue版本的代码

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    * {
      margin: 0;
      height: 0;
    }
    .container {
      margin: 50px auto;
      width: 450px;
      height: 450px;
      display: flex;
      flex-wrap: wrap;
    }
    .c-random__brand {
      width: 50px;
      height: 50px;
      text-align: center;
      box-sizing: border-box;
      line-height: 50px;
      border: 1px solid #000;
    }
    button {
      padding: 20px;
    }
  </style>
</head>
<body>
  <div class="container">
  </div>
  <button onclick="shuffle()">shuffle</button>
  <script>
    const container = document.getElementsByClassName('container')[0]
    function createBrands() {
      return [...Array(81).keys()].map(brand => {
        const div = document.createElement('div')
        div.innerHTML = brand
        div.className = 'c-random__brand'
        return div
      })
    }

    function calculatePlace() {
      brands.forEach(brand => {
        const { left, top } = brand.getBoundingClientRect()
        brand.dataset.left = left
        brand.dataset.top = top
        brand.style.transition = 'unset'
      })
      brands.sort((a, b) => Math.random() > .5 ? -1 : 1)

      Promise.resolve().then(() => {
        brands.forEach(brand => {
          const { left, top } = brand.getBoundingClientRect()
          const { left: oldLeft, top: oldTop } = brand.dataset
          brand.style.transform = `translate3d(${oldLeft - left}px, ${oldTop - top}px, 0)`
        })
      })
    }

    function patchBrandDom() {
      brands.map(container.appendChild.bind(container))
      requestAnimationFrame(() => {
        brands.forEach(brand => {
          const { left, top } = brand.getBoundingClientRect()
          brand.style.transform = `translate3d(0, 0, 0)`
          brand.style.transition = 'all 1s'
        })
      })
    }

    function shuffle() {
      calculatePlace()
      patchBrandDom()
    }
    const brands = createBrands()
    shuffle()
  </script>
</body>
</html>

这里说明下,同一个DOM节点不会被appendChild两次

结束语

永远不要浅尝辄止,很多知识点是可以深挖的~