回顾事件循环
在上节文章中通过几个小案例加强了对宏任务、微任务的理解,本文也用同样的方法举例加深对事件循环的认识。
从上图可知,在事件循环之初会先判断宏任务队列是否为空,若是空的就开始判断微任务队列是否为空,若不是空的就执行一项宏任务,接着再执行微任务直至微任务队列为空。待微任务队列清空后由浏览器判断是否需要重新渲染页面,若是则开始UI渲染,若不是则再次进入宏任务队列,执行剩余的宏任务。
案例一:DOM 事件在事件循环中的执行顺序
来看代码,这里有两个 div 分别给他们添加点击事件,使他们同步的输出 click
,setTimeout
产生一个宏任务,Promise.resolve().then
产生一个微任务,用 Mutationobserver
观察outerBtn 的属性变化,这里又会产生一个微任务。
html
<body>
<div class="outer-btn">外部按钮<div class="inner-btn">内部按钮</div></div>
<script>
var outerBtn = document.querySelector(".outer-btn");
var innerBtn = document.querySelector(".inner-btn");
new MutationObserver(function() {
console.log("mutate 微任务")
}).observe(outerBtn,{
//观察属性变化
attributes:true,
});
//点击方法
function onClick() {
console.log("click");
setTimeout(function() {
console.log("timeout 宏任务");
});
Promise.resolve().then(function() {
console.log("promise 微任务");
})
outerBtn.setAttribute("data-number",Math.random());
}
outerBtn.addEventListener("click", onClick);
innerBtn.addEventListener("click", onClick);
</script>
</body>
注释第 29 行代码,单个按钮点击后代码运行结果如下
上述完整代码,点击内部按钮所在的div,手动触发click事件后代码运行结果如下
过程分析
- 当点击 div 时,宏任务队列 = onClick(inner) + onClick(outer) 。监听函数addEventListener第三个参数默认 false ,表示在事件冒泡阶段调用 ,所以先被触发的是
innerBtn
的点击事件,onClick(inner) 先进入宏任务队列。 - 开始事件循环,执行第一个 onClick(inner),同步输出
"click"
。
- 此时宏任务队列 = onClick(outer) + setTimeout
- 此时微任务队列 = promise + mutate
- 执行微任务输出
promise 微任务
、mutate 微任务
。 - 开始执行第二个事件循环,执行第二个 onClick(outer),同步输出
"click"
。
- 此时宏任务队列 = setTimeout + setTimeout
- 此时微任务队列 = promise + mutate
- 执行微任务输出
promise 微任务
、mutate 微任务
。 - 执行第三个事件循环,输出
timeout 宏任务
。 - 执行第四个事件循环,输出
timeout 宏任务
,清空宏任务队列。
案例二:js点击事件对Dom事件影响
前面的案例执行顺序大家分析正确🙆了吗?
接下来的案例在之前的基础上再多添加行 js 代码,其他保持不变,看看这次你能拿下吗?
js
<body>
<div class="outer-btn">外部按钮<div class="inner-btn">内部按钮</div></div>
<script>
var outerBtn = document.querySelector(".outer-btn");
var innerBtn = document.querySelector(".inner-btn");
new MutationObserver(function() {
console.log("mutate 微任务")
}).observe(outerBtn,{
//观察属性变化
attributes:true,
});
//点击方法
function onClick(ev) {
console.log("click",ev.currentTarget.className);
setTimeout(function() {
console.log("timeout 宏任务");
});
Promise.resolve().then(function() {
console.log("promise 微任务");
})
outerBtn.setAttribute("data-number",Math.random());
}
outerBtn.addEventListener("click", onClick);
innerBtn.addEventListener("click", onClick);
//修改为JS调用触发
innerBtn.click() ;
</script>
</body>
运行结果如右侧图所示,左侧图是没有JS调用的运行结果
仅仅行JS调用语句,使得两次运行结果以及顺序大不一样,这是怎么回事呢?
过程分析
在具体分析之前,我们要了解到使用js
代码或者 dispatchEvent
触发的click
事件不会进入宏任务队列,会作为同步代码执行。执行的顺序依旧是 onClick(inner) 再到 onClick(outer)。
- 执行 onClick(inner),输出
click inner-btn
。
- 此时宏任务队列 = setTimeout
- 此时微任务队列 = promise + mutate
- 这个时候不能紧接着清空微任务队列,因为还有 onClick(outer) 作为同步任务等待执行,于是输出
click outer-btn
。
(在一个事件循环中,MutationObserver只产生一个微任务回调)
- 此时宏任务队列 = setTimeout + setTimeout
- 此时微任务队列 = promise + mutate + promise
- 清空微任务队列,输出
promise 微任务
、mutate 微任务
、promise 微任务
。
- 此时宏任务队列 = setTimeout + setTimeout
- 进入第二个事件循环,输出
timeout 宏任务
。
- 此时宏任务队列 = setTimeout
- 进入第三个事件循环,输出
timeout 宏任务
,清空宏任务队列。
案例二重点总结
- 使用
js
代码或者dispatchEvent
触发的click
事件等同于执行同步代码; - MutationObserver 在一个事件循环中只会产生一个微任务;
案例三:宏任务和微任务
在第三个例子中主要是想证明两方个知识点:
- 第一,假如主JS引擎线程一直在繁忙的工作(被同步任务阻塞)并不影响点击事件的注册;
- 第二,不管是宏任务还是微任务都会阻塞JS线程;
js
<body>
<div>
<button id="btnStart">触发按钮</button>
</div>
<div style="margin-top: 10px;">
<button class="first-btn">首次点击</button>
<button class="second-btn">二次点击</button>
</div>
<script>
var firstBtn = document.querySelector(".first-btn");
var secondBtn = document.querySelector(".second-btn");
//同步耗时操作
function asyncSleep(duration) {
const now = Date.now();
while(now + duration > Date.now()) {
}
}
firstBtn.onclick = function() {
console.log("firstBtn onClick", new Date().toLocaleTimeString());
//2、假设需要执行3s
console.time("firstBtn:cost");
asyncSleep(3000);
Promise.resolve().then(()=>{
console.log("执行 微任务 promise", new Date().toLocaleTimeString());
//3、假设需要执行2s
console.time("promise:cost");
asyncSleep(2000);
console.timeEnd("promise:cost")
})
console.timeEnd("firstBtn:cost")
}
secondBtn.onclick = function() {
console.log("secondBtn onClick", new Date().toLocaleTimeString());
//4、假设需要执行1
console.time("secondBtn:cost");
asyncSleep(1000);
console.timeEnd( "secondBtn:cost" )
}
btnStart.onclick = function() {
//1、假设需妥执行5s
console.log("main:", new Date().toLocaleTimeString())
console.time("main:cost")
asyncSleep(5000);
console.timeEnd("main:cost")
}
</script>
</body>
运行结果
过程分析
依次快速点击页面中的三个按钮,只有第一个按钮在点击时闪烁了一下,其他两个按钮没有反应,但是这并不影响他们的点击事件注册。
不管是宏任务还是微任务,都会阻塞JS线程按照队列顺序依次执行
案例四:同步变异步
设想这样一个场景,假如在代码中直接批量渲染大量的数据,那么页面在加载中势必会卡顿留白,阻塞js事件的执行,给用户造成不好的使用体验,这样的问题应该怎么优化呢?
先看问题代码:
js
<body>
<button type="button" class="log-btn">操作点击事件</button>
<button type="button" class="start">开始添加dom</button>
<script>
var btnLog = document.querySelector(".log-btn");
var startBtn = document.querySelector(".start");
var array = [];
for(var i = 1; i <= 300000; i++) {
array.push(1); //制造300000条数据
};
console.log("数据制造完成");
//渲染数据
var renderDomList = function (data) {
for(var i = 0, l = data.length; i < l; i++) {
var div = document.createElement('div');
div.innerHTML = `列表${i}`
document.body.appendChild(div);
}
};
startBtn.onclick = function() {
console. log ("startBtn clicked:", new Date().toLocaleTimeString());
renderDomList(array)
}
btnLog.onclick = function() {
console. log ("btnLog clicked:", new Date().toLocaleTimeString());
}
</script>
</body>
btnLog
的回调被 startBtn
的回调阻塞,只有等到页面渲染完毕后才执行。
运行结果
优化方式一:setTimeout
使用时间分片,简单点理解就是把数据拆成很多份,分批地来进行渲染。
js
<body>
<button type="button" class="log-btn">操作点击事件</button>
<button type="button" class="start">开始添加dom</button>
<script>
var btnLog = document.querySelector(".log-btn");
var startBtn = document.querySelector(".start");
var array = [];
var step = 1;
for(var i = 1; i <= 300000; i++) {
array.push(1); //制造300000条数据
};
console.log("数据制造完成");
//渲染数据
var renderDomList = function (data, startIndex, endIndex) {
if(startIndex <endIndex && endIndex <= data.length) {
setTimeout(()=>{
for(let i = startIndex; i < endIndex; i++) {
var div = document.createElement('div');
div.innerHTML = `列表${i}`;
document.body.appendChild(div);
}
let nextIndex = endIndex + step > data.length ? data.length : endIndex + step;
let nextStartIndex = endIndex > data.length ? data.length : endIndex;
renderDomList(data, nextStartIndex, nextIndex);
},0)
}
};
startBtn.onclick = function() {
console. log ("startBtn clicked:", new Date().toLocaleTimeString());
renderDomList(array,0,0 + step)
}
btnLog.onclick = function() {
console. log ("btnLog clicked:", new Date().toLocaleTimeString());
}
</script>
</body>
优化后的代码采用分段式异步渲染,JavaScript 引擎不会阻塞异步任务的执行。
运行结果
此时可以看到,btnLog
按钮和 startBtn
按钮的回调事件执行的时间差主要由用户手动点击的时间差造成。通过把同步代码变为异步代码在一定程度上解决了阻塞问题。
优化方式二:虚拟列表
在首屏加载的时候,只加载可视区域内需要的列表项,当滚动发生时,动态通过计算获得可视区域内的列表项,并将非可视区域内存在的列表项删除。
优化方式三:懒加载
只展示视图上可见的数据,当滚动到页面底部时,加载更多数据,通过监听父级元素的 scroll 事件可实现。
关于后两种优化办法,网上都有很多案例,大家可以自行搜索查阅。
总结
-
浏览器通常每秒渲染60次界面(页面平滑流畅标准),平均16ms一次渲染,由于一次事件循环的执行顺序是:一个宏任务 > 所有附属微任务 > UI渲染,所以我们要保障流畅,就必须单个宏任务和所有 附属微任务执行时间为16ms之内
-
JS调用的.click()事件、.dispathEvent()会导致事件同步调度