1.浏览器内核:
在讲JavaScript异步之前,先讲一下JavaScript运行环境,因为JavaScript是否能实现异步是通过运行环境机制决定的,我们经常使用的环境就是浏览器环境了,所以我今天主要讲一下在浏览器的渲染进程(浏览器内核)如何执行异步的。
负责页面的渲染,脚本的执行和时间处理,每一个tab页也都表示一个进程
对于渲染进程来说,它其实就是多线程的:
- GUI渲染线程:负责页面渲染解析 HTML/CSS 生成 DOM 树和 CSSOM 树等
- JS引擎线程:负责解析和执行JavaScript脚本程序,并且一个进程只有一个js引擎线程,js引擎是一个单线程
- 事件触发线程:用来控制事件循环(click,setTimeout,ajax等),当事件满足条件的时候,将事件放到js引擎所在的执行队列中
- 定时触发器线程:setInterval与setTimeout所在的线程, 定时任务并不是由JS引擎计时的,是由定时触发线程来计时的,执行完毕会通知事件触发进程
- 异步http请求线程:处理ajax请求,当完成后,通过回调函数触发事件触发线程
GUI渲染线程和JS引擎线程互斥:
当JS引擎线程执行时GUI渲染线程会被挂起,GUI更新则会被保存在一个队列中等待JS引擎线程空闲时立即被执行,防止渲染结果不可预期。
2.单线程:
javascript是单线程的,说明在任何一个时间点,JavaScript 的主线程(也就是它的调用栈)只能执行一件任务。如果前一个任务没执行完,后一个任务就必须排队等待。
如果是这样就会导致异步阻塞,很多任务没有办法瞬时完成,比如:
- 网络请求:向服务器请求数据,有时可能需要几秒
- 定时器:需要到指定时间才能执行
- 用户事件:等待用户的交互,发生时间不确定
3.异步:
异步就是为了解决单线程的问题,可以实现异步代码不阻塞,当触发到异步代码的时候,把它放到一遍
浏览器内核是如何实现异步呢?
JavaScript本身是单线程的,它实现异步来自于它所运行的环境—通常是浏览器,这个环境提供了一套复杂的机制,我们可以把它里面的一系列线程和机制策略想象成一个分工明确的团队。这个团队由四个核心成员组成:
- JS 主线程 & 调用栈 :这是 JavaScript 唯一的核心员工。他很勤奋,一次只能专心做一件事,所有同步任务都在他这里排队执行。
- 宿主环境 API :这是 JavaScript 的“外包团队”。浏览器提供了很多线程,比如定时器模块、网络请求模块等。这些不占用 JS 主线程的时间,可以在后台独立工作。
- 任务队列:这是一个待办列表。当宿主环境完成了某项异步任务后(比如定时器时间到了,或者网络请求成功返回了),他们不会直接把结果交给 JS 主线程(因为主线程可能正在忙),而是把相应的回调函数放到这个待办列表里排队。
- 事件循环:调用栈 -> 微任务 -> 宏任务 -> 渲染
任务队列又分为两种:
- 宏任务队列:存放 `setTimeout`, `setInterval`, I/O 操作,UI 渲染等任务的回调。我们上面讲的“任务队列”主要指的就是它。
- 微任务队列:存放 `Promise.then()`, `async/await` 等任务的回调。
微任务的优先级高于宏任务,当微任务和宏任务一起执行完毕的时候并且调用栈是空的时候,先执行微任务再执行宏任务
setTimeout(() => console.log('Timeout (宏任务)'), 0);
Promise.resolve().then(() => console.log('Promise (微任务)'));
console.log('Script (同步)');
// 输出顺序:
// Script (同步)
// Promise (微任务)
// Timeout (宏任务)
4.回调地狱:
当多个相互依赖的异步操作需要按顺序执行时,开发者将后一个操作的逻辑写在了前一个操作的回调函数中,导致函数调用层层嵌套,形成一种横向扩展、难以阅读和维护的代码结构。
回调地狱会导致什么问题:
1. 可读性极差: 代码不是从上到下线性执行,而是像剥洋葱一样,一层包着一层。人的大脑很难快速理清其中的逻辑顺序和依赖关系。
2. 难以维护: 如果需求变更,比如要在第二步和第三步之间增加一个新的异步操作,你需要小心翼翼地找到正确的位置,插入新的嵌套层,并调整大量的花括号和缩进。这极易出错。
3. 错误处理复杂: `try...catch` 无法跨越异步边界捕获回调函数中的错误。你必须在每一个嵌套层级都单独处理错误(就像上面代码中的 `err1`, `err2`, `err3`),这导致代码非常冗长和重复。
4. 耦合度高: 每一层的逻辑都和上一层的回调紧紧地耦合在一起,很难将某一步的逻辑抽离出来进行复用。
典型的回调地狱:
console.log('开始!');
setTimeout(() => {
console.log('1秒过去了'); // 第一个回调
// 为了保证顺序,第二个setTimeout必须嵌套在第一个回调内部
setTimeout(() => {
console.log('又2秒过去了'); // 第二个回调
// 第三个setTimeout必须嵌套在第二个回调内部
setTimeout(() => {
console.log('全部完成!'); // 第三个回调
}, 3000); // 3秒
}, 2000); // 2秒
}, 1000); // 1秒
解决办法:
1.使用 `Promise` 链式调用:
function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
delay(1000)
.then(() => {
console.log('1秒过去了');
// 返回一个新的Promise,以便继续链接.then
return delay(2000);
})
.then(() => {
console.log('又2秒过去了');
return delay(3000);
})
.then(() => {
console.log('全部完成!');
})
.catch(err => {
// 统一处理链条中任何环节可能出现的错误
console.error('出错了:', err);
});
2.使用async/await:
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// 必须在一个 async 标记的函数内部使用 await
async function runTimerSequence() {
try {
console.log('开始!');
await delay(1000); // “等待”1秒,但不会阻塞主线程
console.log('1秒过去了');
await delay(2000); // “等待”2秒
console.log('又2秒过去了');
await delay(3000); // “等待”3秒
console.log('全部完成!');
} catch (err) {
console.error('出错了:', err);
}
}
// 执行这个异步函数
runTimerSequence();