JavaScript运行机制
JavaScript 是单线程的,当有异步任务时候运行结果会是什么样的?理解 JS 的运行机制很重要,在大型项目中应用需要理解透。
先看这段代码会输出什么结果:
console.log("script start");
setTimeout(function () {
console.log("setTimeout");
}, 0);
new Promise(function (resolve) {
console.log("promise1");
resolve();
}).then(function () {
console.log("promise2");
});
console.log("script end");
2
3
4
5
6
7
8
9
10
11
12
13
执行结果为:
script start
promise1
script end
promise2
setTimeout
2
3
4
5
原因: 首先,new Promise 是同步任务,会被放到主进程中去立即执行(当作立即执行函数去理解);而 then()函数是异步任务,会放到异步队列中去,当这个 promise 状态结束(执行 reject 或 resolve)的时候,就会立即放到异步队列中去。
上述代码包括 promise 和 setTimeout 的执行顺序,如果加上 async/await 呢,看下面代码:
async function async1() {
console.log("async1 start");
await async2();
console.log("async1 end");
}
async function async2() {
console.log("async2");
}
console.log("script start");
setTimeout(function () {
console.log("setTimeout");
}, 0);
async1();
new Promise(function (resolve) {
console.log("promise1");
resolve();
}).then(function () {
console.log("promise2");
});
console.log("script end");
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
先看结果:
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
2
3
4
5
6
7
8
JS 执行机制:
- 从上到下执行所有同步代码;
- 在执行过程中,遇到宏任务就放到宏任务队列中;遇到微任务就放到微任务队列中;
- 当所有同步代码执行完毕之后,就执行微任务队列中满足需求的所有回调;
- 当微任务队列所有满足需求回调执行完毕之后,就执行宏任务队列中满足需求的所有回调。
在上面例子中:
- new Promise 是同步任务;
- Promise.then() 是微任务,此外 process.nextTick 是 node 中的微任务;
- 整体代码 script、setTimeout、setInterval 是宏任务;
- async 关键字函数会返回一个 promise 对象,如果里面没有 await,执行起来等同于普通函数;
- 如果有 await 关键字(await 要在 async 关键字函数内部),await 就会让出线程,阻塞 async 内后续代码,先去执行 async 外的代码,等外面同步代码执行完毕,才会执行后续的代码。
- await 如果等的是 promise 对象,则会阻塞后面的代码,等 promise 对象 resolve,然后得到 resolve 的值,作为 await 表达式的运算结果。
# JavaScript 单线程语言
JavaScript 是单线程语言,单线程意味着,所以的任务都需要排队,前一个任务结束才会执行下一个任务,如果前面任务耗费的时间很长,那后面的任务就得一直等着,这样的话会造成资源分配的浪费。所以为了规避这个问题,JavaScript 把所有任务分为两种:同步任务和异步任务。
- 同步任务:在主线程上排队的任务,前一个任务执行完毕,才能执行下一个任务;
- 异步任务:不进入主线程,是进入任务队列的任务,只有任务队列通知主线程,某个异步任务可以执行了,任务才会进入主线程执行。
# 同步与异步
异步:
- 计时器
- ajax
- 读取文件
同步程序执行完成后,执行异步程序;
console.log(1);
setTimeout(() => {
console.log(2);
}, 1000);
setTimeout(() => {
console.log(3);
}, 100);
setTimeout(() => {
console.log(4);
}, 1);
console.log(5); // 1 5 4 3 2
2
3
4
5
6
7
8
9
10
11
# 单线程
以下列代码举例子:
for (let i = 0; i < 2000; i++) {
console.log(1);
}
setTimeout(() => {
console.log(2);
}, 1000);
setTimeout(() => {
console.log(3);
}, 100);
setTimeout(() => {
console.log(4);
}, 1);
console.log(5);
2
3
4
5
6
7
8
9
10
11
12
13
上述代码会先输出 2000 个 1,再输出 5 4 3 2,则计时器不准了,说明了 JS 是单线程的,一个任务完成后才能执行另一个任务;
# process.nextTick 和 setImmediate
nextTick 方法会在同步代码执行之后,异步执行之前执行。
setImmmediate 方法在异步代码执行之后执行。
# JavaScript 的事件循环(eventloop)
先执行同步操作,异步操作排在事件队列里。
- 先判断是同步还是异步任务,同步任务进入主线程,异步任务就进入 event table;
- 异步任务在 event table 中注册事件,当满足触发条件的时候,会被推入到 event queue;
- 同步任务进入到主线程中执行,当主线程空闲的时候,才会去 event queue 中看是否有需要执行的异步任务,如果有,就推入主线程执行。
# 宏任务与微任务
同步任务:new Promise 内为同步任务。 异步任务分为:宏任务和微任务。 宏任务包括:setTimeout、setInterval、整体代码 script 微任务包括:Promise.then()
执行顺序:先执行微任务然后执行宏任务。
# JS 执行顺序总结
- 同步先执行,异步后执行;
- 遇到 new Promise 直接执行,then 中的方法直接放入微任务队列;
- 遇到 setTimeout 放入宏任务队列中;
- 执行顺序:同步任务 -- 微任务(promise.then) -- 宏任务(setTimeout)
参考链接:
关于 async/await、promise 和 setTimeout 的执行顺序
# 扩展-浏览器的进程
JS 执行的是单线程的,而 JS 执行是在浏览器中,占用的是浏览器中的一个线程。浏览器不仅是多线程的,而且还是多进程的。
JS 执行的在浏览器的渲染进程中的 JS 引擎线程完成的。渲染进程也不止一个,每个选项卡都有自己的渲染进程,这样单页页面崩溃不会导致浏览器崩溃。
渲染进程是前端主要用到的,其他包括以下进程:
# GUI 线程
GUI 线程负责渲染页面的,用来解析 HTML 和 CSS,然后将他们构建 DOM 树、CSSOM 树,还有渲染树,都是 GUI 线程负责的。
# JS 引擎线程
JS 引擎线程就是负责执行 JS 的主线程,JS 的单线程指的就是这个线程,也就是我们所说的 Chrome V8 引擎是在这个线程运行的。
JS 引擎线程和 GUI 线程是互斥。因为 JS 是可以操作 DOM 的,如果 JS 线程和 GUI 线程同时操作 DOM,就比较混乱,不知道到底渲染哪个结果,所以说如果 JS 长时间运行,GUI 线程就会被阻塞,整个页面感觉像卡死了。
# 定时器线程
JS 定时器方法有 setTimeout、setInterval,它们都运行在定时器线程,跟 JS 主线程根本不在一个地方,所以 JS 作为单线程语言,能够实现异步。
# 事件触发线程
定时器线程其实只是一个计时的作用,他并不会真正执行时间到了的回调,真正执行这个回调的还是 JS 主线程。
当时间到了定时器会将这个回调事件给到事件触发线程,然后事件触发线程将它加到事件队列里面去,最终 JS 主线程从事件队列取出这个回调执行。
事件触发线程不仅会将定时器事件放入任务队列,其他满足条件的事件也是他负责放进任务队列。
# 异步 HTTP 请求线程
这个线程负责处理异步的 ajax 请求,当请求完成后,也会通知事件触发线程,然后事件触发线程将这个事件放入事件队列给主线程执行。
总之,JS 异步的实现实际是靠的是浏览器的多线程,当他遇到异步 API 时候,就将这个任务交给对应的线程;当这个异步 API 满足回调条件时,对应的线程又通过事件触发线程将这个事件放入任务队列,然后主线程从任务队列取出事件继续执行。这里说的任务队列就是 Event Loop,目前 JS 的运行环境有两个:浏览器和 Node.js,两者 event loop 有不同的地方。
# Event Loop
# 浏览器的 Event Loop
事件循环就是一个循环,是各个异步线程用来通讯和协同执行的机制。各个线程为了交换消息,还有一个公用的数据区,就是事件队列。各个异步线程执行完了之后,通过事件触发线程就回调事件放到事件队列,主线程每次干完手上的活儿就来看这个队列有没有新活,有的话就取出来执行。
流程如下:
- 主线程每次执行的时候,先看看要执行的是同步任务,还是异步的 API;
- 同步任务就继续执行,一直执行完;
- 遇到异步 API 就将他交给对应的异步线程,自己继续执行同步任务;
- 异步线程执行异步 API,执行完后,将异步回调时事件通过事件触发线程放入事件队列中;
- 主线程手上的同步任务完成后就来看事件队列里面有没有任务;
- 主线程发现事件队列有任务,就取出里面的任务执行;
- 主线程不断循环上述流程。
# 定时器不准
因为定时器是到了时间会经过事件触发线程放入事件队列中,并且等到主线程执行完之后再来执行,如果主线程被阻塞时间超过定时器时间的话,定时器就不准了。因此要主要不要过长时间占用主线程。
# 微任务
事件队列里面的事件分为两类:宏任务和微任务。其中微任务拥有更高的优先级,当事件循环遍历队列时,会先检查微任务队列,如果有任务,就全部拿来执行,执行完毕之后再执行一个宏任务。执行每个宏任务之前都要检查下微任务队列是否有任务,如果有,优先执行微任务队列。
常见的宏任务和微任务有:
# 宏任务
# | 浏览器 | Node |
---|---|---|
I/O | ✅ | ✅ |
setTimeout | ✅ | ✅ |
setInterval | ✅ | ✅ |
setImmediate | ❌ | ✅ |
requestAnimationFrame | ✅ | ❌ |
# 微任务
# | 浏览器 | Node |
---|---|---|
Promise.prototype.then catch finally | ✅ | ✅ |
process.nextTick | ❌ | ✅ |
MutationObserver | ✅ | ❌ |
# 补充
# 重绘重排
浏览器的渲染进程中,主线程是用来进行 DOM 解析渲染和 JS 解析运行的,还有合成器线程,不是在主线程中的,将渲染的结果通过 IPC 传递给浏览器进程,然后传给 GPU 进行绘制。
如果进行大量的重绘重排的话,会影响性能。
此外如何避免动画卡顿,人眼在 60Hz 刷新率的情况下看不到卡顿,但是如果因为 JS 执行在主线程上的,如果 JS 阻塞了话,会影响合成器线程渲染,导致页面看上去卡顿:
- 可以通过 requestAnimationFrame 来将 JS 切割在每帧空余的时间运行,避免影响渲染;
- 通过 tranform 来实现动画,因为 tranform 动画不会占用主线程的绘制计算,而是在合成器线程进行的。
关于重绘和重排,可以看之前总结过的文章:浏览器渲染过程 (opens new window)
# 进程与线程
进程是程序的一次执行过程,是系统运行程序的基本单位,所以说进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。
比如说,浏览器就是一个应用,每个 tab 栏创建都是多个进程共同运行的结果,前端我们主要关注的是渲染进程,其中又包括多个线程。
线程又是什么呢?线程与进程类似,但是线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是,同类的多个线程共享进程的堆和方法区资源。同一进程中的线程极有可能会相互影响。
比如说,在 JS 执行环境中,任务队列的事件的受到事件触发线程的影响,当满足条件时,将事件推入任务队列当中。
较为官方的术语是:
- 进程是 CPU 资源分配的最小单位(是能拥有资源和独立运行的最小单位)
- 线程是 CPU 调度的最小单位(线程是建立在进程的基础上的一次程序运行单位,一个进程中可以有多个线程)