浏览器和线程、进程
浏览器打开一个页面就相当于开了一个进程(程序),在程序中,我们会同时做很多事情,每一个事情都有一个"线程"去处理,所以一个进程中可能会包含多个线程。
一个 Tab 对应一个渲染进程,渲染进程是多线程的:
-
GUI 渲染线程:页面渲染、绘图、绘制、3d动画
-
Js 渲染引擎:执行 js 代码,当 js 执行时,渲染线程会挂起,当然渲染中也不能执行js。
-
事件触发线程:也就是Eventloop
-
webapi 线程:事件、定时器、ajax请求都会创造一个线程
-
network 线程:http请求
-
webWorker 线程 等
EventLoop
我们知道,浏览器是多线程的,但是 Js 只拥有 Js 引擎线程,Js 本身是单线程的。
这也导致了一种现象,Js 中大部分操作是同步的,但是有少部分操作,结合 EventLoop 机制,实现了异步处理,所以 Js 是单线程异步操作。
宏任务「macrotask」
- js 整体代码块
- ui 渲染
- 定时器
- ie 中的 setImmediate
- I/O事件
- 数据请求 Ajax/Fetch
- 消息队列
- setImmediate (node 独有)
微任务 「microtask」
- Promise.then/catch/finally
- async/await
- generator
- queueMicrotask (手动创建异步微任务的方法)
- MutationObserver (监听 Dom 变化)
- IntersectionObserver (监听 dom 与浏览器窗口是否交叉)
- process.nextTick (node 独有)
渲染任务
下面这两个方法,虽然都是在本轮微任务之后执行,下轮宏任务之前执行「一轮事件环结束执行渲染任务,不过它们并不属于微任务或者宏任务」。
- requestAnimationFrame: 浏览器大约 16.6ms 会渲染一次页面,浏览器判断需要渲染的话,渲染之前会调用该方法。
- requestIdleCallback: 本轮渲染结束后,没有等到下一个16.6ms「下次渲染」,此时的空闲时间会执行 requestIdleCallback
每一轮 eventLoop 后,浏览器都会判断页面要不要渲染「只是有可能渲染」,还取决于有没有到时间(16.6ms) 或者 处于性能考虑,要不要做一次合并渲染,16.6ms 是 1s / 60 哦。
浏览器中的 EventLoop
结合两道题,我们来看下 EventLoop 是怎么执行的。
console.log(1);
// 定时器设置为 0 也不是立即执行
setTimeout(() => {
console.log(2);
}, 0)
console.log(3);
console.time('loop');
for (let i = 0; i < 99999999; i++) {
}
console.timeEnd('loop');
console.log(4);
// 1
// 3
// loop: 95.189ms
// 4
// 2
图来:
题目难度升级:
setTimeout(() => {
console.log(1);
}, 20)
console.log(2);
setTimeout(() => {
console.log(3);
}, 10)
console.log(4);
for (let i = 0; i < 90000000; i++) {} // 79ms 左右
setTimeout(() => {
console.log(6);
}, 8)
console.log(7);
setTimeout(() => {
console.log(8);
}, 15)
console.log(9);
// 2
// 4
// 7
// 9
// 3
// 1
// 6
// 8
一道关于结合渲染线程的题
<body>
<script>
document.body.style.background = 'red';
console.log(1);
Promise.resolve().then(() => {
console.log(2);
document.body.style.background = 'yellow';
});
console.log(3);
</script>
</body>
揭晓答案:
// 1 3 2 页面变黄色
这道题主要迷惑的点在于,渲染线程什么时候执行,因为整体代码作为一个宏任务执行时,此时给页面赋值红色,页面不会进行渲染,渲染进程只有在一轮事件循环结束才有可能执行「宏任务 -> 微任务队列 -> 考虑渲染页面」,所以直到最后页面被设置成黄色,还只是在一轮事件循环里面,最后页面变色被合并,直接变成黄色。
结合渲染线程的变形题
<body>
<script>
document.body.style.background = 'red';
console.log(1);
setTimepit(() => {
console.log(2);
document.body.style.background = 'yellow';
}, 0);
console.log(3);
</script>
</body>
揭晓答案:
// 1 3 2 页面变红色 2 页面变黄色
不过也有可能页面颜色不闪烁,因为浏览器什么时候渲染页面并不固定,仅仅是有可能渲染。
结合原生事件的一道面试题
<body>
<script>
button.addEventListener('click', () => {
console.log('listener1');
Promise.resolve().then(() => console.log('micro task1'))
});
button.addEventListener('click', () => {
console.log('listener2');
Promise.resolve().then(() => console.log('micro task2'))
});
// 点击按钮会发生什么
</script>
</body>
揭晓答案:
dom事件是一个宏任务,所以输出顺序为
// listener1 micro task1
// listener2 micro task
结合原生事件的一道变形题
<body>
<script>
button.addEventListener('click', () => {
console.log('listener1');
Promise.resolve().then(() => console.log('micro task1'))
});
button.addEventListener('click', () => {
console.log('listener2');
Promise.resolve().then(() => console.log('micro task2'))
});
button.click(); // 自动触发
</script>
</body>
揭晓答案:
自动触发的 dom api 不会产生宏任务了,相当于是两个自动执行的函数,是同步代码
// listener1 listener2
// micro task1 micro task2
一道不符合 promise A+ 规范的典型题「必看」
// 前置知识点
// @1 「源码机制」promise1 中返回一个新的 promsie2,会根据 promise1 的 resolve
// 和 reject 当成 promise2 的成功或失败回调,也就是
// promise2.then(resolve, reject);
Promise.resolve().then(() => {
console.log(0);
return Promise.resolve(1);
}).then(res => {
console.log(res);
});
Promise.resolve().then(() => {
console.log(2);
}).then(() => {
console.log(3);
}).then(() => {
console.log(4);
}).then(() => {
console.log(5);
});
我们先尝试利用 promise A+ 的描述去解这道题:
- 第一段代码执行, then0 入微任务队列,此时微任务队列为 [then0]
- 第二段代码执行,then2 入微任务队列,至此,整体代码执行完毕,此时微任务队列为 [then0, then2],同步代码执行完毕,开始清空微任务队列
- then0 开始执行, 先输出 0,然后执行 return Promise.resolve(1),我们假设 Promise.resolve(1) 返回值为 promise2, 其源码内隐藏的 promsie2.then 入微任务队列,代码执行完毕,注意:then1 在等 promise2.then 返回结果,所以 then1 没有入微任务队列,此时微任务队列为 [then2, promsie2.then]
- then2 执行,输出 2,然后 then3 入微任务队列,此时微任务队列为 [promsie2.then, then3]
- promsie2.then 执行,然后 then1 入微任务队列,此时微任务队列为 [then3,then1]
- then3 执行,输出 3,然后 then4 入微任务队列,此时微任务队列为 [then1,then4]
- then1 执行,输出 1,此时微任务队列为 [then4]
- then4 执行,输出 4,然后 then5 入微任务队列,此时微任务队列为 [then5]
- then5 执行,输出 5,任务队列清空
所以我们得到了结果为 0,2,3,1,4,5,然后我们执行代码发现,输出顺序为 0,2,3,4,1,5,为什么会这样呢?
原来本题中涉及到一个 ecma 规范和 promise A+ 规范描述不一致的地方,在 ecmascript 规范中描述:如果返回了一个 promise,它不会立刻处理这个 promise,会将这个 promise 放到异步方法中进行处理(会用一层新的微任务包裹)。
所以,我们进行一次包裹,简称该微任务为 thenWrap
1. [then0]
2. [then0, then2] -> then0 执行,输出 0
3. [then2, thenWrap] -> then2 执行,输出 2
4. [thenWrap, then3] -> thenWrap 执行,promise2.then 入队列,没有输出
5. [then3,promise2.then] -> then3 执行,输出 3
6. [promise2.then,then4] -> promsie2.then 执行,then1 入队列,没有输出
7. [then4, then1] -> then4 执行,输出 4,then5 入队列
8. [then1, then5] -> then1 执行,输出 1
9. [then5] -> then5 执行,输出 5
Node 中的 EventLoop
node 自己实现了一个事件环机制(新版本的 node 执行结果和浏览器完全一致),不过底层实现方式不一样,参考node官方指南
node 认为,下图 6 个阶段就代表了完整的事件环,事件环中的 api (比如 setTimeout) 是它自己实现的,而对于 promise、process.nextTick 这些没有出现在事件环中的原因是,它们只是 v8 实现的异步 api,从技术角度讲并不是事件循环的一部分。
- 浏览器宏任务只有一个队列,但是对于我们 node 而言,有6个阶段「队列」
┌───────────────────────────┐
┌─>│ timers │ 本阶段执行已经被 setTimeout()和 setInterval() 的调度回调函数。
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ 执行延迟到下一个循环迭代的 I/O 回调(上一轮 poll 阶段没有执行到的,这里执行)
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │仅系统内部使用,内置调度方法执行。
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ poll │轮询,检索 I/O 事件并执行回调(定时器检查也在这里进行,如果可以执行定时器回调,则考虑跳回 │ │ timer 阶段『取决于当前或后面两个阶段还有没有要执行的回调』),几乎除了 timer 和 check 阶段以外的 │ │ 回调都在这里执行。 │ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ check │检测阶段,setImmediate() 回调函数在这里执行。 │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ └──┤ close callbacks │一些关闭的回调函数,如:socket.on('close', ...)。 └───────────────────────────┘
其中,第二 & 第三个阶段我们控制不了,为系统内部调度使用的,需要解释的是第二个阶段我们并不知道 I/O 操作什么时候执行完毕,这轮能不能执行上。
那么 Node 中的 eventLoop 究竟是怎么执行的呢?
// Node 事件循环执行流程
// @1 整体代码作为第一个宏任务执行
// @2 检查 timer 队列,立即执行 timer 回调
// @3 进入 poll 阶段轮询,清空 poll 队列中的 I/O 回调,如果后两个阶段有可执行的回调,
// 则继续往下扫描,否则:
// + 有等待中的 I/O 或者定时器任务,在这里阻塞,检测 I/O 和 timer 的完成时间,如果
// I/O 回调先到达可执行阶段,I/O 回调在这里直接执行,如果 timer 回调先到达可执行
// 阶段,则跳回 timer 阶段。
// + 没有 I/O 或者定时器任务,事件环结束。
// @4 如果 check 队列不为空,检查 check 队列,立即执行 setImmeiate 的回调,清空微任务队列
// @5 如果 close 队列不为空,检查 close 队列,立即执行队列中的回调,并跳回 timer 阶段
// 每一轮任务结束(宏任务或微任务),都会检查 nextTick 队列。
// 每一次清空宏任务队列,都会清空微任务队列(queueMicrotask,promise.then 等)
setTimeout 和 setImmediate
setImmeiate: 预定在 I/O 事件的回调之后立即执行的 callback。
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
二者的执行顺序不一定哦,需要看执行到 timer 阶段时,timeout 是否已经准备好,延时 0 并不代表可以直接放到 timer 队列中,跟机器的性能也有关系哦。
require('fs').readFile('./note.md', () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
// immediate timeout
这样写的话,setImmediate 必定先执行
不一样的 node 11+
setTimeout(() => {
console.log('timer1');
Promise.resolve().then(function() {
console.log('promise1');
});
}, 0);
setTimeout(() => {
console.log('timer2');
Promise.resolve().then(function() {
console.log('promise2');
});
}, 0);
node 10 输出如下:
timer1
timer2
promise1
promise2
node11 运行后居然是:
timer1
promise1
timer2
promise2
可以看到,执行了一个 setTimeout 之后,立刻执行了微任务
那么为什么要这么做呢? 为了和浏览器更加趋同。
现在 node11+ 在 timer 阶段的 setTimeout, setInterval 和在 check 阶段的 immediate 都修改为一旦执行一个阶段里的一个任务就立刻扫描 nextTick 队列和微任务队列。
setTimeout(() => {
console.log('timer1');
process.nextTick(() => {
console.log('nextTick1');
});
}, 0);
setTimeout(() => {
console.log('timer2');
process.nextTick(() => {
console.log('nextTick2');
});
}, 0);