🙌 如文章有误,恳请评论区指正,谢谢!
❤ 写作不易,「点赞」+「收藏」+「转发」 谢谢支持!
背景
在之前的业务中遇到有 JS 动画的实现场景,但当电脑打开太多网页或是同时启动很多应用时,JS 动画便开始出现“掉帧”的现象,此篇文章便是探讨用
setInterval
和requestAnimationFrame
实现动画的差异性究竟在哪?为什么普遍都说requestAnimationFrame
更优?
当然,如果业务允许,我们还可以通过 canvas 动画或是帧动画来重新实现该 JS 动画,只不过此篇文章重在探讨 JS 动画。
结论打头
先说选择场景,如果是要实现对时间精度要求高的业务,例如动画或是其他每一帧都要实现数据更新的情况,建议选择 requestAnimationFrame
;如果只是设置定时器,每隔一段时间就执行某件任务(对精度要求不高),例如实现数据上报等等,可以选择 setInterval
。
setInterval 的局限性
1. 时间精度
其实如果单从性能的角度考虑(即对内存占有率),setInterval
与 requestAnimationFrame
其实是没差别的基本,所以两个 API 主要是在时间精度上有区别。
因为 setInterval
是宏任务,所以在浏览器事件循环 Event Loop 中优先级很低,得等前面的微任务队列清空以及浏览器完成渲染更新后,才执行宏任务。
因此,当执行 setInterval
时如果业务同时还需要大量的其他计算,例如 JS 对后端返回的数据进行分类或是 filter,或是有复杂表单需要进行渲染更新时,setInterval
就无法按预期的时间执行,比如原本是每 50ms 执行一次,现在就是 80ms。
2. 内存占用
同时,浏览器还能够做到自动暂停在后台选项卡或隐藏的 <iframe>
中 setInterval
中的 requestAnimationFrame
。而对于 setInterval
,如果你不去 clearInterval
,它就会不断执行,即一直占着部分内存,这也是我们常说的内存泄漏的一种场景(没及时清除计时器)。
requestAnimationFrame 的优点
先看一下来自 MDN - requestAnimationFrame 的介绍:
即 requestAnimationFrame
可以保证浏览器的每一帧都执行你传入的 callback 函数,也就是对于 60HZ 刷新率的屏幕来说,通过递归调用 requestAnimationFrame
,可以实现在 1s(1000ms) 内执行 60 次。通过这个性质,我们可以在 callback 中执行动画操作,以此来实现动画的流畅运行(不掉帧)。
requestAnimationFrame 存在的问题
1. 问题来源(理论上)
虽然该 API 可以保证每一帧都执行 callback,但如果使用不恰当也因此而有动画精度的问题。
例如我要实现 2s(2000ms) 内将元素A 往右移 200px,如果不作任何处理,直接在传入的 callback 中每次执行都往右移动 xxx px,直到到达 200px 才停止递归。听起来实现很简单,甚至在自己的显示器中自测也是发现不了问题。
但对于 144HZ 和 60HZ 刷新率的显示器来说,requestAnimationFrame
执行的频率是不同的,空闲状态下 144HZ 会执行 144次,60HZ 会执行 60次,即在 144HZ 下动画会更快地执行,完成右移的时间会更短。因此其实是不符合用户体验预期的,更快执行让用户会有眼花缭乱的感觉。
2. 实际上呢?
但实际上并不是刷新率越高,requestAnimationFrame
就会执行越频繁,是个概率事件,有时会同步到 144HZ 左右的刷新率,有时仍锁定在 60HZ 左右的刷新率,该 bug 可查看该文章:考古挖掘:高刷显示器下的 rAF。
即的确在一部分用户下,刷新率和 requestAnimationFrame
存在不同步问题。可是在回答区,有一部分用户也反馈,他们屏幕刷新率和 requestAnimationFrame
是同步的。这样也印证了大概率是一个Bug。
如何解决 requestAnimationFrame 的频率不稳定问题
1. 根据不同刷新率屏幕,配置不同的动画速率
我们可以先封装一个 Hook,用于检测当前屏幕的刷新频率,大体代码如下:
const count = useRef(0);const rAF = useRef();useEffect(() => {const prevTime = new Date();const animate = () => {console.log(count.current);count.current = count.current + 1;if (count.current >= 60) {// 就不继续递归了const nowTime = new Date();console.log("此时时间差值是:", nowTime - prevTime);console.log("此时的帧率是:", 60 / (nowTime - prevTime));rAF.current && cancelAnimationFrame(rAF.current);} else {requestAnimationFrame(animate)}}rAF.current = requestAnimationFrame(animate);rAF.current();return () => {rAF.current && cancelAnimationFrame(rAF.current);}}, [])
便有了对比,当我们实现的动画是在 60HZ 刷新率的屏幕下时每 100ms 移动 A px,那么当来到 144HZ 的屏幕时,便是每 100ms 移动 60/144 * A px
即可。
60HZ 屏幕下
144HZ 屏幕下
上图是我在单显示器下时,测试我的两块显示器屏幕的测试结果,可见确实并不一定 144HZ 就一定更快,所以封装该 Hook 可以根据不同刷新率显示器来制定更精确的动画移动步频。
2. 根据时间差值来百分比地移动
这也是 MDN - requestAnimationFrame 的官方介绍用法,requestAnimationFrame
的 callback 回调函数会有一个传入参数,这个参数是一个 long
类型整数值,是在回调列表里的唯一标识符,用于表示上一帧渲染的结束时间。关键代码片段和解释注释如下:
const element = document.getElementById("some-element-you-want-to-animate");
let start, previousTimeStamp;
let done = false;function step(timestamp) {// 第一次就先初始化起始时间,后面就不断更新最新时间来跟这个起始时间 start 作差值,来对比// 等价于第 0 帧的时间戳if (start === undefined) {start = timestamp;}const elapsed = timestamp - start; // 时间差值if (previousTimeStamp !== timestamp) {// 这里使用 Math.min() 确保元素在恰好位于 200px 时停止运动const count = Math.min(0.1 * elapsed, 200);element.style.transform = `translateX(${count}px)`;if (count === 200) done = true;}if (elapsed < 2000) {// 2 秒之后停止动画previousTimeStamp = timestamp;if (!done) {window.requestAnimationFrame(step);}}
}window.requestAnimationFrame(step);
在这个例子中,一个元素的动画时间是 2 秒(2000 毫秒)。该元素以 0.1px/ms
的速度向右移动,所以它的相对位置(以 CSS 像素为单位)可以通过动画开始后所经过的时间(以毫秒为单位)的函数来计算,即 0.1 * elapsed
。该元素的最终位置是在其初始位置的右边 200px(0.1 * 2000
)。
当然,代码中的 0.1
是我们可以改变的,比如你是想 1s 旋转 360度,即 360°/1000ms -> 0.36°/ms
,那就代码改成
const count = Math.min(0.36 * elapsed, 360);
element.style.transform = `rotate(${count}deg)`;
便可实现按时间差值来百分比执行动画的需求了。
时间差值的计算还可以如下计算,该实现就可以不利用传入参数,而是每次都调 performance.now()
来获取最新时间:
const zero = performance.now();
requestAnimationFrame(animate);
function animate() {const value = (performance.now() - zero) / duration;if (value < 1) {element.style.opacity = value;requestAnimationFrame((t) => animate(t));} else element.style.opacity = 1;
}
第二种方案与 setInterval 的不同之处
其实第二种方案也是类似于 setInterval
,因为是按时间差值来执行动画,但通过 requestAnimationFrame
你可以保证动画函数执行的优先级,在保证了每帧必定调用的前提下,再去精确计算该如何执行动画。
当然,如果对 CSS 动画性能比较感兴趣的,也可以查看该 MDN - CSS 动画与 JavaScript 动画的性能 文档进行对比阅读。
最后
我是 Smoothzjc,致力于产出更多且不仅限于前端方面的优质文章
大家也可以关注我的公众号 @ Smooth前端成长记录,及时通过移动端获取到最新文章消息!
写作不易,「点赞」+「收藏」+「转发」 谢谢支持❤
往期推荐
《拒绝死记硬背!清晰思路讲透 控制并发数、Promise.all、Promise.race 的实现逻辑》
《手把手教前端从0到1通过 Node + Express 开发简易接口,项目开发+部署服务器(亲身痛苦经历)》
《都2022年了还不考虑来学React Hook吗?6k字带你从入门到吃透》
《一份不可多得的 Webpack 学习指南(1万字长文带你入门 Webpack 并掌握常用的进阶配置)》
《通过 React15 ~ 17 的优化迭代来简单聊聊 Fiber》
《【offer 收割机之面试必备】一篇非常全面的 从 URL 输入到页面展现的全过程 精华梳理》
《【offer 收割机之手写系列】10分钟带你掌握原理并手写防抖与节流的立即/非立即执行版本》
《【offer 收割机之 CSS 回顾系列】请你解释一下什么是 BFC ?他的应用场景有哪些?》
《Github + hexo 实现自己的个人博客、配置主题(超详细)》
《10分钟让你彻底理解如何配置子域名来部署多个项目》
《一文理解配置伪静态解决 部署项目刷新页面404问题
《带你3分钟掌握常见的水平垂直居中面试题》
《【建议收藏】长达万字的git常用指令总结!!!适合小白及在工作中想要对git基本指令有所了解的人群》
《浅谈javascript的原型和原型链(新手懵懂想学会原型链?看这篇文章就足够啦!!!)》