JS异步的那些事情

380 阅读8分钟

事件循环

  如果需要理解js异步,那么首先就要理解,js异步的本质是什么?众所周知,js是单线程的语言,但是浏览器又可以很好地去处理异步请求,这是为什么?

  首先要清楚,js引擎只有一个调用栈,所有任务都是通过调用栈执行的,但是每个任务调用的时机是不一样的!大体可以分为同步任务和异步任务两种

同步任务

  同步任务也就是需要直接执行的任务,同步任务会直接塞入调用栈直接执行,比如我们看一个例子,完全按照代码顺序执行:

function fn1(){
    console.log(1);
}

function fn2(){
    console.log(2);
    fn1();
    console.log(3);
}

fn2();
//2
//1
//3

可以画一个流程图来表表示这些任务是如何添加到调用栈当中的:

image.png

异步任务

  其中异步任务又被分为宏任务和微任务,js引擎里面有两个任务队列。

  1. 消息队列(宏任务队列)
  2. 微任务队列(微任务队列)   一般来说异步任务,会去判断它是宏任务还是微任务,然后各自进入不同的队列,等待调用栈执行完所有同步任务之后,就会首先去清空微任务队列,再之后会去宏任务队列里面提取出一个任务,进入调用栈执行,然后执行完又去清空微任务队列。这样一直循环下去,这也就是我们所说的事件循环整体代码可以看成是第一个宏任务。所以有的时候,我们想要一个任务在本帧的最后执行,我们可以将它变成异步任务,那么它肯定就是后于本帧同步任务的执行,这其实也是为什么我们浏览器在加载资源的时候并不会卡顿住,因为浏览器加载资源是异步的。不会阻塞浏览器页面元素的显示。这也是为什么我们感官上并没有卡顿的原因。当然这涉及到很多方面的知识,不再做讨论。

宏任务

  主要有:script( 整体代码)setTimeoutsetIntervalI/O、UI 交互事件setImmediate(Node.js 环境)

微任务

  主要有:PromiseMutaionObserverprocess.nextTick(Node.js 环境)

  来看一个有关异步的例子

function fn1(){
    console.log(1);
}

function fn2(){
    setTimeout(() => {
        console.log(2);
    }, 0);
    fn1();
    console.log(3);
}
fn2();
//1
//3
//2

  比如我们让上面的例子加入一个setTimeout宏任务。我们来逐步分析一下。读者可以自行画一下调用栈(我偷个懒~)

  • 首先调用fn2(),将fn2()加入调用栈当中。
  • 碰到setTimeout()函数,setTimeout()属于宏任务,所以将它里面的console.log(2)塞入消息队列当中,先暂存起来不执行。这里要注意,定时器要到0才会将它存入宏任务队列当中,这涉及到webApi的问题。一般来说执行setTimeout等定时器的任务,会先执行定时器程序,到0之后再塞入消息队列当中,因为这里是零,一开始执行,就直接塞入消息队列。(其实这也是为什么js定时器程序执行都要比设定的时间要长一些,首先要等待时间为0才会塞入消息队列,然后再等待调用栈提取宏任务时才会执行)
  • 然后执行fn1(),将console.log(1)塞入调用栈且执行,输出 1
  • 再执行console.log(3)输出3
  • 调用栈同步任务执行完毕,首先清空微任务队列,但是这里并没有微任务,所以提取一个宏任务进调用栈执行,也就是console.log(2)输出2   所以结果为1 3 2

  我们来看一个难一点的例子:

function fn1(){
    console.log('fn1');
}

function fn2(){
    fn1();
    new Promise(()=>{
        console.log('promise');
    }).then(()=>{
        console.log('then_1');
    })
    setTimeout(() => {
        console.log('setTimeout_1');
        Promise.resolve().then(()=>{
            console.log('then_2');
        })
    }, 0);
    setTimeout(() => {
        console.log('setTimeout_2');
    }, 1000);
    setTimeout(() => {
        console.log('setTimeout_3')
    }, 0);
    console.log('fn2');
}
fn2();

分析:

  • 代码执行,首先将fn2()塞入调用栈。任务队列情况:
调用栈:fn2()
消息队列(宏任务队列):
微任务队列:
  • 执行fn1(),将fn1()塞入调用栈。
调用栈:fn2()、fn1()
消息队列(宏任务队列):
微任务队列:
  • fn1()中的console.log('fn1')塞入调用栈
调用栈:fn2()、fn1()、console.log('fn1')
消息队列(宏任务队列):
微任务队列:
  • 执行console.log('fn1')输出fn1,并弹出console.log('fn1'),因为fn1函数执行完毕。所以弹出fn1()
调用栈:fn2()
消息队列(宏任务队列):
微任务队列:
  • 继续执行遇到new Promisepromise构造函数里面的代码是同步任务,console.log('promise')直接加入调用栈,但是then()方法里面的是微任务。所以console.log(then_1)加入微任务队列,暂不执行
调用栈:fn2(),console.log('promise')
消息队列(宏任务队列):
微任务队列:console.log(then_1)
  • 继续执行,执行调用栈内的console.log('promise')输出promise,弹出console.log('promise')
调用栈:fn2()
消息队列(宏任务队列):
微任务队列:console.log(then_1)
  • 因为这里有三个setTimeout任务,这里我们统一说,因为这种定时器任务还要先在定时器执行到时间为0,然后加入宏任务队列当中。这里我们可以看到根据三个定时器时间不同,加入宏任务的顺序为setTimeout1setTimeout3setTimeout2,然后再遇到console.log(fn2)加入调用栈里面。
调用栈:fn2()、console.log('fn2')
消息队列(宏任务队列):setTimeout1,setTimeout3,setTimeout2
微任务队列:console.log(then_1)
  • 然后执行console.log('fn2')输出 2,fn2()函数代码执行完,所以弹出console.log('fn2')、fn2()
调用栈:
消息队列(宏任务队列):setTimeout1,setTimeout3,setTimeout2
微任务队列:console.log(then_1)
  • 此时调用栈为空,需要去清空微任务队列。所以调用栈执行console.log(then_1),输出then_1
调用栈:
消息队列(宏任务队列):setTimeout1,setTimeout3,setTimeout2
微任务队列:
  • 之后消息队列推一个宏任务队列进调用栈里面执行,setTimeout1里面,console.log('setTimeout_1'),加入调用栈,console.log('then_2')加入微任务队列。
调用栈:console.log('setTimeout_1')
消息队列(宏任务队列):setTimeout3,setTimeout2
微任务队列:console.log('then_2')
  • 调用栈立即执行console.log('setTimeout_1')输出setTimeout_1后,调用栈为空,再去清空微任务队列,将微任务加入调用栈之后执行console.log('then_2')输出then_2,消息队列再将一个宏任务推进调用栈当中执行。
调用栈:setTimeout3
消息队列(宏任务队列):setTimeout2
微任务队列:
  • 之后再依次执行setTimeout3setTimeout2里面的代码块内容,直至代码执行完毕。 输出:fn1、promisefn2then_1setTimeout_1then_2setTimeout_3setTimeout_2

Promise

  promise是我们经常用来做异步操作的一种对象。而promise中文翻译为承诺期约。代表这个对象一旦执行,promise对象状态发生改变,就无法再改变它的状态了。对象状态不受外部影响。而promise对象具有三种状态:

  1. pending:待定状态(初始状态),promise对象创建出来就是这个状态
  2. fulfilled:操作成功,代表已解决,调用resole()后变成此状态
  3. rejected:操作失败。代表未解决,已拒绝。调用rejected()后变成此状态

promise的优点

  promise解决了异步回调地狱的问题,比如,我们在加载资源的时候,我们经常需要先加载好一个文件再去加载另一个文件,比如需要加载三个文件,顺序为a->b->c。这个时候假如不使用promise一般会这么做。

var fs=require('fs');
fs.readFile('./src/a.txt','utf-8',function(err,data){
    if(err){
        throw err;
    }
    console.log(data);
    fs.readFile('./src/b.txt','utf-8',function(err,data){
        if(err){
            throw err;
        }
        console.log(data);
        fs.readFile('./src/c.txt','utf-8',function(err,data){
            if(err){
                throw err;
            }
            console.log(data);
        })
    })
})

  一层一层嵌套回调。这种我们称为回调地狱,但是假如我们使用promise来处理这个,就可以解决这个回调地狱的问题。比如:

var fs=require('fs');
function readFile(path){
    return new Promise((resolve,reject)=>{
        fs.readFile(path,'utf-8',function(err,data){
            if(err){
                reject();
            }
            resolve();
        })
    })
}
readFile('./src/a.txt')
    .then(()=>{ return readFile('./src/b.txt')})
    .then(()=>{ return readFile('./src/c.txt')});

缺点

  我们没办法知道promise执行到哪一步了,我们只能知道他在执行中还是执行完成,而且就表象来说,我们无法打断它,但是现在目前已经有一些库可以支持打断promise的执行。有兴趣的可以去了解一下。

async和await

  asyncawait一般是成对出现,创造出来的目的是为了让我们的异步代码看起来更像同步代码,属于异步编程的一种常用方法,await方法,顾名思义,等待!它会等待右边的值是否已经可用。而右边的值主要分为两大类,一个是promise类型,另外一个是非promise类型。而且都会等待await函数右边的值可用。在值可用之前都会阻塞await后面的代码执行。比如:

function fn(){
    return 1;
}
async function asyncTest(){
    const n=await fn();
    console.log(n);
}

console.log('0');
asyncTest();
console.log('2');
//0
//2
//1

  promise类型返回值的会特殊一些,会将reslove()函数的参数作为await的函数的返回值。并且promise变为fulfill状态才会继续执行下去。

function fn(){
    return new Promise((reslove,reject)=>{
        setTimeout(() => {
            reslove('1');
        }, 1000);
    });
}
function fn1(){
    return new Promise((reslove,reject)=>{
        setTimeout(() => {
            reject('2');
        }, 1000);
    })
}
async function asyncTest(){
    const n=await fn();
    console.log(n);
    const m=await fn1();
    console.log(m);
}

console.log('0');
asyncTest();
console.log('3');
//0
//3
//1
//Uncaught (in promise) 2  //这里并没有输出2而是抛出了一个错误,并没有执行console.log(m)