Better

Ethan的博客,欢迎访问交流

JavaScript异步世界的发展

JavaScript 在发展过程中,共经历了回调函数、Promise 对象、Generator 函数,async 函数来处理异步。

发展节点

时间节点:

  • 1995年,当时最流行的浏览器——网景中开始运行 JavaScript
  • 2005 年前后,以 Google 为首开始重视使用 AJAX(即 Asynchronous JavaScript and XML),使得复杂的网页交互体验接近桌面应用
  • 2008年,Google 发布了 JavaScript 引擎 V8 大大改善了 JavaScript 的执行速度,进一步推动了 JavaScript 的繁荣,也为 JavaScript 进军服务器端打下了基础

异步执行函数的发展

  1. 回调函数:回调地狱问题
  2. Promise
  3. Generator 函数:本身并不是为异步设计的,且如今应用场景不多
  4. Await、Async

手动实现 Promise

Promise 已经是 JavaScript 中异步处理的基石,回调的场景将会越来越少,而且现在可以直接在 Node.js 使用 async/await。async/await 基于 Promise,因此需要了解 Promise 来掌握 async/await。

在 ES6 规范中,Promise 是一个类,它的构造函数接受一个 executor 函数。Promise 类的实例有一个 then() 方法。我们在这里不考虑其他特性,动手实现一个mini版Promise。

promise 是一个状态机,包含三个状态:

  • pending:初始状态,既不是成功,也不是失败状态
  • fulfilled:意味着操作成功完成,返回结果值
  • rejected:意味着操作失败,返回错误信息

promise 中最复杂也是最有用的部分:链式调用,直接看实践源码

与 Async/Await 一起使用

  • 关键字 await 会暂停执行一个 async 函数,直到等待的 promise 变成 settled 状态。
  • await 隐式调用 .then() 中的 onFulfilled() 和 onRejected() 函数,这是 V8 底层的 C++ 代码(native code)。
  • await 会一直等待调用 .then() 直到下一个时序。

Promise 使用技巧

有了上面的手动实现,下面的使用技巧也就很好理解了

  • 链式调用
    • 每次调用 .then 都会产生一个新的 Promise
    • 你可以在 .then 回调里返回 Promise
  • 在任何情况下,Promise resolve/reject 状态都是一致的,一个 promise 在多个地方使用,当它被 resolve 或者 reject 的时候,都会获得通知。
  • Promise 构造函数不是万金油
    • Promise.resolve API,是产生 Promise 对象的一种快捷方式,这个 promise 对象是被 resolve 的。
    • Promise.reject
  • Promise.all
    • 接受一个 promise 的数组
    • 等待所有这些 promise 完成
    • 返回一个新的 Promise,将所有的 resolve 结果放进一个数组里
    • 只要有一个 promise 失败/rejected,这个新的 promise 将会被 rejected
  • 不要害怕 reject 或者不要在每一个 .then 后面使用 .catch,因为你可以在任何你想处理的地方解决或者延续 rejection。
  • 避免 .then 嵌套

async/await

async / await 是 ES2017中引入的,为了使异步操作得更加方便,本质上 async 函数是 Generator 函数的语法糖。

async 函数书写的方式跟我们普通的函数书写方式一样,只不过是前面多了一个 async 关键字,并且函数返回的是一个 Promise 对象,所接收的值就是函数 return 的值。

在 async 函数内部可以使用 await 命令,表示等待一个异步函数的返回。await 后面跟着的是一个 Promise 对象,如果不是的话,系统会调用 Promise.resolve() 方法,将其转为一个 resolve 的 Promise 的对象。

如果 await 后面的 Promise 的状态是 reject ,那么整个 async 函数就会中断执行,错误会被 async 函数的 catch 捕获到。

async function 返回值有个需要注意的地方,当调用一个 async 函数时,会返回一个 Promise 对象,当这个 async 函数返回一个值时,Promise 的 resolve 方法会负责传递这个值;

当 async 函数抛出异常时,Promise 的 reject 方法也会传递这个异常值。async 函数中可能会有 await 表达式,await表达式会使 async 函数暂停执行,直到表达式中的 Promise 解析完成后继续执行 async中await 后面的代码并返回解决结果。

既然返回的是Promise 对象,所以在最外层不能用 await 获取其返回值的情况下,那么肯定可以用原来的方式:then() 链来处理这个 Promise 对象

async 函数返回一个 Promise 对象,当函数执行的时候,一旦遇到 await 就会先返回,等到触发的异步操作完成,再接着执行函数体内后面的语句。

总的来说

  • async 告诉程序这是一个异步,awiat 会暂停执行async中的代码,等待await 表达式后面的结果,跳过async 函数,继续执行后面代码
  • async 函数会返回一个Promise 对象,那么当 async 函数返回一个值时,Promise 的 resolve 方法会负责传递这个值;当 async 函数抛出异常时,Promise 的 reject 方法也会传递这个异常值
  • await 操作符用于等待一个Promise 对象,并且返回 Promise 对象的处理结果(成功把resolve 函数参数作为await 表达式的值),如果等待的不是 Promise 对象,则用 Promise.resolve(xx) 转化

但是这个写法有个需要注意的地方,看下面的例子

async function asyncReadFile() {
    console.log(0);
    let a = await readFile('./a.txt');
    console.log(a.toString());
    console.log(1)
    let b = await readFile('./b.txt');
    console.log(b.toString());
    console.log(2)
    let c = await readFile('./c.txt');
    console.log(c.toString());
    console.log(3);
}
function readFile(url){
    return new Promise((resolve,reject)=>{
        setTimeout(()=>{
            console.log(url),resolve(url)
        },1000)
    })
}
asyncReadFile();
console.log(123);

输出结果为:

# 立即输出
0
123
# 1秒后
./a.txt
./a.txt
1
# 2秒后
./b.txt
./b.txt
2
# 3秒后
./c.txt
./c.txt
3

整个函数执行完毕,大概需要3秒,自信理解一下,async声明的函数,表示异步,碰到await后,立即让出线程,但await会使得async函数内部处于等待状态,await 的promise没有完成,就会一直等待。因此上述代码有个问题就是,原本我们可以同时读取三个文件内容,如今却导致我们只能依次读取,这显然是不对的。

async/await hell

在使用异步JavaScript时,人们通常会依次编写多个语句,并在函数调用之前进行等待。这会导致性能问题,因为一个语句多次不依赖于前一个语句 - 但是您仍然必须等待前一个语句完成。

我们来看一个极端的例子:

async function orderItems() {
  const items = await getCartItems()    // async call
  const noOfItems = items.length
  for(var i = 0; i < noOfItems; i++) {
    await sendRequest(items[i])    // async call
  }
}

在这种情况下,for循环必须等待 sendRequest() 函数完成后才能继续下一次迭代。但是,我们并不需要等待。我们希望尽快发送所有请求,然后我们可以等待所有请求完成。

promises的一个有趣属性是,你可以在一行中得到promise,并等待它在另一行中解决。这是避免async/await hell的关键。

如何解决这个问题呢,那就是创建多个async函数,将有前后依赖放进一个函数,没有依赖关系,需要并发执行的,则分离到另一个函数。上章的例子修改如下:

async function asyncReadFileTarget(tatget,index){
    let a = await readFile(tatget);
    console.log(a.toString());
    console.log(index)
    return a.toString();
}
async function asyncReadFile() {
    console.log(0);
    const  a = asyncReadFileTarget('./a.txt',1);
    const  b = asyncReadFileTarget('./b.txt',2);
    const  c = asyncReadFileTarget('./c.txt',3);
    // way 1
    var a_r = await a;
    var b_r = await b;
    var c_r = await c;
    // way2
    Promise.all([a,b,c]).then(res=>console.log(res))
}
function readFile(url){
    return new Promise((resolve,reject)=>{
        setTimeout(()=>{
            console.log(url),resolve(url)
        },1000)
    })
}
asyncReadFile();
console.log(123);

此时就是并发完成了文件的读取。上述极端例子,我们需要处理未知数量的promises。处理这种情况非常简单:我们只需创建一个数组并在其中实现承诺。然后使用 Promise.all() 我们同时等待所有的承诺解决。

async function orderItems() {
  const items = await getCartItems()    // async call
  const noOfItems = items.length
  const promises = []
  for(var i = 0; i < noOfItems; i++) {
    const orderPromise = sendRequest(items[i])    // async call
    promises.push(orderPromise)    // sync call
  }
  await Promise.all(promises)    // async call
}

实现并发

思考上述的循环问题,我们实现了并发请求,但是如果noOfItems数目很大呢?会对服务器造成很大的压力,如果是1000个,10000个, 那问题就更大了, 甚至到了一定程度, 会超过操作系统允许打开的连接数, 对客户端本身也会有很大的影响。所以我们需要限制并发数目!修改如下:

async function orderItems() {
  const items = await getCartItems()    // async call
  const noOfItems = items.length
  const MAX_CURRENCY = 3;
  const result = [];
  for(var i = 0; i < noOfItems; i += MAX_CURRENCY) {
    const promises = []
    for (let j = i; j < i + MAX_CURRENCY && j <= noOfItems; j += 1) {
        promises.push(sendRequest(items[j]));
    }
    const r = await promises.push(orderPromise)    // sync call
    result.push(...r);
  }
  await Promise.all(promises)    // async call
}

哈哈,到这里看上去是不是很完美了,但其实不是的,如果每个请求所需要的时间不一样呢?上述实现方式是每三个一组, 等着三个都完成了, 再进行下一组请求。 那么如果三个任务中, 有一个花费的时间比较多, 另外两个任务完成了之后, 本来可以继续开始新的任务的,现在必须等着第三个任务完成了才能开始新的任务。甚至如果三个任务需要的时间都不一样,那么第一个需要等第二个和第三个,第二个需要等第三个, 整个系统就被最慢的那个任务拖累了。

我们只需要这么做: 构建一个任务池, 一开始并发三个任务, 每个任务回来之后不用等其他两个任务, 直接看一下任务池还有任务么, 有的话就直接去做,直到所有任务都完成即可。由于Node.js里面没有信号量来同步各个“线程”之间的工作, 这里用了递归并操作公共变量的方式实现,注意, “并发地修改共享变量是万恶之源, 有data race的问题, 好在JS里面是单线程, 所以没有这个问题。卧槽,这个我看着有点懵逼!更多请看资料!

资料



留言