异步在面试中非常容易被出题拷打,开发中也常常困于生命周期,最好能够一次性全部掌握!让我们来看看这到底怎么个事儿嘎嘎嘎
异步操作是指程序中的操作可以同时进行,不需要等待上一个操作完成才能进行下一个操作。异步操作通常会通过回调函数、Promise 或者 async/await 等机制来处理。这种执行方式可以提高程序的效率和响应速度,特别适合处理大量的I/O操作或网络请求。
1、回调函数
回调函数是最早期的异步编程方式。
函数A作为参数(函数引用)传递到另一个函数B中,并且这个函数B执行函数A。我们就说函数A叫做回调函数。如果没有名称(函数表达式),就叫做匿名回调函数。
//定义主函数,回调函数作为参数
function A(callback) {
callback();
console.log('我是主函数');
}
//定义回调函数
function B(){
setTimeout(() => {
console.log("我是回调函数");
}, 3000); //setTimeout也是一个异步操作
}
//调用主函数,将函数B传进去
A(B);
//输出结果
我是主函数
我是回调函数
回调函数这种方式的优点是比较容易理解,可以绑定多个事件,每个事件可以指定多个回调函数,而且可以”去耦合“,有利于实现模块化。缺点是整个程序都要变成事件驱动型,运行流程会变得很不清晰。
2、Promise
2.1 Promise简介
Promise是对上面说到的回调函数处理异步编程的一个进阶方案。在传统的ajax请求中,当异步请求之间的数据存在依赖关系的时候,就可能产生很难看的多层回调地狱,这样会使代码逻辑很容易造成混乱不便于阅读和后期维护。我们引入promise来降低异步编程的复杂性。
Promise给每个异步任务返回一个Promise对象,该对象有一个then方法,允许指定回调函数,将先前的回调函数变成链式写法,程序流程清晰。
指定多个回调函数:
f1().then(f2).then(f3);
指定发生错误时回调函数:
f1().then(f2).fail(f3);
2.2 串行异步任务
Promise实际上就是一个特殊的Javascript对象,反映了“异步操作的最终值”,无论异步操作成功与否,这个对象最终都会返回一个值给你。请求成功调用resolve回调函数,请求失败调用reject回调函数。
const promise = new Promise((resolve, reject) => {
$.ajax('https://github.com/users', (value) => {
resolve(value);//成功
}).fail((err) => {
reject(err);//失败
});
});
promise.then((value) => {
console.log(value);
},(err) => {
console.log(err);
});
//也可以采取下面这种写法
promise.then(value => console.log(value)).catch(err => console.log(err));
需要注意的是,then()返回的必须是一个Promise实例。
var src1 = 'https://www.imooc.com/static/img/index/logo_new.png'
var result1 = loadImg(src1) //result1是Promise对象
var src2 = 'https://img1.mukewang.com/545862fe00017c2602200220-100-100.jpg'
var result2 = loadImg(src2) //result2是Promise对象
result1.then(function (img1) {
console.log('第一个图片加载完成', img1.width)
return result2 // 链式操作
}).then(function (img2) {
console.log('第二个图片加载完成', img2.width)
}).catch(function (ex) {
console.log(ex)
})
then 方法可以被同一个 promise 调用多次,then 方法必须返回一个 promise 对象。上例中result1.then如果没有明文返回Promise实例,就默认为本身Promise实例即result1,result1.then返回了result2实例,后面再执行.then实际上执行的result2.then。
2.3 并行异步任务
Promise.all()
执行多个并行任务,并在执行完后都调用then()
var p1 = new Promise(function (resolve, reject) {
setTimeout(resolve, 500, 'P1');
});
var p2 = new Promise(function (resolve, reject) {
setTimeout(resolve, 600, 'P2');
});
// 同时执行p1和p2,并在它们都完成后执行then:
Promise.all([p1, p2]).then(function (results) {
console.log(results); // 获得一个Array: ['P1', 'P2']
});
Promise.race()
执行多个并行请求,只需要获得先返回的结果即可,先返回的调用then()
var p1 = new Promise(function (resolve, reject) {
setTimeout(resolve, 500, 'P1');
});
var p2 = new Promise(function (resolve, reject) {
setTimeout(resolve, 600, 'P2');
});
Promise.race([p1, p2]).then(function (result) {
console.log(result); // 'P1'
});
Promise.all接受一个promise对象的数组,待全部完成之后,统一执行success;
Promise.race接受一个包含多个promise对象的数组,只要有一个完成,就执行success。
值得拓展的是:之前在一次面试时被问到:Promise.all 中的多个 Promise 会“并行”开始执行(准确说是它们是同步创建并异步执行的),即使其中一个失败了,其他的仍然会继续执行,不会被中断,那么:想要收集所有结果(无论成功或失败),再执行then,该如何操作?
有两种方案
方案一:使用 Promise.allSettled
Promise.allSettled 会等待所有 Promise 完成(无论成功或失败),返回一个包含每个 Promise 结果的对象数组:
Promise.allSettled([promise1, promise2, promise3])
.then(results => {
results.forEach(result => {
if (result.status === 'fulfilled') {
console.log('成功:', result.value);
} else {
console.log('失败:', result.reason);
}
});
});
方案二:手动处理拒绝的 Promise
将每个 Promise 包裹,确保它们始终解决(resolve),并在结果中区分状态:
const safePromises = promises.map(p =>
p.then(value => ({ status: 'fulfilled', value }))
.catch(reason => ({ status: 'rejected', reason }))
);
Promise.all(safePromises)
.then(results => {
// 处理所有结果
});
3、Async/Await
async/await是基于Promise实现的,其提供了更简洁和同步的写法,适用于异步代码的顺序执行和错误处理。使得异步代码更像同步代码,减少了回调地狱的问题,但本质上它还是依赖于 Promise 来实现异步操作。
对比:
Promise:
const makeRequest = () =>
getJSON()
.then(data => {
console.log(data)
return "done"
})
makeRequest()
async/await:
const makeRequest = async () => {
console.log(await getJSON())
return "done"
}
makeRequest()
await关键字只能用在async定义的函数内。async函数会隐式地返回一个Promise,该Promise的resolve值就是函数return的值。
在Promise中,同步错误:通过 try...catch 处理,异步错误:通过 Promise 的 .catch 处理。
Async/Await中,try/catch可以同时处理同步和异步错误。
const makeRequest = async () => {
try {
const data = JSON.parse(await getJSON())
console.log(data)
} catch (err) {
console.log(err)
}
}
4、JavaScript 中的事件循环(Event Loop)机制
提完异步的几种形式,那包是要讲讲异步跟同步是如何一起在JS中工作的:
在 JavaScript 中,事件循环是指浏览器或 Node.js 运行时环境中负责管理执行顺序的机制。
简单来说,就是一种线程调度机制。事件循环不断地监听事件队列,当事件队列中有任务时,会从中取出一个任务并执行。执行完当前任务后,再次检查事件队列,如此循环,直到事件队列为空。
这种机制使得 JavaScript 能够处理异步操作,例如定时器、网络请求、事件监听等,而不会阻塞主线程的执行。这样就实现了非阻塞的异步编程,提高了程序的性能和响应速度。
将浏览器JS线程所有的任务分为宏任务与微任务。
宏任务:script,setTimeout,setInterval,setImmediate,I/O,UI-rendering(ui渲染),Ajax,DOM事件(如点击、输入)
微任务:promise、Object observe、Mutation Observe、async/await
4.1 Event-Loop执行顺序
- 执行同步代码(这属于宏任务)
- 当执行栈为空时,查询是否有异步需要执行
- 如有则执行微任务
- 如果有需要则会渲染页面
- 执行宏任务(下一次event loop的开始)
宏任务 > 所有微任务 > 宏任务
- 将所有任务看成两个队列:执行队列与事件队列。
- 执行队列是同步的,事件队列是异步的,宏任务放入事件列表,微任务放入执行队列之后,事件队列之前。
- 当执行完同步代码之后,就会执行位于执行列表之后的微任务,然后再执行事件列表中的宏任务
4.2 例子
例子1
console.log("stard") //第一个输出stard
async function async1(){ //async 相当于new Promise,构造函数属于同步代码
await async2()
console.log("saync1 end"); //第一个进入微任务队列
}
async function async2(){
console.log("saync2 end"); //第二个输出saync2 end
}
async1()
setTimeout(function(){ //第一个进入宏任务队列
console.log("setTimeout");
},0)
new Promise((resolve)=>{
console.log("promise"); //第三个输出promise
resolve()
})
.then(()=>{ //第二个进入微任务队列
console.log("then1");
})
.then(()=>{ //第三个进入微任务队列
console.log("then2")
})
console.log("end"); //第四个输出end
第一行,输出stard
async1和async2的函数声明不用运行
运行到第九行async1(),因为async相当于new Promise构造函数,属于同步代码,直接运行到await所在语句
await后一行代码会进入微任务队列
直接执行await async2(),第二个输出saync2 end
第十行,setTimeout属于宏任务,所以第一个进入宏任务队列
运行到第十三行,new Promise 构造函数属于同步代码,所以直接运行,第三个输出promise。
继续运行到第十七行,(promise.then)属于微任务,所以先后两个.then都进入微任务
继续运行到第二十三行,同步代码直接运行,所以第四个输出end。
此时同步代码执行结束,开始执行微任务
第一个进入微任务队列的是第四行代码,所以第五个输出saync1 end,第二、三进入微任务的是两个.then,所以第六个输出的是then1,第七个输出的是then2
此时异步中微任务队列也全部执行完毕,开始执行宏任务
所以第八个输出的是setTimeout。
例子2
let a = () => {
setTimeout(() => {
console.log('任务队列函数1')
}, 0)
for (let i = 0; i < 5; i++) {
console.log('a的for循环')
}
console.log('a事件执行完')
}
let b = () => {
setTimeout(() => {
console.log('任务队列函数2')
}, 0)
for (let i = 0; i < 10; i++) {
console.log('b的for循环')
}
console.log('b事件执行完')
}
let c = () => {
setTimeout(() => {
console.log('任务队列函数3')
}, 0)
for (let i = 0; i < 3; i++) {
console.log('c的for循环')
}
console.log('c事件执行完')
}
a();
b();
c();
当a、b、c函数都执行完成之后,三个setTimeout才会依次执行
5、生命周期钩子在其中的执行顺序(以useEffect为例)
React 的生命周期钩子(例如 useEffect、componentDidMount 等) 并不直接在微任务队列中执行,而是由 React 调度管理,首次通常会在渲染工作完成后执行。
如果生命周期钩子内部涉及到 Promise 或异步操作,这些异步回调会被放入微任务队列中。
生命周期钩子(如 useEffect)的执行时机是在所有同步操作执行完后,微任务队列清空之前执行的,类似于微任务的时机,但它们是 React 自定义调度的结果。
以下列程序为例:
import React, { useState, useEffect } from 'react';
function Demo1() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log('useEffect triggered, count:', count);
return()=>{
console.log('useEffect回调',count);
};
}, [count]);
console.log('Rendered'); // 每次渲染时都会打印
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2 start');
console.log('async2 end');
}
console.log('script start');
async1();
setTimeout(function () {
console.log('setTimeout');
}, 0);
new Promise(function (resolve) {
console.log('promise1');
resolve();
}).then(function () {
console.log('promise2');
});
const let100=()=>{
new Promise(function (resolve) {
console.log('let100begin');
setCount(100);
resolve();
}).then(function () {
console.log('let100then');
});
}
console.log('script end');
return (
<div>
<button onClick={let100}>Increment</button>
</div>
);
}
export default Demo1;
点击前控制台结果:
在最后输出count现在状态值。
点击后控制台结果:
- let100 被触发:
- 点击按钮时,let100() 函数被调用,首先输出 let100begin。
- 在 let100() 中,创建了一个新的 Promise,并且在其执行过程中调用了 setCount(100),这个 setCount 会触发组件的重新渲染。
- 组件重新渲染:
- React 会在状态更新后重新渲染组件,输出console.log('Rendered') 。
- React 在渲染过程中,首先是同步的操作。此时,useEffect 不会立即执行,它是异步的。
- 执行中间同步操作
- 输出srcipt start到script end为止的所有同步操作
- useEffect 会在所有同步任务执行完成后运行
-
由于副效应函数是每次渲染都会执行,回调函数不仅在组件卸载时执行一次,每次副效应函数重新执行之前也会执行一次,用来清理上次渲染的副效应。
-
输出uesEffect回调0,输出新的count值useEffect triggered,count:100
-
输出微任务队列
let100then、async1 end、promise2
-
输出宏任务队列
SetTimeout
6、如何实现精确的setInterval、setTimeout
setInterval是JavaScript中用于周期性执行代码的定时器函数。它会按照指定的时间间隔重复执行某个回调函数,直到被clearInterval显式清除或页面被关闭。
let count = 0;
let intervalId = setInterval(() => {
count++;
console.log(`This is message #${count}`);
if (count === 5) {
clearInterval(intervalId); // 在5次后停止定时器
console.log('Interval stopped');
}
}, 1000);
//输出
This is message #1
This is message #2
This is message #3
This is message #4
This is message #5
Interval stopped
6.1 不精准的原因:
(1)事件循环模型影响回调执行时机:
JavaScript 是单线程的,当存在同步任务时,异步任务(包括setInterval和setTimeout)只能等到主线程空闲时执行。如果有多个同步任务执行,同步阻塞,定时器的回调可能会延迟,导致无法达到精确执行。
(2)按照W3C的标准,浏览器实现计时器计时,如果嵌套层数大于五层,则最短时间间隔拉长为4ms
根据 W3C 的 HTML5 定时器规范,浏览器通常会限制定时器的最小时间间隔,特别是在嵌套深度较大时(例如,调用 setTimeout 或 setInterval 嵌套的层数超过五层)。
假设使用以下代码,设置定时器的时间间隔为 1 毫秒:
setTimeout(() => { // 宏任务 1
console.log("First setTimeout");
setTimeout(() => { // 宏任务 2
console.log("Second setTimeout");
setTimeout(() => { // 宏任务 3
console.log("Third setTimeout");
setTimeout(() => { // 宏任务 4
console.log("Fourth setTimeout");
setTimeout(() => { // 宏任务 5
console.log("Fifth setTimeout");
}, 1);
}, 1);
}, 1);
}, 1);
}, 1);
理论上,定时器应该在 1 毫秒后执行,但根据浏览器实现的限制,每个回调函数可能会延迟到 4 毫秒后执行。
(3)计算机硬件没有原子钟,无法做到精准计时。
(4)操作系统的计时函数本身少量偏差。
(5)失活页面定时器执行间隔会被调整到1s
许多现代浏览器在页面处于非活动状态(如页面未被用户交互、标签页处于后台、或者浏览器最小化时),会主动对定时器执行间隔进行优化,将其间隔时间延长,通常会将setInterval或setTimeout的执行间隔调整为1秒。这是为了节省CPU资源,并减少浏览器在后台运行时的功耗。
6.2 解决方案
(1)根据Performance.now通过时间调整间隔偏差
可以使用 Performance.now() 来检测每次定时器回调执行的时间,然后根据实际间隔调整下一次定时器的启动时间,使得定时器能够尽量接近理想的执行间隔,减少因其他操作导致的误差。
let count = 0;
let start = performance.now(); // 获取当前时间
function accurateInterval() {
count++;
let elapsed = performance.now() - start;
let expectedInterval = count * 1000; // 理论上的间隔时间
let drift = elapsed - expectedInterval; // 实际与理论时间的偏差
console.log(`Message #${count}, Drift: ${drift.toFixed(2)}ms`);
if (count === 5) {
console.log('Interval stopped');
return;
}
setTimeout(accurateInterval, 1000 - drift); // 调整偏差
}
accurateInterval();
(2)requestAnimationFrame(适用于动画、渲染的周期性任务)
它能够同步于浏览器的刷新频率(一般是 60 FPS,即每 16.67 毫秒)。它不会受到失活页面的影响,而且浏览器通常会根据当前渲染帧的进度来决定何时执行回调,因此它在动画类应用中比 setInterval 更加精确。但其受很多其他因素影响,例如浏览器卡了、电脑卡了等等都会影响渲染帧。
let count = 0;
function animate() {
count++;
console.log(`Message #${count}`);
if (count < 5) {
requestAnimationFrame(animate); // 下一帧继续调用
} else {
console.log('Animation stopped');
}
}
requestAnimationFrame(animate);
(3)Web Worker (适用于CPU密集型操作)
Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。这样的好处是,一些计算密集型或高延迟的任务,被 Worker 线程负担了,主线程就会很流畅,不会被阻塞或拖慢。Web Workers允许在独立的线程中执行JavaScript代码,不会受到浏览器渲染周期、页面失活等因素的影响,适合于那些需要精确时间控制、后台计算的任务。
// main.js
let worker = new Worker('worker.js');
worker.postMessage('start'); // 启动 Web Worker 计时器
worker.onmessage = function(event) {
console.log(event.data);
};
// worker.js
let count = 0;
function startTimer() {
count++;
postMessage(`Message #${count}`);
if (count < 5) {
setTimeout(startTimer, 1000); // 每秒执行一次
} else {
postMessage('Worker stopped');
}
}
onmessage = function(e) {
if (e.data === 'start') {
startTimer();
}
};
Web Worker有诸多限制,例如读取文件限制(无法读取本地文件)、DOM操作限制(无法读取主线程所在网页的DOM对象)、通信限制(跟主线程不能直接通信)等等,可以把 Worker 封装为类,在类内部处理好逻辑,然后暴露 setInterval 等方法给外部实例调用。
可以作为一个优化思路。
7、总结
总的来说,本文简单介绍了异步机制:回调函数->Promise->Async/Await,重在由Event Loop机制 理清异步编程中事件的调用顺序,在此基础上结合自身开发遇到的问题例如生命周期钩子调用顺序、计时器等等小做拓展。希望对大家有帮助kiki😇😇😇