Day 15 - Asynchronous 非同步进化顺序 - Callback 与 Promise

前言

在上一篇文章中,我们深入了解 Javascript 非同步的核心,到底在背景做了哪些事,才构成了我们实际看到的画面。

今天让我们来了解一些常见的非同步操作,callback、Promise,他们的演化脉络,以及各自的优缺点吧!

非同步第一步 - Callback Function

中文可以叫做「回呼函式」,基本上就是一个普通的函式,但因为被应用在「非同步结束後才呼叫」,所以多得到一个 callback function 的称号。

有没有觉得 callback 这个词特别眼熟?昨天在讨论非同步核心时,有提到一个:

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

没错,就是你想的那样!

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

Callback function 出没地

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

elem.addEventListener('click', callback);

或者计时器:

setTimeout(callback, 1000);

这个东西很常会跟我们在 Day 8 提到的高阶函式 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 Hell

但如果今天有一件事情,我告诉它下一步,还要再告诉它下一步的下一步,以及下一步的下一步的下一步...

我下一步你的下一步!
我 callback 的时候 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 本身是很好的方式,但在处理一连串巢状 callback 时,视觉上容易出错(这个右括号是哪一层的右括号?),虽然可以用 linter 解决,但要做错误处理(error handling)也会更为复杂,每一层的 code 又会增加,最後变成一大坨难以维护的「程序码群」。

非同步第二步 - Promise

於是 Promise 诞生了,拯救在地狱浮沉的 callback 们。

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

  • pending
  • fulfilled
  • rejected

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

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

生活化来说,Promise 就像是在商店街跟店家点餐时,店家不可能一点餐就立刻出餐,但也不好意思让客人一直站在旁边等,所以店家会给你这个:

取餐叫号机

取餐叫号机,就像是 Promise,你刚拿到时,餐点还没好,所以是 pending,当餐点做好时,就会震动闪光(?)转成 fulfilled 状态,告诉你该做下一步了。(不过取餐叫号机好像很少有 rejected 状态?也许是你的餐做失败了,请回来重新点餐...)

我们可以用 .then() 的方式拿到他的 result,.catch() 抓到他的error:

const doSomethingReturnPromise = () => {
    // 用 setTimeout 来模拟一个非同步的 Promise
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        // 一秒後回传资料
        resolve({ data: 123 });
      }, 1000);
    })
};

doSomethingReturnPromise()
    .then(function (result) {  
        // ...
    })
    .catch(function (err) {  
        // ...
    });

Promise 解决了什麽问题

可以看到其实 Promise 也运用了一些 callback 进来,但最大的不同是,他的 then()catch() 都可以回传一个 Promise (可以想像成,每个阶段工作人员都会给你一个叫号机),因此可以「串接」下去:

doSomethingReturnPromise()
    .then(function (result) {  
        // ...
        // return 另一个 promise
    })
    .then(function (result) {  
        // ...
        // return 另一个 promise
    })
    .then(function (result) {  
        // ...
    })
    .catch(function (err) {  
        // ...
    });

在 callback hell 遇到的问题,多层的缩排瞬间就被拉成一层,只要每个 .then() 都回传 Promise,就可以确保 A 完成了做 B,B完成了做 C,你要串几个都很容易扩充(promise chain)。

而且错误处理的部分也简单了许多,中间不管有几个 .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);
    });

(上面这一段真的可以复制贴到 console 执行,每日笑话lol)

结语

非同步系列的演化脉络,虽然大家可能早就用 async/await 飞来飞去了,但就像我们学习历史一样,监古知今。

  • 在 ES5 以前是如何使用非同步语法?
  • 遇到了什麽困难?
  • ES6 多了 Promise 又解决了什麽问题?

重点就在於「解决了什麽问题」。

当我们更清楚这些新技术的诞生,不是为了出现而横空出世,而是为了解决实际 coding 会遇到,可读性、可维护性的问题,才会对於手上正在用的工具更加清晰。

有感於许多踏入前端领域的人,已经是框架时代的起飞时期,这些人可能不知道 jQuery 有划时代的意义,只觉得是过时的产物,这样盲目追求的人,容易不清楚手上工具的优势与劣势,简单的展示页面也动辄 Angular、React 飞来飞去,其实是很危险的。

希望这几天的「非同步讲古入门」,能够点出每个工具在那个时代的意义。

一次次的等候
在愈来愈深的洞穴
渐渐迷失了归途

参考资料

MDN - Promise
MDN - fetch


<<:  【Day30】函式常见的陷阱题

>>:  [Day 20] 2D 批次渲染 (二) - BURN OUT

Day 29 Quantum Protocols and Quantum Algorithms

Understanding Quantum Teleportation 1.Creates an e...

[DAY 02] 早餐:猪头饭

永茂猪头饭 地点:台南市盐水区朝琴路48号(盐水信义路与朝琴路的交叉口) 时间:早上 第一次到盐水时...

【Day8】千算万算的运算子

运算子算是比较繁杂的部分,需要多些耐心来理解与记忆,没办法用一个简明的观念来一以贯之。 算术运算子...

CSS display:grid

Gird是一种二维的布局方式,相较flex来说grid还多控制了列~ example : <d...

【Day27】建立一个 QA Bot

今天要来跟各位一起解析 QnA Maker Bot,以下简称 QA Bot。 今天是参考 官方范例程...