前言
在Web开发中,我们经常听到关于JavaScript是单线程的说法。但是,这种单线程的执行模型又是如何在浏览器中工作的呢?本文将深入探讨JavaScript的进程、线程以及Event-Loop,以帮助我们更好地理解和利用JavaScript的异步编程特性。
进程与线程
进程是指CPU在运行指令和保存上下文所需的时间,是程序的一次执行过程。而线程是进程中更小的单位,指的是一段指令执行所需的时间。在浏览器中,一个Tab页可以看作一个进程,而渲染线程、HTTP请求线程和JS引擎线程则是协同工作的线程。
// 代码示例 - 创建一个新的进程(Tab页)
// 由于JavaScript无法直接操作进程和线程,这里用伪代码表示
const newTab = createNewTab();
newTab.createThread("渲染线程");
newTab.createThread("HTTP请求线程");
newTab.createThread("JS引擎线程");
JavaScript的单线程特性
JavaScript是单线程的,这意味着它一次只能执行一个任务。这种设计带来了一些优点,比如节约内存和减少上下文切换的时间。
// 代码示例 - 单线程执行
function task1() {
console.log("Task 1");
}
function task2() {
console.log("Task 2");
}
task1();
task2(); // 只有当task1执行完毕后,才会执行task2
异步任务
异步任务分为宏任务和微任务,通过事件循环机制来执行。
- 宏任务(macrotask):script,setTimeout,setInterval,setImmediate, I/O,UI-rendering
- 微任务(microtask):promise.then(), MutationObserver, process.nextTick()
Event-Loop的运行机制
Event-Loop是JavaScript异步执行的基础,其运行机制可以简述为:
- 执行同步代码,属于宏任务。
- 当执行栈为空时,查询是否有异步任务需要执行。
- 执行微任务队列中的任务。
- 如果需要,渲染页面。
- 执行宏任务队列中的任务,开始下一轮Event-Loop。
只需要按照这几个步骤来解题,你就会解决基本上所有的这种题目。
接下来我给大家讲几个面试中常考的题目来理解一下这几个步骤:
console.log(1);
new Promise((resolve, reject) => {
console.log(2);
resolve()
})
.then(() => {
console.log(3);
})
.then(() => {
console.log(4);
})
setTimeout(function () {
console.log(5);
})
console.log(6);
输出:
这题比较简单,我来给大家分析梳理一下:
1.第一行代码,输出1
2.第二行代码,调用promise函数,注意这里是调用promise,不是上面微任务说的promise.then(),因此是一个同步代码,输出2.
3.第六行代码,这里才是异步代码里面的微任务,加入微任务队列先挂起(then1)
4.第九行代码也是异步代码里的微任务,因此加入微任务队列,挂起(then2)排在then1后面
5.第十二行代码是异步任务里的宏任务,加入宏任务队列挂起(set)
6.第十五行代码为同步任务,输出6.
到这里,第一次事件循环机制里的同步任务就全部执行完毕了,开始寻找是否有异步代码需要执行。
7.如果有的话就先执行微任务,因此then1出队列,输出3,接着then2出队列,输出4.
8.微任务执行完毕后,如果有需要的话就会渲染页面,也就是html页面的加载,这里没有。因此执行异步中的宏任务,也是第二次事件循环机制的开始,set出队列,输出5.
9.因此最终结果输出126345.
例二:
console.log(1);
new Promise((resolve, reject) => {
console.log(2);
resolve()
})
.then(() => {
console.log(3);
setTimeout(() => {
console.log(4);
}, 0)
})
setTimeout(() => {
console.log(5);
setTimeout(() => {
console.log(6);
}, 0)
}, 0)
console.log(7);
咱们再来分析一下这题:
1.首先第一步第一行执行同步代码输出1毋庸置疑。
2.第二行同样是调用promise函数,是一个同步代码,输出2
3.第四行调用resolve(), . then()就能执行了,是异步代码里的微任务,因此整体整个then()先加入微任务队列(输出3挂起)
4.到第十二行,是一个定时器,属于异步代码里的宏任务,先加入红队伍队列set1
5.到达18行,输出7
至此,第一次事件循环的第一步同步代码就执行结束了,接下来执行微任务
6.执行微任务,then1出队列,但是then1中也有同步和异步,不管,先执行同步代码,输出3,发现有个定时器,于是将set2加入宏任务队列,排在set1也就是第十二行的set后面。微任务就执行1结束了。
7.接下来开始执行宏任务,set1出队列,宏任务开启一次新的事件循环,同步任务先执行,输出5,然后第二次事件循环发现了一个定时器宏任务,set3把它加入宏任务队列,排在set2也就是第八行的set后面。第二次事件循环的同步结束,然后去找微任务队列,发现微任务队列是空的,没有微任务要执行,接着去宏任务队列找宏任务,开启第三次事件循环,set2出队列,因此输出4,这也意味着第二次事件循环宏任务结束,第二次事件循环结束,输出4即是第二次时间循环的结束也是第三次事件循环的开始,紧接重复刚刚的步骤去微任务队列找,发现没有,然后去宏任务队列找,set3出队列,输出6。
因此最终输出的结果为1273546.你做对了嘛?
例三:
console.log('script start');
async function async1() {
await async2()
console.log('async1 end');
}
async function async2() {
console.log('async2 end');
}
async1()
setTimeout(function () {
console.log('setTimeout');
}, 0)
new Promise(function (resolve, reject) {
console.log('promise');
resolve()
})
.then(() => {
console.log('then1');
})
.then(() => {
console.log('then2');
})
console.log('script end');
输出结果:
咱们来分析一下这题:
1.第一行执行同步代码,输出script start
2.第三行和第七行都是函数的声明并没有调用,先不管,到第十行,async1的调用,但是由于async2前有个await 需要等到async2执行结束才会运行后面的代码,因此先调用async2,async2里面是同步代码,直接输出async2 end,接着轮到第五行代码执行,但是由于await会将这行打印放到了微任务队列,因此async1 end加入微任务队列。
这里有个重要的点,就是await会将后续代码阻塞进微任务队列,这也是为什么await后面的函数会先执行的原因,你可以理解为它就是一个强盗,很霸道,不仅我要先执行,我还要把我后面的代码全部放进微任务队列里。
这里再跟大家说一下这个async,为什么要加这个东西呢?其实加入这个就是相当于在函数里里面返回了一个promise函数,还记得promise函数的作用,
- 通过Promise来处理异步操作,实现将多个异步操作按照顺序依次执行;
- 通过每个Promise的
.then或.catch方法可以返回一个新的Promise来实现链式调用;
也就是不受定时器等耗时代码的影响,所以这里的async你可以理解为一个封装,官方打造出来的一个让你可以直接用,不用在写promise了,更加方便。好啦这里插入了一点小插曲,回到咱们刚刚的步骤,
3.第十一行是一个定时器,异步任务中的宏任务,放进宏队伍队列set
4.第十四行,是一个同步任务,输出promise.
5.第十八行,是一个微任务,放进微任务队列then1
6.第二十一行,也是一个微任务,放进微任务队列里then2,排在async1 end 和then1的后面。
7.第二十四行,同步任务,输出script end
8.到这里所有同步代码都执行完毕了,开始执行微任务,async1 end ,then1,then2,依次出队列,输出async1 end,then1,then2.
9.微任务全部执行完毕,执行宏任务,set出队列,打印setTimeout.
结语
深入理解JavaScript的进程、线程与Event-Loop是提高开发效率和代码性能的关键。通过灵活应用异步编程、任务队列和Event-Loop的原理,我们可以更好地处理复杂的业务逻辑和提升用户体验。在实际开发中,合理利用JavaScript的单线程特性将成为我们编写高效、流畅代码的利器。