【JavaScript】事件循环

进程与线程

进程

进程是操作系统进行任务调度和资源分配的基本单位。

  • 进程是操作系统对程序的一种抽象,它包含了程序执行所需的资源和状态信息。
  • 操作系统可以同时运行多个进程,每个进程都是相互独立的执行环境。
  • 进程之间通过进程间通信(IPC,Interprocess Communication)机制来进行交互和数据传输。

线程

线程是进程内的执行单元,是操作系统调度的最小执行单位。

  • 一个进程至少有一个线程。在进程开启后会自动创建一个线程来运行代码,该线程称之为主线程。
  • 一个进程可以包含多个线程,这些线程共享进程的系统资源,可以并发执行不同的任务。
  • 线程之间的切换更加高效,因为它们共享相同的上下文。



浏览器中的进程与线程

浏览器是一个多进程多线程的应用程序。

浏览器内部的工作极其复杂,为了减少连环崩溃的几率,浏览器会启动多个进程,去完成不同的功能。


可以在浏览器的任务管理器中查看当前的所有进程,可以看见有:浏览器进程、网络进程、渲染进程…

  1. 浏览器进程:浏览器的主进程,负责管理和协调其他进程的工作。
  2. 网络进程:负责处理网络请求和响应。
  3. 渲染进程:负责处理并呈现网页内容。



渲染进程

渲染进程是一个多线程的环境,其中包括主线程和其他辅助线程。主线程负责处理文档解析、样式计算、布局和绘制等任务。而其他线程,如网络线程、定时器线程、事件线程等则负责处理网络请求、定时器操作、事件处理等功能,以提高浏览器的并发处理能力。

默认情况下,浏览器会为每个标签页开启一个新的渲染进程,以保证标签页之间不会相互影响。



渲染主线程

渲染主线程有很多工作需要完成:

  1. 解析 HTML,构建 DOM(文档对象模型)树。
  2. 解析 CSS,构建 CSSOM(CSS 对象模型)树
  3. 合并 DOM 树和 CSSOM 树,构建 Render 树(渲染树)。
  4. 布局(Layout):计算每个元素在页面中的位置和尺寸。
  5. 绘制(Painting):将页面元素转换为实际的像素信息。
  6. 合成(Compositing):将绘制的图像元素合成为最终的页面图像,并将其提交给合成线程进行显示。
  7. 每秒把页面画 60 次
  8. 执行全局 JS 代码
  9. 执行事件处理函数
  10. 执行计时器的回调函数

渲染主线程通过排队的方式一个个去完成这些工作:

  1. 最开始时,渲染主线程会进入一个无限循环。
  2. 每一次循环都会检查消息队列中是否有任务存在。如果有,就取出第一个任务执行,执行完再进入下一次循环;如果没有,则进入休眠状态。
  3. 其他所有线程(包括其他进程的线程)随时可以向消息队列添加任务。新任务会加到消息队列的末尾。添加新任务时,如果渲染主线程处于休眠状态,则会将其唤醒以继续循环拿取任务。

这一整个过程,就是事件循环



线程阻塞

每个页面只有一个渲染主线程,而渲染主线程又承担着诸多工作。如果执行大量的 JavaScript 计算或其他耗时操作,可能会阻塞渲染主线程,导致页面的渲染和响应变慢。

name: <span>superman</span>
<button>change</button>

<script>
    const span = document.querySelector('span');
    const btn = document.querySelector('button');

    // 死循环指定的时间
    function delay(duration) {
        const start = Date.now();
        while (Date.now() - start < duration) {}
    }

    btn.onclick = function () {
        span.textContent = 'monster';
        delay(2000);
    };
</script>

上例中,点击 “change” 可以看见,页面卡顿了 2s 后 “superman” 才变成 “monster”。这是因为 “delay” 函数耗时太久了,导致线程阻塞,页面无法重新渲染了。



任务队列

根据 W3C 的最新解释:

  • 每个任务都有一个任务类型,同一个类型的任务必须在一个队列,不同类型的任务可以分属于不同的队列。
    在一次事件循环中,浏览器可以根据实际情况从不同的队列中取出任务执行。
  • 浏览器必须准备好一个微任务队列,微任务队列中的任务优先级最高。

在目前 chrome 的实现中,至少包含了下面的队列:

  • 延时队列:用于存放计时器到达后的回调任务,优先级「中」。
  • 交互队列:用于存放用户操作后产生的事件处理任务,优先级「高」。
  • 微任务队列:用户存放需要最快执行的任务,优先级「最高」。



同步任务 & 异步任务

  • 同步任务:即 “非耗时程序”,都是在渲染主线程上排队执行的任务。
  • 异步任务:即 “耗时程序” (事件Ajax定时器等),由 JS 委托宿主环境 (Node / 浏览器) 执行。

常见的微任务:① process.nextTick(node.js)、② MutationObserver(浏览器)、③ Promise实例.then catch finallyasync-await

其他异步任务:① 事件、② 定时器、③ Ajax、④ I/O 操作、⑤ requestAnimationFrame(浏览器)


根据 JS 单线程异步模型的规则,异步操作得通过 [回调函数] 实现。
∴ 异步任务一定有回调函数!但请注意,有回调函数的不一定是异步任务!

setTimeout(function () {
    console.log('异步任务');
}, 0);

[1].forEach(item => {
    console.log('同步任务');
});

在主线程中,JS 代码会被从上往下地一行行执行;遇到异步任务,则委托给宿主环境开辟新线程执行;
异步任务执行完后,会将其 [回调函数] 放入任务队列 Task Queue 等待;
等主线程将同步任务都执行完,就从 Task Queue 中获取最先入队的 [回调函数] 执行。

setTimeout(() => {
    console.log('异步任务');
}, 0);
for (let i = 0; i < 5000; i++) {
    console.log('一个耗时很长的同步任务');
}
// for 循环执行完后,才会执行 setTimeout 里面的代码

在异步操作完成时间不同的情况下,先完成的异步操作会先进入 Task Queue:
下例输出顺序:sync - setTimeout 2 - setTimeout 1

setTimeout(function () {
    console.log('setTimeout 1'); // 这个耗时 2s
}, 2000);

setTimeout(function () {
    console.log('setTimeout 2'); // 这个耗时 1s
}, 1000);

console.log('sync');

在异步操作完成时间相同的情况下,先注册的异步操作会先进入 Task Queue:
下例输出顺序:sync - setTimeout 1 - setTimeout 2

setTimeout(function () {
    console.log('setTimeout 1'); // 前面的代码先注册
}, 2000);

setTimeout(function () {
    console.log('setTimeout 2'); // 后面的代码后注册
}, 2000);

console.log('sync');

任务队列前面的异步操作被阻塞时,后面的异步操作也会被阻塞:
下例的输出顺序:sync - setTimeout 1 start - setTimeout 1 end - setTimeout 2

setTimeout(function () {
    // 先进入执行栈
    console.log('setTimeout 1 start');
    // 被阻塞了
    for (let i = 0; i < 5000; i++) {
        console.log('一个耗时很长的同步任务');
    }
    console.log('setTimeout 1 end');
}, 1000);

// 后面的异步操作也会被阻塞
setTimeout(function () {
    console.log('setTimeout 2');
}, 1000);
console.log('sync');



面试题

如何理解 JS 的异步?

JS 是一门单线程的语言,这是因为它运行在浏览器的渲染主线程中,而渲染主线程只有一个。

渲染主线程承担着诸多的工作,渲染页面、执行 JS 都在其中运行。

如果使用同步的方式,就极有可能导致主线程产生阻塞,从而导致消息队列中的很多其他任务无法得到执行。所以浏览器采用异步的方式来避免。

具体做法是当某些任务发生时,比如计时器、网络、事件监听,主线程将任务交给其他线程去处理,自身立即结束任务的执行,转而执行后续代码。当其他线程完成时,将事先传递的回调函数包装成任务,加入到消息队列的末尾排队,等待主线程调度执行。

这种异步模式能最大限度的保证单线程的流畅运行。


阐述一下 JS 的事件循环

事件循环又叫做消息循环,是浏览器渲染主线程的工作方式。

在 Chrome 的源码中,它开启一个不会结束的 for 循环,每次循环从消息队列中取出第一个任务执行,而其他线程只需要在合适的时候将任务加入到队列末尾即可。

过去把消息队列简单分为宏队列和微队列,这种说法目前已无法满足复杂的浏览器环境,取而代之的是一种更加灵活多变的处理方式。

根据 W3C 官方的解释,每个任务有不同的类型,同类型的任务必须在同一个队列,不同的任务可以属于不同的队列。不同任务队列有不同的优先级,在一次事件循环中,由浏览器自行决定取哪一个队列的任务。但浏览器必须有一个微队列,微队列的任务一定具有最高的优先级,必须优先调度执行。


JS 中的计时器能做到精确计时吗?为什么?

不行,因为:

  1. 操作系统的计时函数本身就有少量偏差,由于 JS 的计时器最终调用的是操作系统的函数,也就携带了这些偏差。
  2. 按照 W3C 的标准,浏览器实现计时器时,如果嵌套层级 >= 5 层,计时器都会带有 4 毫秒的最少时间。
  3. 受事件循环的影响,计时器的回调函数只能在主线程空闲时运行,因此又带来了偏差。

面试题 1
console.log("A");

setTimeout(() => {
    console.log("B");
    new Promise<void>(resolve => {
        console.log("C");
        resolve();
    }).then(() => {
        console.log("D");
    });
}, 0);

new Promise<void>(resolve => {
    console.log("E");
    resolve();
}).then(() => {
    console.log("F");
    setTimeout(() => {
        console.log("G");
    }, 0);
});

输出顺序:A E F B C D G

- 主线程中有俩同步执行的 log `A` `E`;
- 然后是微任务 then():
    - 其回调函数中有同步执行的 log `F`;
    - 没有微任务;
    - 有 setTimeout,异步执行完后,将其 [回调函数] 放置 Queue 的最后
- 然后是第 1 个 setTimeout:
    - 其回调函数中有同步执行的 log `B` `C`;
    - 然后是微任务;
        - 其回调函数中有同步执行的 log `D`;
        - 没有微任务;
        - 没有其他异步任务;
    - 没有其他异步任务;
- 然后是第 2 个 setTimeout:
    - 其回调函数中有同步执行的 log `G`;
    - 没有微任务;
    - 没有其他异步任务;

面试题 2
document.addEventListener('click', function () {
    Promise.resolve().then(() => console.log(1));
    console.log(2);
});
document.addEventListener('click', function () {
    Promise.resolve().then(() => console.log(3));
    console.log(4);
});

输出顺序:2 1 4 3

解析:① 两个事件的完成时间一样,先注册的先执行,所以会先执行第 1 个事件;
② 在第 1 个事件中,有 then 操作,属于异步操作中的微任务,所以输出顺序是 2 1
③ 在第 2 个事件中,同理可得输出顺序 4 3


面试题 3
const fs = require('fs');
const util = require('util');

const myReadFile = util.promisify(fs.readFile);

console.log('A');
myReadFile('./1.txt')
    .then(_ => console.log('B'))
    .then(_ => console.log('C'));
myReadFile('./2.txt').then(_ => console.log('D'));
setTimeout(() => console.log('E'), 0);
console.log('F');

输出顺序:A F E B C D / A F E D B C

解析:① 先执行同步任务,所以先输出 A F
② [读取操作] & [事件]:[事件] 完成的时间较短,先进入任务队列,所以先输出 E
③ 先完成的 [读取操作] 先进入任务队列;如果先读取完 1.txt 文件,则先执行该任务下的微任务,输出 B C;否则先输出 D


面试题 4
setTimeout(function () {
    console.log('timer 1');
}, 0);
requestAnimationFrame(function () {
    console.log('UI update');
});
setTimeout(function () {
    console.log('timer 2');
}, 0);
new Promise<void>(function executor(resolve) {
    console.log('promise 1');
    resolve();
    console.log('promise 2');
}).then(function () {
    console.log('promise then');
});

执行顺序 1:promise 1 - promise 2 - promise then - timer1 - timer2 - UI update
执行顺序 2:promise 1 - promise 2 - promise then - UI update - timer1 - timer2

解析:① 先执行同步任务:构造函数 Promise 的执行器 executor 是同步执行的
所以前 3 的输出顺序一定是 promise 1 - promise 2
② Promise 实例的 then 操作是异步操作中的微任务,所以后面一定是先打印 promise then
③ 对于两个 setTimeout 异步操作,因为完成的时间一样,所以是先注册的先执行,所以一定是 timer1 - timer2
④ 对于 setTimeout 和 requestAnimationFrame,setTimeout 是人为设置的异步操作,requestAnimationFrame 是浏览器自动执行的异步操作,我们每次执行定时器时,都不知道 requestAnimationFrame 执行到哪里,所以有可能是 requestAnimationFrame 先输出,也有可能是 setTimeout 先输出;
∴ 输出的顺序有如上两种情况!!!

另外,requestAnimationFrame 用于浏览器画面的渲染,是按照浏览器的刷新率来执行的,也就是屏幕刷新⼀次 函数就触发⼀次,每秒约执行 60 次;setTimeout 是定时器,默认每秒约执行 200 次;因为 setTimeout 的执行速度较快,所以它先输出的概率较高!!


面试题 5
setTimeout(() => {
    console.log(0);
}, 0);

new Promise(resolve => {
    console.log(1);
    resolve();
})
    .then(() => {
        console.log(2);
        new Promise(resolve => {
            console.log(3);
            resolve();
        })
            .then(() => {
                console.log(4);
            })
            .then(() => {
                console.log(5);
            });
    })
    .then(() => {
        console.log(6);
    });

new Promise(resolve => {
    console.log(7);
    resolve();
}).then(() => {
    console.log(8);
});
1. 先执行同步代码, 输出 1 7
    注册 setTimeout;  注册第一, 二个微任务
2. 执行第一个微任务, 输出 2 3
    注册第三, 四个微任务
3. 执行第二个微任务, 输出 8
4. 执行第三个微任务, 输出 4
    注册第五个微任务
5. 执行第四个微任务, 输出 6
6. 执行第五个微任务, 输出 5
7. 第一轮微任务执行完毕, 执行 setTimeout, 输出 0

  • 4
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

JS.Huang

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值