动画requestAnimationFrame与浏览器事件循环
前言
我们都知道使用requestAnimationFrame实现动画可以使得动画更加平滑流畅,那为什么呢?浏览器渲染和我们平常写的JS又是如何协调及相互影响?
Event loop 和 JS 引擎、渲染引擎的关系
JavaScript 引擎
定义: JavaScript 引擎负责解释和执行 JavaScript 代码。
作用: 当浏览器加载一个包含 JavaScript 的网页时,JavaScript 引擎会解析并执行页面上的 JavaScript 代码。常见的 JavaScript 引擎有 V8(Chrome、Node.js)、SpiderMonkey(Firefox)、JavaScriptCore(Safari)等。
任务: 执行 JavaScript 代码、处理异步任务、管理内存等。
组成: Parser,Interpreter,Compiler,GC(Garbage Collector)
Parser
(V8 Ignition)
Parser解析器将 JavaScript 源码解析为 AST,解析过程分为词法分析和语法分析,V8 通过预解析提升解析效率, 预解析,他会跳过一些未被使用的代码,比如函数的声明。在这个过程,他仅仅解析语法、声明的作用域等
Interpreter
一边解释一边执行
解释器 (V8 Ignition) 根据 AST 生成字节码并执行。这个过程中会收集执行反馈信息,交给 Compiler 进行优化编译;
Compiler
优化编译器
(V8 TurboFan)
当 V8 认为某个函数的执行频率足够高时,TurboFan 会介入,将字节码和收集到的信息作为输入,生成优化后的机器码,以提高性能
如果TurboFan基于某些假设进行的优化在运行时不再成立,V8会触发去优化。 去优化会撤销TurboFan所做的某些优化,将执行控制权交还给基线编译器或Ignition。
为什么解释字节码而不是直接生成机器码?
一些解释字节码的优势和原因:
- 跨平台性: 字节码是一种中间表示,与特定硬件架构无关。解释字节码的方法允许同一份字节码在不同平台上运行,而无需为每个平台生成特定的机器码。这使得实现跨平台的 JavaScript 引擎更加方便。
- 即时编译(Just-In-Time Compilation,JIT): 字节码的解释执行过程相对快速,因为不需要等待整个程序编译成机器码。在运行时,JIT编译器可以根据运行时的上下文和执行路径,选择性地将热点代码转换为机器码,以提高性能。
- 优化机会: 字节码提供了更多优化的机会。由于它是一种中间表示,可以在解释和执行的过程中进行更多的分析和优化,以提高整体性能。这种优化通常包括内联缓存、函数内联、去掉未使用的代码等。
- 更快的启动时间: 直接生成机器码可能会导致较长的启动时间,因为在执行之前需要先编译整个程序。而解释字节码可以更快地启动应用程序,因为只需解释器直接执行字节码而不需要额外的编译时间。
- 动态语言支持: 字节码执行更适合动态语言,因为它们的类型和结构可能在运行时发生变化。解释器可以更灵活地处理这些动态性的特征。
- 更小的内存占用: 其实在 V8 设计的初期,是直接将 AST 编译成机器码、中间没有字节码这么个东西的。但是因为直接编译成机器码来执行的话,大小往往是源代码的几十倍,占用了不小的内存空间。在移动设备(内存较小)流行时,这样的缺点非常突出。
一些V8优化
- 尾调用优化:尾调用优化是指函数的最后一步是调用另一个函数(尾调用优化要求被调用的函数不依赖于外层函数的任何状态),这样的调用称为尾调用。尾调用优化可以避免不必要的函数调用栈的增长,提高性能和内存使用率。V8 会对尾调用进行优化,将其转换为跳转指令,复用一个上下文,以避免不必要的函数调用栈的增长。
- 内联缓存:内联缓存是一种缓存机制,用于提高动态语言的函数调用性能。在动态语言中,函数调用时需要进行动态类型检查,以确定调用的是哪个函数。内联缓存会缓存函数调用的类型信息,以避免重复的类型检查。对象属性的访问非常频繁,内联缓存通过缓存这些属性的查找结果。V8 会对函数调用进行内联缓存,以提高性能。
- 隐藏类:隐藏类是一种缓存机制,用于提高动态语言的属性访问性能。在动态语言中,对象的属性可以在运行时动态添加和删除,这会导致属性的偏移量发生变化,从而导致属性的访问性能下降。隐藏类会缓存属性的偏移量,以避免重复的偏移量计算。V8 会对对象属性进行隐藏类优化,以提高性能。
- 具有相同结构(相同属性定义顺序相同)的对象共享相同的 Hidden Class
- 默认情况下,添加的每个新命名属性都会导致创建一个新的 Hidden Class。
- 添加数组索引属性不会创建新的 Hidden Class。
gc
js垃圾回收js 垃圾回收机制 | HzmBlog (huangzumao.space)
编译流水线
一般的 JS 引擎的编译流水线是 parse 源码成 AST,之后 AST 转为字节码,解释执行字节码。运行时会收集函数执行的频率,对于到达了一定阈值的热点代码,会把对应的字节码转成机器码(JIT),然后直接执行。
来源于:Event loop 和 JS 引擎、渲染引擎的关系(精致版) - 知乎 (zhihu.com)
渲染引擎
渲染引擎是浏览器中负责解析和渲染网页内容的关键组件。它将HTML、CSS、JavaScript等资源解析并转换成用户可以看到的页面。以下是渲染引擎的主要功能和工作流程:
- HTML 解析: 渲染引擎从网络或本地缓存中获取HTML文档,然后开始解析HTML代码,构建DOM树(文档对象模型)。DOM树表示了HTML文档的层次结构,包括页面的元素、属性和它们之间的关系。
- CSS 解析: 同时,渲染引擎解析CSS样式表,构建CSSOM树(CSS对象模型)。CSSOM树表示了CSS样式规则的层次结构,包括样式规则的选择器、属性和值。
- Render 树构建: 渲染引擎将DOM树和CSSOM树合并成一个Render树。Render树包含了页面中可见的节点,每个节点都与实际渲染的元素相对应,包括计算后的样式信息。
- 布局(Layout): Render树的节点包含了每个元素的位置和大小信息。渲染引擎进行布局阶段,计算每个元素在屏幕上的准确位置,这个过程也称为回流(reflow)。
- 绘制(Painting): 在布局之后,渲染引擎将每个元素绘制到屏幕上。这个过程包括填充颜色、绘制边框、渲染文本等,生成位图图层。
- 合成(Composite): 渲染引擎将各个位图图层按照正确的顺序合成,形成最终的页面。这个过程被称为合成阶段。
- 重绘和回流: 当页面发生变化时,渲染引擎可能需要进行重绘(repaint)或回流(reflow)操作。重绘只涉及颜色的变化,而回流涉及布局的变化。这两个操作都会消耗一定的性能,因此在开发中需要谨慎操作,避免不必要的重绘和回流。
- 事件处理: 渲染引擎还负责处理用户交互事件,例如鼠标点击、键盘输入等。渲染引擎负责渲染和绘制页面,但并不直接处理用户交互事件。然而,当用户触发了一个事件,例如点击了页面上的元素,渲染引擎会生成相应的事件对象,并将其传递给浏览器的事件系统
不同的浏览器使用不同的渲染引擎,例如Chrome使用的是Blink引擎,Firefox使用的是Gecko引擎。
来源于:Event loop 和 JS 引擎、渲染引擎的关系(精致版) - 知乎 (zhihu.com)
事件循环Event Loop:
谈到事件循环,有些人会以为它是js引擎提供的,其实事件循环是由 JavaScript 运行时环境提供的机制,如浏览器内核,NodeJS环境等等。
eventloop规范:HTML Standard (whatwg.org)
大概的流程是:
从 task 队列(可以理解是宏任务)中选出最老的一个 task,执行它。
清空所有的微任务
更新Render,但是在这里会判断是否有Rendering opportunities,可以理解为是不是需要重新渲染,浏览器只需保证 60Hz 的刷新率即可(在机器负荷重时还会降低刷新率),若 eventloop 频率过高,即使渲染了浏览器也无法及时展示,类似的,如果一个顶层浏览器上下文在后台运行,用户代理可能决定将该页面的刷新率降到 4Hz,甚至更低。所以并不是每轮 eventloop 都会执行 UI Render。
该流程包括:(仅针对可见区域的doucument For each fully active
Document
in docs)- 执行各种渲染所需工作,如 触发 resize、scroll 事件、建立媒体查询、运行 CSS 动画
- 执行 animationframecallbacks 也就是requestAnimationFrame
- 执行 IntersectionObserver callback
- 渲染
如果task为空,微任务队列没任务,还没达到需要重新渲染的时间,进行
Idle
空闲周期的算法,判断是否要执行requestIdleCallback
的回调函数,可以给
rIC
传入第二个参数timeout
,保证即使没有空闲时间,也会在这个最长等待时间过后执行worker事件响应
用一张图来概括整体流程:(来源于:深入探究 eventloop 与浏览器渲染的时序问题 - 404Forest)
task(macrotask)
何为 task?task 又称 macrotask。我们查看 HTML 规范中 task 的有关章节 webappapis.html#concept-task。
一个 eventloop 有一或多个 task 队列。每个 task 由一个确定的 task 源提供。从不同 task 源而来的 task 可能会放到不同的 task 队列中。例如,浏览器可能单独为鼠标键盘事件维护一个 task 队列,所有其他 task 都放到另一个 task 队列。通过区分 task 队列的优先级,使高优先级的 task 优先执行,保证更好的交互体验。
task 源包括:(webappapis.html#generic-task-sources)
- DOM 操作任务源:如元素以非阻塞方式插入文档
- 用户交互任务源:如鼠标键盘事件。用户输入事件(如 click) 必须使用 task 队列
- 网络任务源:如 XHR 回调
- history 回溯任务源:使用 history.back() 或者类似 API
此外 setTimeout、setInterval、IndexDB 数据库操作等也是任务源。总结来说,常见的 task 任务有:
- 事件回调
- XHR 回调
- IndexDB 数据库操作等 I/O
- setTimeout / setInterval
- history.back
requestAnimationFrame的优势
与浏览器的绘制过程同步,以提供最佳的性能和动画效果。
在事件循环中,可以看待在渲染帧中看到,在最后更新UI前,会执行requestAnimationFrame,使得与绘制同步,使得动画更加流畅。
为什么setTimeout不行会有卡顿感?常常会觉得setTimeout太慢了,但恰恰相反因为setTimeout属于task,如果不拥挤,在一个event loop上,是会执行多次task而不执行渲染,也就是说,动画变化太快,但是渲染跟不上,因此requestAnimationFrame可以和渲染同步。
参考文献
深入探究 eventloop 与浏览器渲染的时序问题 - 404Forest
深入解析你不知道的 EventLoop 和浏览器渲染、帧动画、空闲回调(动图演示) - 掘金 (juejin.cn)
Event loop 和 JS 引擎、渲染引擎的关系(精致版) - 知乎 (zhihu.com)
chatgpt