JS 异步

作者: _于曼丽_ | 来源:发表于2022-04-28 17:03 被阅读0次

单线程

单线程是指:只有一个 JS 主线程,负责脚本的编译和运行。

进程是资源分配的最小单位,线程是 cpu 调度的最小单位。系统会为进程分配 cpu 和内存,进程中的所有线程共享该进程的 cpu 和内存资源。

整个浏览器是一个 Browser 进程,一个 tab 页面是一个 Render 进程。

Render 进程包括:

  • UI 线程:解析并渲染 HTML CSS
  • JS 主线程:编译并运行 JS 脚本
  • 工作线程:由 JS 主线程调用一些 API,开启工作线程,例如定时器线程、文件读取线程、Ajax 线程

事件循环和任务队列

主线程首先执行同步脚本 (按顺序执行当前页面中的所有 <script></script> 编译单元)。

主线程在执行同步脚本的时候,调用某些 API 会开启工作线程来执行异步任务,工作线程和主线程会同时进行。

当工作线程异步任务完成之后,会查看当初主线程开启工作线程的时候是否指定了回调函数。如果没有指定回调函数,则异步任务完成之后不会通知主线程。如果指定了回调函数,则将回调函数和异步任务的结果封装成一条消息放到任务队列中,等待主线程空闲时从任务队列中取出该消息并执行。

主线程不断从任务队列当中取消息并执行消息称为事件循环,每取出并执行一条消息称为一次事件循环。

事件循环和任务队列

MacroTask 和 MicroTask

异步任务分为 MacroTask 和 MicroTask

主线程执行完某个 MacroTask 之后,会先执行该 MacroTask 产生的所有 MicroTask,然后交给 UI 线程进行渲染,然后再交给 JS 主线程执行下一个 MacroTask。

MacroTask 包括:

  • 同步脚本中的每个编译单元 <script></script> 为一个 MacroTask
  • 任务队列中的每条消息为一个 MacroTask

MicroTask 包括:

  • Promise
  • process.nextTick
  • MutationObserver
MacroTask 和 MicroTask

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

渲染流程

渲染流程
  1. HTML 文档解析形成 DOM Tree 之后,浏览器触发 DOMContentLoaded 事件
  2. 所有资源都已经下载并且解析完成之后,浏览器触发 load 事件
  3. CSS 的下载和解析默认不阻塞 HTML 的解析
  4. JS 的下载和解析阻塞 HTML 的解析
  5. JS 在解析之前,需要等待 CSS 的下载和解析

DOMContentLoaded 与 load

无论任何情况,DOMContentLoaded 事件都会比 load 事件先触发

规则

同步脚本

当 HTML 文档被解析时如果遇见(同步)脚本,则停止解析,先去加载脚本,然后执行,执行结束后继续解析 HTML 文档。过程如下图:

同步脚本

同步脚本执行时,HTML 文档还没有解析完毕,因此还没有触发 DOMContentLoaded 事件,也没有触发 load 事件。

同步脚本 -> DOMContentLoaded -> load。

defer

当 HTML 文档被解析时如果遇见 defer 脚本,则在后台加载脚本,文档解析过程不中断,而等文档解析结束之后,defer 脚本执行。过程如下图:

defer

当 HTML 文档中有 defer 脚本时候,即便文档解析结束,也暂时先不触发 DOMContentLoaded 事件,而是等所有 defer 脚本执行完毕,才触发 DOMContentLoaded 事件。

defer 脚本 -> DOMContentLoaded -> load。

async

当 HTML 文档被解析时如果遇见 async 脚本,则在后台加载脚本,文档解析过程不中断。脚本加载完成后,文档停止解析,脚本执行,执行结束后文档继续解析。过程如下图:

async

只要 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

相关文章

网友评论

      本文标题:JS 异步

      本文链接:https://www.haomeiwen.com/subject/saedyrtx.html