js学习之异步处理

学习js开发,不管是前端开发仍是node.js,都避免不了要接触异步编程这个问题,就和其它大多数以多线程同步为主的编程语言不一样,js的主要设计是单线程异步模型。正由于js天生的不同凡响,才使得它拥有一种独特的魅力,也给学习者带来了不少探索的道路。本文就从js的最初设计开始,整理一下js异步编程的发展历程。javascript

什么是异步

在研究js异步以前,先弄清楚异步是什么。异步是和同步相对的概念,同步,指的是一个调用发起后要等待结果返回,返回时候必须拿到返回结果。而异步的调用,发起以后直接返回,返回的时候尚未结果,也不用等待结果,而调用结果是产生结果后经过被调用者通知调用者来传递的。前端

举个例子,A想找C,可是不知道C的电话号码,可是他有B的电话号码,因而A给B打电话询问C的电话号码,B须要查找才能知道C的电话号码,以后会出现两种场景看下面两个场景:java

  • A不挂电话,等到B找到号码以后直接告诉A
  • A挂电话,B找到后再给A打电话告诉A

能感觉到这两种状况是不一样的吧,前一种就是同步,后一种就是异步。node

为何是异步的

先来看js的诞生,JavaScript诞生于1995年,由Brendan Eich设计,最先是在Netscape公司的浏览器上实现,用来实如今浏览器中处理简单的表单验证等用户交互。至于后来提交到ECMA,造成规范,种种历史不是这篇文章的重点,提到这些就是想说一点,js的最初设计就是为了浏览器的GUI交互。对于图形化界面处理,引入多线程势必会带来各类各样的同步问题,所以浏览器中的js被设计成单线程,仍是很容易理解的。可是单线程有一个问题:一旦这个惟一的线程被阻塞就没办法工做了--这确定是不行的。因为异步编程能够实现“非阻塞”的调用效果,引入异步编程天然就是瓜熟蒂落的事情了。git

如今,js的运行环境不限于浏览器,还有node.js,node.js设计的最初想法就是设计一个彻底由事件驱动,非阻塞式IO实现的服务器运行环境,由于网络IO请求是一个很是大的性能瓶颈,前期使用其余编程语言都失败了,就是由于人们固有的同步编程思想,人们更倾向于使用同步设计的API。而js因为最初设计就是全异步的,人们不会有不少不适应,加上V8高性能引擎的出现,才造就了node.js技术的产生。node.js擅长处理IO密集型业务,就得益于事件驱动,非阻塞IO的设计,而这一切都与异步编程密不可分。github

js异步原理

这是一张简化的浏览器js执行流程图,nodejs和它不太同样,可是都有一个队列编程

这个队列就是异步队列,它是处理异步事件的核心,整个js调用时候,同步任务和其余编程语言同样,在栈中调用,一旦赶上异步任务,不马上执行,直接把它放到异步队列里面,这样就造成了两种不一样的任务。因为主线程中没有阻塞,很快就完成,栈中任务边空以后,就会有一个事件循环,把队列里面的任务一个一个取出来执行。只要主线程空闲,异步队列有任务,事件循环就会从队列中取出任务执行。api

说的比较简单,js执行引擎设计比这复杂的多得多,可是在js的异步实现原理中,事件循环和异步队列是核心的内容。promise

异步编程实现

异步编程的代码实现,随着时间的推移也在逐渐完善,不止是在js中,许多编程语言的使用者都在寻找一种优雅的异步编程代码书写方式,下面来看js中的曾出现的几种重要的实现方式。浏览器

最经典的异步编程方式--callback

提起异步编程,不能不提的就是回调(callback)的方式了,回调方式是最传统的异步编程解决方案。首先要知道回调能解决异步问题,可是不表明使用回调就是异步任务了。下面以最多见的网络请求为例来演示callback是如何处理异步任务的,首先来看一个错误的例子:

function getData(url) {
    const data = $.get(url);
    return data;
}

const data = getData('/api/data'); // 错误,data为undefined

因为函数getData内部须要执行网络请求,没法预知结果的返回时机,直接经过同步的方式返回结果是行不通的,正确的写法是像下面这样:

function getData(url, callback) {
    $.get(url, data => {
        if (data.status === 200) {
            callback(null, data);
        } else {
            callback(data);
        }
    });
}

getData('/api/data', (err, data) => {
    if (err) {
        console.log(err);
    } else {
        console.log(data);
    }
});

callback方式利用了函数式编程的特色,把要执行的函数做为参数传入,由被调用者控制执行时机,确保可以拿到正确的结果。这种方式初看可能会有点难懂,可是熟悉函数式编程其实很简单,很好地解决了最基本的异步问题,早期异步编程只能经过这种方式。

然而这种方式会有一个致命的问题,在实际开发中,模型总不会这样简单,下面的场景是常有的事:

fun1(data => {
    // ...
    fun2(data, result => {
        // ...
        fun3(result, () => {
            // ...
        });
    });
});

整个随着系统愈来愈复杂,整个回调函数的层次会逐渐加深,里面再加上复杂的逻辑,代码编写维护都将变得十分困难,可读性几乎没有。这被称为毁掉地狱,一度困扰着开发者,甚至是曾经异步编程最为人诟病的地方。

从地狱中走出来--promise

使用回调函数来编程很简单,可是回调地狱实在是太可怕了,嵌套层级足够深以后绝对是维护的噩梦,而promise的出现就是解决这一问题的。promise是按照规范实现的一个对象,ES6提供了原生的实现,早期的三方实现也有不少。在此不会去讨论promise规范和实现原理,重点来看promise是如何解决异步编程的问题的。

Promise对象表明一个未完成、但预计未来会完成的操做,有三种状态:

  • pending:初始值,不是fulfilled,也不是rejected
  • resolved(也叫fulfilled):表明操做成功
  • rejected:表明操做失败

整个promise的状态只支持两种转换:从pending转变为resolved,或从pending转变为rejected,一旦转化发生就会保持这种状态,不能够再发生变化,状态发生变化后会触发then方法。这里比较抽象,咱们直接来改造上面的例子:

function getData(url) {
    return new Promise((resolve, reject) =>{
        $.get(url, data => {
            if (data.status === 200) {
                reject(data);
            } else {
                resolve(data);
            }
        });
    });
}

getData('/api/data').then(data => {
    console.log(data);
}).catch(err => {
    console.log(err);
});

Promise是一个构造函数,它建立一个promise对象,接收一个回调函数做为参数,而回调函数又接收两个函数作参数,分别表明promise的两种状态转化。resolve回调会使promise由pending转变为resolved,而reject 回调会使promise由pending转变为rejected。

当promise变为resolved时候,then方法就会被触发,在里面能够获取到resolve的内容,then方法。而一旦promise变为rejected,就会产生一个error。不管是resolve仍是reject,都会返回一个新的Promise实例,返回值将做为参数传入这个新Promise的resolve函数,这样就能够实现链式调用,对于错误的处理,系统提供了catch方法,错误会一直向后传递,老是能被下一个catch捕获。用promise能够有效地避免回调嵌套的问题,代码会变成下面的样子:

fun1().then(data => {
    // ...
    return fun2(data);
}).then(result => {
    // ...
    return fun3(result);
}).then(() => {
    // ...
});

整个调用过程变的很清晰,可维护性可扩展性都会大大加强,promise是一种很是重要的异步编程方式,它改变了以往的思惟方式,也是后面新方式产生的重要基础。

转换思惟--generator

promise的写法是最好的吗,链式调用相比回调函数而言倒是可维护性增长了很多,可是和同步编程相比,异步看起来不是那么和谐,而generator的出现带来了另外一种思路。

generator是ES对协程的实现,协程指的是函数并非整个执行下去的,一个函数执行到一半能够移交执行权,等到能够的时候再得到执行权,这种方式最大的特色就是同步的思惟,除了控制执行的yield命令以外,总体看起来和同步编程感受几乎同样,下面来看一下这种方式的写法:

function getDataPromise(url) {
    return new Promise((resolve, reject) =>{
        $.get(url, data => {
            if (data.status === 200) {
                reject(data);
            } else {
                resolve(data);
            }
        });
    });
}

function *getDataGen(url) {
    yield getDataPromise(url);
}

const g = getDataGen('/api/data');
g.next();

generator与普通函数的区别就是前面多一个*,不过这不是重点,重点是generator里面可使用yield关键字来表示暂停,它接收一个promise对象,返回promise的结果而且停在此处等待,不是一次性执行完。generator执行后会返回一个iterator,iterator里面有一个next方法,每次调用next方法,generator都会向下执行,直到赶上yield,返回结果是一个对象,里面有一个value属性,值为当前yield返回结果,done属性表明整个generator是否执行完毕。generator的出现使得像同步同样编写异步代码成为可能,下面是使用generator改造后的结果:

* fun() {
    const data = yield fun1();
    // ...
    const result = yield fun2(data);
    // ...
    yield fun3(result);
    // ...
}

const g = fun();
g.next();
g.next();
g.next();
g.next();

在generator的编写过程当中,咱们还须要手动控制执行过程,而实际上这是能够自动实现的,接下来的一种新语法的产生使得异步编程真的和同步同样容易了。

新时代的写法--async,await

异步编程的最高境界,就是根本不用关心它是否是异步。在最新的ES中,终于有了这种激动人心的语法了。async函数的写法和generator几乎相同,把*换成async关键字,把yield换成await便可。async函数内部自带generator执行器,咱们再也不须要手动控制执行了,如今来看最终的写法:

function getDataPromise(url) {
    return new Promise((resolve, reject) =>{
        $.get(url, data => {
            if (data.status === 200) {
                reject(data);
            } else {
                resolve(data);
            }
        });
    });
}

async function getData(url) {
    return await getDataPromise(url);
}

const data = await getData(url);

除了多了关键字,剩下的和同步的编码方式彻底相同,对于异常捕获也能够采起同步的try-catch方式,对于再复杂的场景也不会逻辑混乱了:

* fun() {
    const data = await fun1();
    // ...
    const result = await fun2(data);
    // ...
    return await fun3(result);
    // ...
}
fun()

如今回去看回调函数的写法,感受好像换了一个世界。这种语法比较新,在不支持的环境要使用babel转译。

写在最后

在js中,异步编程是一个长久的话题,很庆幸如今有这么好用的async和await,不过promise原理,回调函数都是要懂的,很重要的内容,弄清楚异步编程模式,算是扫清了学习js尤为是node.js路上最大的障碍了。


尊重原创,转载分享前请先知悉做者,也欢迎指出错误不足共同交流,更多内容欢迎关注做者博客点击这里

相关文章
相关标签/搜索