大家好,我是 CUGGZ。,浏览器中的 JavaScript 是典型的事件驱动型程序,即它们会等待用户触发后才真正的执行,而基于的JavaScript的服务器通常要等待客户端通过网络发送请求,然后才能执行。这种异步编程在JavaScript是很常见的,下面就来介绍几个异步编程的重要特性,它们可以使编写异步代码更容易。,本文将按照异步编程方式的出现时间来归纳整理:,
,下面先来看看同步和异步的概念:,同步: 在执行某段代码时,在没有得到返回结果之前,其他代码暂时是无法执行的,但是一旦执行完成拿到返回值,即可执行其他代码。也就是说,在此段代码执行完未返回结果之前,会阻塞之后的代码执行,这样的情况称为同步。,异步: 当某一代码执行异步过程调用发出后,这段代码不会立刻得到返回结果。而是在异步调用发出之后,一般通过回调函数处理这个调用之后拿到结果。异步调用发出后,不会影响阻塞后面的代码执行,这样的情况称为异步。,下面来看一个例子:,这里定义了同步函数 syncAdd 和异步函数 asyncAdd,调用 syncAdd(1, 2) 函数时会等待得到结果之后再执行后面的代码。而调用 asyncAdd(1, 2) 时则会在得到结果之前继续执行,直到 1 秒后得到结果并打印。,我们知道,JavaScript 是单线程的,如果代码同步执行,就可能会造成阻塞;而如果使用异步则不会阻塞,不需要等待异步代码执行的返回结果,可以继续执行该异步任务之后的代码逻辑。因此,在 JavaScript 编程中,会大量使用异步。,那为什么单线程的JavaScript还能实现异步呢,其实也没有什么魔法,只是把一些操作交给了其他线程处理,然后采用了事件循环的机制来处理返回结果。,在最基本的层面上,JavaScript的异步编程式通过回调实现的。回调的是函数,可以传给其他函数,而其他函数会在满足某个条件时调用这个函数。下面就来看看常见的不同形式的基于回调的异步编程。,一种最简单的异步操作就是在一定时间之后运行某些代码。如下面代码:,setTimeout()方法的第一个参数是一个函数,第二个参数是以毫秒为单位的时间间隔。asyncAdd()方法可能是一个回调函数,而setTimeout()方法就是注册回调函数的函数。它还代指在什么异步条件下调用回调函数。setTimeout()方法只会调用一次回调函数。,给目标 DOM 绑定一个监听函数,用的最多的是 addEventListener:,通过给 id 为 myDiv 的一个元素绑定了点击事件的监听函数,把任务的执行时机推迟到了点击这个动作发生时。此时,任务的执行顺序与代码的编写顺序无关,只与点击事件有没有被触发有关。,这里使用addEventListener注册了回调函数,这个方法的第一个参数是一个字符串,指定要注册的事件类型,如果用户点击了指定的元素,浏览器就会调用回调函数,并给他传入一个对象,其中包含着事件的详细信息。,JavaScript中另外一种常见的异步操作就是网络请求:,这里使用XMLHttpRequest类及回调函数来发送HTTP请求并异步处理服务器返回的响应。,Node.js服务端JavaScript环境底层就是异步的,定义了很多使用回调和事件的API。例如读取文件默认的API就是异步的,它会在读取文件内容之后调用一个回调函数:,fs.readFile()方法以接收两个参数的回调作为最后一个参数。它会异步读取指定文件,如果读取成功就会将第二个参数传递给回调的第二个参数,如果发生错误,就会将错误传递给回调的第一个参数。,Promise是一种为简化异步编程而设计的核心语言特性,它是一个对象,表示异步操作的结果。在最简单的情况下,Promise就是一种处理回调的不同方式。不过,使用Promise也有实际的用处,基于回调的异步编程会有一个很现实的问题,那就是经常出现回调多层嵌套的情况,会造成代码难以理解。Promise可以让这种嵌套回调以一种更线性的链式形式表达出来,因此更容易阅读和理解。,回调的另一个问题就是难以处理错误, 如果一个异步函数抛出异常,则该异常没有办法传播到异步操作的发起者。异步编程的一个基本事实就是它破坏了异常处理。而Promise则标准化了异步错误处理,通过Promise链提供一种让错误正确传播的途经。,实际上,Promise就是一个容器,里面保存着某个未来才会结束的事件(通常是异步操作)的结果。从语法上说,Promise 是一个对象,它可以获取异步操作的消息。Promise 提供了统一的 API,各种异步操作都可以用同样的方法进行处理。,(1)Promise实例有三个状态:,(2)Promise实例有两个过程:,Promise的特点:,一旦状态改变就不会再变,promise对象的状态改变,只有两种可能:从pending变为fulfilled,从pending变为rejected。当 Promise 实例被创建时,内部的代码就会立即被执行,而且无法从外部停止。比如无法取消超时或消耗性能的异步调用,容易导致资源的浪费;,如果不设置回调函数,Promise内部抛出的错误,不会反映到外部;,Promise 处理的问题都是“一次性”的,因为一个 Promise 实例只能 resolve 或 reject 一次,所以面对某些需要持续响应的场景时就会变得力不从心。比如上传文件获取进度时,默认采用的就是事件监听的方式来实现。,下面来看一个例子:,可以看到,Promise 会接收一个执行器,在这个执行器里,需要把目标异步任务给放进去。在 Promise 实例创建后,执行器里的逻辑会立刻执行,在执行的过程中,根据异步返回的结果,决定如何使用 resolve 或 reject 来改变 Promise实例的状态。,在这个例子里,当用 resolve 切换到了成功态后,Promise 的逻辑就会走到 then 中传入的方法里去;用 reject 切换到失败态后,Promise 的逻辑就会走到 catch 传入的方法中。,这样的逻辑,本质上与回调函数中的成功回调和失败回调没有差异。但这种写法大大地提高了代码的质量。当我们进行大量的异步链式调用时,回调地狱不复存在了。取而代之的是层级简单、赏心悦目的 Promise 调用链:,Promise对象代表一个异步操作,有三种状态:pending(进行中)、fulfilled(已成功)和rejected(已失败)。,Promise构造函数接受一个函数作为参数,该函数的两个参数分别是resolve和reject。,一般情况下,我们会用new Promise()来创建Promise对象。除此之外,还也可以使用promise.resolve和 promise.reject这两个方法来创建:,(1)Promise.resolve,Promise.resolve(value)的返回值是一个promise对象,我们可以对返回值进行.then调用,如下代码:,resolve(11)会让promise对象进入确定(resolve状态),并将参数11传递给后面then中指定的onFulfilled 函数;,(2)Promise.reject,Promise.reject 的返回值也是一个promise对象,如下代码:,上面是以下代码的简单形式:,下面来综合看看resolve方法和reject方法:,上面的代码给testPromise方法传递一个参数,返回一个promise对象,如果为true,那么调用Promise对象中的resolve()方法,并且把其中的参数传递给后面的then第一个函数内,因此打印出 “hello world”, 如果为false,会调用promise对象中的reject()方法,则会进入then的第二个函数内,会打印No thanks。,在开发中可能会碰到这样的需求:使用ajax发送A请求,成功后拿到数据,需要把数据传给B请求,那么需要这样编写代码:,这段代码之所以看上去很乱,归结其原因有两点:,第一是嵌套调用,下面的任务依赖上个任务的请求结果,并在上个任务的回调函数内部执行新的业务逻辑,这样当嵌套层次多了之后,代码的可读性就变得非常差了。,第二是任务的不确定性,执行每个任务都有两种可能的结果(成功或者失败),所以体现在代码中就需要对每个任务的执行结果做两次判断,这种对每个任务都要进行一次额外的错误处理的方式,明显增加了代码的混乱程度。,既然原因分析出来了,那么问题的解决思路就很清晰了:,消灭嵌套调用;,合并多个任务的错误处理。,这么说可能有点抽象,不过 Promise 解决了这两个问题。接下来就看看 Promise 是怎么消灭嵌套调用和合并多个任务的错误处理的。,Promise出现之后,代码可以这样写:,通过引入 Promise,上面这段代码看起来就非常线性了,也非常符合人的直觉。Promise 利用了三大技术手段来解决回调地狱:回调函数延迟绑定、返回值穿透、错误冒泡。,下面来看一段代码:,可以看到,回调函数不是直接声明的,而是通过后面的 then 方法传入的,即延迟传入,这就是回调函数延迟绑定。接下来针对上面的代码做一下调整,如下:,根据 then 中回调函数的传入值创建不同类型的 Promise,然后把返回的 Promise 穿透到外层,以供后续的调用。这里的 x 指的就是内部返回的 Promise,然后在 x 后面可以依次完成链式调用。这便是返回值穿透的效果,这两种技术一起作用便可以将深层的嵌套回调写成下面的形式。,这样就显得清爽许多,更重要的是,它更符合人的线性思维模式,开发体验更好,两种技术结合产生了链式调用的效果。,这样解决了多层嵌套的问题,那另外一个问题,即每次任务执行结束后分别处理成功和失败的情况怎么解决的呢?Promise 采用了错误冒泡的方式。下面来看效果:,这样前面产生的错误会一直向后传递,被 catch 接收到,就不用频繁地检查错误了。从上面的这些代码中可以看到,Promise 解决效果也比较明显:实现链式调用,解决多层嵌套问题;实现错误冒泡后一站式处理,解决每次任务中判断错误、增加代码混乱度的问题。,Promise常用的方法:then()、catch()、all()、race()、finally()、allSettled()、any()。,(1)then(),当Promise执行的内容符合成功条件时,调用resolve函数,失败就调用reject函数。那Promise创建完了,该如何调用呢?这时就该then出场了:,then方法接受两个回调函数作为参数。第一个回调函数是Promise对象的状态变为resolved时调用,第二个回调函数是Promise对象的状态变为rejected时调用。其中第二个参数可以省略。,then方法返回的是一个新的Promise实例。因此可以采用链式写法,即then方法后面再调用另一个then方法。当写有顺序的异步事件时,需要串行时,可以这样写:,(2)catch(),Promise对象的catch方法相当于then方法的第二个参数,指向reject的回调函数。,不过catch方法还有一个作用,就是在执行resolve回调函数时,如果出现错误,抛出异常,不会停止运行,而是进入catch方法中:,(3)all(),all方法可以完成并行任务, 它接收一个数组,数组的每一项都是一个promise对象。当数组中所有的promise的状态都达到resolved时,all方法的状态就会变成resolved,如果有一个状态变成了rejected,那么all方法的状态就会变成rejected:,调用all方法时的结果成功的时候是回调函数的参数也是一个数组,这个数组按顺序保存着每一个promise对象resolve执行时的值。,(4)race(),race方法和all一样,接受的参数是一个每项都是promise的数组,但与all不同的是,当最先执行完的事件执行完之后,就直接返回该promise对象的值。,如果第一个promise对象状态变成resolved,那自身的状态变成了resolved;反之,第一个promise变成rejected,那自身状态就会变成rejected。,那么race方法有什么实际作用呢?当需要执行一个任务,超过多长时间就不做了,就可以用这个方法来解决:,(5)finally(),finally方法用于指定不管 Promise 对象最后状态如何,都会执行的操作。该方法是 ES2018 引入标准的。,上面代码中,不管promise最后的状态如何,在执行完then或catch指定的回调函数以后,都会执行finally方法指定的回调函数。,下面来看例子,服务器使用 Promise 处理请求,然后使用finally方法关掉服务器。,finally方法的回调函数不接受任何参数,这意味着没有办法知道,前面的 Promise 状态到底是fulfilled还是rejected。这表明,finally方法里面的操作,应该是与状态无关的,不依赖于 Promise 的执行结果。,finally本质上是then方法的特例:,上面代码中,如果不使用finally方法,同样的语句需要为成功和失败两种情况各写一次。有了finally方法,则只需要写一次。,(6)allSettled(),Promise.allSettled 的语法及参数跟 Promise.all 类似,其参数接受一个 Promise 的数组,返回一个新的 Promise。唯一的不同在于,执行完之后不会失败,也就是说当 Promise.allSettled 全部处理完成后,我们可以拿到每个 Promise 的状态,而不管其是否处理成功。,下面使用 allSettled 实现的一段代码:,可以看到,Promise.allSettled 最后返回的是一个数组,记录传进来的参数中每个 Promise 的返回值,这就是和 all 方法不太一样的地方。你也可以根据 all 方法提供的业务场景的代码进行改造,其实也能知道多个请求发出去之后,Promise 最后返回的是每个参数的最终状态。,(7)any(),any 方法返回一个 Promise,只要参数 Promise 实例有一个变成 fullfilled 状态,最后 any 返回的实例就会变成 fullfilled 状态;如果所有参数 Promise 实例都变成 rejected 状态,包装实例就会变成 rejected 状态。,下面对上面 allSettled 这段代码进行改造,来看下改造完的代码和执行结果:,可以看出,只要其中一个 Promise 变成 fullfilled 状态,那么 any 最后就返回这个 Promise。由于上面 resolved 这个 Promise 已经是 resolve 的了,故最后返回结果为 2。,错误处理是所有编程范型都必须要考虑的问题,在使用 JavaScript 进行异步编程时,也不例外。如果我们不做特殊处理,会怎样呢?来看下面的代码,先定义一个必定会失败的方法,调用:,可以看到打印出了 1 和 2,并在 1 秒后,获得一个“Uncaught Error”的错误打印,注意观察这个错误的堆栈:,可以看到,其中的 setTimeout (async) 这样的字样,表示着这是一个异步调用抛出的堆栈。但是,captured”这样的字样也并未打印,因为母方法 fail() 本身的原始顺序执行并没有失败,这个异常的抛出是在回调行为里发生的。 从上面的例子可以看出,对于异步编程来说,我们需要使用一种更好的机制来捕获并处理可能发生的异常。,Promise 除了支持 resolve 回调以外,还支持 reject 回调,前者用于表示异步调用顺利结束,而后者则表示有异常发生,中断调用链并将异常抛出:,上面的代码中,flag 参数用来控制流程是顺利执行还是发生错误。在错误发生的时候,no 字符串会被传递给 reject 函数,进一步传递给调用链:,上面的调用链,在执行的时候,第二行就传入了参数 false,它就已经失败了,异常抛出了,因此第三行的 exe 实际没有得到执行,执行结果如下:,这就说明,通过这种方式,调用链被中断了,下一个正常逻辑 exe(true) 没有被执行。 但是,有时候需要捕获错误,而继续执行后面的逻辑,该怎样做?这种情况下就要在调用链中使用 catch 了:,这种方式下,异常信息被捕获并打印,而调用链的下一步,也就是第四行的 exe(true) 可以继续被执行。将看到这样的输出:,这一部分就来简单实现一下Promise及其常用的方法。,(1)Promise,(2)Promise.then,then 方法返回一个新的 promise 实例,为了在 promise 状态发生变化时(resolve / reject 被调用时)再执行 then 里的函数,我们使用一个 callbacks 数组先把传给then的函数暂存起来,等状态改变时再调用。,那么,怎么保证后一个 then 里的方法在前一个 then(可能是异步)结束之后再执行呢?,可以将传给 then 的函数和新 promise 的 resolve 一起 push 到前一个 promise 的 callbacks 数组中,达到承前启后的效果:,承前:当前一个promise 完成后,调用其 resolve 变更状态,在这个 resolve 里会依次调用 callbacks 里的回调,这样就执行了 then 里的方法了,启后:上一步中,当then 里的方法执行完成后,返回一个结果,如果这个结果是个简单的值,就直接调用新 promise 的 resolve,让其状态变更,这又会依次调用新 promise 的 callbacks 数组里的方法,循环往复。。如果返回的结果是个 promise,则需要等它完成之后再触发新 promise 的 resolve,所以可以在其结果的 then 里调用新 promise 的 resolve,注意:,连续多个then 里的回调方法是同步注册的,但注册到了不同的 callbacks 数组中,因为每次 then 都返回新的 promise 实例(参考上面的例子和图),注册完成后开始执行构造函数中的异步事件,异步完成之后依次调用callbacks 数组中提前注册的回调,(3)Promise.all,该方法的参数是 Promise 的实例数组, 然后注册一个 then 方法。 待数组中的 Promise 实例的状态都转为 fulfilled 之后则执行 then 方法.,这里主要就是一个计数逻辑, 每当一个 Promise 的状态变为 fulfilled 之后就保存该实例返回的数据, 然后将计数减一, 当计数器变为 0 时, 代表数组中所有 Promise 实例都执行完毕.,(4)Promise.race,该方法的参数是 Promise 实例数组, 然后其 then 注册的回调方法是数组中的某一个 Promise 的状态变为 fulfilled 的时候就执行. 因为 Promise 的状态只能改变一次, 那么我们只需要把 Promise.race 中产生的 Promise 对象的 resolve 方法, 注入到数组中的每一个 Promise 实例中的回调函数中即可:,(1)Generator,Generator(生成器)是 ES6 中的关键词,通俗来讲 Generator 是一个带星号的函数(它并不是真正的函数),可以配合 yield 关键字来暂停或者执行函数。先来看一个例子:,输出结果如下:,Generator 中配合使用 yield 关键词可以控制函数执行的顺序,每当执行一次 next 方法,Generator 函数会执行到下一个存在 yield 关键词的位置。,总结,Generator 的执行的关键点如下:,调用 gen() 后,程序会阻塞,不会执行任何语句;,调用 g.next() 后,程序继续执行,直到遇到 yield 关键词时执行暂停;,一直执行 next 方法,最后返回一个对象,其存在两个属性:value 和 done。,(2)yield,yield 同样也是 ES6 的关键词,配合 Generator 执行以及暂停。yield 关键词最后返回一个迭代器对象,该对象有 value 和 done 两个属性,其中 done 属性代表返回值以及是否完成。yield 配合着 Generator,再同时使用 next 方法,可以主动控制 Generator 执行进度。,下面来看看多个 Generator 配合 yield 使用的情况:,执行结果如下:,可以看到,使用 yield 关键词的话还可以配合着 Generator 函数嵌套使用,从而控制函数执行进度。这样对于 Generator 的使用,以及最终函数的执行进度都可以很好地控制,从而形成符合你设想的执行顺序。即便 Generator 函数相互嵌套,也能通过调用 next 方法来按照进度一步步执行。,其实 Promise 也可以和 Generator 配合来实现上面的效果。还是利用上面的输出文件的例子,对代码进行改造,如下所示:,可以看到,thunk 函数的方式和通过 Promise 方式执行效果本质上是一样的,只不过通过 Promise 的方式也可以配合 Generator 函数实现同样的异步操作。,co 函数库用于处理 Generator 函数的自动执行。核心原理其实就是通过和 thunk 函数以及 Promise 对象进行配合,包装成一个库。它使用起来非常简单,比如还是用上面那段代码,第三段代码就可以省略了,直接引用 co 函数,包装起来就可以使用了,代码如下:,这段代码比较简单,几行就完成了之前写的递归的那些操作。那么为什么 co 函数库可以自动执行 Generator 函数,它的处理原理如下:,因为 Generator 函数就是一个异步操作的容器,它需要一种自动执行机制,co 函数接受 Generator 函数作为参数,并最后返回一个 Promise 对象。,在返回的 Promise 对象里面,co 先检查参数 gen 是否为 Generator 函数。如果是,就执行该函数;如果不是就返回,并将 Promise 对象的状态改为 resolved。,co 将 Generator 函数的内部指针对象的 next 方法,包装成 onFulfilled 函数。这主要是为了能够捕捉抛出的错误。,关键的是 next 函数,它会反复调用自身。,ES7 新增了两个关键字: async和await,代表异步JavaScript编程范式的迁移。它改进了生成器的缺点,提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力。其实 async/await 是 Generator 的语法糖,它能实现的效果都能用then链来实现,它是为优化then链而开发出来的。,从字面上来看,async是“异步”的简写,await则为等待,所以 async 用来声明异步函数,这个关键字可以用在函数声明、函数表达式、箭头函数和方法上。因为异步函数主要针对不会马上完成的任务,所以自然需要一种暂停和恢复执行的能力,使用await关键字可以暂停异步代码的执行,等待Promise解决。async 关键字可以让函数具有异步特征,但总体上代码仍然是同步求值的。,它们的用法很简单,首先用 async 关键字声明一个异步函数:,然后就可以在这个函数内部使用 await 关键字了:,这里,await关键字会接收一个期约并将其转化为一个返回值或一个抛出的异常。通过情况下,我们不会使用await来接收一个保存期约的变量,更多的是把他放在一个会返回期约的函数调用面前,比如上述例子。这里的关键就是,await关键字并不会导致程序阻塞,代码仍然是异步的,而await只是掩盖了这个事实,这就意味着任何使用await的代码本身都是异步的。,下面来看看async函数返回了什么:,
,可以看到,async 函数返回的是 Promise 对象。如果异步函数使用return关键字返回了值(如果没有return则会返回undefined),这个值则会被 Promise.resolve() 包装成 Promise 对象。异步函数始终返回Promise对象。,那await到底在等待什么呢?,一般我们认为 await 是在等待一个 async 函数完成。不过按语法说明,await 等待的是一个表达式,这个表达式的结果是 Promise 对象或其它值。,因为 async 函数返回一个 Promise 对象,所以 await 可以用于等待一个 async 函数的返回值——这也可以说是 await 在等 async 函数。但要清楚,它等的实际是一个返回值。注意,await 不仅用于等 Promise 对象,它可以等任意表达式的结果。所以,await 后面实际是可以接普通函数调用或者直接量的。所以下面这个示例完全可以正确运行:,await 表达式的运算结果取决于它等的是什么:,如果它等到的不是一个 Promise 对象,那 await 表达式的运算结果就是它等到的内容;,如果它等到的是一个 Promise 对象,await 就就会阻塞后面的代码,等着 Promise 对象 resolve,然后将得到的值作为 await 表达式的运算结果。,下面来看一个例子:,这就是 await 必须用在 async 函数中的原因。async 函数调用不会造成阻塞,它内部所有的阻塞都被封装在一个 Promise 对象中异步执行。await暂停当前async的执行,所以'cug''最先输出,hello world'和 cuger 是3秒钟后同时出现的。,单一的 Promise 链并不能凸显 async/await 的优势。但是,如果处理流程比较复杂,那么整段代码将充斥着 then,语义化不明显,代码不能很好地表示执行流程,这时async/await的优势就能体现出来了。,假设一个业务,分多个步骤完成,每个步骤都是异步的,而且依赖于上一个步骤的结果。首先用 setTimeout 来模拟异步操作:,现在用 Promise 方式来实现这三个步骤的处理:,输出结果 result 是 step3() 的参数 700 + 200 = 900。doIt() 顺序执行了三个步骤,一共用了 300 + 500 + 700 = 1500 毫秒,和 console.time()/console.timeEnd() 计算的结果一致。,如果用 async/await 来实现呢,会是这样:,结果和之前的 Promise 实现是一样的,但是这个代码看起来会清晰得多,几乎和同步代码一样。,async/await对比Promise的优势就显而易见了:,代码读起来更加同步,Promise虽然摆脱了回调地狱,但是then的链式调⽤也会带来额外的理解负担;,Promise传递中间值很麻烦,⽽async/await⼏乎是同步的写法,⾮常优雅;,错误处理友好,async/await可以⽤成熟的try/catch,Promise的错误捕获比较冗余;,调试友好,Promise的调试很差,由于没有代码块,不能在⼀个返回表达式的箭头函数中设置断点,如果在⼀个.then代码块中使⽤调试器的步进(step-over)功能,调试器并不会进⼊后续的.then代码块,因为调试器只能跟踪同步代码的每⼀步。,利用 async/await 的语法糖,可以像处理同步代码的异常一样,来处理异步代码,这里还用上面的示例:,
© 版权声明
文章版权归作者所有,未经允许请勿转载。