从元素渐入渐出到浏览器 Event Loop

2020-06-26 笔记本
Cover Image

本文摸索了另一种更加优雅的元素渐入渐出实现方式,但是不拘于此,我试图理解背后的原理,便学习了 Event Loop 事件环。诚然,只有在实践中学习才是最高效的。

从元素渐入渐出说起

我曾试图重构博客导航栏,并放入站内导航链接。但随之而来的一个问题就是:移动端该如何处理?由于移动终端尺寸限制,不可能像在桌面端那样直接将链接展现在导航栏上。此时我的想法是,在宽度低于一定阈值的设备上,导航链接收纳到一个抽屉菜单内。这个抽屉菜单一开始是不显示的,而是使用一个 JavaScript 监听按钮的点击事件,点击按钮以展开此抽屉菜单。

抽屉菜单直接蹦出来有点太突兀了,我像给他加上一个渐入渐出的动画。好在 CSS 已经有 transition 控制过渡动画。于是我们 0.1 版本的代码出来了。

<!-- html -->

<a class="dropdown-icon" id="btn-dropdown">
<!-- icon -->
</a>
<div class="dropdown-menus" id="dropdown-menus">
<a class="dropback-icon" id="btn-dropback">
<!-- icon -->
</a>
<!-- links -->
</div>
// css (stylus)

.dropdown-menus
display none
transition all 0.35s ease
transform translateY(2.25rem)
opacity 0
......
// JavaScript

document.getElementById('btn-dropdown').addEventListener('click', () => {
const dd = document.getElementById('dropdown-menus');

dd.style.display = 'flex';
dd.style.transform = 'translateY(0)';
dd.style.opacity = '1';
});

document.getElementById('btn-dropback').addEventListener('click', () => {
const dd = document.getElementById('dropdown-menus');

dd.style.transform = 'translateY(2.25rem)';
dd.style.opacity = '0';
dd.style.display = 'none';
});

JavaScript 是会一步步执行我们的代码。如果按照一个普通人的理解,就拿渐入举例:JavaScript 会先将抽屉菜单样式 display 赋为 flex 覆盖默认的 none,从而让其显示;紧接着,将纵向位移调为 0 并且将透明度调为 1(不透明);由于设置了 transition 过渡动画,最终效果应该是从下往上滑入、由透明变不透明的渐入动画。

但是打开浏览器预览,似乎并不是我们想象中的模样,抽屉菜单还是一下子就蹦出来了,毫无美感可言。收回抽屉菜单也是如此,完全没有所谓的「渐入」、「渐出」。

抽屉菜单 渐入渐出 失败

怎么回事?JavaScript 难道不是一条条执行的吗,甚至连 CSS 中的 transition 都没有起作用。但是打开浏览器 DevTools 检查,DOM 确实是在以我们希望的形式更新着。

对于「面向 Google 编程」的我,自然是跑去求助搜索引擎了。转了一圈,甚至还有用 setInterval 反复调整透明度及位移来模拟动画的。不过,it works。

function fadeIn(ele, opacity, speed) {
if (ele) {
var v = ele.style.opacity || 100;
var count = speed / 1000;
var avg = count < 2 ? (opacity / count) : (opacity / count - 1);

if (v < 1) {v = v * 100;}

var timer = setInterval(function() {
if (v < opacity) {
v += avg;
setOpacity(ele, v);
} else {
clearInterval(timer);
}
}, 16);
}
}

function fadeOut(ele, opacity, speed) {
if (ele) {
var v = ele.style.opacity || 100;
var count = speed / 1000;
var avg = (100 - opacity) / count;

if (v < 1) {v = v * 100;}

var timer = setInterval(function() {
if (v - avg > opacity) {
v -= avg;
setOpacity(ele, v);
} else {
clearInterval(timer);
}
}, 16);
}
}

这时的我已经一头雾水,为什么 JavaScript 模拟动画就可以实现,而 CSS 自己的 transition 就毫无反应呢?

浏览器 Event Loop 事件环

这个问题大概放了几天,突然看到 Otstar Lin 的一篇 浅谈浏览器Event Loop [更新],也把 Jake Archibald: 在循环 – JSConf.Asia 认真看了一遍,才对着背后的事情有所了解。

众所周知,JavaScript 是单线程语言。倒不是说多线程不好,只是万一多个 JavaScript 同时修改一个 DOM 时,就会产生争议。所以 JavaScript 一直采用「简单」的单线程模式。

而单纯的单线程也会引发一些问题,比如需要执行一个耗时很长的任务,可能会阻塞页面,严重影响用户体验。为此,JavaScript 引入了异步操作,当 JS 进程需要执行异步操作时,便将其加入到任务队列,当主线程空闲时,JS 就会处理任务队列中的任务。如果又遇到新的异步操作就继续加入任务队列,等待执行,如此往复。

引入这样一套异步逻辑,浏览器便能权衡任务与用户体验。而反复入队和处理任务队列的过程,就称为 Event Loop 事件循环

Event Loop All

Task Queue 任务队列

任务队列,顾名思义,用于存放异步任务的队列。当队列中没有任务时,事件循环也会以一定频率空转,因为它可能还需要处理渲染操作或者处理入队的新任务。

Event Loop 空转

当任务队列中有多个任务时,事件循环每次也只执行一个任务,等待下一个循环才有机会继续执行。

Event Loop 多个任务

再比如,当某个任务修改的 DOM 时,下一次循环会就会先处理渲染,再到下一个任务。

Event Loop 任务-渲染

而渲染前可能比较陌生的 rAFrequestAnimationFrame,这是你在渲染前最后的操作机会。

MacroTask 与 MicroTask

为了方便理解,我们通常将 Task 拆分成 MacroTask 宏任务 以及 MicroTask 微任务。宏任务就是一般的异步任务,例如比较常见的 setTimeout,等待一定时间后将一个任务入队。这一切都是异步的,不会阻塞主线程。而微任务最常见的就属 promise 了,微任务会在宏任务完成且 JS 主栈为空时执行。

举个例子模拟一遍流程。

console.log('1');

// sT1
setTimeout(() => {
console.log('2');
}, 1);

var promise = new Promise((resolve, reject) => {
// sT2
setTimeout(() => {
console.log('3');
}, 1);
console.log('4');
resolve();
});

promise.then(() => {
console.log('5');

// sT3
setTimeout(() => {
console.log('6');
}, 1);
});

console.log('7');
  1. 进场先打印 1;然后 sT1 扔出主线程,计时完成后加入任务队列,等待下一轮 Event Loop 处理
  2. Promiseexecutor 函数则是同步执行,所以将 sT2 扔出主线程,打印 4
  3. resolve()promise 返回 fulfilled 状态,使得 promise.then 加入微任务队列
  4. 最后打印 7,此时主线程清空
  5. 开始处理微任务队列,promise.then 打印 5,并将 sT3 扔出主线程,微任务队列清空
  6. 此时微任务队列与主线程都清空,开始处理宏任务队列,按顺序处理 sT1sT2sT3,依次打印 236

最后输出顺序就是:1 -> 4 -> 7 -> 5 -> 2 -> 3 -> 6

当然,这个例子并不是很好,我们还可以在 sT2 再加一个 Promise,更好地感受微任务的处理逻辑。

再看最初代码

0.1 版的代码中,我天真地认为 JavaScript 每修改一次 DOM,浏览器都会紧跟着渲染变化。而实际并非如此,JavaScript 监听到点击事件后,处理完 3 次修改,Event Loop 才进入渲染阶段。

由于 display: none 会使整个元素不可见,动画效果自然也不生效,结果就是抽屉菜单直接蹦出来了。而之后使用 setInterval 实现时,除了第一次细分动画没有显示,后续每一次都是会显示的,因为此时 display: flex 已经更新。

尽管 setInterval 能够实现,但绝对称不上优雅。霸占 Event Loop,浪费性能不说,还可能影响其他任务的执行。

有了上面的铺垫,很自然的想到,只要让 display 先更新并渲染,在下一个循环再更新 CSS 然后渲染,这样就能看到过渡动画了。

于是,0.3 版诞生了。而这与 0.1 版唯一的区别不过是用 setTimeout 将某些 CSS 更新操作一脚踹出当前进程,方便部分 CSS 更新立即渲染,剩下的等到合适的时机再执行、渲染。

<!-- html -->

<a class="dropdown-icon" id="btn-dropdown">
<!-- icon -->
</a>
<div class="dropdown-menus" id="dropdown-menus">
<a class="dropback-icon" id="btn-dropback">
<!-- icon -->
</a>
<!-- links -->
</div>
// css (stylus)

.dropdown-menus
display none
transition all 0.35s ease
transform translateY(2.25rem)
opacity 0
......
// JavaScript

document.getElementById('btn-dropdown').addEventListener('click', () => {
const dd = document.getElementById('dropdown-menus');

dd.style.display = 'flex';
setTimeout(() => {
dd.style.transform = 'translateY(0)';
dd.style.opacity = '1';
}, 1);
});

document.getElementById('btn-dropback').addEventListener('click', () => {
const dd = document.getElementById('dropdown-menus');

dd.style.transform = 'translateY(2.25rem)';
dd.style.opacity = '0';
setTimeout(() => {
dd.style.display = 'none';
}, 350);
});

只要注意淡出操作 setTimeout 等待时间不要小于过渡动画时间。于是,这次过渡是:

抽屉菜单 渐入渐出 成功

更进一步,我们还可以通过 requestAnimationFrame 来处理,它会在每次渲染前给你最后操作的机会,所以还可以这样写。

document.getElementById('btn-dropdown').addEventListener('click', () => {
const dd = document.getElementById('dropdown-menus');

requestAnimationFrame(() => {
dd.style.display = 'flex';
requestAnimationFrame(() => {
dd.style.transform = 'translateY(0)';
dd.style.opacity = '1';
});
});
});

通常情况下 rAF 在渲染前处理,至少「规范」是这样规定的。但就是有些浏览器将其放在渲染之后,这样一来样式的修改只有等到下一次渲染时才生效。不过晚一帧也无伤大雅,用户基本感受不到。

其实,还可以用一点小 hack,用 getComputedStyle 强迫浏览器提前进行样式计算。

document.getElementById('btn-dropdown').addEventListener('click', () => {
const dd = document.getElementById('dropdown-menus');

dd.style.display = 'flex';

getComputedStyle(dd).display;

dd.style.transform = 'translateY(0)';
dd.style.opacity = '1';
});

但是这样做要小心,原本在一帧内浏览器只进行一次样式计算,现在你强迫它计算多次,可能会带来某些问题。

还有一些奇怪的实现,由于抽屉菜单在页面顶部,我们可以设置 translateY(calc(-100% - 30px)) 将整个抽屉菜单收至屏幕上方不可见区域。我最初遇到问题就采取这样一种折中方案,这些就不再细提了。

本文配图、理论基础大多从 Jake Archibald: 在循环 – JSConf.Asia 收获,有条件的同学可以看看这个演讲。再结合我自己的一次实操,也应该对 Event Loop 有一定了解了。

本文作者:ChrAlpha

本文链接: https://blog.ichr.me/post/from-fade-to-event-loop/

文章默认使用 CC BY-NC-SA 4.0 协议进行许可,使用时请注意遵守协议。

笔记本

评论

您所在的地区可能无法访问 Disqus 评论系统,请切换网络环境再尝试。