从 Promise 来看 JavaScript 的异步处理

一.前言

在早期 JavaScriptES5 语法中,多层函数的回调嵌套是一件让人很头疼的事儿,行内黑话一般称之为回调地狱

可能有些伙计还没遇到过此类业务场景,但是没关系,只要在前端圈里混,苍天会绕过谁呢?所以为了大家,我就举个特别常见的业务场景:

  • 有三个接口,分别为 URL-A, URL-B, URL-C (都是 get 请求),我们需要分别向这三个接口请求获取数据。
  • 请求 URL-B 时需要带上 URL-A 返回的数据,同理,请求 URL-C 时也要带上 URL-B 返回的数据。

我们来看看用早期的 jquery ajax 会怎么处理:

$.get('/URL-A', function(resA){
    // do Something
    $.get('/URL-B?query=' + resA,function(resB){
        // do Something
        $.get('/URL-C?query=' + resB, function(resC){
            // do Something
        })
    })
})

从上面我们可以看出,这一段代码是很不健康的,为什么这么说?有以下几点理由:

  1. 代码横向发展,而不是纵向变多,就像人不长高反而长胖一般,十分不健康。
  2. 业务逻辑不够直观,维护困难。
  3. 业务代码与公用代码难以抽离,函数之间强耦合,一旦报错很难快速定位问题所在。

当然,我们也可以用函数内 callback 的形式来改写上面的这段代码,使之变得更直观些:

// 请求 URL-C 
function getURLCData(res){
     $.get('/URL-C?query=' + res, function(res){
          // do Something
    })
}

// 请求 URL-B
function getURLBData(res){
     $.get('/URL-B?query=' + res, function(res){
          // do Something
        getURLCData(res)
    })
}

// 请求 URL-A 
function getURLAData(){
    $.get('/URL-A', function(res){
       // do Something
        getURLBData(res)
    })
}

这样我们就避免了函数的纵向发展,公共代码与业务代码也可以抽离,但是这种方式还不够直观,在复杂业务,超高并发请求下,业务代码依旧晦涩。

所以,在 ES6 中提出了 Promise 用来解决回调嵌套的问题。

以上代码的 Promise 改写我们在下文再讲,我们先讲讲何为 Promise

二. Promise 的基本用法

对于 Promise 我们可以这么理解,如果一个函数 Promise (数据准备好了)了,那么我们就可以 then 干点事情。

MDN 对其有以下描述:

Promise 对象是一个代理对象(代理一个值),被代理的值在Promise对象创建时可能是未知的。它允许你为异步操作的成功和失败分别绑定相应的处理方法(handlers)。 这让异步方法可以像同步方法那样返回值,但并不是立即返回最终执行结果,而是一个能代表未来出现的结果的promise对象

Promise 有以下四个函数可以调用:

  1. Promise.all(iterable)
    这个方法返回一个新的 promise 对象,该 promise 对象在 iterable 参数对象里所有的 promise 对象都成功的时候才会触发成功,一旦有任何一个 iterable 里面的 promise 对象失败则立即触发该 promise 对象的失败。
    注意,iterabe 参数为数组,数组里存放 promise 对象。
  2. Promise.race(iterable)
    当iterable参数里的任意一个子promise被成功或失败后,父promise马上也会用子promise的成功返回值或失败详情作为参数调用父promise绑定的相应句柄,并返回该promise对象。
  3. Promise.resolve(value)
    返回一个状态由给定value决定的Promise对象。
  4. Promise.reject(reason)
    返回一个状态为失败的Promise对象,并将给定的失败信息传递给对应的处理方法

我们来看个例子:

实现一个简单的定时 promise:

function delayLogNum(){
    return new Promise((resolve, reject) => {
        setTimeout(()=> {
            console.log('success');
            resolve('ok');
        }, 3000)
    })
}

delayLogNum().then(res => {
    console.log(res)
})

结果如下所示:
在这里插入图片描述

三.Promise 处理串行和并行

JavaScript 中已经有同步,异步,串行,并行这些概念了,大家需分清楚其中的区别:

  • 同步异步是指是在 JavaScript 的主线程中执行(同步)还是丢到任务队列中执行(异步)。
  • 串行并行是指在异步任务队列中的函数是按顺序一个一个执行(串行)还是所有队列中的函数一起执行,但是必须在所有函数执行完毕后再接着执行下一步。

ES7 中新增加了 asyncawait 关键字:

  • async 用于定义一个返回 AsyncFunction 对象的异步函数。异步函数是指通过事件循环异步执行的函数,它会通过一个隐式的 Promise 返回其结果。
  • await 操作符用于等待一个Promise 对象。它只能在异步函数 async function 中使用。

ok,梳理完了前置知识点,我们来看看利用 Promiseasync await 怎么处理串行。

举一个例子:遍历一个 Number 数组并且在每次遍历时延时 1 秒输出遍历的数值。

function delay(){
    return new Promise((resolve, reject) => {
        setTimeout(resolve, 1000)
    })
}

async function eachEveryVal(item){
    await delay();
    console.log(item)
}

async function eachArr(data){
    for(var val of data){
       await eachEveryVal(val)
    }
}

eachArr([1, 2, 3, 4])

我们再举一个例子,就拿最开始那段代码来说,就是典型的异步串行操作,我们可以这么来改写它:

function getURL(url){
    return new Promise((resolve, reject) => {
        $.get(url, res => {
            resolve(res)
        })
    })
}

async getData(){
    let dataA = await getURL('URL-A');
    let dataB = await getURL('URL-B?query=' + dataA);
    let dataC = await getURL('URL-C?query=' + dataB);
};

getData();

讲完了串行,我们再来讲讲异步并行。假设我们有以下需求:

  • 有三个接口,分别为 URL-A, URL-B, URL-C (都是 get 请求),我们需要分别向这三个接口请求获取数据。
  • 在三个请求都结束后,拿到他们的数据进行业务处理。

这就是一个典型的并行的业务需求,我们也可以用 promise 来实现它。

const URI_LIST = ["URL-A", "URL-B", "URL-C"];

function getURL(url){
    return new Promise((resolve, reject) => {
        $.get(url, res => {
            resolve(res)
        })
    })
}

async function getData(){
    const promises = URI_LIST.map(url => getURL(url));
    Promise.all(promises).then(res => console.log(res))  
};

getData();

我大概解释下这段代码:

  • getURL() 函数返回一个 promise,并在传入 url 参数给 $.get() 调用,请求成功后调用 reslove(res) 来返回请求结果。
  • getData() 函数声明一个 promises 来存放 getURL(url) 返回的 promise 对象,通过 URI_LIST.map() 来得到我们在基础用法中所讲的 iterable 参数对象,并将此对象传入 Promise.all() 中,最后通过 then() 获取结果。要注意的是,此结果是三个请求返回的数据组成的数组。

四.Promise 常见特性

有以下5个特性需要大家理解:

  1. Promise 捕获错误与 try catch 等同.
  2. Promise 拥有状态变化.
  3. Promise 方法中的回调是异步的.
  4. Promise 方法每次都返回一个新的 Promise.
  5. Promise 会存储返回值.

下面我来一一解释这 5 点特性:

1.Promise 捕获错误与 try catch 等同

这句话的意识就是说,在 new Promise(()=>{}) 中直接去 throw err,是可以通过 Promise.catch() 方法捕捉的,这也就意味中 Promise 内部也通过 try catch 进行了异常处理。

2.Promise 拥有状态变化

Promise 有以下三种状态:

  • pending: 初始状态,既不是成功,也不是失败状态。
  • fulfilled: 意味着操作成功完成。
  • rejected: 意味着操作失败。

Promise.resolve()Promise.reject() 都会改变 promise 的状态值,其中 resolve 会将此状态值修改为 fulfilled, 而 reject 会将此状态值修改为为 rejected

特别的,一旦 Promise 的状态值被改变,就会被固定,不再发生变化。也就是说只要你 resolve() 或者 reject() 了一次,在这之后无论你再调用几次这两个方法都不起效果。

3.Promise 方法中的回调是异步的

先解释一下,Promise 方法中的回调是异步的这句话中的方法是指 Promise 中的 catch,then,finally这些方法,而不是指 new Promise() 中的 executor 函数,这个函数你可以把它理解为一个立即执行函数。

想要真正理解 Promise 方法中的回调是异步的这句话,还没有这么简单,为什么这么说,因为 setTimeout 也是异步的,如果它们两同时存在且作用域平级,那么谁先执行,谁后执行,它们之间的竞争关系怎么确认?

想要了解这其中的原理,我们就需要了解一个概念:微任务(microtasks)宏任务(tasks)

我们已经知道,JavaScript 是单线程的操作,正是因为如此,才有了现在的同步和异步之分。在主线程中,一般是按顺序执行同步任务。而其他的异步任务则会挂起,当它们有返回值后会添加到任务队列中。等到主线程的同步任务执行完毕后,它会去任务队列中读取(按先进先出的原则)异步任务执行。以此形成一个反复的过程被称为事件循环。借用一个掘金上的图片,侵删:

在这里插入图片描述

而在异步任务中,其实又可以细分为宏任务和微任务。

  • 宏任务可以当成了广义的异步队列中的任务,严格按照顺序压栈和执行。比如说 整体代码, setTimeoutsetInterval, MessageChannel(Web Worker中的管道通信)。
  • 微任务是当前宏任务执行完成后立即执行的任务。

而在整个异步流程中,JavaScript 会先进入整体代码执行宏任务,然后再检查是否有微任务需要执行,如果有,则需要立即执行;如果没有则检查队列,开始执行下一批宏任务并检查微任务。借用一个掘金上的图片,侵删:

在这里插入图片描述

总结一下, Promise 中的 executor 函数是处于主线程同步队列中执行(立即执行函数),而其他的方法诸如 then, catch 等,则是异步任务队列中的微任务,诸如 setTimeout,setInterval 等函数必须在微任务执行完毕后再开始执行。

所以,看到这儿,整个 Promise 中的函数内部在整个执行栈的执行顺序和竞争关系就已经很清晰了。

4.Promise 方法每次都返回一个新的 Promise

这儿的意思很直白,意味着无论是 then,catch 亦或是 finally 都会返回 一个新的 Promise 对象。

5.Promise 会存储返回值

一般情况下我们都会这样来使用 Promise:

function p(flag){
    return new Promise((resolve, reject) => {
        if(flag){
            resolve('success')
        }else{
            reject('error')
        }
    })
};

p(true).then(res => console.log('res', res))
   

可以看到,我们通常会把一些参数或者函数在成功状态下通过 resolve() 传递给 then() 函数来接收并作相应处理;在失败状态下通过 reject() 把错误信息传递给 catch() 函数来处理。

特别的,如果你在 Promise 直接返回某些参数, Pormise 也会捕捉到你返回的参数并把它包装成 Promise 对象并传递给对应的接收函数。

五. Promise 面试题

5.1 请用 Pormise 实现以下流水灯,已知红黄绿三个函数,要求红灯3秒执行一次,黄灯2秒执行一次,绿灯1秒执行一次:

function red() {
    console.log('red');
}
function green() {
    console.log('green');
}
function yellow() {
    console.log('yellow');
}

function delay(time){
    return new Promise((resolve, reject) => {
        setTimeout(resolve,time)
    })
}

async function runTask(){
    await delay(3000);
    red();
    await delay(2000);
    green();
    await delay(1000);
    yellow();
    // 递归循环播放
    runTask()
}
runTask();

5.2 请用 Pormise 实现 mergePromise 函数,把传进去的数组按顺序先后执行,并且把返回的数据先后放到数组 data 中:

const timeout = ms => new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve();
    }, ms);
});

const ajax1 = () => timeout(2000).then(() => {
    console.log('1');
    return 1;
});

const ajax2 = () => timeout(1000).then(() => {
    console.log('2');
    return 2;
});

const ajax3 = () => timeout(2000).then(() => {
    console.log('3');
    return 3;
});

const mergePromise = ajaxArray => {
    // 在这里实现你的代码

};

mergePromise([ajax1, ajax2, ajax3]).then(data => {
    console.log('done');
    console.log(data); // data 为 [1, 2, 3]
});

// 要求分别输出
// 1
// 2
// 3
// done
// [1, 2, 3]

我们先分析一下题目,看到这个题目是不是就有一种很熟悉的感觉?像不像我们在上面改写的异步并行

你的感觉没错,实际上这道题考查的就是让你手写一个简单的 Promise.all() 函数。

所以,我们就能知道,上题中 ajaxArray 参数实际上就是一个包含多个 Promise 对象的数组,我们可以用并行遍历的方式来处理它。

const mergePromise = ajaxArray => {
    let seq = Promise.resolve();
    let data = [];
    ajaxArray.map(func => {
        seq = seq.then(func).then(res => {
            data.push(res);
            return data;
        })
    })
    return seq;
};

六.小结

如果你看到这了这,那么恭喜你,不管你有没有吸收其中的内容,你至少你知道了整个 Promise 应该怎么去学。实际上在工作中 Promise 的应用是很多的,包括我们使用的 babel 中也会有 Promise-polyfill。现在已经是 9102 年了,前端圈已经逐渐稳定下来,这意味着你我的时间已然不多,所以加油吧,伙计们。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章