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

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

哈哈哈

普通箭头的实现

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

<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度)的多个箭头额。因而面试

钝角箭头

苦思冥想,屡次尝试以后,决定简简单单用两个线绝对定位造成一个钝角。promise

<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个箭头一块儿动。markdown

//关键代码
.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 个小箭头添加样式实现。async

纯 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 函数中调用自身实现每隔固定时间周期性的执行的效果。oop

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. 年轻人,想要看到奇迹,那就去成为奇迹。

相关文章
相关标签/搜索