【JS】异步编程的前世今生

711 阅读7分钟

前言:异步编程的前世今生

众所周知,JavaScript 是单线程的。如果 JS 都是同步代码执行意味着什么呢?这样可能会造成阻塞,如果当前我们有一段代码需要执行时,如果使用同步的方式,那么就会阻塞后面的代码执行;而如果使用异步则不会阻塞,我们不需要等待异步代码执行的返回结果,可以继续执行该异步任务之后的代码逻辑。
下面我们来纵观异步编程的发展历史,来具体看看回调函数PromiseGeneratorasync/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。为了实现这个业务逻辑,上面实现的代码就很容易形成回调地狱

回调地狱的根本问题在于:

  1. 嵌套函数存在耦合性,一旦有所改动,就会牵一发而动全身
  2. 嵌套函数一多,就很难定位处理错误

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 结合

Generatorthunk 函数结合,可以更好的解决异步回调的问题,下面来看个异步读取文件的例子:

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 的 * 换成 asyncyield 换成 await,但其实 async 的内部做了不少工作。总结下来,主要体现在以下3点:

  1. 内置执行器Generator 函数的执行需要靠执行器,因为不能一次性执行完成,所以之后才有了开源的 Co 函数。async 函数和 Co 函数一样可以自动执行,而且语法更简洁。
  2. 适用性更好Co 函数有条件约束,yield 命令后面只能是 Thunk 函数或是 Promise 对象,await 关键词后面可以不受约束。
  3. 可读性更好asyncawait,比使用 * 号和 yield,语义更清晰明了。

总结

最后我们来比较下各个异步编程方式的优缺点,如下:

回调函数PromiseGeneratorCoasync/await
优点简单直观链式调用,一定程度解决”回调地狱“分阶段”协程“执行可以自动执行Generator1. 语法简洁优雅,可读性好;
2. 内置执行器;
3. 适应性更好。
缺点容易形成“回调地狱“依旧支持嵌套写法,没有根除”回调地狱“不能一次性自动执行还需要依赖第三方Co函数工具库-