面试官:讲讲前端异步的几种形式🥹

185 阅读6分钟

异步在面试中非常容易被出题拷打,开发中也常常困于生命周期,最好能够一次性全部掌握!让我们来看看这到底怎么个事儿嘎嘎嘎 lQDPKIJNwTckVW_NBDjNBOCwnns13sqruWYH_lLIlkaiAA_1248_1080.jpg

异步操作是指程序中的操作可以同时进行,不需要等待上一个操作完成才能进行下一个操作。异步操作通常会通过回调函数、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执行顺序

  1. 执行同步代码(这属于宏任务)
  2. 当执行栈为空时,查询是否有异步需要执行
  3. 如有则执行微任务
  4. 如果有需要则会渲染页面
  5. 执行宏任务(下一次event loop的开始)

宏任务 > 所有微任务 > 宏任务

image.png

  1. 将所有任务看成两个队列:执行队列与事件队列。
  2. 执行队列是同步的,事件队列是异步的,宏任务放入事件列表,微任务放入执行队列之后,事件队列之前。
  3. 当执行完同步代码之后,就会执行位于执行列表之后的微任务,然后再执行事件列表中的宏任务

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现在状态值。

点击后控制台结果:

  1. let100 被触发:
  • 点击按钮时,let100() 函数被调用,首先输出 let100begin
  • 在 let100() 中,创建了一个新的 Promise,并且在其执行过程中调用了 setCount(100),这个 setCount 会触发组件的重新渲染。
  1. 组件重新渲染:
  • React 会在状态更新后重新渲染组件,输出console.log('Rendered')
  • React 在渲染过程中,首先是同步的操作。此时,useEffect 不会立即执行,它是异步的。
  1. 执行中间同步操作
  • 输出srcipt startscript end为止的所有同步操作
  1. useEffect 会在所有同步任务执行完成后运行
  • 由于副效应函数是每次渲染都会执行,回调函数不仅在组件卸载时执行一次,每次副效应函数重新执行之前也会执行一次,用来清理上次渲染的副效应。

  • 输出uesEffect回调0,输出新的count值useEffect triggered,count:100

  1. 输出微任务队列

    let100then、async1 end、promise2

  2. 输出宏任务队列

    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😇😇😇