前言
先要了解同步的概念再阅读这一章哦,传送门 彻底搞懂 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 (因为有函式参数带入)。
所以上面两个例子中,
addEventListener
、setTimeout
就是 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 存在的问题
延续上一个例子,我需要在下订单之前,先查询我的钱包是否有余额,那就会变这样:
- 先帮我查余额
- 查完余额帮我下订单
- 下完订单帮我送过来
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 时,视觉上容易出错 (这个右括号是哪一层的右括号?)
- callback要做错误处理 (error handling) 也会更为复杂。
- 做了错误处理之后每一层的 code 又会增加,最后变成一大坨难以维护的「程式码群」。
非同步第二步 - Promise
于是 Promise 诞生了。简单来说,Promise
按字面上的翻译就是「承诺、约定」之意,回传的结果要嘛是「完成」,要嘛是「拒绝」。
语法如下:
-
要提供一个函式
promise
功能,让它回传一个promise
物件即可: -
当
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
的状态。
图片来源: MDN: Promise
语法还有...
-
如果我们需要依序串连执行多个
promise
功能的话,可以透过.then()
来做到。同时,promise
还会控制多个函式间执行的顺序。 -
如果我们不在乎
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
再赋值):
- 程式执行到第 11 行,看到
await
所以要等,setTimeout
被启动开始倒数 - (等了 3 秒之后) 赋值给
a
- 程式执行到第 12 行,看到
await
所以要等,setTimeout
被启动开始倒数 - (等了 3 秒之后) 赋值给
b
- 程式执行到第 13、14 行,相加之后印出
第二段(函式先赋值再await
):
- 程式执行到第 17 行,没有
await
所以不等,把 Promise 物件赋值给c
,setTimeout
被启动开始倒数 - 程式执行到第 18 行,没有
await
所以不等,把 Promise 物件赋值给d
,setTimeout
被启动开始倒数 - 程式执行到第 19 行,看到
await
所以要等c
完成 - (等了 3 秒之后)
- 看到
await
所以要等d
完成,但刚刚那 3 秒已经让d
也完成了,所以相加之后印出
另一个值得思考的点是,因为 await
的顺序不同,导致a
、b
这两个变数存的是number
,而c
、d
则存了 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}`;
}
结语
非同步很复杂,但也就是因为很复杂,可以玩的花样更多,许多复杂的需求其实都是建构在非同步程式中,否则我们的程式就永远只能同步一行一行执行,那也太不智能了...
无论如何看待
我终将完成我的
承诺