关于javascript:requestAnimationFrame-执行机制探索

43次阅读

共计 6685 个字符,预计需要花费 17 分钟才能阅读完成。

1. 什么是 requestAnimationFrame

window.requestAnimationFrame() 通知浏览器——你心愿执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该办法须要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。
依据以上 MDN 的定义,requestAnimationFrame 是浏览器提供的一个按帧对网页进行重绘的 API。先看上面这个例子,理解一下它是如何应用并运行的:

const test = document.querySelector<HTMLDivElement>("#test")!;
let i = 0;
function animation() {if (i > 200) return;
  test.style.marginLeft = `${i}px`;
  window.requestAnimationFrame(animation);
  i++;
}
window.requestAnimationFrame(animation);

下面的代码 1s 大概执行 60 次,因为个别的屏幕硬件设施的刷新频率都是 60Hz,而后每执行一次大概是 16.6ms。应用 requestAnimationFrame 的时候,只须要重复调用它就能够实现动画成果。

同时 requestAnimationFrame 会返回一个申请 ID,是回调函数列表中的一个惟一值,能够应用 cancelAnimationFrame 通过传入该申请 ID 勾销回调函数。

const test = document.querySelector<HTMLDivElement>("#test")!;
let i = 0;
let requestId: number;
function animation() {test.style.marginLeft = `${i}px`;
  requestId = requestAnimationFrame(animation);
  i++;
  if (i > 200) {cancelAnimationFrame(requestId);
  }
}
animation();

下图 1 是下面例子的执行后果:

2.requestAnimationFrame 执行的困惑

应用 JavaScript 实现动画的形式还能够应用 setTimeout,上面是实现的代码:

const test = document.querySelector<HTMLDivElement>("#test")!;
let i = 0;
let timerId: number;
function animation() {test.style.marginLeft = `${i}px`;
  // 执行距离设置为 0,来模拟 requestAnimationFrame
  timerId = setTimeout(animation, 0);
  i++;
  if (i > 200) {clearTimeout(timerId);
  }
}
animation();

在这里将 setTimeout 的执行距离设置为 0,来模拟 requestAnimationFrame

单单从代码上实现的形式,看不出有什么区别,然而从上面具体的实现后果就能够看出很显著的差距了。

下图 2 是 setTimeout 执行后果:

残缺的例子戳 codesandbox。

很显著能看出,setTimeoutrequestAnimationFrame 实现的动画“快”了很多。这是什么起因呢?

可能你也猜到了,Event LooprequestAnimationFrame 在执行的时候有些非凡的机制,上面就来探索一下 Event LooprequestAnimationFrame 的关系。

3.Event Loop 与 requestAnimationFrame

Event Loop(事件循环)是用来协调事件、用户交互、脚本、渲染、网络的一种浏览器外部机制。

Event Loop 在浏览器内也分几种:

  • window event loop
  • worker event loop
  • worklet event loop

咱们这里次要探讨的是 window event loop。也就是浏览器一个渲染过程内主线程所管制的 Event Loop

3.1 task queue

一个 Event Loop 有一个或多个 task queues。一个 task queue 是一系列 tasks 的汇合。

注:一个 task queue 在数据结构上是一个汇合,而不是队列,因为事件循环解决模型会从选定的 task queue 中获取第一个 可运行工作(runnable task),而不是使第一个 task 出队。
上述内容来自 HTML 标准。这里让人蛊惑的是,明明是汇合,为啥还叫“queue”啊 T.T

3.2 task

一个 task 能够有多种 task sources (工作源),有哪些工作源呢?来看下标准里的 Gerneric task sources:

  • DOM 操作工作源,比方一个元素以非阻塞的形式插入文档
  • 用户交互工作源,用户操作(比方 click)事件
  • 网络工作源,网络 I/O 响应回调
  • history traversal 工作源,比方 history.back()

除此之外还有像 Timers (setTimeoutsetInterval等)、IndexDB 操作也是 task source

3.3 microtask

一个 event loop 有一个 microtask queue,不过这个“queue”它的确就是那个“FIFO”的队列。

标准里没有指明哪些是 microtask 的工作源,通常认为以下几个是 microtask:

  • promises
  • MutationObserver
  • Object.observe
  • process.nextTick (这个货色是 Node.js 的 API,暂且不探讨)

3.4 Event Loop 处理过程

  1. 在所选 task queue (taskQueue)中约定必须蕴含一个可运行工作。如果没有此类 task queue,则跳转至上面 microtasks 步骤。
  2. 让 taskQueue 中最老的 task (oldestTask) 变成第一个可执行工作,而后从 taskQueue 中删掉它。
  3. 将下面 oldestTask 设置为 event loop 中正在运行的 task。
  4. 执行 oldestTask。
  5. 将 event loop 中正在运行的 task 设置为 null。
  6. 执行 microtasks 检查点(也就是执行 microtasks 队列中的工作)。
  7. 设置 hasARenderingOpportunity 为 false。
  8. 更新渲染。
  9. 如果以后是 window event loop 且 task queues 里没有 task 且 microtask queue 是空的,同时渲染机会变量 hasARenderingOpportunity 为 false,去执行 idle period(requestIdleCallback)。
  10. 返回到第一步。

以上是来自标准对于 event loop 处理过程的精简版整顿,省略了局部内容,完整版在这里。

大体上来说,event loop 就是不停地找 task queues 里是否有可执行的 task,如果存在行将其推入到 call stack(执行栈)里执行,并且在适合的机会更新渲染。

下图 3(源)是 event loop 在浏览器主线程上运行的一个清晰的流程:

对于主线程做了些什么,这又是一个巨大的话题,感兴趣的同学能够看看浏览器外部揭秘系列文章。

在下面标准的阐明中,渲染的流程是在执行 microtasks 队列之后,更进一步,再来看看渲染的处理过程。

3.5 更新渲染

  1. 遍历以后浏览上下文中所有的 document,必须按在列表中找到的程序解决每个 document。
  2. 渲染机会(Rendering opportunities):如果以后浏览上下文中没有到渲染机会则将所有 docs 删除,勾销渲染(此处是否存在渲染机会由浏览器自行判断,依据硬件刷新率限度、页面性能或页面是否在后盾等因素)。
  3. 如果以后文档不为空,设置 hasARenderingOpportunity 为 true。
  4. 不必要的渲染(Unnecessary rendering):如果浏览器认为更新文档的浏览上下文的出现不会产生可见成果且文档的 animation frame callbacks 是空的,则勾销渲染。(终于看见 requestAnimationFrame 的身影了
  5. 从 docs 中删除浏览器认为出于其余起因最好跳过更新渲染的文档。
  6. 如果文档的浏览上下文是顶级浏览上下文,则刷新该文档的主动对焦候选对象。
  7. 解决 resize 事件,传入一个 performance.now() 工夫戳。
  8. 解决 scroll 事件,传入一个 performance.now() 工夫戳。
  9. 解决媒体查问,传入一个 performance.now() 工夫戳。
  10. 运行 CSS 动画,传入一个 performance.now() 工夫戳。
  11. 解决全屏事件,传入一个 performance.now() 工夫戳。
  12. 执行 requestAnimationFrame 回调,传入一个 performance.now() 工夫戳
  13. 执行 intersectionObserver 回调,传入一个 performance.now() 工夫戳。
  14. 对每个 document 进行绘制。
  15. 更新 ui 并出现。

下图 4(源)是该过程一个比拟清晰的流程:

至此,requestAnimationFrame 的回调机会就分明了,它会在 style/layout/paint 之前调用。

再回到文章开始提到的 setTimeout 动画比 requestAnimationFrame 动画更快的问题,这就很好解释了。

首先,浏览器渲染有个渲染机会(Rendering opportunity)的问题,也就是浏览器会依据以后的浏览上下文判断是否进行渲染,它会尽量高效,只有必要的时候才进行渲染,如果没有界面的扭转,就不会渲染。依照标准里说的一样,因为思考到硬件的刷新频率限度、页面性能以及页面是否存在后盾等等因素,有可能执行完 setTimeout 这个 task 之后,发现还没到渲染机会,所以 setTimeout 回调了几次之后才进行渲染,此时设置的 marginLeft 和上一次渲染前 marginLeft 的差值要大于 1px 的。

下图 5 是 setTimeout 执行状况,红色圆圈处是两次渲染,两头四次是解决 setTimout task,因为屏幕的刷新频率是 60 Hz,所以大抵在 16.6ms 之内执行了屡次 setTimeout task 之后才到了渲染机会并执行渲染。

requestAnimationFrame 帧动画不同之处在于,每次渲染之前都会调用,此时设置的 marginLeft 和上一次渲染前 marginLeft 的差值为 1px。

下图 6 是 requestAnimationFrame 执行状况,每次调用完都会执行渲染:

所以看上去 setTimeout“快”了很多。

4. 不同浏览器的实现

下面的例子都是在 Chrome 下测试的,这个例子根本在所有浏览器下出现的后果都是统一的,看看上面这个例子,它来自 jake archilbald 早在 2017 年提出的这个问题:

test.style.transform = 'translate(0, 0)';
document.querySelector('button').addEventListener('click', () => {const test = document.querySelector('.test');
  test.style.transform = 'translate(400px, 0)';
  
  requestAnimationFrame(() => {
    test.style.transition = 'transform 3s linear';
    test.style.transform = 'translate(200px, 0)';
  });
});

这段代码在 Chrome、Firefox 执行状况如下图 7:

简略解释一下,该例中 requestAnimationFrame 回调里设置的 transform 笼罩了 click listener 里设置的 transform,因为 requestAnimationFrame 是在计算 css (style) 之前调用的,所以动画向右挪动了 200 px。

注:下面代码是在 Chrome 暗藏模式下执行的,当你的 Chrome 浏览器有很多插件或者关上了很多 tab 时,也可能呈现从右往左滑动的景象。
在 safari 执行状况如下图 8:

edge 之前也是也是和 safari 一样的执行后果,不过当初曾经修复了。

造成这样后果的起因是 safari 在执行 requestAnimationFrame 回调的机会是在 1 帧渲染之后,所以以后帧调用的 requestAnimationFrame 会在下一帧出现。所以 safari 一开始渲染的地位就到了左边 400px 的地位,而后朝着右边 200px 的地位挪动。

对于 event loop 和 requestAnimationFrame 更具体的执行机制解释,jake 在 jsconf 里有过专题演讲,举荐小伙伴们看一看。

5. 其余执行规定

持续看后面 jake 提出的例子,如果在标准规范实现下,想要实现 safari 出现的成果(也就是从右往左挪动)须要怎么做?

答案是再加一层 requestAnimationFrame 调用:

test.style.transform = 'translate(0, 0)';
document.querySelector('button').addEventListener('click', () => {const test = document.querySelector('.test');
  test.style.transform = 'translate(400px, 0)';
  
  requestAnimationFrame(() => {requestAnimationFrame(() => {
      test.style.transition = 'transform 3s linear';
      test.style.transform = 'translate(200px, 0)';
    });
  });
});

下面这段代码的执行后果和 safari 统一,起因是 requestAnimationFrame 每帧只执行 1 次,新定义的 requestAnimationFrame 会在下一帧渲染前执行。

6. 其余利用

从下面的例子咱们得悉:应用 setTimeout 来执行动画之类的视觉变动,很可能导致丢帧,导致卡顿,所以应尽量避免应用 setTimeout 来执行动画,举荐应用 requestAnimationFrame 来替换它。

requestAnimationFrame 除了用来实现动画的成果,还能够用来实现对大工作的分拆执行。

从图 4 的渲染流程图能够得悉:执行 JavaScript task 是在渲染之前,如果在一帧之内 JavaScript 执行工夫过长就会阻塞渲染,同样会导致丢帧、卡顿

针对这种状况能够将 JavaScript task 划分为各个小块,并应用 requestAnimationFrame() 在每个帧上运行。如下例(源)所示:

var taskList = breakBigTaskIntoMicroTasks(monsterTaskList);
requestAnimationFrame(processTaskList);
function processTaskList(taskStartTime) {
  var taskFinishTime;
  do {
    // 假如下一个工作被压入 call stack
    var nextTask = taskList.pop();
    // 执行下一个 task
    processTask(nextTask);
    // 如何工夫足够继续执行下一个
    taskFinishTime = window.performance.now();} while (taskFinishTime - taskStartTime < 3);
  if (taskList.length > 0) {requestAnimationFrame(processTaskList);
  }
}

7. 参考资料

WHATWG HTML Standard

古代浏览器外部揭秘

JavaScript main thread. Dissected.

requestAnimationFrame Scheduling For Nerds

jake jsconf 演讲

optimize javascript execution

从 event loop 标准探索 javaScript 异步及浏览器更新渲染机会


欢送关注凹凸实验室博客:aotu.io

或者关注凹凸实验室公众号(AOTULabs),不定时推送文章。

正文完
 0