前言:异步编程的前世今生
众所周知,JavaScript
是单线程的。如果 JS
都是同步代码执行意味着什么呢?这样可能会造成阻塞,如果当前我们有一段代码需要执行时,如果使用同步的方式,那么就会阻塞后面的代码执行;而如果使用异步则不会阻塞,我们不需要等待异步代码执行的返回结果,可以继续执行该异步任务之后的代码逻辑。
下面我们来纵观异步编程的发展历史,来具体看看回调函数、Promise、Generator、async/await逐步演进的过程。
异步:简单说就是一个任务不是连续完成的,可以理解成该任务被人为分成两段,先执行第一段,然后转而执行其他任务,等做好了准备,再回过头执行第二段。
回调函数
早些年为了实现JS的异步编程,一般采用传统的回调函数,比如事件回调,ajax
请求回调,或者用 setTimeout
/setInterval
来实现的异步编程。
使用回调函数,虽然直观,但是有个致命弱点,嵌套层级较多会造成回调地狱,代码不易维护。
下面来看个代码案例:
fs.readFile(A, 'utf-8', function(err, data) {
fs.readFile(B, 'utf-8', function(err, data) {
fs.readFile(C, 'utf-8', function(err, data) {
fs.readFile(D, 'utf-8', function(err, data) {
//....
});
});
});
});
从上面的代码可以看出,其逻辑为先读取 A 文本内容,再根据 A 文本内容读取 B,然后再根据 B 的内容读取 C。为了实现这个业务逻辑,上面实现的代码就很容易形成回调地狱。
回调地狱的根本问题在于:
- 嵌套函数存在耦合性,一旦有所改动,就会牵一发而动全身
- 嵌套函数一多,就很难定位处理错误
Promise
为了解决回调地狱的问题,社区提出了 Promise
的解决方案,ES6又将其写进了语言标准。
我们还是针对上面例子,用 Promise
改造之后可得:
function read(url) {
return new Promise((resolve, reject) => {
fs.readFile(url, 'utf8', (err, data) => {
if(err) reject(err);
resolve(data);
});
});
}
read(A).then(data => {
return read(B);
}).then(data => {
return read(C);
}).then(data => {
return read(D);
}).catch(reason => {
console.log(reason);
});
从上面的代码可以看出,针对回调地狱进行这样的改进,可读性的确有一定的提升,优点
是可以将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数。
Promise
也存在一些问题,链式调用过多还是一堆then
。而且Promise
依旧支持嵌套函数,并没有从根本上解决回调地狱。有没有更好的写法?那么我们继续探索吧~~
Generator函数
Generator函数
传统的编程语言,早有多任务执行方案解决,即“协程”(多线程互相协作,完成异步任务)。Generator
函数是协程在 ES6 的实现,最大特点就是可以交出函数的执行权(即暂停执行)。
function* gen() {
let a = yield 111;
let b = yield 222;
let c = yield 333;
let d = yield 444;
}
let t = gen();
t.next(); // {value: 111, done: false}
t.next(); // {value: 222, done: false}
t.next(); // {value: 333, done: false}
t.next(); // {value: 444, done: false}
t.next(); // {value: undefined, done: true}
有上述代码,可以看出 next
方法分阶段执行 Generator
函数。每次调用next
方法,会返回一个对象,表示当前阶段的信息(value
属性和done
属性)。value
属性是 yield
语句后面表达式的值,表示当前阶段的值;done
属性是一个布尔值,表示 Generator
函数是否执行完毕,即是否还有下一个阶段。
因此,我们刚好可以利用 Generator
分阶段执行的特性,来解决异步回调的问题。
扩展
Thunk函数
Thunk
函数早在上个世纪 60 年代就诞生了。那时,编程语言刚刚起步,计算机学家还在研究,编译器怎么写比较好。一个争论的焦点是"求值策略",即函数的参数到底应该何时求值。
- 一种是“传值调用”
- 另一种是“传名调用” Thunk函数就是包装成惰性函数,是“传名调用”的实现。
上面的概念理解可能有些晦涩,下面我们改造一段判断类型的代码,来具体理解 thunk
函数吧:
let isString = (obj) => {
return Object.prototype.toString.call(obj) === '[object String]';
};
let isFunction = (obj) => {
return Object.prototype.toString.call(obj) === '[object Function]';
};
let isArray = (obj) => {
return Object.prototype.toString.call(obj) === '[object Array]';
};
....
// thunk函数封装判断类型的函数
let isType = (type) => {
return (obj) => {
return Object.prototype.toString.call(obj) === `[object ${type}]`;
}
}
// 使用
let isString = isType('String');
let isArray = isType('Array');
isString('123');// true
isArray([1,2,3]);// true
看了上述 isType
函数的案例,大家应该get到 thunk
函数的思想了吧,就是将多参数函数,替换成只接受单参数函数。这样的函数,我们在一些开源项目,抽象度比较高的代码经常见到。
Generator 和 thunk 结合
Generator
和 thunk
函数结合,可以更好的解决异步回调的问题,下面来看个异步读取文件的例子:
const readFileThunk = (filename) => {
return (callback) => {
fs.readFile(filename, callback)
}
}
const gen = function* (){
const data1 = yield readFileThunk('1.txt')
console.log(data1.toString())
const data2 = yield readFileThunk('2.txt')
console.log(data2.toString())
}
let g = gen();
g.next().value((err, data1)=> {
g.next(data1).value((err, data2)=>{
g.next(data2)
})
})
上述 readFileThunk
就是一个 thunk
函数,Generator
来操作异步任务,但是执行调用的时候,多层嵌套还是容易造成回调地狱。那么我们可以将执行调用的代码再做进一步封装,如下:
function run(gen) {
const next = (err, data) => {
let res = gen.next(data);
if(res.done) return;
res.next(next)
}
next()
}
run(g);
通过递归的方式,我们解决了多层嵌套的问题,并且完成了异步操作一次性执行的效果。虽然Thunk
函数和Generator
函数结合可以解决异步回调地狱,但是使用相对复杂,有没有更好的办法呢?
Co函数
Co
函数是著名程序员 TJ Holowaychuk 发布的小工具,用来处理 Generator
函数的自动执行。Co
函数实质是将 Thunk
函数和 Promise
对象包装成一个库,接收 Generator
作为参数,它使用起来非常简单。使用代码如下:
const co = require('co');
let g = gen();
co(g).then(res =>{
console.log(res);
})
关于 co
的内部原理,有兴趣的可以去 co 的源码库学习。
async/await
JS
的异步编程从最开始的回调函数方式,演进到 Promise
对象,再到 Generator
函数,每次都有一些改变,但又让人觉得不彻底,使用还需要去了解底层机制。
ES7
推出了 async/await
,它是相对完善的解决方案,可以用同步的方式编写异步代码,使用时不必关心底层机制。
还是以读取文件为例,我们来感受下 async/await
的语法糖:
// readFilePromise 依旧返回 Promise 对象
const readFilePromise = (filename) => {
return new Promise((resolve, reject) => {
fs.readFile(filename, (err, data) => {
if(err) {
reject(err);
}else {
resolve(data);
}
})
}).then(res => res);
}
// 这里把 Generator的 * 换成 async,把 yield 换成 await
const gen = async function() {
const data1 = await readFilePromise('1.txt')
console.log(data1.toString())
const data2 = await readFilePromise('2.txt')
console.log(data2.toString)
}
从上面代码来看,表面语法只是将 Generator
的 * 换成 async
,yield
换成 await
,但其实 async
的内部做了不少工作。总结下来,主要体现在以下3点:
- 内置执行器:
Generator
函数的执行需要靠执行器,因为不能一次性执行完成,所以之后才有了开源的Co
函数。async
函数和Co
函数一样可以自动执行,而且语法更简洁。 - 适用性更好:
Co
函数有条件约束,yield
命令后面只能是Thunk
函数或是Promise
对象,await
关键词后面可以不受约束。 - 可读性更好:
async
和await
,比使用 * 号和yield
,语义更清晰明了。
总结
最后我们来比较下各个异步编程方式的优缺点,如下:
回调函数 | Promise | Generator | Co | async/await | |
---|---|---|---|---|---|
优点 | 简单直观 | 链式调用,一定程度解决”回调地狱“ | 分阶段”协程“执行 | 可以自动执行Generator | 1. 语法简洁优雅,可读性好; 2. 内置执行器; 3. 适应性更好。 |
缺点 | 容易形成“回调地狱“ | 依旧支持嵌套写法,没有根除”回调地狱“ | 不能一次性自动执行 | 还需要依赖第三方Co函数工具库 | - |