JavaScript异步编程
JavaScript 异步编程
JavaScript 事件循环模型
1. JavaScript 运行机制
介绍
JavaScript 是单线程语言(默认情况下,同一时间节点只能做一件事),造成一定局限性,如果按照单线程同步的方式运行,一旦有 HTTP 请求向服务器发送,就会出现等待数据返回之前网页假死的效果出现,但实际开发中,并没有遇见这种情况。
同步和异步
基于以上描述,因此在 JavaScript 中存在一种解决方案,用来处理单线程造成的诟病,这就是同步【阻塞】和异步【非阻塞】执行模式的出现。
2. 阻塞式代码与非阻塞式代码
同步【阻塞】
及严格按照单线程(从上到下,从左到右的方式)执行代码逻辑,进行代码解释和运行。
var a = 1;
var b = 2;
var c = a + b;
// 不会存在先执行第三行然后再实行第二行和第一行的情况
console.log(c);
如果在代码存在循环,循环结束前,后续代码不会运行,且浏览器进入类似假死的状态。
var a = 1;
var b = 2;
var t1 = new Date().getTime();
var t2 = new Date().getTime();
while (t2 - t1 <= 2000) {
t2 = new Date().getTime();
}
// while 循环结束前不会执行
console.log(a + b);
异步【非阻塞】
异步的意思就是和同步对立,也就是说代码不会按照默认顺序执行,但异步并不是多线程。JavaScript 执行器在工作时,仍然按照从上到下从左到右的方式解释和运行代码,当遇到异步模式的代码时,引擎会将当前任务“挂起”并略过,也就是先不执行这一段代码,继续向下运行非异步模式的代码,直到所有的同步代码全部执行完成后,程序会将之前“挂起”的异步代码按照“特定的顺序”来进行执行,所以异步代码不会【阻塞】同步代码的执行。
var a = 1;
var b = 2;
setTimeout(() => {
console.log('异步');
}, 2000);
// 会先输出3,然后等待两秒后输出“异步”
console.log(a + b);
JavaScript 的运行顺序就是完全单线程的异步模型:同步在前,异步在后
。所有的异步任务都要等待当前的同步任务执行完毕之后才能执行。
var a = 1;
var b = 2;
setTimeout(() => {
console.log('异步');
}, 1000);
var t1 = new Date().getTime();
var t2 = new Date().getTime();
while (t2 - t1 < 2000) {
t2 = new Date().getTime();
}
// 先输出3,然后输出“异步”
// 代码运行到setTimeout时,任务挂起并开始计时
// 1000ms后,执行回调函数,但此时同步代码还没执行完成
// 只有当同步代码执行完成后,才会执行定时器回调中的代码
console.log(a + b);
3. JavaScript 线程模型
虽然浏览器时单线程运行 JavaScript 代码的,但是浏览器实际是以多个线程协助操作来实现单线程异步模型的。
- GUI 渲染线程
- JavaScript 引擎线程
- 事件触发线程
- 定时器触发线程
- http 请求线程
- 其他线程
在 JavaScript 代码运行过程中,同时只存在一个活动线程,这里实现同步异步就是靠多线程切换的形式来实现的。
所以通常会将上面的细分线程归纳为下列两条线程:
- 【主线程】:用来执行页面渲染,JavaScript 代码运行,事件触发等等
- 【工作线程】:幕后工作,用于处理异步任务,实现非阻塞的运行模式
根据上图,主线程就是 JavaScript 执行代码的线程。当主线程运行时,会按照同步和异步代码将其分成两个去处,同步代码会放到“函数执行栈”中执行,异步代码放到“工作线程”中暂时挂起。当主线程代码筛选完毕后,进入执行栈的函数会按照从外到内的顺序依次执行,运行中涉及到的对象数据在堆内存中进行保存。当执行栈中的任务全部执行后,执行栈会清空,“事件循环”就会工作,“事件循环”会检测“任务队列”中是否有要执行的任务(任务的来源就是工作线程,程序运行期间,工作线程会将到期的定时任务、返回数据的 http 任务等“异步任务”按先后顺序插入“任务队列”中),如果“任务队列”中存在任务,按顺序(先进先出)放在执行栈中继续执行,知道任务队列清空。
4. 线程执行栈
执行栈就是一个栈的数据结构,运行单层函数时,执行栈执行的函数进栈后,会出栈销毁然后下一个进栈下一个出栈,当函数嵌套调用时,栈中就会堆积栈帧。
function func1() {
console.log('方法一');
func2(); // 方法二进栈
console.log('方法二结束'); // 方法二出栈
}
function func2() {
console.log('方法二');
}
func1(); // 方法一进栈
console.log('方法一结束'); // 方法一出栈
JavaScript 调用函数时,函数进入执行栈,去执行方法内部的代码,如果执行的代码中存在其他的函数,则会将对应的函数加入执行栈,所以函数执行是先进后出。
5. 递归深度问题
关于递归
递归函数是项目开发过程中经常涉及到的场景。通常在未知深度的树形结结构,或者其他合适的场景中使用递归。
面试时也经常问到递归的风险问题,如果了解了执行栈的执行逻辑后,递归函数就可以看成是在一个函数中嵌套 n 层执行,那么在执行过程中就会出现大量的栈帧堆积,如果处理数据过大,会导致执行栈的高度不过放置新的栈帧,就是造成栈溢出,所以在做海量数据递归的时候一定要注意这个问题。
执行栈高度
执行栈的高度根据不同的浏览器和 JS 引擎有着不同的区别。
var i = 0;
function task() {
i++;
// 报错前最后一次输入,即为当前环境下,执行栈的高度
console.log(`递归了${i}次`);
task();
}
task();
直接递归,没有退出条件,就会导致栈帧不断堆叠,最后导致栈溢出。
6. 宏任务和微任务
所有除同步代码以外的代码都会在工作线程中,按照时间节点有序的进入任务队列,而任务队列中的异步任务又分为“宏任务”和“微任务”。
var a = '同步';
setTimeout(() => {
Promise.resolve().then(() => {
console.log('宏任务中的微任务');
});
console.log('宏任务');
setTimeout(() => {
console.log('宏任务中的宏任务');
}, 0);
}, 0);
setTimeout(() => {
console.log('宏任务2');
}, 0);
Promise.resolve().then(() => {
console.log('微任务');
});
console.log(a);
微任务的执行是先于宏任务。新增的宏任务会追加到任务队列的尾部,而微任务会进入下一次宏任务之前的微任务队列,并按照先进先出的顺序执行,知道微任务队列全部执行完成后,才会执行下一个宏任务。
宏任务
宏任务就是 JavaScript 中最原始的异步任务,包括 setTimeOut、setInterVal,AJAX 等。在代码执行环境中按照同步代码的顺序,逐个进入工作线程中挂起,再按照异步任务到达的时间节点,逐个进入异步任务队列中,最终按照队列中的顺序进入函数执行栈中进行执行。
微任务
微任务是随着 ECMA 标准升级提出的新的异步任务,微任务就是在异步任务队列的基础上增加了“微任务”的概念,每一个宏任务执行前,程序会检测当此时间循环中为执行的微任务,优先清空本次微任务后,再执行下一个宏任务,微任务也是按照进入队列的顺序执行的。
常见的宏任务和微任务划分
宏任务 | 浏览器 | node |
---|---|---|
I/O | ✅ | ✅ |
setTimeOut | ✅ | ✅ |
setInterVal | ✅ | ✅ |
setImmediate | ❌ | ✅ |
requestAnimationFrame | ✅ | ❌ |
微任务 | 浏览器 | node |
---|---|---|
process.nextTick | ✅ | ✅ |
MutationObserver | ✅ | ✅ |
Promise.then.finally | ✅ | ✅ |
7. 面试
Promise 演进史
1. 回调地狱
在 JavaScript 的主要异步处理方式,是采用回调函数的方式来处理的
setTimeout(() => {
console.log('步骤一');
setTimeout(() => {
console.log('步骤二');
setTimeout(() => {
console.log('步骤三');
}, 1000);
}, 1000);
}, 1000);
代码按顺序执行,并且每一步都必须拿到前一步执行的结果,这样避免不了大量的逻辑在回调函数中不停嵌套,这就是“回调地狱”
2. 为什么使用 Promise
Promise 对象的主要作用是通过链式调用的结构,将原本回调嵌套的异步处理流程转换成“对象.then().then()...”的链式结构
const first = new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 1000);
});
first
.then(() => {
console.log('步骤一');
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 1000);
});
})
.then(() => {
console.log('步骤二');
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 1000);
});
})
.then(() => {
console.log('步骤三');
});
使用 Promise 后的代码,将原本嵌套的异步回调,拆解成三次 then 包裹的回调函数,按从上向下的顺序进行编写,这样便于查看这段代码的执行流程,代价就是代码的编写量增加
3. 使用 Promise 解决异步代码
介绍
Promise 的作用是解决“回调地狱”,将回调嵌套拆成链式调用,便于按照上下顺序进行异步代码的流程控制
Promise 对象是一个 JavaScript 对象,在支持 ES6 语法的运行环境中作为全局对象提供
// fn: 是初始化过程中调用的函数,是同步的回调函数
const p = new Promise(fn);
关于回调函数
在 JavaScript 中,有一种特殊的函数叫回调函数,将函数作为变量看待,由于 JavaScript 变量可以作为函数的形参并且函数可以通过声明变量的⽅式匿名创建,所以我们可以在定义函 数时将⼀个函数的参数当作函数来执⾏,进⽽在调⽤时在参数的位置编写⼀个执⾏函数
// 把 fn 作为函数对象,那么可以在 test 函数中执行
function test(fn) {
fn()
}
test(() => {
...
})