彻底搞懂Asynchronous Function(二)

504 阅读12分钟

前言

先要了解同步的概念再阅读这一章哦,传送门 彻底搞懂 Asynchronous Function (一)

非同步第一步 - Callback Function

你可能常听到人家在讲 Callback function,但你真的知道 Callback function 是什么吗?其实 Callback function 跟一般的函式没什么不同,差别在于被呼叫执行的时机,被应用在「非同步结束后才呼叫

有没有觉得callback这个词特别眼熟?昨天在讨论 彻底搞懂 Asynchronous Function (一)时,有提到一个:

Callback Queue:用来存放从 Web api 过来,准备要进入 Call Stack 的指令

没错,就是你想的那样!

Callback Queue 就是用来让 callback function 排队等待的地方。

Callback function 使用场景

callback function 最常见的就是 DOM 事件绑定:

elem.addEventListener('click', callback);

或者计时器:

setTimeout(callback, 1000);

或者「用来控制多个函式间执行的顺序

// 为了确保先执行 funcA 再执行 funcB
// 我们在 funcA 加上 callback 参数
var funcA = function(callback){
  var i = Math.random() + 1;

  window.setTimeout(function(){
    console.log('function A');

    // 如果 callback 是个函式就呼叫它
    if( typeof callback === 'function' ){
      callback();
    }

  }, i * 1000);
};

var funcB = function(){
  var i = Math.random() + 1;

  window.setTimeout(function(){
    console.log('function B');
  }, i * 1000);
};

// 将 funcB 作为参数带入 funcA()
funcA( funcB );

像这样,无论 funcA 在执行的时候要等多久,funcB都会等到 console.log('function A'); 之后才执行。

Callback function这个东西很常会跟我们在彻底搞懂 Functional Programming (一)提到的高阶函式HoF (Higher-order function) 搞混。

简单来说,把一个 function A,传进给另外一个 function B 当参数,等到 B 的事情做完,他会去呼叫并执行那个 A。

我们会称呼 function A 为callback function (因为带入),而 function B 为HoF (因为有函式参数带入)。

所以上面两个例子中,addEventListenersetTimeout就是 HoF,而 callback 就是 callback function。

Callback 解决了什么问题

面对不知道要等多久的事情,与其站在那边等,不如直接给它一个 function,告诉它事情做完就来执行 function,我就可以去做其他事情了。

生活化来说,就像订网购一样,我在网站上下订商品 (执行 function B),但不可能下订之后就立刻准备好,所以我也同时告诉它,包裹准备好之后,帮我送到我家 (执行 function A)。

值得注意的是,我完成下订后,就可以去浇我的花、追我的剧,不用特地等包裹准备好才跟对方说地址,我可以同时去做我其他事情,包裹就会自动送到了。

写成简单的程式码就像是这样:

const doOrder = () => {
    console.log('传送交易资料到主机预计 2 秒...');
    setTimeout(() => {
        console.log('传送完成,开始配送预计 10 秒...');
        setTimeout(() => {
            console.log('包裹已送达!');
        }, 10000);
    }, 2000);
    
    
};

doOrder();
console.log('订单送出去了,可以去浇花、追剧啰!');


执行结果

传送交易资料到主机预计 2 秒...
订单送出去了,可以去浇花、追剧啰!
// (2秒空档)
传送完成,开始配送预计 10 秒...
// (10秒空档)
包裹已送达!

在 setTimeout 的第一个参数,就是我们的 callback function,我们没有被动地等 setTimeout 的秒数跑完,而是主动告诉它下一步是什么,然后就可以利用时间去做其他事

可以说,callback function 是一个转被动为主动的姿态啊!

Callback 存在的问题

延续上一个例子,我需要在下订单之前,先查询我的钱包是否有余额,那就会变这样:

  1. 先帮我查余额
  2. 查完余额帮我下订单
  3. 下完订单帮我送过来
const doOrder = () => {
    console.log('查询钱包预计 3 秒...');
    setTimeout(() => {
        console.log('查询完成,传送交易资料到主机预计 2 秒...');
        setTimeout(() => {
            console.log('传送完成,开始配送预计 10 秒...');
            setTimeout(() => {
                console.log('包裹已送达!');
            }, 10000);
        }, 2000);
    }, 3000);
};

doOrder();
console.log('订单送出去了,可以去浇花、追剧啰!');
执行结果

查询钱包预计 3 秒...
订单送出去了,可以去浇花、追剧啰!
传送交易资料到主机预计 2 秒...
// (2秒空档)
传送完成,开始配送预计 10 秒...
// (10秒空档)
包裹已送达!

从上面这个例子你可以看见callback的缺点如下:

  1. 在处理一连串巢状 callback 时,视觉上容易出错 (这个右括号是哪一层的右括号?)
  2. callback要做错误处理 (error handling) 也会更为复杂
  3. 做了错误处理之后每一层的 code 又会增加,最后变成一大坨难以维护的「程式码群」。

非同步第二步 - Promise

于是 Promise 诞生了。简单来说,Promise按字面上的翻译就是「承诺、约定」之意,回传的结果要嘛是「完成」,要嘛是「拒绝」。

语法如下:

  1. 要提供一个函式 promise 功能,让它回传一个 promise 物件即可:

  2. 当 Promise 被完成的时候,我们就可以呼叫resolve(),然后将取得的资料传递出去。或是说想要拒绝Promise当个完全没有信用的人, 那么就呼叫 reject() 来拒绝。

//声明
const myFirstPromise = new Promise((resolve, reject) => {
  resolve(someValue);         // 完成
  reject("failure reason");   // 拒绝
});
//调用
function myAsyncFunction(url) {
  return new Promise((resolve, reject) => {
    // resolve() or reject()
  });
};

Promise可以想像是一种特别的物件,这种物件是用状态机的方式,来表达任务执行的状态,主要有三种状态:

  • pending
  • fulfilled
  • rejected

一个 Promise 刚建立的时候是pending,接下来有两种可能:

  • 成功,执行 resolve 并回传 result,转变成 fulfilled 状态,
  • 拒绝,执行 reject 并回传 error,转变成 rejected 的状态。

image.png 图片来源: MDN: Promise

语法还有...

  1. 如果我们需要依序串连执行多个 promise 功能的话,可以透过 .then() 来做到。同时,promise还会控制多个函式间执行的顺序

  2. 如果我们不在乎 funcA() funcB() funcC() 谁先谁后,只关心这三个是否已经完成呢? 那就可以透过 Promise.all() 来做到: 范例如下:

//-------------------.then()--------------------------
function funcA(){
  return new Promise(function(resolve, reject){
    window.setTimeout(function(){
      console.log('A');
      resolve('A');
    }, (Math.random() + 1) * 1000);
  });
}

function funcB(){
  return new Promise(function(resolve, reject){
    window.setTimeout(function(){
      console.log('B');
      resolve('B');
    }, (Math.random() + 1) * 1000);
  });
}

function funcC(){
  return new Promise(function(resolve, reject){
    window.setTimeout(function(){
      console.log('C');
      resolve('C');
    }, (Math.random() + 1) * 1000);
  });
}

funcA().then(funcB).then(funcC);

.then()就可以做到等 funcA() 被「resolve」之后再执行funcB(),然后 resolve 再执行 funcC() 的顺序了。

// funcA, funcB, funcC 的先后顺序不重要
// 直到这三个函式都回复 resolve 或是「其中一个」 reject 才会继续后续的行为

Promise.all([funcA(), funcB(), funcC()])
       .then(function(){ console.log('上菜'); });

Promise.all()来得到做完funcA, funcB, funcC 就行了,我不关心顺序。

Promise 解决了什么问题

可以看到其实 Promise 也运用了一些 callback 进来,但最大的不同是,他的 then() 跟 catch() 都可以回传一个 Promise,Promise作为物件可以保存状态,就像状态机一样,而callback function作为pure function是不能保存状态的。状态的保存使得各个Promise像乐高一样方便串接。

状态的保存也使得错误处理的部分中间不管有几个.then(),只要有任何一个 Promise 转为rejected,就会进入.catch(),不用再一个个处理 error,只要处理一次即可。

Promise 实战

const doQueryWallet = () => {
    // 用 setTimeout 模拟从资料库 IO 
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        // 一秒后回传资料
        resolve({ balance: 100 });
      }, 1000);
    })
};

const doOrder = () => {
    // 用 setTimeout 模拟资料库 IO
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        // 一秒后回传资料
        resolve({ status: 'ok' });
      }, 1000);
    })
};

doQueryWallet()
    .then(function (result) {  
        if (result.balance > 0) {
            return doOrder();
        } else {
            console.log('余额不足');
            // 往下回传 pending 的 Promise (类似中断 Promise)
            return new Promise((resolve, reject) => {})
        }
    })
    .then(function (result) {  
        if (result.status === 'ok') {
            console.log('下单成功');
        } else {
            console.log('下单失败');
        }
    })
    .catch(function (err) {
        console.error(err);
    });

fetch

ES6 的 fetch 语法是最容易接触到的 Promise 语法:

fetch('https://api.jokes.one/jod')
    .then(function(response) {
        return response.json();
    })
    .then(function(myJson) {
        console.log(myJson);
    })
    .catch(function(error) {
        console.log(error.message);
    });

非同步第三步 - async await

async/await 是 ES7 版本的一个 Promise 语法糖,透过 async 跟 await 两个关键字,可以将原本执行多行 Promise 程式简化成一行,并且使用方式非常贴近一般的同步程式码,大幅提高程式的可读性。

  • async关键字放在 function 的前面,代表「宣告一个非同步的函式
  • await关键字放在呼叫 async function 的前面,代表「呼叫并等待这个非同步函式
  • async function 必须回传 Promise 物件
  • async跟 await 是成对出现的

async function 其实也是一种物件种类,有兴趣可以查看 Mozilla MDN

await 的两种摆法

// 用 setTimeout 模拟一个要等 3 秒的 Promise
const wait3Seconds = async (x) => {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve(x);
    }, 3000);
  });
}

// 函式先 await 再赋值
const a = await wait3Seconds(1);
const b = await wait3Seconds(2);
const sum = a + b;
console.log(sum); // 3

// 函式先赋值再 await
const c = wait3Seconds(1);
const d = wait3Seconds(2);
const sum2 = await c + await d;
console.log(sum2); // 3

有注意到上面的例子中,await 的摆放位置不同吗?可是执行出来的 sum 跟 sum2 结果都一样耶,所以 await 放哪很重要吗?

好奇的话不妨复制贴到 console 跑跑看,记得分两段跑,1-14 行先跑,看完结果再跑 17-20 行。

先公布答案:

第一段要等 6 秒才会有结果,而第二段只要等 3 秒。为什么呢?

新手如果不熟,不妨这样记忆:看到 await 代表要等

第一段(函式先 await 再赋值):

  1. 程式执行到第 11 行,看到 await 所以要等,setTimeout被启动开始倒数
  2. (等了 3 秒之后) 赋值给a
  3. 程式执行到第 12 行,看到 await 所以要等,setTimeout被启动开始倒数
  4. (等了 3 秒之后) 赋值给b
  5. 程式执行到第 13、14 行,相加之后印出

第二段(函式先赋值再await):

  1. 程式执行到第 17 行,没有 await 所以不等,把 Promise 物件赋值给csetTimeout被启动开始倒数
  2. 程式执行到第 18 行,没有 await 所以不等,把 Promise 物件赋值给dsetTimeout被启动开始倒数
  3. 程式执行到第 19 行,看到 await 所以要等 c 完成
  4. (等了 3 秒之后)
  5. 看到 await 所以要等 d 完成,但刚刚那 3 秒已经让 d 也完成了,所以相加之后印出

另一个值得思考的点是,因为 await 的顺序不同,导致ab这两个变数存的是number,而cd则存了 Promise 物件,有兴趣不妨印出来看看。

async await 解决了什么问题

简化了 Promise 复杂的结构,让非同步函式可以变得像是同步一样,不仅可读性更高,在 error handling 的方面,也可以使用既有的 try catch 来解决,某种程度上也让新手更容易上手。

async await 实战

如同昨天 Promise 的例子,同步 / 非同步的界线非常鲜明,要改动常常要顾虑这一行到底是同步还非同步,但改用 async/await 之后,看起来全都像是同步程式码:

const doQueryWallet = async () => {
    // 这里都跟原本一样,只是多了 async
};

const doOrder = async () => {
    // 这里都跟原本一样,只是多了 async
};

const wallet = await doQueryWallet();
if (wallet.balance > 0) {
    const order = await doOrder();
    if (order.status === 'ok') {
        console.log('下单成功');
    } else {
        console.log('下单失败');
    }
} else {
    console.log('余额不足');
}

当然 error handling 的部分,也可以用熟悉的 try catch 包起来:

try {
    const wallet = await doQueryWallet();
    if (wallet.balance > 0) {
        const order = await doOrder();
        if (order.status === 'ok') {
            console.log('下单成功');
        } else {
            console.log('下单失败');
        }
    } else {
        console.log('余额不足');
    }
} catch (err) {
    console.error(err);
}

非同步常见的三种顺序

以上讲的 case,大部分都还是基于「由上而下」的顺序,也就是我们很熟悉同步的程式码顺序,一个一个由上而下执行,第二行绝对会等第一行结束才执行。

非同步有以下三种常见的顺序,可以透过 async/await 及 Promise 简单达到以下效果:

  • sequence(序列)
  • parallel (平行)
  • race (竞争)

sequence

这是我们最常见的顺序,A 执行完换 B,B 执行完换 C,后者不能早于前者,通常是因为后者依赖于前者的资料

比如:一定要先查完帐户余额,确定有余额才能够下单。

const sequence = async () => {
    const output1 = await a();
    const output2 = await b();
    const output3 = await c();
    return `sequence is done ${output1} ${output2} ${output3}`;
}
成功的情況

a
|--------|
         b
         |--------|
                  c
                  |--------|
                           非同步結束
                           |----------
拒绝的情況

a
|--------|
         b
         |--------X
                  非同步結束(throw error)
                  |--------

parallel

这是一个比较有效率的执行方式,不管顺序,把所有非同步程式都撒出去执行,等全部都完成 ( fulfilled) 再一次告诉我。通常代表这几个非同步函式没有互相依赖的资料,可以同时执行

比如:查询店家评论、查询我的优惠券、查询帐户余额,三者没有互相依赖的资料,但是会出现在同一个页面,因此把三个函式都发出去,全部回来再一次做接下来的动作 (比如把 loading 图示关掉)。

const parallel = async () => {
    const [output1, output2, output3] = await Promise.all([a(), b(), c()]);
    return `parallel is done ${output1} ${output2} ${output3}`;
}

成功的情况

a
|-----|
b
|--------------|
c
|--------|
               非同步结束,取得回传结果
               |----------

拒绝的情况

a
|-----X
b
|--------------|
c
|--------|
      非同步结束,取得回传错误
      |----------

注意,成功与失败的回传值是不同的,成功时把结果包成一个 array 回传,里头的顺序即 Promise.all 里面的顺序。而拒绝则是任何一个 Promise 拒绝 ( reject) 就会立刻回传最早拒绝的结果。

注意,Promise 是不可打断的,发出去的函式跟泼出去的水一样,唯一能做的,就是忽略回传值,所以即便上图 a 最早就被拒绝了,b 跟 c 还是会把他们该做的事情在背景跑完,只是我们不会收到结果就是了。

race

这算是 parallel 的一个变种 (?),也是把所有非同步程式都撒出去执行,但就像赛跑一样,只要任何一个完成 ( fulfilled) 或拒绝 ( reject),就立刻回传告诉我,其他未完成的则直接忽略,比较是运用在较特殊的情境。

比如:可以帮 request 设定一个 timeout 的时间限制,放一个 setTimout 10 秒就会 reject 的 Promise 进去Promise.race,就会强制在 10 秒之内得到结果。

成功的情況

a
|-----|
b
|--------------|
c
|--------|
       非同步结束,取得回传错误
      |----------
拒绝的情況

a
|-----X
b
|--------------|
c
|--------|
       非同步结束,取得回传错误
      |----------
const race = async () => {
    const output1 = await Promise.race([a(), b(), c()]);
    return `race is done ${output1}`;
}

结语

非同步很复杂,但也就是因为很复杂,可以玩的花样更多,许多复杂的需求其实都是建构在非同步程式中,否则我们的程式就永远只能同步一行一行执行,那也太不智能了...

无论如何看待
我终将完成我的
承诺

参考资料

MDN - Promise
MDN - fetch