异步编程

异步使 JavaScript 这个单线程语言在不阻塞的情况下可以并行的执行多任务,这带来了性能的极大提升,然而异步风格也给流程控制,错误处理带来了更多的麻烦。

本文介绍处理异步的 5 种方式,Callback、Event、Promise、Generator、Async/Await。

Callback

回调是 JavaScript 的基础,函数可以作为参数传递并在恰当的时机执行,比如有下面的两个函数 f1 f2,如果 f1 中存在异步操作,比如 ajax 请求,并且 f2 需要在 f1 执行完毕之后执行,使用回调的方式,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
const foo1 = (cb) => {
$.ajax({
url: '...',
type: 'get',
success: (res) => {
cb && cb(res);
}
})
}
const foo2 = (data) => {
// do something after foo1 complete ...
}
foo1(foo2)

缺点

通过回调来处理异步简单方便,但是它的缺点也很明显,可读性差,难以并发控制,难以错误处理。

  • Callback Hell

多层回调函数的嵌套形成了强耦合,流程难以管理,代码风格丑陋,陷入回调函数地狱 Callback Hell。

1
2
3
4
5
6
7
8
9
10
11
12
13
foo1((err, data) => {
foo2((err, data) => {
foo3((err, data) => {
foo4((err, data) => {
foo5((err, data) => {
foo6((err, data) => {
// maybe more ...
})
})
})
})
})
})
  • Release Zalgo

可能同步也可能异步调用的回调以及包裹它的函数, 被称作是 “Zalgo” (魔鬼),而编写这种函数的行为被称作是 “release Zalgo” (将 Zalgo 释放出来了)。因为函数的调用时间是不确定,导致行为难以预测。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const foo = (callback) => {
if (Math.random() < 0.5) {
callback(2)
}
setTimeout(() => {
callback(3)
}, 0)
}

let a = 1
foo((data) => {
a = data
})
console.log(a) // 正常应该打印 1,但是由于 foo 调用时机不确定性,还可能打印 2
  • 异常不能被捕获
1
2
3
4
5
6
7
8
9
10
11
function foo() {
setTimeout(() => {
throw new Error('fail')
}, 1000)
}

try {
foo()
} catch(ex) {
// 不能捕获
}

try...catch 只能捕获同步的异常,不能捕获异步异常。异常不能被捕获会报 Uncaught Error: failUncaught (in promise) fail 错误。只能在异步函数内部处理异常。

1
2
3
4
5
6
7
8
9
10
function foo() {
setTimeout(() => {
try {
const a = 1
a = 2
} catch(ex) {
// ...
}
}, 1000)
}

Event

另一种解决异步流程控制的传统解决方法是事件,事件驱动不但可以用于处理用户交互、也可以用来处理通信。发布订阅模式和观察者模式都是 Event 模式。

1
2
3
4
5
6
7
8
9
10
11
12
const foo = () => {
setTimeout(() => {
// 发布
Event.trigger('loaded', argvs)
}, 2000)
}
foo()

// 监听/订阅
Event.on('loaded', (argvs) => {
// do something ...
})

Web API 中内置了许多标准化事件:

1
2
3
xhr.onreadystatechange = () => {
// do something ...
}

Event 的缺点也很明显,事件的监听和触发散落在不同的地方,程序趋于复杂之后,复杂度也极大提高。

Promise

在 ES6 Promise 未出现前,为了优雅的解决异步流程控制的问题,社区产出了很多方案,比如 co、async 等。

串行和并行

// https://juejin.cn/post/7263089207128850489
// https://blog.csdn.net/m0_58016522/article/details/119443440

  • 串行

链式串行:

thencatchfinally 方法会隐式返回一个新的 Promise 对象,也可显式的返回 Promise 对象,如果返回的是一个非 Promise 对象,这会被当作新 Promiseresolve。这也是 Promise 能够链式调用的原因。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const foo1 = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(1)
}, 1000)
})
}
const foo2 = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(2)
}, 0)
})
}
const foo3 = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(3)
}, 500)
})
}

foo1()
.then((result1) => {
console.log('Result from foo1: ', result1)
return foo2()
})
.then((result2) => {
console.log('Result from foo2: ', result2)
return foo3()
})
.then((result3) => {
console.log('Result from foo3: ', result3)
})
.catch((error) => {
console.error('An error occurred: ', error)
})

reduce 串行:

reduce 串行是链式串行的简化,本质上还是链式串行,reduce 本身是同步执行的,每次 reduce 返回的值都会作为下次 reduce 回调函数的第一个参数,利用这个特性,可在内存中构造出上述 Promise 执行队列。

1
2
3
4
5
6
7
8
9
10
11
12
const fooList = [() => foo1(), () => foo2(), () => foo3()]

// 此函数展开其实就是上面的 Promise 执行队列
const finallyPromise = fooList.reduce((acc, item) => {
return acc.then(() => item())
},
Promise.resolve() // 创建一个初始 Promise,用于链接数组内的 Promise
)

finallyPromise.then((res) => {
console.log('>>> Result: ', res)
});
1
2
3
4
5
6
// 上述代码虽然能串行,但是只返回最后一个 Promise 的结果,可将所有结果累积起来,在最后一个 Promise 返回
const finallyPromise = fooList.reduce((acc, item) => {
return acc.then((results) =>
item().then((result) => results.concat(result))
);
}, Promise.resolve([]))

await 串行:

1
2
3
4
5
const fooList = [() => foo1(), () => foo2(), () => foo3()]
for (const fooItem of fooList) {
const res = await fooItem()
console.log('>>> Result: ', res)
}
  • 并行
1
2
3
4
5
6
7
8
const getPlayUrlList = () => {
const ids = [1, 2, 3, 4]
const getPlayUrl = (id) => Promise.resolve(`https://zxxxx.com/${id}.mp4`)
const promiseList = ids.map(item => getPlayUrl(item))
return Promise.all(promiseList)
}

const res = await getPlayUrlList()

并发数控制,参考 p-queuep-limit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 默认下每组完成后再进行下一组,也可以自定义分组间隔时间
async function executeTasksWidthConcurrency(tasks, concurrency, interval = 0) {
const results = []
for (let i = 0; i < tasks.length; i += concurrency) {
const group = tasks.slice(i, i + concurrency);
const groupResults = await Promise.all(group.map((task) => task()))
results.push(...groupResults)
// 当前组的任务执行完成后,如果还有更多的任务,则等待指定的 interval 时间后再执行下一组任务,相当于 sleep
if (i + concurrency < tasks.length) {
await new Promise((resolve) => setTimeout(resolve, interval))
}
}
return results;
}

const ids = [1, 2, 3, 4, 5, 6, 7]
const fetchList = ids.map((id) => {
return async () => {
return await fetch(`/?id=${id}`)
}
});

executeTasksWidthConcurrency(fetchList, 2).then((res) => {
console.log('>>> Result: ', res)
})

错误处理

在一个 Promise 链中,错误会找到后续链中最近的一个 catch

1
2
3
4
5
6
7
8
9
10
11
12
// 在这个链中 3,4 步骤会被跳过,但 catch 过后的 then 依然可以正常执行
Promise.resolve(1)
.then(a => console.log(1))
.then(a => console.log(2))
.then(a => Promise.reject('error'))
.then(a => console.log(3))
.then(a => console.log(4))
.catch(err => console.log('err1', err))
.then(a => console.log(5))
.then(a => console.log(6))
.catch(err => console.log('err2', err))
.then(() => console.log('all done'))

Promise.all 中有一个 Promise 实例出现异常,都会导致全部结果被丢弃。

1
2
3
4
5
6
7
8
9
10
11
const tasks = [
Promise.resolve(1),
Promise.resolve(2),
Promise.reject(3),
Promise.resolve(4),
Promise.resolve(5),
];

Promise.all(tasks)
.then(arr => console.log(arr))
.catch(err => console.log(err)) // 只能 `catch` 错误 3,其他结果被丢弃

如果想要所有结果,可 catch reject 返回新的 promise resolve

1
2
3
4
5
6
7
8
9
10
11
const tasks = [
Promise.resolve(1),
Promise.resolve(2),
Promise.reject(3).catch(err => err),
Promise.resolve(4),
Promise.resolve(5),
];

Promise.all(tasks)
.then(arr => console.log(arr))
.catch(err => console.log(err))
1
2
3
4
// 可使用 map 批量 catch
Promise.all(tasks.map(p => p.catch(e => e)))
.then(arr => console.log(arr))
.catch(err => console.log(err))

改造 Sub-Pub 模型

使用 Promise 将发布订阅模型 Request-Response 模型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const promiseMap = {}
function send() {
return new Promise((resolve, reject) => {
promiseMap.a = {
resolve,
reject
}
})
}

document.body.addEventListener('click', () => {
promiseMap.a.resolve('hello world')
})

// 订阅被触发时,resolve 被执行
send().then((res) => {
console.log('>>> res', res)
})
1
2
3
4
5
6
ws.send().then(() => {})
// 实际上是以下的简写
ws.send()
ws.onNoify('eventName', () => {

})

ws promise 封装,和 jsBridge 封装

缺点

相比传统的 CallbackPromise 虽然解决了 Callback Hell 的问题,但是仍需要 then 方法注册回调,虽然只有一层,但是多个任务队列时,Promise 链会显得冗长,不容易理解和维护。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
foo1()
.then((result1) => {
console.log('Result from foo1: ', result1)
return foo2()
})
.then((result2) => {
console.log('Result from foo2: ', result2)
return foo3()
})
.then((result3) => {
console.log('Result from foo3: ', result3)
})
.catch((error) => {
console.error('An error occurred: ', error)
})

Generator

Generator 函数是一个状态机,封装了多个内部状态。形式上,Generator 函数是一个普通函数,但是有两个特征。一是,function 关键字与函数名之间有一个星号,二是,函数体内部使用 yield 表达式,定义不同的内部状态,yield 是暂停执行的标记。

执行 Generator 函数会返回一个遍历器对象(Iterator Object),该函数内部此时并不执行,使用该遍历器对象的 next() 方法,可以遍历 Generator 函数内部的每一个状态,直到 return 语句。

next() 方法遇到 yield 表达式,就暂停执行后面的操作,并将紧跟在 yield 后面的那个表达式的值,作为返回的对象的 value 属性值。下一次调用 next 方法时,再继续往下执行,直到遇到下一个 yield 表达式。如果没有再遇到新的 yield 表达式,就一直运行到函数结束,直到 return 语句为止,并将 return 语句后面的表达式的值,作为返回的对象的 value 属性值。如果该函数没有 return 语句,则返回的对象的 value 属性值为 undefined

1
2
3
4
5
6
7
8
9
10
11
12
function* helloWorldGenerator() {
yield 'hello'
yield 'world'
return 'ending'
}

var hw = helloWorldGenerator()

hw.next() // { value: 'hello', done: false }
hw.next() // { value: 'world', done: false }
hw.next() // { value: 'ending', done: true }
hw.next() // { value: undefined, done: true }

yield 表达式本身没有返回值,或者说总是返回 undefinednext 方法可以带一个参数,该参数就会被当作上一个 yield 表达式的返回值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function * geneDemo(a, b) {
console.log(a, b)
var c = yield 'c'
console.log('c:', c)
var d = yield 'd'
console.log('d:', d)
return 6
}

var g = geneDemo('a', 'b')

var r1 = g.next('e1', 'e2')
var r2 = g.next('f1', 'f2')
var r3 = g.next()
console.log('r1:', r1)
console.log('r2:', r2)
console.log('r3:', r3)

在异步中的应用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const fs = require('fs')

const readFile = function (fileName) {
return new Promise(function (resolve, reject) {
fs.readFile(fileName, function(error, data) {
if (error) return reject(error)
resolve(data)
})
})
}

const gen = function* () {
const f1 = yield readFile('/etc/fstab')
const f2 = yield readFile('/etc/shells')
console.log(f1.toString())
console.log(f2.toString())
}

const g = gen()

g.next().value.then((rep) => {
console.log(rep)
})

g.next().value.then((rep) => {
console.log(rep)
});

缺点

Generator 不自带执行器,默认情况下异步请求的返回值不能作为 yield 的返回值,必须依靠执行器才能执行(如 co),执行器可以自动调用 next,并将上一个请求的返回值作为参数传进 next 中。

Async、Await

ES2017 标准引入了 async 函数,使得异步操作变得更加方便。async 函数返回一个 Promise 对象,当函数执行的时候,一旦遇到 await 就会先返回,等到异步操作完成(await 的那个函数调用),再接着执行函数体内后面的语句,async 函数内部 return 语句相当于 Promise 函数的 resolve。

1
2
3
// async 默认一个 Promise 对象
var foo = async function() {}
foo() // Promise
1
2
3
4
5
// 遇到 await 就会先返回,后面的代码待异步操作完成后执行
async function getUserInfo() {
const rep = await API.User.getUserInfo()
return rep
}

async 默认隐式返回 Promisereturn 其实是 resolve,要想 rejectreturn Promise.reject() 即可

可以通过 Promise.reject() 或 throw new Error() 处理 resolve 和 reject

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var foo = async function (param) {
if (param === 1) {
return 1
} else {
await Promise.reject() // reject 返回的是 Promise,所以需要 await
}
}

var foo = async function (p) {
if (p === 1) {
return
} else {
throw new Error()
}
}

foo().then((d) => {
console.log(d, 223)
}).catch((d) => {
console.log(d)
})

asnyc/await 是 Generator 函数的语法糖,号称是异步的终极解决方案。上面 Generator 中的函数 gen 改写成 async 函数,就是下面这样。

1
2
3
4
5
6
const asyncReadFile = async function () {
const f1 = await readFile('/etc/fstab')
const f2 = await readFile('/etc/shells')
console.log(f1.toString())
console.log(f2.toString())
};

一比较就会发现,async 函数就是将 Generator 函数的星号(*)替换成 async,将 yield 替换成 await,仅此而已。具体的改进包括:内置执行器、更好的语义、更广的适用性、返回值是 Promise。

优点

  • 异步同步化

异步同步化,处理了 Callback Hell 和 Promise 链问题。

1
2
3
4
5
async login(params) {
const token = await this.$API.Auth.login(params);
token.timestamp = new Date().getTime();
localStorage.token = JSON.stringify(token);
}
  • 可被 try...catch

异步代码同步化后,就可以使用 try...catch 了。但是需要注意的是只能 catch reject,不能 catch 显式的 throw 异常或隐式的执行异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function doSomething() {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject('fail'); // throw new Error('fail')
});
});
}

async function main() {
try {
await doSomething();
} catch (e) {
// fail
}
}

main();

下面的错误就只能在同步中才能 catch 到。

1
2
3
4
5
6
7
8
9
10
// 显式 throw 
async function doSomething() {
throw new Error('fail');
}

// 隐式执行异常
async function doSomething() {
const a = 1;
a = 2;
}

注:大部分情况下请求异常会提前拦截处理(只需提示),不需要在各个业务里面处理,不过也有特例,比如:

1
2
3
4
5
6
7
8
9
10
async handleSwitchActivityState(activity) {
const enable = activity.isEnable ? 1 : 0;

try {
await this.$API.Coupon.switchActiveState(activity.activityId, enable);
this.getActivityList();
} catch (ex) {
activity.isEnable = !activity.isEnable;
}
}

另外,为了防止项目中 try catch 导致代码可读性问题,可采用以下方式更优雅的处理 async 异常:

1
2
3
await foo().catch((err) => {
// do something ...
})
1
2
3
4
5
6
7
8
9
10
// 通过 `promise` 将 `reject` 转为 `resolve`
handlePromise(promise) {
try {
return promise.then(data => [null, data]).catch(err => [err])
} catch (err) {
return [err]
}
}

const [err, res] = await handlePromise(foo)