JavaScript 异步编程语法
这篇文章讲的是异步编程语法的发展过程,异步方法的调用是如何逐渐变成同步语法的。
异步编程的方法,大概有下面四种。
- 回调函数
- 事件监听
- 发布/订阅
- Promise 对象
ECMAScript 6 (简称 ES6 )作为下一代 JavaScript 语言,将 JavaScript 异步编程带入了一个全新的阶段。这组系列文章的主题,就是介绍更强大、更完善的 ES6 异步编程方法。
新方法比较抽象,初学时,我常常感到费解,直到很久以后才想通,异步编程的语法目标,就是怎样让它更像同步编程。
这边文章讲的是这几种语法之间的关联,如果阅读过程中对于任意一个语法有深入理解的需求,可以去看一下阮一峰老师的ES6语法入门。
Generator
总的来说,async 是Generator函数的语法糖,并对Generator函数进行了改进。
Generator函数简介
Generator 函数是一个状态机,封装了多个内部状态。执行 Generator 函数会返回一个遍历器对象,可以依次遍历 Generator 函数内部的每一个状态,但是只有调用next
方法才会遍历下一个内部状态,所以其实提供了一种可以暂停执行的函数。yield
表达式就是暂停标志。
有这样一段代码:
1 | function* helloWorldGenerator() { |
调用及运行结果:
1 | hw.next()// { value: 'hello', done: false } |
由结果可以看出,Generator函数
被调用时并不会执行,只有当调用next方法
、内部指针指向该语句时才会执行,即函数可以暂停,也可以恢复执行
。每次调用遍历器对象的next方法,就会返回一个有着value
和done
两个属性的对象。value
属性表示当前的内部状态的值,是yield表达式后面那个表达式的值;done
属性是一个布尔值,表示是否遍历结束。
Generator函数暂停恢复执行原理
要搞懂函数为何能暂停和恢复,那你首先要了解协程的概念。
一个线程(或函数)执行到一半,可以暂停执行,将执行权交给另一个线程(或函数),等到稍后收回执行权的时候,再恢复执行。这种可以并行执行、交换执行权的线程(或函数),就称为协程。
协程是一种比线程更加轻量级的存在。普通线程是抢先式的,会争夺cpu资源,而协程是合作的,可以把协程看成是跑在线程上的任务,一个线程上可以存在多个协程,但是在线程上同时只能执行一个协程。它的运行流程大致如下:
- 协程
A
开始执行 - 协程
A
执行到某个阶段,进入暂停,执行权转移到协程B
- 协程
B
执行完成或暂停,将执行权交还A
- 协程
A
恢复执行
协程遇到yield命令
就暂停,等到执行权返回(调用next方法),再从暂停的地方继续往后执行。它的最大优点,就是代码的写法非常像同步操作,如果去除yield命令,简直一模一样。
执行器
通常,我们把执行生成器的代码封装成一个函数,并把这个执行生成器代码的函数称为执行器,co 模块
就是一个著名的执行器。
Generator 是一个异步操作的容器。它的自动执行需要一种机制,当异步操作有了结果,能够自动交回执行权。两种方法可以做到这一点:
- 回调函数。将异步操作包装成 Thunk 函数,在回调函数里面交回执行权。
- Promise 对象。将异步操作包装成 Promise 对象,用then方法交回执行权。
回调函数的执行器(yield Thunk函数)
下面就是一个基于 Thunk 函数的 Generator 执行器。
1 | function run(fn) { |
上面代码的 run 函数,就是一个 Generator 函数的自动执行器。内部的 next 函数就是 Thunk 的回调函数。 next 函数先将指针移到 Generator 函数的下一步(gen.next 方法),然后判断 Generator 函数是否结束(result.done 属性),如果没结束,就将 next 函数再传入 Thunk 函数(result.value 属性),否则就直接退出。
有了这个执行器,执行 Generator 函数方便多了。不管有多少个异步操作,直接传入 run 函数即可。当然,前提是每一个异步操作,都要是 Thunk 函数,也就是说,跟在 yield 命令后面的必须是 Thunk 函数。
1 | var gen = function* (){ |
上面代码中,函数 gen 封装了 n 个异步的读取文件操作,只要执行 run 函数,这些操作就会自动完成。这样一来,异步操作不仅可以写得像同步操作,而且一行代码就可以执行。
Thunk 函数并不是 Generator 函数自动执行的唯一方案。因为自动执行的关键是,必须有一种机制,自动控制 Generator 函数的流程,接收和交还程序的执行权。回调函数可以做到这一点,Promise 对象也可以做到这一点。
Promise对象的执行器(yield Promise)
一个基于 Promise 对象的简单自动执行器(或者说一个只能yield Promise的执行器):
1 | function run(gen){ |
我们使用时,可以这样使用即可,
1 | function* foo() { |
上面代码中,只要 Generator 函数还没执行到最后一步,next函数就调用自身,以此实现自动执行。通过使用生成器配合执行器,就能实现使用同步的方式写出异步代码了,这样也大大加强了代码的可读性。
*上面这种方式看起来就很像async/await了,把上面的yield换成await,function 换成async,其实就是await在等待执行器将运行权限返回回来。**有一点通过内置执行器的不断回调将回调或者then给捋直了,捋成了同步的样子,通过yield将线程的执行权交出去,执行器中的异步函数返回数据后,将数据连同执行权通过next方法交会给生成器中。
CO函数
有一个 Generator 函数,用于依次读取两个文件。
1 | var gen = function* (){ |
co 函数库可以让你不用编写 Generator 函数的执行器。
1 | var co = require('co'); |
上面代码中,Generator 函数只要传入 co 函数,就会自动执行。
co 函数返回一个 Promise 对象,因此可以用 then 方法添加回调函数。
1 | co(gen).then(function (){ |
上面代码中,等到 Generator 函数执行结束,就会输出一行提示。
CO函数源码
首先,co 函数接受 Generator 函数作为参数,返回一个 Promise 对象。
1 | function co(gen) { |
在返回的 Promise 对象里面,co 先检查参数 gen 是否为 Generator 函数。如果是,就执行该函数,得到一个内部指针对象;如果不是就返回,并将 Promise 对象的状态改为 resolved 。
1 | function co(gen) { |
接着,co 将 Generator 函数的内部指针对象的 next 方法,包装成 onFulefilled 函数。这主要是为了能够捕捉抛出的错误。
1 | function co(gen) { |
最后,就是关键的 next 函数,它会反复调用自身。
1 | function next(ret) { |
上面代码中,next 函数的内部代码,一共只有四行命令。
第一行,检查当前是否为 Generator 函数的最后一步,如果是就返回。
第二行,确保每一步的返回值,是 Promise 对象。
第三行,使用 then 方法,为返回值加上回调函数,然后通过 onFulfilled 函数再次调用 next 函数。
第四行,在参数不符合要求的情况下(参数非 Thunk 函数和 Promise 对象),将 Promise 对象的状态改为 rejected,从而终止执行。
async/await
ES7 中引入了 async/await,这种方式能够彻底告别执行器和生成器,实现更加直观简洁的代码。根据 MDN 定义,async 是一个通过异步执行并隐式返回 Promise 作为结果的函数。可以说async 是Generator函数的语法糖,并对Generator函数进行了改进。
前文中的代码,用async
实现是这样:
1 | const foo = async () => { |
一比较就会发现,async函数就是将 Generator 函数的星号(*)替换成async,将yield替换成await,仅此而已。
async函数对 Generator 函数的改进,体现在以下四点:
内置执行器
。Generator 函数的执行必须依靠执行器,而 async 函数自带执行器,无需手动执行 next() 方法。更好的语义
。async和await,比起星号和yield,语义更清楚了。async表示函数里有异步操作,await表示紧跟在后面的表达式需要等待结果。更广的适用性
。co模块约定,yield命令后面只能是 Thunk 函数或 Promise 对象,而async函数的await命令后面,可以是 Promise 对象和原始类型的值(数值、字符串和布尔值,但这时会自动转成立即 resolved 的 Promise 对象)。返回值是 Promise
。async 函数返回值是 Promise 对象,比 Generator 函数返回的 Iterator 对象方便,可以直接使用 then() 方法进行调用。
这里的重点是自带了执行器,相当于把我们要额外做的(写执行器/依赖co模块)都封装了在内部。比如:
1 | async function fn(args) { |
等同于:
1 | function fn(args) { |
综上所述,也就是说async/await其实就是将生成器和执行器封装在了一起,把本来要执行的函数封装进了生成器中,并用内置的执行器去执行它。
总结
Generator可以在等待异步方法(回调函数,Promise)返回的时候将线程执行权暂时交出去,而执行器可以帮忙执行生成器的同时去等待异步方法返回后调用生成器的next方法,将异步方法的返回数据连同执行权交还给生成器。
总的来说,我们可以通过回调,Promise等方法进行异步编程,Promise解决了回调噩梦,但是还是和同步编程语法有所区别,所以就利用了生成器Generator,在yiled Promise的结果的时候先将线程的执行权交出去,等到Promise resolve时,通过next方法把执行权连同Promise的结果一起交给生成器继续执行直到下一个yield或结束。
此时看起来已经像是同步编程了,yield在等待异步的结果返回后继续执行,但是上述的这个过程中需要我们编写一个执行器去执行生成器,在yield的时候将执行权交出去,在yield的Promise resolve后调用Generator的next把执行权交回来,这个执行器一开始比较出名的是CO,它能够帮助我们执行Generator,但是yield的只能是Thunk或Promise。
于是后来就出现了async/await,用async定义的函数,会被放入一个生成器中,然后返回一个Promise。它不仅让语法更好理解,而且内置了一个支持范围更广的执行器(Promise)去执行这个生成器,这个Promise就是一个执行器,然后利用执行器内部函数的不断递归回调去调用生成器的next方法,去逐个执行所有的异步函数,直到所有的await执行完成后调用Promise的resolve,将结果返回。
参考链接: