js异步编程与async/await替代糖

js是所有编程语言里最容易实现异步操作的语言,设计极其巧妙的setTimeout函数及回调函数设计,让异步编程无声无息的进入每一个前端程序员的世界。但是,对于异步编程的概念,以及它背后运行的原理,语言的设计者为何要设计成这样的语法结构或api?本文就试图对这个话题展开谈一谈,聊一聊看上去不起眼的问题。

异步

js借鉴了编程领域的“异步事件模型”实现了异步,如果你想真正了解操作系统层面如何实现异步,则需要了解这个模型,基于这个模型,很多语言都实现了异步编程。简单的说,异步是指在执行任务1的时候,先执行它的前半部分,并将它的后半部分通过某个机制,放到任务2后面执行。而这个“前半部分”往往是一些条件预设编程,而“后半部分”则是在满足条件,并被触发时执行的目的行为。最为典型的就是ajax的实现。

“同步”和“异步”是一对反义词,同步是指一个任务必须从头到尾执行完毕,在这个过程中,其他任何任务不得执行。

而另一个经常讨论的话题是,js是单线程的,什么是单线程呢?简单的解释是,一段js代码,会按照解释顺序,将不同部分(作用域,块)的程序放于同一调用栈中逐一运行。举个例子:

var a = 1 // 1
a ++ // 2
setTimeout(() => { // 3
  console.log(a) // 5
}, 0)
a += 10 // 4

程序就像一列火车一样,程序中的每个部分被分别放在一个车厢里,在一种类似$digest循环的过程中,出于同步的部分被首先按顺序加入到火车头部,也就是上面的1234,而异步程序被加入到所处结构中同步部分的后面,在检查异步代码时,也是这样的一个流程,因为异步程序内部本身也可能有同步与异步区分。因此,在这列火车中,虽然在代码中这些车厢位置是12354,但在js的线程调用栈中,车厢顺序是12345。

多线程也是编程世界中非常重要的角色,很多语言具备多线程能力。但是多线程涉及线程安全问题,为了保证js的安全性,js语言被设计为单线程应用。浏览器本身也是多线程,包括

  • javascript引擎线程
  • 界面渲染线程
  • 浏览器事件触发线程
  • Http请求线程

这些线程在js运行的时候同时运行,不同的任务会被分配到不同的线程中,再通过异步的方式相互通知,js编程只能在js引擎线程中工作,因此说js是单线程的。但是现代HTML标准使得在浏览器环境中(node也可以)js具备了多线程编程能力,即通过webworker实现。在主线程和worker线程之间,也是通过异步方式相会调用(通知)。多线程可以解放单线程带来的一些运算能力局限,例如,在你的代码中,有一段代码需要对100K行的数据进行处理,每次程序运行到这里,你的浏览器就开始卡,即使你把它放在异步任务中进行处理,也会一样。但是,如果你把这段代码放到另外一个worker线程中处理,处理完之后,把结果返回给主线程,那效果就不一样,worker线程中的代码不会对界面渲染产生任何影响(当然,当内存用完的情况除外),因此,只要使用合理,可以对web应用起到性能提升的作用。

在我看来,异步是js语言里面最美的特性之一,异步编程让这门语言充满想象力。

js异步编程的形式

js中有多种异步编程的形式,这里说的形式,我们需要在后面去认知看下,并不是说具备了这种形式就是异步编程,而是说,这些形式是异步编程的常用手段:

  • 回调函数
  • 事件绑定,例如document.addEventListener('click', callback)
  • 事件勾子函数,例如window.onload = callback
  • 队列/观察者
  • setTimeout、requestAnimationFrame等内置接口
  • Promise
  • generator
  • await
  • Ajax

特别是回调函数,我们要区分回调函数在实际程序中的执行阶段,很多情况下回调函数是在调用栈(所在的程序部分)同步执行的,而非异步,例如:

function run(callback) {
  let a = 1
  let b = callback(a)
  return a / b
}

这样的回调写法是我们常用手法,但很明显,这里的编程跟异步是完全两回事。而下面的代码则不同:

function run(url, callback) {
  fetch(url).then(callback)
}

这则是明显的异步回调,所以正如前文所说,异步编程和它的形式没有必然关系。

Promise

Promise是js标准,它是异步操作非常典型的一种形式。但在一上来就then之前,让我们重新认识一下这个Promise接口。在一开始,我们要区分作为标准的Promise和作为接口的Promise,作为标准的Promise规定了它的实现规范和原理逻辑,但具体怎么实现这一规范,不同的厂商并不相同,但是对于开发者而言,它们的表象都是一样的,也就是作为接口的Promise,在同一套规范下,任何浏览器提供的Promise接口都是一致的,我们写相同的代码,在不同浏览器里面跑,应该得到相同结果。

接口层面,Promise是一个浏览器提供的构造函数,可以被实例化为一个promise实例。构造时传入一个函数,包含两个参数,并且在构造时,这个函数会被执行,函数内可进行异步操作,当参数被调用时,promise状态被修改。前面讲过,异步分为两部分,而Promise构造函数内传入的参数函数,则是对“前半部分”的实现,“后半部分”的实现则被丢到promise的then方法中执行。这样的编程风格非常有利于逻辑的分离,因此被广泛使用。

let defer = new Promise((resolve, reject) => {
  fetch('xxx').then(resolve).catch(reject)
})
defer.then((res) => { ... }).catch((error) => { ... })

这样的编程风格使得代码分块非常清晰,因此被追捧。不过,使用Promise的时候,要注意几个点:

  • 构造函数传入的函数参数,是同步执行的,也就是说它会被置于new所在程序流的同级调用栈中,而非被认为是异步任务置于调用栈末尾,这一点非常重要,对于利用Promise进行异步编程也是一个关键点
  • 一个rejected的promise会抛出异常,需要对该一场进行捕获,而捕获方式只能使用它的catch方法,而不要使用try...catch语法,因为try...catch是同步的
  • 被catch过的promise不会继续抛出错误,反而被认为是resolved的,也就是说,.then(a1).catch(a2).then(a3)中a3无论如何都会被执行
  • promise实例不能手动调用resolve()方法使它完成,一旦一个实例创建,只能等它自己完成或抛出错误时才能进行后续操作
  • 浏览器原生的Promise不支持abort操作

只有当我们熟练掌握了这些坑,才能写出更大的坑给后人享用。

async/await语法

ES6提供了Promise的语法糖,即async/await语法,它让Promise编程极为简洁,使得代码美观度上升N个档次。但是,我们需要对语法糖本身进行复原,否则我们无法掌握它的精髓。

async语法是对new Promise的包装,await语法是对then方法的提炼。

看上去这句话非常简单,然鹅它所蕴含的能量,逐一让你产生无数bug。之所以这么说,是因为躺完坑,仍心有余悸。我们来看下async/await的用法:

async function get(url) {
  let res = await fetch(url)
  let data = await res.json()
  return data
}

上面的代码虽然短,但是每一句都极为重要。第一句通过await等待一个promise,第二句里面直接使用第一句的res,第三句直接返回data作为async函数的返回值,这和同步编程几乎一样。

await只能用在async函数中。

虽然await语法后面跟的是一个promise,并且前面将promise的resolve值进行返回,但是前提是await只能用在async函数中。原因很简单,因为await语法是then的语法糖,then的前提是promise实例,async是new Promise语法糖,只有new Promise之后,才能使用then。绕完这一圈,你应该懂了,为何await一定要用在async函数中。高能!!!千万要注意async函数中独立的回调函数内,如果这个回调函数本身不是async函数,里面的await会报错!!!

await后面可以是单个变量。

async () => {
  await defer
}

await可以不返回值给变量。

虽然await语法并没有把结果返回赋值给某个变量,但它仍然是一个异步操作,await后面的程序代码将会在当前语句resolve之后才会执行。

async () => {
  await defer1
  await defer2
}

await的字面意思是“等待返回结果”。

在await关键字后面的defer没有返回结果之前,程序不会执行下方的代码。

async函数被调用时是同步执行。

当async函数被调用时,处于第一个await关键字前面的所有代码和关键字后面的半行代码,会立即执行。如果一个async函数从头到尾没有执行到某个await,那么与同步函数无异。

循环中的await的出乎意料。

js循环具有非常奇特的作用域,在循环中使用await,会出现一些你可能意料不到的情况。你需要在脑海中谨记,await关键字是一个等待动作,程序运行到此处时,会进行等待,知道await后面的表达式返回成功。当进入下一个循环的时候,await上方循环体内的代码,将会在此处被断开。总之,如果你发现自己的循环中使用了await,一定要使用真实数据进行调试一下。

迭代遍历慎用async函数。

另外,千万不能使用asnyc函数作为forEach、map等迭代函数的参数,迭代函数本身的迭代过程是同步的,但是由于async函数返回的是promise,因此,迭代过程中程序不会等待当前promise成功后才进入下一个迭代。

async/await异步替代糖

接下来我要构造自己的async/await样式语法,之所以这样做,是因为async函数调用时,第一个await前的代码被同步执行,我们希望写一个替代方式,实现真正的全量异步,使得没有任何代码可以阻塞当前进程。

具体的实现已经通过hello-async这个库实现了,你可以通过源码查看。这里主要讲一下思路。

new Promise的构造函数参数函数是同步执行的,因此,如果直接在new Promise的参数函数中使用逻辑代码,就会因此进程阻塞。怎么才能真正全量异步呢?经过研究,then()参数函数的代码是异步执行的。这完全符合我们前面提到的“前半部分”“后半部分”逻辑。因此,我们要构造一种形式,使得函数一开始执行,就是在then中执行,于是:

function $async(fn) {
  return (...args) => {
    return new Promise((resolve, reject) => {
      Promise.resolve().then(() => fn(...args)).then(resolve).catch(reject)
    })
  }
}

为什么需要这么复杂的结构呢?我们不能直接返回Promise.resolve(),虽然直接返回Promise.resolve(),然后在它的then()里面去写我们的逻辑代码,但是这样在场景中,我们首先得到了一个resolved promise,而非一个pending promise,而返回一个new Promise,再在构造函数中使用异步操作,则正是我们想要的场景。通过上面这段代码,就将一个同步函数,转换为真正的异步函数,在调用函数的那一刻,什么事情都不会发生,异步动作会自动运行,等到满足条件时,就会resolve。

function $await(input, then) {
  const defering = (input) => {
    return Promise.resolve(input)
  }
  if (typeof then === 'function') {
    return new Promise((resolve, reject) => {
      defering(input).then($async(then)).then(resolve).catch(reject)
    })
  }
  else {
    return defering(input)
  }
}

这里$await函数对一个defer或普通值进行包裹,返回一个新defer,而这个新defer默认会继承defer的返回值。通过上面这两个函数,我们来对async/await语法进行改造:

async function get(url) {
  let res = await fetch(url)
  let data = res.json()
  return data
}

=>

const get = $async(function(url) {
  let res = $await(fetch(url))
  let data = $await(res, res => res.json())
  return data
})

从句法上看,这两者之间非常相似,不过$await不是语法,它无法在循环中使用,也不会拦截进程,也就是说,它下方的代码不会在promise resolve之后执行,而是直接被执行,要在异步流中逐一被触发,只能将代码写在then这个回调函数中。

小结

本文主要探讨了一些js中异步编程的问题,只谈到了冰山一脚,无法展示该问题的全貌。不过从中也可以窥见,js异步编程就像一门艺术,而想要写出好的艺术品,必须拥有更扎实的基础功。

2018-08-05 283 , ,

为价值买单

本文价值2.83RMB