一个不安分的箭头引发的思考(JS动画实现方式对比)

3,233 阅读14分钟

产品组的小仙女为了表示数据的流向提出向做一个动态的箭头,但是她没有想好要怎么做。于是,我给了她2套效果。啦啦啦啦~

哈哈哈

普通箭头的实现

普通箭头我们通过一个正方形的div,显示 div 的上边框和右边框,同时旋转 45 度就可以实现一个直角的箭头。

    <style>
        .wrap{
            width: 10px;
            height: 10px;
            border-top: 2px solid red;
            border-right: 2px solid red;
            transform: rotate(45deg);
        }
    </style>
    <div class="wrap"></div>

而我们的产品想要的是钝角(大于90度)的多个箭头额。于是

钝角箭头

苦思冥想,多次尝试之后,决定简简单单用两个线绝对定位形成一个钝角。

<style>
    .top{
        width: 4px;
        height: 10px;
        transform: rotate(23deg);
        position: relative;
        top: -1px;
        background-color: #FFBD1D;
    }
    .bottom{
        width: 4px;
        height: 10px;
        transform: rotate(-23deg);
        position: relative;
        bottom: -1px;
        background-color: rgb(255, 189, 29);
    }
    .arrow-wrap{
        font-size: 0;
    }
</style>
<div class="wrap">
    <div class="arrow-wrap move">
        <div class="arrow">
            <div class="bottom"></div>
            <div class="top"></div>
        </div>
    </div>   
</div>

复制 5 个过后就是完整的箭头了,有了完整的箭头我们就可以开始写动画啦。

动画一

5个箭头一起动。

//关键代码
.move{
    animation: my-animation 2s;

}
@keyframes my-animation{
    0%{transform: translate(0px)}
    25%{transform: translate(13px)}
    50%{transform: translate(0px)}
    75%{transform: translate(13px)}
    100%{transform: translate(0px)}
}

整体箭头可以旋转一下

  .arrow-wrap{
      display: inline-block;
      font-size: 0;
      transform: rotate(45deg)
  }
  .move{
      animation: my-animation 2s;

  }
  @keyframes my-animation{
      0%{transform: translate(0px) rotate(45deg);}
      25%{transform: translate(13px, 13px) rotate(45deg);}
      50%{transform: translate(0px) rotate(45deg);}
      75%{transform: translate(13px, 13px) rotate(45deg);}
      100%{transform: translate(0px) rotate(45deg);}
  }

动画二

5个箭头分别出现再消失形成一种移动的错觉。页面初始的时候5个箭头 opacity:0; 每过固定时间如 90ms 依次显示(opacity:1)箭头,就可以产生箭头移动的效果。我们可以利用 animation 动画的延迟依次让各个箭头显示。我们也可以利用 js 控制好时间间隔给 5 个小箭头添加样式实现。

纯 CSS 实现

不嫌繁琐,我们为每个小箭头定义了 animation 的属性值,通过为 .arrow-wrap 类下面的 5 个 .arrow 类依次添加动画属性实现。缺点是 CSS 动画只能第一满足我们顺序执行的需求。

  • .arrow-wrap .arrow:nth-child(3){}; 表示的是 .arrow-wrap 类下面的,第 3 个子元素同时是 arrow class 的元素的样式;
  • animation-fill-mode: forwards; 控制动画结束后保持最后一帧的状态,也就是 opacity: 1;
  • 通过 animation-delay 属性设置了不同箭头的延迟执行。
  • 延迟执行是一次性的,只有动画的第一次有效。那么通过 CSS 实现的箭头也只能是一次性的,只能执行一次动画。
<style>
    .arrow{
        display: inline-block;
        margin-left: 7px;
        opacity: 0;
    }
    .top{
        width: 4px;
        height: 10px;
        transform: rotate(23deg);
        position: relative;
        top: -1px;
        background-color: rgb(255, 189, 29);
    }
    .bottom{
        width: 4px;
        height: 10px;
        transform: rotate(-23deg);
        position: relative;
        bottom: -1px;
        background-color: rgb(255, 189, 29);
    }
    .arrow-wrap{
        display: inline-block;
        min-width: 40px;
        font-size: 0;
    }
    .arrow-wrap .arrow:first-child{
        animation-name: my-animation;
        animation-duration: 0.1s;
        animation-fill-mode: forwards;

    }
    .arrow-wrap .arrow:nth-child(2){
        animation-name: my-animation;
        animation-delay: 0.08s;
        animation-duration: 0.1s;
        animation-fill-mode: forwards;
    }
    .arrow-wrap .arrow:nth-child(3){
        animation-name: my-animation;
        animation-delay: 0.18s;
        animation-duration: 0.1s;
        animation-fill-mode: forwards;
    }
    .arrow-wrap .arrow:nth-child(4){
        animation-name: my-animation;
        animation-delay: 0.28s;
        animation-duration: 0.1s;
        animation-fill-mode: forwards;
    }
    .arrow-wrap .arrow:last-child{
        animation-name: my-animation;
        animation-delay: 0.38s;
        animation-duration: 0.1s;
        animation-fill-mode: forwards;
    }

    @keyframes my-animation{
        100%{opacity: 1;}
    }
</style>
<div class="wrap">
    <div class="arrow-wrap">
        <div class="arrow">
            <div class="bottom"></div>
            <div class="top"></div>
        </div>
        <div class="arrow">
            <div class="bottom"></div>
            <div class="top"></div>
        </div>
        <div class="arrow">
            <div class="bottom"></div>
            <div class="top"></div>
        </div>
        <div class="arrow">
            <div class="bottom"></div>
            <div class="top"></div>
        </div>
        <div class="arrow">
            <div class="bottom"></div>
            <div class="top"></div>
        </div>
    </div>   
</div>
setInterval

固定时间执行的函数我们很容易想到 setTimeout 和 setInterval 两个函数。 setTimeout 表示延迟多久执行;setInterval 表示每隔固定时间周期性的执行。当然我们其实可以在使用setTimeout 函数中调用自身实现每隔固定时间周期性的执行的效果。

setTimeout 的返回值 timeoutID 是一个正整数,表示定时器的编号。这个值可以传递给clearTimeout()来取消该定时器。

setInterval 的返回值 intervalID 是一个非零数值,用来标识通过setInterval()创建的计时器,这个值可以用来作为clearInterval()的参数来清除对应的计时器 。

使用 setInterval 实现动画的代码会简洁一些:

<style>
    .ease-in{
        animation-name: my-aimation;
        animation-duration: 0.09s;
        animation-fill-mode: forwards;
    }

    @keyframes my-aimation{
        100%{opacity: 1;}
    }
</style>
<script>
    const markers = document.getElementsByClassName('arrow');
    let index = 0;
    //先为第一个小箭头添加动画,每隔 0.09s 依次为每个小箭头添加动画。
    markers[index].setAttribute("class", "arrow ease-in");
    let shrinkTimer = setInterval(()=>{
        index++;
        if(index == markers.length){
            clearInterval(shrinkTimer);
            return;
        }
        markers[index].setAttribute("class", "arrow ease-in");
    }, 90);
</script>

我们可以通过修改 setInterval 函数里面的逻辑,实现动画的循环播放:

    .ease-in{
        animation-name: my-aimation;
        animation-duration: 0.2s;
        animation-fill-mode: forwards;
    }
    @keyframes my-aimation{
        100%{opacity: 1;}
    }
  <script>
      const markers = document.getElementsByClassName('arrow');
      let index = 0;
      markers[index].setAttribute("class", "arrow ease-in");
      let shrinkTimer = setInterval(()=>{
          index++;
          if(index == markers.length){
              index = 0;
              [...markers].forEach(item=>{
                  item.setAttribute("class", "arrow");
              });
          }
          markers[index].setAttribute("class", "arrow ease-in");
      }, 200);
  </script>
requestAnimationFrame

大多数 电脑显示器的刷新频率是60Hz,大概相当于每秒钟重绘60次。大多数浏览器都会对重绘操作加以限制,不超过显示器的重绘频率,因为即使超过那个频率用户体验也不会有提升。因此,最平滑动画的最佳循环间隔是1000ms/60,约等于16.7ms。requestAnimationFrame 是 HTML5新增的定时器。由系统来决定回调函数的执行时机。具体一点讲就是,系统每次绘制之前会主动调用 requestAnimationFrame 中的回调函数。

  • window.requestAnimationFrame(fn) 告诉浏览器你希望在浏览器下一次重绘之前执行 fn 函数中的逻辑。与 window.setTimeout(fn, duration) 类似,本身只能执行一次。如果想实现类似于 setInterval 周期性执行函数 fn 的话,需要在 fn 中再次调用window.requestAnimationFrame()

  • window.requestAnimationFrame(fn) 中 fn 函数本身是由一个默认的参数的 timestamp ,该参数 timestamp 与performance.now()的返回值相同,它表示requestAnimationFrame() 开始去执行回调函数的时刻。我们可以理解为与 Date.now() 类似的表示时间,timestamp 以浮点数的形式表示时间,精度最高可达微秒级。

  • requestAnimationFrame 的返回值是一个 long 整数,请求 ID ,是回调列表中唯一的标识。是个非零值,没别的意义。你可以传这个值给 window.cancelAnimationFrame() 以取消回调函数。

  • 在页面 A 中使用了 requestAnimationFrame 函数循环执行动画的时候,切换到页面 B,这个时候 A 页面的 requestAnimationFrame 是不会执行,因为这个时候本身 A 页面也没有内容要重绘到屏幕上。

利用 requestAnimationFrame 的动画代码如下:

<script>
    let index = 0;
    const markers = document.getElementsByClassName('arrow');
    let count = 0;
    let myReq;
    const times = 5;
    function loop(){
        myReq = window.requestAnimationFrame(function(){
            count++;
            if(count%times === 0){
                markers[index].setAttribute("class", "arrow ease-in");
                index++;
            }
            loop();
        });
        if(count > times * markers.length){
            window.cancelAnimationFrame(myReq);
        }

    }
    loop();
</script>

思考

既生瑜何生亮呢?已经有 setInterval 和 setTimeout 这些定时器来帮助我们完成 CSS 不能完成的动画了,为什么还要有 window.requestAnimationFrame(fn) 的出现呢?让我们来分析一下 setInterval、setTimeout 存在的问题。

setInterval 的问题分析:

  1. setInterval 设置的时间间隔 duration 代表的是按照 duration 的间隔执行一定的逻辑。 duration 不是 16.7ms, 比如说是 10ms。那么 10ms 后到了执行了一定的逻辑,但是不会渲染到页面上,得等到 16.7ms 的时候才会渲染。渲染流程如下:

    • 10ms 执行移动 1px 的函数;16.7ms 的时候渲染
    • 20ms 执行移动 2px 的函数;不会渲染
    • 30ms 执行移动 3px 的函数;33.4ms 的时候渲染...

    可以看到 20ms 的移动没有被渲染,会出现 丢帧 的问题。页面上给人一种顿顿顿的卡顿。导致这个问题的原因是我们执行函数的渲染频率跟页面实际的渲染频率没有保持一致。如果我们直接使用 requestAnimationFrame 来控制动画效果,显然就没有这个问题。

  2. 既然是频率不一致导致的,那我们使得它们一致不就可以了吗?是的,这样是可以的。我们尽量让我们 setInterval 执行频率与页面渲染频率保持一致(或者间隔时间是屏幕渲染时间间隔的倍数)。但是需要注意两个方面。一方面,屏幕刷新频率受 屏幕分辨率屏幕尺寸 的影响,不同设备的屏幕绘制频率可能会不同,我们不能直接固定死 setInterval 的执行频率;另一方面,setInterval 本身执行时间和间隔其实是有不确定性的。为什么这么说呢?原因有三点:

    • 事件循环机制
    • setInterval 重复定时器的问题。
    • tab 页面切换的时候 setInterval 仍然执行,页面并没有渲染。
事件循环

我们都知道 JavaScript 是一门单线程且非阻塞的脚本语言,这意味着 JavaScript 代码在执行的时候都只有一个主线程来处理所有任务。而非阻塞是指当代码需要处理异步任务时,主线程会挂起(pending)这个任务,当异步任务处理完毕后,主线程再根据一定规则去执行相应回调。

事实上,当任务处理完毕后,JavaScript 会将这个事件加入到一个队列中,我们称这个队列为 事件队列。被放入事件队列中的事件不会立即执行其回调,而是等待当前执行栈中的所有任务执行完毕后,主线程会去查找事件队列中是否有任务。

异步任务有两种类型:微任务(microtask)和宏任务(macrotask)。不同类型的任务会被分配到不同的任务队列中。

当执行栈中的所有任务都执行完毕后(同步代码执行完毕后),会去检查微任务队列中是否有事件存在,如果存在,则会依次执行微任务队列中事件对应的回调,直到为空。然后去宏任务队列中取出一个事件,把对应的回调加入当前执行栈,当执行栈中的所有任务都执行完毕后,检查微任务队列是否有事件存在。无限重复此过程,就形成了一个无限循环。这个循环就叫作 事件循环

属于微任务的事件包括但不限于:

  • Promsie.then
  • MutationObserver
  • Object.observe
  • process.nextTick

属于宏任务的事件包括但不限于:

  • setTimeout
  • setInterval
  • setImmediate
  • MessageChannel
  • requestAnimationFrame
  • I/O
  • UI 交互事件

setInterval 和 setTimeout 都属于宏任务。对于比较复杂的 JavaScript 业务代码里面,setInterval 和 setTimeout 的执行时间是不确定的。setTimeout(fn, duration); 浏览器只是在 duration 时间后,将 fn 加入到宏任务队列中,具体执行的时间要看事件循环执行宏任务的时间了。

插播一道面试题目,说明的更详细一些:

  console.log('script start')

  async function async1() {
    await async2()
    console.log('async1 end')
  }
  async function async2() {
    console.log('async2 end')
  }
  async1()

  setTimeout(function() {
    console.log('setTimeout')
  }, 0)

  new Promise(resolve => {
    console.log('Promise')
    resolve()
  })
    .then(function() {
      console.log('promise1')
    })
    .then(function() {
      console.log('promise2')
    })

  console.log('script end')
上述代码执行顺序:   
  script start  
  async2 end   
  Promise   
  script end  
  async1 end  
  promise1  
  promise2  
  setTimeout  

事件循环的执行顺序是:同步代码—> 微任务(要全部执行)—>宏任务(执行一个)—>微任务(全部执行)—>宏任务(执行一个) 说明:async function async1(){...} 函数体内的同步代码其实相当于 new Promise(resolve=>{...; resolve()}) 的代码。是同步代码。遇到 await 相当于 new Promise().then(res=>{...}); 是 微任务,会被放入微任务队列中,等待执行。这个和我的另一篇博文中解释的是一致的 juejin.cn/post/688367…

细心的你一定会发现 requestAnimationFrame 也属于宏任务。是的,requestAnimationFrame 也属于宏任务。跟 setTimeout 和 setInterval 不同的是我们不需要考虑动画执行频率和屏幕渲染频率是否一致的问题。使用 requestAnimationFrame 实现的动画会更加丝滑。

setInterval 重复定时器的问题

在《JavaScript高级程序设计》这本书中有介绍。我们已经了解到其实 setInterval 每次执行的时间其实是待定的。那就存在多次函数都未执行的情况。使用 setInterval()创建的定时器确保了定时器代码规则地插入队列中。这个方式的问题在于,定时器代码可能在代码再次被添加到队列之前还没有完成执行,结果导致定时器代码连续运行好几次,而之间没有任何停顿。幸好 JavaScript 引擎够聪明,能避免这个问题。当使用 setInterval()时,仅当没有该定时器的任何其他代码实例时,才将定时器代码添加到队列中。 这确保了定时器代码加入到队列中的最小时间间隔为指定间隔。

这种重复定时器的规则有两个问题: (1) 某些间隔会被跳过; (2) 多个定时器的代码执行之间的间隔可能会比预期的小。

《JavaScript高级程序设计》也介绍了怎么解决这个问题,那就是使用 setTimeout 调用自身的方式实现:

setTimeout(function(){
  //处理中
  setTimeout(arguments.callee, interval);
}, interval);

这个模式链式调用了 setTimeout(),每次函数执行的时候都会创建一个新的定时器。第二个 setTimeout()调用使用了 arguments.callee 来获取对当前执行的函数的引用,并为其设置另外一个定时器。这样做的好处是,在前一个定时器代码执行完之前,不会向队列插入新的定时器代码,确保不会有任何缺失的间隔。而且,它可以保证在下一次定时器代码执行之前,至少要等待指定的间隔,避免了连续的运行。这个模式主要用于重复定时器。

这样就完美了吗?其实不是的, setTimeout() 属于宏任务同样具有执行时间不确定的问题的。setTimeout(fn, duration); 浏览器也只是在 duration 时间后,将 fn 加入到宏任务队列中,具体执行的时间要看事件循环执行宏任务的时间了。利用 setTimeout()调用自身可以实现代码重复被执行,优于直接使用 setInterval 实现的方式。

tab 页面切换的问题

使用 setTimeout 或者 setInterval 函数实现的动画在克服了渲染频率不一致的问题后,看起来还可以,但当我们切换了页面,等待一会儿后,再返回动画页面会发现出现有些诡异的现象。

比如我们使用 setTimeout 或者 setInterval 实现了轮播图;切换页面后,其实 setTimeout、 setInterval 函数仍然在执行,但是页面并没有继续渲染保留的是切换前的位置。当我们切换回页面的时候,setTimeout、 setInterval 函数执行的位置肯定跟之前是不一致的。这就导致了动画看起来是不连贯的。

这个问题也是有破解方法的,那就是监听页面被隐藏和激活的事件。在页面被隐藏的时候清除动画,保留动画当前的状态;页面被激活的时候重新开始动画。代码可以参考 juejin.cn/post/688361… 博客。

还有一点,我看很多博客没有介绍。那就是 requestAnimationFrame 在页面切换的时候不会执行,但是如果我们的代码利用了 requestAnimationFrame 回调函数中的时间值 timestamp 的话要注意了,timestamp 是随着时间增长的,表示每次回调执行的时间。看下面的例子:

  const element = document.getElementById('myDiv');
  let start;
  function step(timestamp) {
      if (start === undefined)
          start = timestamp;
      const elapsed = timestamp - start;
      element.style.transform = 'translateX(' + 0.1 * elapsed + 'px)';
      window.requestAnimationFrame(step);
  }
  window.requestAnimationFrame(step);

页面不切换的话, myDiv 丝滑般在页面滑动,但是当我们切换了页面。虽然 requestAnimationFrame 函数并不执行,当我们再切回来的时候,myDiv 的位置并不是我们切换页面前的位置了,因为每次执行 requestAnimationFrame 的回调函数中 timestamp 的值表示的是一个客观值,是随着时间增长的。

总结

setInterval:

  • setInterval 是宏任务,受事件循环机制的影响可能不会按照我们期望的时间顺序执行;
  • 同时,setInterval 还有重复定时器的问题:仅当没有该定时器的任何其他代码实例时,才将定时器代码添加到队列中。这就导致了2个问题(1) 某些间隔会被跳过; (2) 多个定时器的代码执行之间的间隔可能会比预期的小。
  • 最后一点是 setInterval 在页面切换的时候也会执行,导致了动画的不连贯问题。解决方法是监听页面激活和隐藏事件。

setTimeout:

  • setTimeout 也是宏任务,会受事件循环机制的影响可能不会按照我们期望的时间执行;
  • 利用 setTimeout 调用自身的方式可以实现函数按固定时间间隔重复执行,实现效果优于直接使用 setInterval。
  • 同 setInterval 一样,页面切换后仍然在执行,存在动画的不连贯问题。解决方法是监听页面激活和隐藏事件。

requestAnimationFrame:

  • requestAnimationFrame 的出现解决了 setTimeout 和 setInterval 实现动画频率和刷新频率不一致导致页面不够丝滑的问题。
  • requestAnimationFrame 本身在页面切换后不会执行,是优点也是一个小坑。使用时需要根据具体动画效果考虑。
  • 最后一点, requestAnimationFrame 毕竟是 HTML5 才新增的定时器,需要通过 setTimeout 进行pollfy。
let lastTime = 0
const prefixes = 'webkit moz ms o'.split(' ') // 各浏览器前缀

let requestAnimationFrame
let cancelAnimationFrame

const isServer = typeof window === 'undefined'
if (isServer) {
  requestAnimationFrame = function() {
    return
  }
  cancelAnimationFrame = function() {
    return
  }
} else {
  requestAnimationFrame = window.requestAnimationFrame
  cancelAnimationFrame = window.cancelAnimationFrame
  let prefix
    // 通过遍历各浏览器前缀,来得到requestAnimationFrame和cancelAnimationFrame在当前浏览器的实现形式
  for (let i = 0; i < prefixes.length; i++) {
    if (requestAnimationFrame && cancelAnimationFrame) { break }
    prefix = prefixes[i]
    requestAnimationFrame = requestAnimationFrame || window[prefix + 'RequestAnimationFrame']
    cancelAnimationFrame = cancelAnimationFrame || window[prefix + 'CancelAnimationFrame'] || window[prefix + 'CancelRequestAnimationFrame']
  }

  // 如果当前浏览器不支持requestAnimationFrame和cancelAnimationFrame,则会退到setTimeout
  if (!requestAnimationFrame || !cancelAnimationFrame) {
    requestAnimationFrame = function(callback) {
      const currTime = new Date().getTime()
      // 为了使setTimteout的尽可能的接近每秒60帧的效果
      const timeToCall = Math.max(0, 16 - (currTime - lastTime))
      const id = window.setTimeout(() => {
        callback(currTime + timeToCall)
      }, timeToCall)
      lastTime = currTime + timeToCall
      return id
    }

    cancelAnimationFrame = function(id) {
      window.clearTimeout(id)
    }
  }
}

export { requestAnimationFrame, cancelAnimationFrame }

参考:
www.cnblogs.com/onepixel/p/…
www.cnblogs.com/xiaohuochai…

感谢

如果本文有帮助到你的地方,记得点赞哦,这将是我持续不断创作的动力~

You want to see a miracle, son? Be the miracle.
年轻人,想要看到奇迹,那就去成为奇迹。