前提
作为前端程序员我们肯定听过下面一段关于javascript语言的描述:
接下来解释下这句话的意思。
单线程
单线程:js执行环境中负责执行代码的线程只有一个,可叫该线程为主线程。
浏览器采用单线程的原因:与早期设计初衷有关,早期javascript语言就是运行在浏览器上的脚步语言。他的目的是页面的动态交互,实现页面交互核心就是dom的操作。 这就决定了它必须使用单线程操作,否则就会出现复杂的多线程问题。试想一下, 如果js是多线程工作,一个线程修改了其中一个dom元素,另外一个线程删除了该dom元素,浏览器就不知道以谁的为准。
缺点:遇到一个耗时的任务,必须等待耗时操作完成。导致整个程序的执行。
console.log('start....')
console.time('计时')
for(let i = 0; i < 2000000000; i++) {
}
console.timeEnd('计时')
console.log('end')
我们运行上面一段代码发现:
- end的打印必须等待循环执行完成以后才结束
- 执行循环的过程中,浏览器不能进行任何操作
同时html5 提出Web Worker,允许javascript创建多个子线程,但是子线程完全受主线线程控制,且不能操作dom。所以子线程可以做一些耗时的计算,如下例子🌰:
// 卡死页面
console.log('script end')
let count = 0
setTimeout(() => {
for(let i = 0; i < 2000000000; i++) {
count += 1
}
console.log(count)
}, 1000)
console.log(count)
// 不卡死页面
function getdata() {
console.log('this', this)
let count = 0
for(let i = 0; i < 2000000000; i++) {
count += 1
}
this.postMessage(count)
}
function createWorker(f) {
var blob = new Blob(['(' + f.toString() + ')()'])
var url = window.URL.createObjectURL(blob)
var worker = new Worker(url)
return worker
}
const pollingWorker = createWorker(getdata)
pollingWorker.onmessage = function(e) {
console.log('e', e)
}
大多数人可能有一个误区:有了异步以后,页面就不会卡顿了,我们只要把卡顿的任务放到异步就不会卡顿了。真的是这样吗?
答案是否定的,为什么我们要引入子线程呢?因为异步的任务最终都会要js引擎去调用。耗时的操作异步以后一样会卡住页面。
当我执行这段代码们发现,页面一样卡顿得白屏。过来1000ms以后打印出count。
当我们写代码有那种耗时操作的时候可以使用子线程去计算,不用导致页面卡顿。
Web Worker 参考这篇文章:
浏览器多线程
说一下一个误区,本人工作3/4年一直存在这样的误会:认为浏览器运行就是一个单线程。这是不对的。
我们所说的js是单线程只是浏览器的一个线程---JS引擎线程。其实,目前浏览器大多是多线程,就算单线程估计早就淘汰没人用了吧。
一个浏览器通常有以下常驻的线程:
- 渲染引擎:负责页面的渲染
- JS引擎线程: 负责JS的解析和执行
- 定时器触发器线程: 处理定时器事件,比如,setTimeout setInterval
- 事件触发线程:处理DOM事件
- 异步http线程:处理http请求
上面测试单线程的时候,js在循环大量的循环代码并且浏览器是不能进行任何操作的原因是因为,渲染线程和js引擎线程是互斥的。
所以,虽然运行js代码的线程虽然只有一个,但是浏览器提供了其他的线程。一些IO操作,定时器任务,点击事件,画面渲染都是由浏览器提供的线程完成。
正是由于这些线程,配合咋们的事件队列,完成了javascript的异步机制。具体的工作机制我们留到事件队列讲解。
同步/异步
同步:所谓同步不是只代码同时运行,而是代码必须一行接着一行运行。上一行的代码运行得到预期结果,才进行下一行。
debugger
console.log('script start')
function bar () {
debugger
console.log('bar task')
}
function foo() {
debugger
console.log('foo task')
bar()
}
foo()
console.log('script end')
大家可以debugger看下浏览器的call stack 函数按照执行顺序进栈,执行完成以后出栈。
异步:异步就是程序调用以后不会马上得到结果,也不会阻塞后面代码的执行。调用者不必主动查询结果,当异步任务🈶️了结果,会主动通知到调用者。
异步更多是配合其他线程和事件队列。让我们进入事件循环吧。
一,事件循环 even-loop/消息队列
看下面这张图来讲解一下事件循环。
图中主线程就是我们的js引擎主要执行代码中同步的代码,包括变量/函数的声明/赋值等等。
当我们主线程代码触发了其他线程的任务时候会发起异步任务,比如当我们发起一个AJAX线程,或者触发了一个定时器任务,再或者改变页面布局/触发点击操作等等
当主线程发起了一个异步任务以后接着往下执行其他代码,同时AJAX线程(异步线程)执行异步任务,并且监听该任务是否完成,如果异步任务得到结果(比如后台返回了结果,定时器倒计时结束),此时异步线程会把对应的callback和结果一起存放在消息队列中,等到执行。
消息队列和主线程是怎么工作的呢?这里留一个问题大家去调研:是主线程空闲以后去调用消息队列的的任务还是消息队列监听主线程执行代码情况,若为空闲则推入主线程执行呢? 不管如何最后结论是消息队列的一系列消息会到主线程中执行。
既然到主线程执行,上面所说的,只要是耗时操作,放到异步队列一样解决不了页面卡顿的问题。
定时器:
函数 setTimeout
接受两个参数:待加入队列的消息和一个时间值(可选,默认为 0)。这个时间值代表了消息被实际加入到队列的最小延迟时间。如果队列中没有其它消息并且栈为空,在这段延迟时间过去之后,消息会被马上处理。但是,如果有其它消息,setTimeout
消息必须等待其它消息处理完。因此第二个参数仅仅表示最少延迟时间,而非确切的等待时间。
三,宏任务/微任务
上面讲到事件循环,同时我们作为前端开发人员也知道关于宏任务/微任务。是的,在消息队列中,不只有一条任务队列,而是两条任务队列。其中微任务队列是vip队列,微任务队列的优先级更高。
看下主线程是如何消费这2条队列的。
执行顺序:
- 主线程获取任务,如果没有进入等待
- 当执行完一个宏任务以后,会执行所有的微任务
- 然后获取下一个微任务
while (获取任务()) {
执行任务()
微任务队列.forEach(微任务 => 执行微任务())
}
那么考点来了,哪些属于宏任务,哪些属于微任务呢? 宏任务/微任务是消息队列的两种队列类型,其中微任务是后面为了区分一些优先级更高的任务而产生。主线程每次在执行宏任务以前都会清空微任务队列。
- 宏任务:正常的异步请求,定时器,io任务,requestAnimationFrame
- 微任务:promise,async/await,process.nextTick,queueMicrotask, MutationObserver
四,面试题
function app() {
setTimeout(() => {
console.log("1-1");
Promise.resolve().then(() => {
console.log("2-1");
});
});
console.log("1-2");
Promise.resolve().then(() => {
console.log("1-3");
setTimeout(() => {
console.log("3-1");
});
});
}
app();
// 1-2
// 1-3
// 1-1
// 2-1
// 3-1
解析:
- 先同步任务,打印1-2
- 第一层的微任务,打印1-3
- 第一层的宏任务,打印1-1
- 第二层的微任务 2-1
- 第二层的宏任务 3-1
如果你觉得此文对你有一丁点帮助,点个赞,鼓励一下😂。