yinguangyao / blog

关于 JavaScript 前端开发、工作经验的一点点总结。
262 stars 12 forks source link

深入理解 generator 和 async #52

Open yinguangyao opened 3 years ago

yinguangyao commented 3 years ago

1. 前言

上篇文章,我们介绍过了最常见的两种解决异步的方式 —— 回调函数和 Promise,这篇文章我们进一步介绍两种终极解决异步的方法 —— generator 和 async/await。

2. generator

generator 是一个状态机,内部封装了状态。generator 返回了一个遍历器对象,可以遍历函数内部的每一个状态。 generator 函数声明的时候,在 function 和函数名之间会有一个星号*用来说明当前是一个 generator 函数,同时在函数体内会有 yield 关键字,用于定义状态。我们通过 next 方法不断地调用。

function* test() {
    yield 1;
    yield 2;
    yield 3;
}

image_1dj4mt3si1bjb15r58ef1486h2fm.png-32.3kB

从图上可以看到,每次执行完 next 方法后,会返回一个对象,里面有 value 和 done 两个值。 value 就是 yield 表达式后面的返回值。done 则表示函数是否终止。当执行完所有的 yield 后,最后一次返回的 done 就是 true。 同时,next 也可以接受一个参数,作为上一个 yield 的返回值。

function* test() {
    const a = yield 1;
    const b = yield a * 2;
    return b;
}
const gen = test()
gen.next() // {value: 1, done: false}
gen.next(10) // {value: 20, done: false}
gen.next() // {value: undefined, done: true}

给第二个 next 方法传了参数 10,这个 10 就是第一个 yield 1 执行后返回的结果,赋值给了a,因此第二个 yield 得到的结果是20。

2.1 generator 的异步用法

由于我们可以在外部获得 generator 的执行控制权,也能通过 value 拿到执行后的结果,所以 generator 也可以被用来处理异步。 我们以前面的红绿灯为例:

function* lightGenerator() {
    yield green(60);
    yield red(60);
    yield green(60);
    yield red(60);
    yield green(60);
    yield red(60);
}

在这种场景下看,generator 比 Promise 和回调函数都要更加简洁。但是在调用的时候又会比较繁琐,我们每次都需要手动调用 next 方法,而不是自动执行。

const gen = lightGenerator();
gen.next();
gen.next();
gen.next();
gen.next();
gen.next();
gen.next();

如果是在网络请求中,generator 调用会更加复杂。

function* fetchGenerator(){
    const url = 'https://baidu.com'; // 假设请求的是百度
    const res = yield fetch(url);
    return res;
}

我们在调用 gen 函数获得返回结果的时候,就需要这么做。

const gen = fetchGenerator(),
    result = gen.next();
result.value
.then(data => data.json())
.then(data => gen.next(data))

因为 fetch 函数执行后返回的是一个 Promise,所以 result.value 是一个 Promise,需要通过 then 来获取到请求到的数据,再将 data 传给 gen.next,让 yield 后面的代码可以继续正常执行。

2.2 自动执行

这是只有一个请求的情况,如果有多个请求呢?那岂不是要多次调用 then?这样代码的可读性非常差了。

function* fetchGenerator(){
    const res1 = yield fetch('https://baidu.com');
    const res2 = yield fetch('https://google.com');
    const res3 = yield fetch('https://bing.com');
    return [res1, res2, res3];
}
const gen = fetchGenerator(),
    result = gen.next();
result.value
.then(data => data.json())
.then(data => gen.next(data).value)
.then(data => data.json())
.then(data => gen.next(data).value)
.then(data => data.json())
.then(data => gen.next(data).value)

那么有没有一种方法,不需要我们手动调用 next,可以让 generator 自动执行呢? 你可能会想到,既然可以通过 done 来判断是否执行结束,那么用 while 循环不就行了?

let g = gen(),
    res = g.next();

while(!res.done){
  console.log(res.value);
  res = g.next();
}

这样看起来是可以一下子全部执行了,但对于需要上个请求结束后再发送下个请求的场景,这里是无法保证顺序的。 那么我们是否可以利用 Promise 来保证前一步执行完,才能执行后一步呢?参考上述代码,我们可以用递归来实现。 在每次请求拿到 data 后,将这个 data 通过 next 传给下一次的 yield,这样就实现了自动执行。

function run(gen) {
    const g = gen();

    function next(data) {
        const result = g.next(data);

        if (result.done) return;

        result.value
        .then(data => data.json())
        .then(data => next(data))

    }
    next();
}

run(fetchGenerator);

其实著名的 co 模块也是为了解决这个问题而出现的,这个则是 co 模块的简化版实现。

3. async

在 ES2017 中引入了 async 函数,async 函数是处理异步的终极方案。相比 generator,async 不需要我们一步步调用 next 方法。同时,async 返回了一个 Promise,而不是一个 Iterator。

async function foo(){
    const res1 = await fetch('https://baidu.com');
    const res2 = await fetch('https://google.com');
    const res3 = await fetch('https://bing.com');
}
foo();

我们可以清楚地看到,async 和 generator 写法很像,用 async 关键字代替星号,await 关键字代替 yield。 我们使用 async 来解决上面那个红绿灯的例子(当然了,green 和 red 方法都是用 Promise 来实现的)。

const green = (time) => {
    return new Promise(resolve => {
        setTimeout(() => {
            console.log('green')
            resolve()
        }, time)
    })
}
const red = (time) => {
    return new Promise(resolve => {
        setTimeout(() => {
            console.log('red')
            resolve()
        }, time)
    })
}
async function light() {
    await green(60);
    await red(60);
    await green(60);
    await red(60);
    await green(60);
    await red(60);
}
light();

3.1 异常捕获

从这个例子看着,async 是不是比 generator 方便了很多?除此之外,相对于 Promise,async 在错误捕获方面更加优秀。 由于 Promise 异步错误无法通过 try...catch 来捕获,所以我们一般会用 try...catch 来捕获 Promise 构造函数中的错误,用.catch` 来捕获异步错误,这样实际上非常繁琐。

function test() {
    try {
        new Promise((resolve) => {
            // ...
        }).then(() => {
        }).catch(() => {
        })
    } catch (err) {
        console.log(err)
    }
}

而在 async 里面,捕获错误变得如此简单,只需要用 try...catch 就能够捕获到所有异常。

async function test() {
    try {
        const res = await fetch('www.baidu.com');
    } catch (err) {
        console.log(err)
    }
}

除此之外,我们还可以让 await 后面的 Promise 直接调用 catch,这样可以避免 `try...catch 处理多个不同错误时导致的问题。

async function test() {
    const res = await fetch('www.baidu.com')
    .catch(err => {
        console.log(err)
    })
}

3.2 断点调试

同时,由于 Promise then 的异步缘故,导致打断点的时候,经常会先走后面的代码,再回到 then 里面,这样对于断点调试来说非常不方便。

promise.gif-1025kB

但是在 async 里面,断点调试表现就像同步一样,让调试更加方便。

await.gif-1746.7kB

3.3 注意

在使用 async 的时候,经常会有人滥用 await,导致原本没有依赖关系的两个操作必须按顺序才能执行,比如:

async function test() {
    const getUserInfo = await fetchUserInfo();
    const getHotelList = await fetchHotelList();
}

可以看到原本没有关联的两个接口 fetchUserInfo 和 fetchHotelList,现在 fetchHotelList 必须要等 fetchUserInfo 接口获取到数据后才开始调用,大大提高了原本要花费的时间。 有两种方式可以解决这个问题。 我们可以改变一下 await 的写法,让两个接口同时调用,再用 await 去等待返回值,这样耗时是请求时间最长的那个。

async function test() {
    const getUserInfo = fetchUserInfo();
    const getHotelList = fetchHotelList();
    await getUserInfo;
    await getHotelList;
}

还可以用 Promise.all 来解决这个问题,原理和上面的例子差不多。

function test() {
    Promise.all([fetchUserInfo(), fetchHotelList()])
    .then(dataArr => {
    })
}

3.4 在循环中使用

如果在循环中使用 async/await,那么就要注意一下,在 for 循环和 each/map 中完全是不同的意思。 在 for 循环中表现的是继发,后面的一定会等待前面的返回后才会执行。

async function execute(promises) {
    const len = promises.length;
    for(let i = 0; i < len; i++) {
        await promises[i]()
    }
}

而在 forEach 和 map 中则表现为并发。这是为什么呢?

async function execute(promises) {
    promises.forEach(async p => {
        await p()
    })
}

究其原因是 forEach 并不是一个 async 函数,执行 callback 的时候没有用await等待返回,这样自然是同时执行,而非后面的执行需要依赖前面的执行完成。

// 实际上 forEach 并不是一个 async 函数
const forEach = (arr, callback) => {
    let len = arr.length;
    for(let i = 0; i < len; i++) {
        callback(arr[i], i, arr);
    }
}

3.5 实现一个 await

await 的原理就是返回了一个 Promise,使用一个函数让 generator 自动迭代,而执行的时机则是由 Promise 来控制,其实原理和上面的 run 方法很相似。 首先定义一下 myAsync 方法,让其返回一个 Promise 对象。

function myAsync(fn) {
    return function(...args) {
        return new Promise((resolve, reject) => {
             const gen = fn.apply(self, args);
             gen.next();
        })
    }
}

这样一个大体的结构就实现了,但现在这个 myAsync 函数只能够让 generator 执行一次,还需要做到让它自动迭代。参考前文我们实现的那个 run 方法,这里考虑使用递归实现。 可以设计一个 next 方法,来递归调用这个 next,在 next 中去执行 generator 的 next 方法。是否执行结束则通过 done 属性来判断。

function next(value) {
    try {
        let result = gen.next(value);
        let value = result.value;
        if (result.done) {
            return resolve(value);
        }
        return next(value);
    } catch (err) {
        reject(err)
        return
    }
}

next 函数是成功实现了,可是还有一些问题,比如 await 后面是可以跟一个原始类型的值的,会默认用 Promise.resolve 将其包起来。这里还需要将返回值用 Promise.resolve 再进行一次封装。

function next(value) {
    try {
        let result = gen.next(value);
        let value = result.value;
        if (result.done) {
            return resolve(value);
        }
        return Promise.resolve(value).then(next);
    } catch (err) {
        reject(err);
        return
    }
}

如果你比较细心,还会发现虽然这个 generator 可以自执行了,但还缺少了错误处理,如果在 await 函数后面跟着一个失败的 Promise,该怎么处理呢? 所以我们来修改一下 next 函数,兼容错误处理,加上一个 error 函数来捕获。

function error(err) {
    try {
        let result = gen.throw(err);
        let value = result.value;
    } catch (err) {
        reject(err);
        return
    }
}
function next(value) {
    try {
        let result = gen.next(value);
        let value = result.value;
        if (result.done) {
            return resolve(value);
        }
        return Promise.resolve(value).then(next, error);
    } catch (err) {
        reject(err);
        return
    }
}

最终的完成版如下:

function myAsync(fn) {
    return function(...args) {
        return new Promise((resolve, reject) => {
            const gen = fn.apply(self, args);

            function run(gen, resolve, reject, next, error, key, arg) {
                try {
                    let result = gen[key](arg);
                    let value = result.value;
                    if (result.done) {
                        return resolve(value);
                    }
                    return Promise.resolve(value).then(next, error);
                } catch (err) {
                    reject(err);
                    return
                }
            }
            function next(value) {
                run(gen, resolve, reject, next, error, 'next', value);
            }
            function error(err) {
                run(gen, resolve, reject, next, error, 'throw', err);
            }
            next();
        })
    }
}

找几个例子来测试一下这个 myAsync 函数到底行不行。

const test = myAsync(function *() {
  const a = yield 1;
  const b = yield Promise.resolve(2);
  const c = yield Promise.reject(3);
  return [a, b, c]
});

test()
.then(res => console.log('res', res))
.catch(err => console.log('err', err))
// err 3

4. 总结

在平时使用中,Promise 和 async 使用最多,generator 比较不常用。async 在一定程度上可以简化代码,提高可读性和方便调试。但 Promise.allPromise.race 在一些场景下会更加适用。 在未来的一些新特性和提案上,Promise 家族还增加了 Promise.allSettledPromise.any 两个新成员。