通常我们说DOM更新发生在一个宏任务执行完之后,但并不是每次宏任务执行后都会进行DOM更新。浏览器会维护一个相对稳定的帧率,根据硬件条件和页面性能表现。那么事件循环如何判断是否应该更新DOM呢?
先上一波HTML规范指定的事件循环处理章程:
- 从事件循环的任务队列中取一个任务队列taskQueue,这里的任务队列指的是宏任务队列。注意,一个事件循环可能并不只有一个宏任务队列。具体有哪些以及这里怎么选由用户代理决定。
- taskQueue中出队一个(宏)任务oldestTask,并设置事件循环的当前执行的任务为taskQueue。
- 执行taskQueue。
- 设置事件循环的当前执行的任务为null。
- 执行微任务队列中的(task)。
- 处理DOM更新。
上面的描述省略了一些步骤,完整版参考规范。注意worker环境的事件循环不在本文讨论范畴之内。
可以发现DOM更新确实发生在两个宏任务执行之间,但如果我们深入DOM更新步骤,会发现还有个渲染机会(rendering opportunity)的概念。DOM更新时会忽略没有渲染机会的文档。
那么什么时候一个DOM文档(准确说是其浏览上下文)拥有渲染机会呢?前面说过,浏览器会维护一个相对稳定的帧率,例如30fps,那么1秒内应该最多拥有30次渲染机会。浏览器根据这些条件和是否有DOM更新(DOM操作,动画等),决定文档是否应该拥有渲染机会。相对稳定的意思是,当硬件条件或者页面性能导致无法维持较高帧率时可能会降帧。虽然这并不是规范规定的,但规范中以此为例,而且主流浏览器应该都是这么实现的。
了解这些有什么用呢?我们可以更精细地把握自己的代码。考虑以下代码:
setTimeout(() => {
//依赖DOM的操作
}, 0);
我们可能会用定时器来等待dom更新后执行一些操作,因为定时器创建宏任务,而DOM会在当前宏任务执行结束后更新(错误)。
有趣的是,这往往也不会出大问题。一来一般这时候已经执行了不少代码,可能正好浏览器在当前宏任务执行完后更新了DOM,二来DOM操作往往会导致DOM强制更新(如读取DOM元素几何相关的数值等,但不绝对触发强制更新),也可能会得到想要的结果。
然后结果是,bug若有若无的,全看人品。啊,我测试的时候好好的,演示的时候怎么就...😅
上面的例子不够具体,来个更实际的。假设要实现一个DOM元素从远处飞回来的动画,我们不用css animation,用transform。
首先我们需要给DOM元素一个初始偏移量,否则起点就是终点,就没有动画效果了。然后给它设置transition样式,并把偏移量还原。
// 获取DOM元素,这里getElement函数未实现
const el = getElement();
// 初始偏移量
el.style.transform = 'translateX(-100px)';
// ...???
el.style.transition = 'transform 100ms linear';
el.style.transform = 'translateX(0)';
为了让动画从初始偏移量开始,我们需要在???
前后代码执行之间触发一次DOM更新。
最简单的就是触发DOM强制更新。
// 获取DOM元素,这里getElement函数未实现
const el = getElement();
// 初始偏移量
el.style.transform = 'translateX(-100px)';
el.getBoundingClientRect();
el.style.transition = 'transform 100ms linear';
el.style.transform = 'translateX(0)';
通过getBoundingClientRect()
读取元素的几何信息,迫使DOM重新计算元素的属性。但这可能造成额外的运算,带来性能损失。虽然一般场景下问题不大。
如果我们想将???
后面的代码延迟到下一帧之后执行呢?靠setTimeout()
的话,timeout设置小了就回到bug与人品的故事了,设置大了又会产生卡顿影响体验。这时候我们可以通过一个神奇的API来解决:requestAnimationFrame
。
// 获取DOM元素,这里getElement函数未实现
const el = getElement();
// 初始偏移量
el.style.transform = 'translateX(-100px)';
requestAnimationFrame(() => {
setTimeout(() => {
el.style.transition = 'transform 100ms linear';
el.style.transform = 'translateX(0)';
}, 0);
});
首先,requestAnimationFrame()
的回调会在下一帧绘制之前执行。我的理解是,执行requestAnimationFrame的回调函数列表作为一个宏任务被调度,并且此时该DOM文档获得渲染机会,因此回调执行结束后DOM将更新,而当回调函数中设置的定时器任务被执行的时候已经处于新的宏任务中,此时DOM文档已更新。
注意:对于处理一个浏览上下文的requestAnimationFrame回调时的宏任务和下一个宏任务之间,该浏览上下文一定获得会渲染机会是否成立,尚未找到规范证明,仅作参考。
本文链接:https://blog.mattuy.top/archives/dom-update-and-browser-event-loop