JS 异步编程都有哪些解决方案?

1,601

这是我参与8月更文挑战的第20天,活动详情查看:8月更文挑战

前言

众所周知,JS是一门单线程语言,并且浏览器使用异步非阻塞的事件循环模型来进行JS任务调度,因此,JS的异步编程可以说在日常的前端业务开发中经常出现,我们在日常开发中都用过哪些异步编程的方式?总结起来无外乎有这几种:回调函数、事件监听、PromiseGeneratorasync/await。回调函数是异步编程的通用方式,后随着 ES标准的发展,PromiseGeneratorasync/await 接连出现。

什么是同步执行

同步执行就是在执行某段代码时,在没有得到执行结果前,其他代码的执行会被阻塞,暂时无法执行,但是一旦执行完成之后,就可以执行其他代码了。换句话说,在此段代码执行完未返回结果之前,会阻塞之后的代码执行。

什么是异步执行

异步执行就是当某一代码执行的异步过程调用发出后,这段代码不会立刻得到返回结果,而是在异步调用发出之后,引擎将这个任务放入任务队列,然后继续执行后面的代码,当这个任务执行完毕后,引擎通知主线程,然后主线程执行这个任务的回调函数。异步执行不会阻塞后续代码的运行。

JS中的异步编程方式

浏览器端,JS任务的异步执行可以帮助浏览器在渲染页面时同时处理各种网络请求、用户交互等。服务端,基于事件循环的异步非阻塞模型天生具备高并发的处理能力。因此异步编程是JS编程中非常重要的一环,让我们来看下有哪些异步编程方式,并且都解决了哪些痛点和有什么不足。

回调函数

回调函数是JS异步编程的最基本、最原始的方式,例如事件回调、setTimeout/setIntervalajax等,但是使用回调函数存在一个非常棘手的问题,那就是回调地狱,一开始写没什么,等过一段时间后,不管自己看还是别人看,都会觉得这代码写的真恶心。

fs.readFile(A, 'utf-8', function(err, dataA) {
    fs.readFile(B, 'utf-8', function(err, dataB) {
        fs.readFile(C, 'utf-8', function(err, dataC) {
            fs.readFile(D, 'utf-8', function(err, dataD) {
                fs.readFile(E, 'utf-8', function(err, dataE) {
                    //....
                });
            });
        });
    });
});

像上面这种一个异步请求的发生依赖于上一个异步请求的结果的代码,使用回调函数来解决就会出现回调地狱,但是在早期,包括各种库实现,可以说回调函数是异步编程的唯一方式,社区苦回调地狱久矣!

Promise

Promise 是异步编程的一种解决方案,比传统的解决方案 “回调函数和事件” 更合理和更强大。它由社区最早提出和实现,ES6 将其写进了语言标准,统一了用法,原生提供了Promise对象,在一定程度上解决了回调地狱的问题。

Promise是一个包含三种状态(pending待定、fulfilled兑现、rejected拒绝)的有限状态机,状态名称很好地解释了它为什么叫Promise(承诺、期约)。一个Promise实例持有着一个异步操作未来的某个状态,即前面提到的三种状态之一,它提供统一的API来让我们获取异步操作的执行结果。

使用Promise提供的then链式调用改写上面的代码:

function read(path) {
    return new Promise((resolve, reject) => {
        fs.readFile(path, 'utf8', (err, data) => {
            if(err) reject(err);
            resolve(data);
        });
    });
}
read(A).then(dataA => {
    return read(dataA);
}).then(dataB => {
    return read(dataB);
}).then(dataC => {
    return read(dataC);
}).then(dataD => {
    return read(dataD);
}).then(dataE => {
    console.log(dataE)
}).catch(reason => {
    console.log(reason);
});

从上面的代码可以看出,针对回调地狱进行这样的改进,可读性的确有一定的提升,优点是可以将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数,并且使用Promise提供catch方法可以统一处理then链式调用可能产生的异常。但是存在的问题也很明显,过多使用then链式调用,其实并没有从根本上解决回调地狱的问题,只是换了一种写法,可读性虽然有所提升,但是依旧很难维护。

对于相互间没有依赖关系的多个异步请求,Promise提供all方法来帮助我们集中处理它们,它接受一个迭代对象,迭代元素都是一个Promise实例,当全部实例都处于兑现状态时返回一个包含执行结果的数组,否则产生一个异常。

function read(path) {
    return new Promise((resolve, reject) => {
        fs.readFile(path, 'utf8', (err, data) => {
            if(err) reject(err);
            resolve(data);
        });
    });
}

// 通过 Promise.all 可以实现多个异步操作并行执行
Promise.all([read(A), read(B), read(C)]).then(result => {
    console.log(result);
}).catch(reason => 
    console.log(reason)
);

Generator

Generator同样是ES6新增的特性之一,也是一种异步编程解决方案,它最大的特点就是可以交出函数的执行权,这一点就于协程非常类似,可以把它看作协程的ES6实现。Generator 函数可以看出是异步任务的容器,通常配合yield来使用,在需要暂停的地方,都用 yield 关键字来标注。Generator 函数最后返回的是迭代器对象。

使用Generator创建一个随机数生成器函数:

function * randomFrom(base) {
  while (true)
    yield arr[Math.floor(Math.random() * base];
}

const getRandom = randomFrom(10);
getRandom.next().value; // 返回随机的一个数

那么在异步操作方面如何使用生成器解决呢?

继续以上面的文件读取代码为例子:

function read(path) {
    return new Promise((resolve, reject) => {
        fs.readFile(path, 'utf8', (err, data) => {
            if(err) reject(err);
            resolve(data);
        });
    });
}

function* readGenerator(path){
  let dataA = yield readFile(path);
  let dataB = yield readFile(dataA);
  let result = yield readFile(dataB);
  yield result
}

let gen = readGenerator(A)

gen.value.then(function(dataA){
  return gen.next(dataA);
}).then(function(dataB){
  return gen.next(dataB);
}).then(function(resuly){
  console.log(result);
});

Generator更多地是用在异步流程控制上。

async/await

async/awaitES7提出的一种异步解决方案,它相当于Generator + 执行器的语法糖,就目前来说,是最佳的异步解决方案,真正实现了异步代码,同步表示。

首先,async用于标识一个函数,这个函数的任何返回结果都会被包装成一个Promise实例,同时,对于Promsie实例,使用await来进行解析。

使用方式也很简单,创建一个async标识的函数,表明这个函数内部将执行异步操作,对于函数内部的异步操作,使用await来标识,await可以看作是一个Promise的状态解析器,即自动调用Promise实例的then方法并将结果取出来。

使用async/await改进上面的Generator代码:

function read(path) {
    return new Promise((resolve, reject) => {
        fs.readFile(path, 'utf8', (err, data) => {
            if(err) reject(err);
            resolve(data);
        });
    });
}

const asyncReadFile = async function (path) {
  let dataA = await readFile(path);
  let dataB = await readFile(dataA);
  let result = await readFile(dataB);
  console.log(result);
};

可以看到,代码书写完全和同步代码一样,非常的优雅,不仅可读性强,更利于维护。

总结

上面介绍了JS中的异步编程的各种解决方案,它们各有千秋,分别在不同的场景下发挥着作用,如果对于某个解决方案不熟悉的话,可以在MDN上查看详细的使用。