事件循环
引言
关于 js 的事件循环和任务队列详细知识在前面的文章已经谈过,本次主要是补充一些新学的知识点关于浏览器的事件循环,由于本次学习的知识点比较零散就按分点来总结。
知识点
setTimeout 在进行一些例如元素移动的时候并不是时间延迟为 0 最好(不用 while 的原因:会阻碍主线程渲染界面,进入一个无法更新界面的状态),因为我们人眼并不会说需要观察那么快,而且浏览器会根据屏幕性能决定最好的刷新时机(就像我们现在在一个代码块连续个一个元素加 style,并不会造成多次更新,而是一次更新完成),所以
4.7
是最合适的目前来看。我们可以把事件循环看成一个帧,所有渲染放在一开始例如样式计算,布局,绘制,然后执行 javascript 的 task。
setTimeout 并不适合用于做动画,因为如果任务执行很慢,会导致帧里面的任务
漂移
,有的帧一次执行多个任务,而有的帧一个都不执行,产生漂移的动画。我们看一下一个代码
jsbutton.addEventListener("click", () => { box.style.transform = "translateX(1000px)"; box.style.transition = "transform 1s ease-in-out"; box.style.transform = "translateX(400px)"; });
我们很明显的可以看出我们的意图是希望一个盒子往右偏移 1000 像素然后再往左偏移 600 像素,然而我们会发现浏览器只会向右偏移 400 像素,这是由于浏览器并不会在意你在 js 写的过程,只会关注最终的结果,就如我们上面所说的写多个 style 并不会多次更新而是一次更新最终结果
所以我们推荐 requestAnimationFrame(在 css 更新前执行会调),但是我们发现将上面的代码改造成:
jsbutton.addEventListener("click", () => { box.style.transform = "translateX(1000px)"; box.style.transition = "transform 1s ease-in-out"; requestAnimationFrame(() => { box.style.transform = "translateX(400px)"; }); });
仍然还是只会向右偏移 400 像素,这是有由于我们会发现代码是事件是这样:点击按钮 => js 事件 => requestAnimationFrame => 更新界面,所以我们需要再套入一层 requestAnimationFrame,这样就能实现我们的效果或者说使用 getComputedStyle 迫使浏览器更早进行样式计算从而更早进行渲染(可能会让你浏览器在一帧呢做很多多余的工作)。
jsbutton.addEventListener("click", () => { box.style.transform = "translateX(1000px)"; box.style.transition = "transform 1s ease-in-out"; // 方案1 getComputedStyle(box).transform; box.style.transform = "translateX(400px)"; // 方案2 requestAnimationFrame(() => requestAnimationFrame(() => { box.style.transform = "translateX(400px)"; }); ); });
但是值得注意的是 edge 和 safari 的实现可能是错的,他们是将 requestAnimationFrame 放在更新界面之后和回调函数之前执行的,所以用户可能提前看到页面变化,意味直到下一帧才能看到页面变化而且会造成延迟,并且很难批量执行更新任务。
微任务例如 promise 等异步任务,是在宏任务之后执行的,但不代表他们会屈服于渲染,例如我们做一个死循环
function loop() { Promise.resolve().then(loop); }
然后调用 loop 函数,页面也会处于一个无法更新的状态宏任务是一次执行一个,Animation callback 和 Micro tasks 队列都是一次性执行完,如果我们像上一点一样微任务添加的速度大于清空的速度,事件循环就会阻塞。
看一个有趣的代码
jsbutton.addEventListener("click", () => { Promise.resolve().then(console.log("Microtask 1")); console.log("Listen 1"); }); button.addEventListener("click", () => { Promise.resolve().then(console.log("Microtask 2")); console.log("Listen 2"); }); // user click the button: Listen 1, Microtask 1, Listen 2, Microtask 2 button.addEventListener("click", () => { Promise.resolve().then(console.log("Microtask 1")); console.log("Listen 1"); }); button.addEventListener("click", () => { Promise.resolve().then(console.log("Microtask 2")); console.log("Listen 2"); }); button.click(); // javascript click the button: Listen 1, Listen 2, Microtask 1, Microtask 2
在一个按钮上绑定两个点击事件,不同情况下输出不一样,这个原因是我们用户点击按钮时,会先调用第一个回调,然后就清空了一个宏任务,然后执行微任务,然后继续下一个宏任务,然后清空,所以会产生这样的结果。而我们如果通过 javascript 点击的时候,button 的 click 函数还没执行完,所以并不算结束了宏任务,我们要继续调用第二个回调,所以就会产生两种不同的输出。简单来说就是处于最外层的宏任务不同,第一种我们的宏任务是两个回调函数,第二种我们的宏任务是
button.click()
。 所以我们模拟的时候可以使用 promise 来模拟链接的下一次点击jsconst nextClick = new Promise((resolve) => { link.addEventListener("click", resolve, { once: true }); }); nextClick.then((event) => { event.preventDefault(); });
最后总结一下单击链接如何工作:
- 对于用户,我们首先创建了一个事件对象,然后调用每一个监视器,传入事件对象,然后我们检查事件对象的 canceled 属性,如果是就不会打开链接,如果没有 cancel,就打开链接。当调用 event.preventDefault()时,事件会被标记为 canceled,如果用户单击一个链接,那么我的微任务就会在每次回调后发生,因为 Java Script 堆栈清空了。
- 但是当我们通过 JavaScript 调用 click 时,它会执行完链接点击的操作,只有在算法完成后才会返回,因此 JavaScript 堆栈永远不会清空,因此在算法完成之前无法调用微任务,即使你有很多 Promise 想要调用
event.preventDefault()
也已经晚了,他会打开超链接然后执行 Promise,记住微任务会因为 JavaScript 堆栈而不同。