单线程
单线程是指:只有一个 JS 主线程,负责脚本的编译和运行。
进程是资源分配的最小单位,线程是 cpu 调度的最小单位。系统会为进程分配 cpu 和内存,进程中的所有线程共享该进程的 cpu 和内存资源。
整个浏览器是一个 Browser 进程,一个 tab 页面是一个 Render 进程。
Render 进程包括:
- UI 线程:解析并渲染 HTML CSS
- JS 主线程:编译并运行 JS 脚本
- 工作线程:由 JS 主线程调用一些 API,开启工作线程,例如定时器线程、文件读取线程、Ajax 线程
事件循环和任务队列
主线程首先执行同步脚本 (按顺序执行当前页面中的所有 <script></script>
编译单元)。
主线程在执行同步脚本的时候,调用某些 API 会开启工作线程来执行异步任务,工作线程和主线程会同时进行。
当工作线程异步任务完成之后,会查看当初主线程开启工作线程的时候是否指定了回调函数。如果没有指定回调函数,则异步任务完成之后不会通知主线程。如果指定了回调函数,则将回调函数和异步任务的结果封装成一条消息放到任务队列中,等待主线程空闲时从任务队列中取出该消息并执行。
主线程不断从任务队列当中取消息并执行消息称为事件循环,每取出并执行一条消息称为一次事件循环。
![](https://img.haomeiwen.com/i13549063/eb327f35e74d4e48.png)
MacroTask 和 MicroTask
异步任务分为 MacroTask 和 MicroTask
主线程执行完某个 MacroTask 之后,会先执行该 MacroTask 产生的所有 MicroTask,然后交给 UI 线程进行渲染,然后再交给 JS 主线程执行下一个 MacroTask。
MacroTask 包括:
- 同步脚本中的每个编译单元
<script></script>
为一个 MacroTask - 任务队列中的每条消息为一个 MacroTask
MicroTask 包括:
- Promise
- process.nextTick
- MutationObserver
![](https://img.haomeiwen.com/i13549063/928a3a9bda241f9e.png)
JS 编译与执行
一个 <script></script>
为一个编译单元,每个编译单元为一个 MacroTask。
对于编译单元中的代码,JS 主线程先编译,后执行。在编译阶段,会将变量声明和函数声明放到相应的作用域中。在执行阶段,遇到变量会从作用域链中查找。
所有的编译单元共享一个全局作用域,因此在不同编译单元中声明的顶层变量,都是全局变量,可以由不同编译单元共享。
<script>
console.log(foo); // undefined
var foo = 100;
hello(); // 'hello world'
function hello () {
console.log('hello world')
}
</script>
以上代码实际上相当于下面代码:
<script>
// 编译阶段
function hello () {
console.log('hello world')
}
var foo;
// 执行阶段
console.log(foo);
foo = 100;
hello();
</script>
当页面中存在多个编译单元 <script></script>
的时候,则按顺序编译并执行。先编译并执行 <script>编译单元 1</script>
,再编译并执行 <script>编译单元 2</script>
。
<script>
hello(); // 报错 ReferenceError
</script>
<script>
function hello () {
console.log('hello world')
}
</script>
以上代码会报错,因为在编译并执行第一个 <script></script>
的时候,hello
函数还没有定义。
实例1
代码:
<script>
console.log('script 1')
Promise.resolve().then(() => console.log('promise 1'))
setTimeout(() => console.log('timeout 1'), 0)
</script>
<script>
console.log('script 2')
Promise.resolve().then(() => console.log('promise 2'))
setTimeout(() => console.log('timeout 2'), 0)
</script>
输出:
script 1
promise 1
script 2
promise 2
timeout 1
timeout 2
讲解:
每个编译单元执行完成之后,先执行当前编译单元产生的 microtask 队列,然后再执行下一个编译单元。
所有编译单元执行完成之后,也就是说所有同步代码都执行完毕,再从任务队列里读取任务并执行。
实例2
代码:
// index.html
<script src="1.js"></script>
<script src="2.js"></script>
// 1.js
setTimeout(function () {
console.log('timeout-1')
}, 0)
console.log(1)
// 2.js
setTimeout(function () {
console.log('timeout-2')
}, 0)
console.log(2)
输出:
1
2
timeout-1
timeout-2
或者
1
timeout-1
2
timeout-2
讲解:
setTimeout() 最小时间为 4ms。
1.js 执行的时候,开启了一个定时器线程,4ms 之后向任务队列添加一个任务,然后控制台输出 '1'。
1.js 执行完毕,主线程处于空闲。这时开始下载 2.js。此时定时器线程仍然在计时,4ms 计时时间一到,便会向任务队列添加一个任务。
如果 2.js 下载时间超过 4ms,则主线程取出第一个定时器任务并执行,输出 'timeout-1'。然后 2.js 下载结束,开始执行 2.js。
如果 2.js 下载时间小于 4ms,则主线程执行 2.js,输出 '2.js',然后输出 'timeout-1' 和 'timeout-2'
实例3
代码:
// index.html
<script src="1.js"></script>
<script src="2.js" onload="console.log('2.js loaded')"></script>
<script src="3.js"></script>
// 1.js
console.log('1.js')
// 2.js
console.log('2.js')
// 3.js
console.log('3.js')
输出:
1
2
2.js loaded
3
讲解:
2.js 下载并执行结束,则将 onload 回调函数添加到任务队列。
浏览器下载 3.js 需要一定时间,这时主线程处于空闲阶段,因此从任务队列取出回调函数并执行,输出 '2.js loaded'。
3.js 下载结束,等回调函数执行结束之后,再执行 3.js
渲染流程
![](https://img.haomeiwen.com/i13549063/fc2a460e848eb3cb.png)
- HTML 文档解析形成 DOM Tree 之后,浏览器触发 DOMContentLoaded 事件
- 所有资源都已经下载并且解析完成之后,浏览器触发 load 事件
- CSS 的下载和解析默认不阻塞 HTML 的解析
- JS 的下载和解析阻塞 HTML 的解析
- JS 在解析之前,需要等待 CSS 的下载和解析
DOMContentLoaded 与 load
无论任何情况,DOMContentLoaded 事件都会比 load 事件先触发
![](https://img.haomeiwen.com/i13549063/a5914038d7b72918.png)
同步脚本
当 HTML 文档被解析时如果遇见(同步)脚本,则停止解析,先去加载脚本,然后执行,执行结束后继续解析 HTML 文档。过程如下图:
![](https://img.haomeiwen.com/i13549063/debdb2e10623338f.png)
同步脚本执行时,HTML 文档还没有解析完毕,因此还没有触发 DOMContentLoaded 事件,也没有触发 load 事件。
同步脚本 -> DOMContentLoaded -> load。
defer
当 HTML 文档被解析时如果遇见 defer 脚本,则在后台加载脚本,文档解析过程不中断,而等文档解析结束之后,defer 脚本执行。过程如下图:
![](https://img.haomeiwen.com/i13549063/752103f65cf62047.png)
当 HTML 文档中有 defer 脚本时候,即便文档解析结束,也暂时先不触发 DOMContentLoaded 事件,而是等所有 defer 脚本执行完毕,才触发 DOMContentLoaded 事件。
defer 脚本 -> DOMContentLoaded -> load。
async
当 HTML 文档被解析时如果遇见 async 脚本,则在后台加载脚本,文档解析过程不中断。脚本加载完成后,文档停止解析,脚本执行,执行结束后文档继续解析。过程如下图:
![](https://img.haomeiwen.com/i13549063/2d8c7cdf0d2b3977.png)
只要 HTML 文档解析结束,就立即触发 DOMContentLoaded 事件,不管 async 脚本是否执行。
如果 async 在文档解析结束之前就执行了,则 async 脚本 -> DOMContentLoaded -> load。
如果文档解析结束之后,async 脚本还没有加载完毕,那么 DOMContentLoaded -> async 脚本 -> load。
动态脚本
<script>
元素还可以动态生成,生成后再插入页面,从而实现脚本的动态加载。
动态外部脚本
动态生成的外部脚本相当于 async 脚本,立即下载,下载结束之后立即执行 (需要等到当前同步代码都执行完毕之后),异步脚本下载并执行结束之后执行 onload 回调函数。
实例 1
// async.js
setTimeout(function () {
console.log('5')
}, 0)
console.log('3')
// index.js
console.log('1')
const script = document.createElement('script')
script.onload = function () {
console.log('4')
}
script.src = 'async.js'
document.body.appendChild(script)
console.log('2')
输出:
1
2
3
4
5
实例 2
// 4.js
console.log(4)
// index.html
<script>
console.log(1)
const script = document.createElement('script')
script.onload = function () {
console.log(5)
}
script.src = '4.js'
document.body.appendChild(script)
console.log(2)
</script>
<script>
console.log(3)
</script>
输出:
1
2
3
4
5
实例 3
// 3.js
console.log(3)
// 4.js
console.log(4)
// index.html
<script>
console.log(1)
const script = document.createElement('script')
script.onload = function () {
console.log(5)
}
script.src = '4.js'
document.body.appendChild(script)
console.log(2)
</script>
<script src="3.js"></script>
输出:
1
2
3
4
5
讲解:
相当于
<script>
console.log(1)
console.log(2)
</script>
<script src="3.js"></script>
<script src="4.js" async></script>
动态嵌入脚本
动态生成的嵌入脚本相当于调用 eval() 函数,立即执行。
console.log('1')
const script = document.createElement('script')
script.appendChild(new Text('console.log("2")'))
document.body.appendChild(script)
console.log('3')
输出顺序:
1
2
3
总结
onload 特性指定的回调函数,在当前外部脚本下载并执行结束之后,立即执行。
<script src="1.js" onload="console.log('loaded')"></script>
<script src="2.js"></script>
1
loaded
2
<script src="1.js" onload="console.log('loaded')"></script>
<script>
console.log('2')
</script>
1
loaded
2
内嵌脚本看做是同步脚本,主线程执行完所有的内嵌 script 脚本,才从任务队列里取出任务执行。
<script>
console.log('1')
setTimeout(() => console.log('timeout 1'), 0)
// for 循环为了拖时间,拖过 4ms,保证当前代码没执行完,就已经将定时器任务添加到任务队列中。
for(let i = 0; i < 100000000; i++) {
Math.random()
}
</script>
<script>
console.log('2')
</script>
1
2
timeout 1
加载外部脚本会放到任务队列中,等到任务主线程空闲时候执行。
<script>
console.log('1')
setTimeout(() => console.log('timeout 1'), 0)
// for 循环为了拖时间,拖过 4ms,保证当前代码没执行完,就已经将定时器任务添加到任务队列中。
for(let i = 0; i < 100000000; i++) {
Math.random()
}
</script>
<script src="2.js"></script>
1
timeout 1
2
网友评论