JavaScript异步编程

前言

从咱们一开始学习JavaScript的时候就听到过一段话:JS是单线程的,天生异步,适合IO密集型,不适合CPU密集型。可是,多数JavaScript开发者历来没有认真思考过本身程序中的异步究竟是怎么出现的,以及为何会出现,也没有探索过处理异步的其余方法。到目前为止,还有不少人坚持认为回调函数就彻底够用了。javascript

可是,随着JavaScript面临的需求愈来愈多,它能够运行在浏览器、服务器、甚至是嵌入式设备上,为了知足这些需求,JavaScript的规模和复杂性也在持续增加,使用回调函数来管理异步也愈来愈让人痛苦,这一切,都须要更强大、更合理的异步方法,经过这篇文章,我想对目前已有JavaScript异步的处理方式作一个总结,同时试着去解释为何会出现这些技术,让你们对JavaScript异步编程有一个更宏观的理解,让知识变得更体系化一些。java

正文

Step1 - 回调函数

回调函数你们确定都不陌生,从咱们写一段最简单的定时器开始:ajax

setTimeout(function () {
    console.log('Time out');
}, 1000);

定时器里面的匿名函数就是一个回调函数,由于在JS中函数是一等公民,因此它能够像其余变量同样做为参数进行传递。这样看来,经过回调函数来处理异步挺好的,写着也顺手,为何要用别的方法呢?编程

咱们来看这样一个需求:小程序

上面是微信小程序的登陆时序图,咱们的需求和它相似但又有些差异,想要获取一段业务数据,整个过程分为3步:微信小程序

  • 调用秘钥接口,获取key
  • 携带key调用登陆接口,获取token和userId
  • 携带token和userId调用业务接口,获取数据

可能上述步骤和实际业务中的有些出入,可是却能够用来讲明问题,请你们谅解。api

咱们写一段代码来实现上述需求:数组

let key, token, userId;

$.ajax({
    type: 'get',
    url: 'http://localhost:3000/apiKey',
    success: function (data) {
        key = data;
        
        $.ajax({
            type: 'get',
            url: 'http://localhost:3000/getToken',
            data: {
                key: key
            },
            success: function (data) {
                token = data.token;
                userId = data.userId;
                
                $.ajax({
                    type: 'get',
                    url: 'http://localhost:3000/getData',
                    data: {
                        token: token,
                        userId: userId
                    },
                    success: function (data) {
                        console.log('业务数据:', data);
                    },
                    error: function (err) {
                        console.log(err);
                    }
                });
            },
            error: function (err) {
                console.log(err);
            }
        });
    },
    error: function (err) {
        console.log(err);
    }
});

能够看到,整段代码充满了回调嵌套,代码不只在纵向扩展,横向也在扩展。我相信,对于任何人来讲,调试起来都会很困难,咱们不得不从一个函数跳到下一个,再跳到下一个,在整个代码中跳来跳去以查看流程,而最终的结果藏在整段代码的中间位置。真实的JavaScript程序代码可能要混乱的多,使得这种追踪难度会成倍增长。这就是咱们常说的回调地狱(Callback Hell)promise

为何会出现这种现象?浏览器

若是某个业务,依赖于上层业务的数据,上层业务又依赖于更上一层的数据,咱们还采用回调的方式来处理异步的话,就会出现回调地狱。

大脑对于事情的计划方式是线性的、阻塞的、单线程的语义,可是回调表达异步流程的方式是非线性的、非顺序的,这使得正确推导这样的代码的难度很大,很容易产生Bug。

这里咱们引出了回调函数解决异步的第1个问题:回调地狱

回调函数还会存在别的问题吗?
让咱们再深刻思考一下回调的概念:

// A
$.ajax({
    ...
    success: function (...) {
        // C
    }
});

// B

A和B发生于如今,在JavaScript主程序的直接控制之下,而C会延迟到未来发生,而且是在第三方的控制下,在本例中就是函数$.ajax(…)。从根本上来讲,这种控制的转移一般不会给程序带来不少问题。

可是,请不要被这个小几率迷惑而认为这种控制切换不是什么大问题。实际上,这是回调驱动设计最严重(也是最微妙)的问题。它以这样一个思路为中心:有时候ajax(…),也就是你交付回调函数的第三方不是你编写的代码,也不在你的直接控制之下,它是某个第三方提供的工具。

这种状况称为控制反转,也就是把本身程序一部分的执行控制交给某个第三方,在你的代码和第三方工具直接有一份并无明确表达的契约。

既然是没法控制的第三方在执行你的回调函数,那么就有可能存在如下问题,固然一般状况下是不会发生的:

  • 调用回调过早
  • 调用回调过晚
  • 调用回调次数太多或者太少
  • 未能把所需的参数成功传给你的回调函数
  • 吞掉可能出现的错误或异常
  • ……

这种控制反转会致使信任链的彻底断裂,若是你没有采起行动来解决这些控制反转致使的信任问题,那么你的代码已经有了隐藏的Bug,尽管咱们大多数人都没有这样作。

这里,咱们引出了回调函数处理异步的第二个问题:控制反转

综上,回调函数处理异步流程存在2个问题:

  1. 缺少顺序性: 回调地狱致使的调试困难,和大脑的思惟方式不符
  2. 缺少可信任性: 控制反转致使的一系列信任问题

那么如何来解决这两个问题,先驱者们开始了探索之路……

Step2 - Promise

开门见山,Promise解决的是回调函数处理异步的第2个问题:控制反转。

至于Promise是什么,你们确定都有所了解,这里是PromiseA+规范,ES6Promise也好,jQueryPromise也好,不一样的库有不一样的实现,可是你们遵循的都是同一套规范,因此,Promise并不指特定的某个实现,它是一种规范,是一套处理JavaScript异步的机制。

咱们把上面那个多层回调嵌套的例子用Promise的方式重构:

let getKeyPromise = function () {
    return new Promsie(function (resolve, reject) {
        $.ajax({
            type: 'get',
            url: 'http://localhost:3000/apiKey',
            success: function (data) {
               let key = data;
               resolve(key);         
            },
            error: function (err) {
                reject(err);
            }
        });
    });
};

let getTokenPromise = function (key) {
    return new Promsie(function (resolve, reject) {
        $.ajax({
            type: 'get',
            url: 'http://localhost:3000/getToken',
            data: {
                key: key
            },
            success: function (data) {
                resolve(data);         
            },
            error: function (err) {
                reject(err);
            }
        });
    });
};

let getDataPromise = function (data) {
    let token = data.token;
    let userId = data.userId;
    
    return new Promsie(function (resolve, reject) {
        $.ajax({
            type: 'get',
            url: 'http://localhost:3000/getData',
            data: {
                token: token,
                userId: userId
            },
            success: function (data) {
                resolve(data);         
            },
            error: function (err) {
                reject(err);
            }
        });
    });
};

getKeyPromise()
    .then(function (key) {
        return getTokenPromise(key);
    })
    .then(function (data) {
        return getDataPromise(data);
    })
    .then(function (data) {
        console.log('业务数据:', data);
    })
    .catch(function (err) {
        console.log(err);
    });

能够看到,Promise在必定程度上其实改善了回调函数的书写方式,最明显的一点就是去除了横向扩展,不管有再多的业务依赖,经过多个then(…)来获取数据,让代码只在纵向进行扩展;另一点就是逻辑性更明显了,将异步业务提取成单个函数,整个流程能够看到是一步步向下执行的,依赖层级也很清晰,最后须要的数据是在整个代码的最后一步得到。

因此,Promise在必定程度上解决了回调函数的书写结构问题,但回调函数依然在主流程上存在,只不过都放到了then(…)里面,和咱们大脑顺序线性的思惟逻辑仍是有出入的。

这里我想主要讨论的是,Promise是如何解决控制反转带来的信任缺失问题

首先明确一点,Promise能够保证如下状况,引用自JavaScript | MDN

  1. 在JavaScript事件队列的当前运行完成以前,回调函数永远不会被调用
  2. 经过 .then 形式添加的回调函数,甚至都在异步操做完成以后才被添加的函数,都会被调用
  3. 经过屡次调用 .then,能够添加多个回调函数,它们会按照插入顺序而且独立运行

下面咱们针对前面提过的回调函数处理异步致使的一系列信任问题来讨论,若是是用Promise来处理,是否还会存在这些问题,固然前提是实现的Promise彻底遵循PromiseA+规范。

调用过早

当使用回调函数的时候,咱们没法保证或者不知道第三方对于回调函数的调用是何种形式的,若是它在某种状况下是当即完成以同步的方式来调用,那可能就会致使咱们代码中的逻辑错误。

可是,根据PromiseA+规范,Promise就没必要担忧这种问题,由于即便是当即完成的Promise(相似于new Promise(function (resolve, reject) {resolve(2);})),也没法被同步观察到。

也就是说,对一个Promise调用then(…)的时候,即便这个Promise已经决议,提供给then(…)的回调也总会在JavaScript事件队列的当前运行完成后,再被调用,即异步调用。

调用过晚

当Promise建立对象调用resolve(…)reject(…)时,这个Promise经过then(…)注册的回调函数就会在下一个异步时间点上被触发。

而且,这个Promise上的多个经过then(…)注册的回调都会在下一个异步时间点上被依次调用,这些回调中的任意一个都没法影响或延误对其余回调的调用。

举例以下:

p.then(function () {
    p.then(function () {
        console.log('C');
    });
    console.log('A');
})
.then(funtion () {
    console.log('B');
});

// 打印 A B C

经过这个例子能够看到,C没法打断或抢占B,因此Promise没有调用过晚的现象,只要你注册了then(…),就确定会按顺序依次调用,由于这就是Promise的运做方式。

回调未调用

没有任何东西(甚至JavaScript错误)能阻止Promise向你通知它的决议(若是它决议了的话)。若是你对一个Promise注册了一个成功回调和拒绝回调,那么Promise在决议的时候总会调用其中一个。

固然,若是你的回调函数自己包含JavaScript错误,那可能就会看不到你指望的结果,但实际上回调仍是被调用了。

p.then(function (data) {
    console.log(data);
    foo.bar();       // 这里没有定义foo,因此这里会报Type Error, foo is not defined
}, function (err) {

});
调用次数太多或者太少

根据PromiseA+规范,回调被调用的正确次数应该是1次。“太少”就是不调用,前面已经解释过了。

“太多”的状况很容易解释,Promise的定义方式使得它只能被决议一次。若是处于多种缘由,Promise建立代码试图调用屡次resolve(…)或reject(…),或者试图二者都调用,那么这个Promise将只会接受第一次决议,并默默忽略任何后续调用。

因为Promise只能被决议一次,因此任何经过then(…)注册的回调就只会被调用一次。

未能传递参数值

若是你没有把任何值传递给resolve(…)或reject(…),那么这个值就是undefined。但无论这个值是什么,它都会被传给全部注册在then(…)中的回调函数。

若是使用多个参数调用resolve(…)或reject(…),那么第一个参数以后的全部参数都会被忽略。若是要传递多个值,你就必须把它们封装在单个值中进行传递,好比一个数组或对象。

吞掉可能出现的错误或异常

若是在Promise的建立过程当中或在查看其决议结果的过程当中的任什么时候间点上,出现了一个JavaScript异常错误,好比一个TypeError或ReferenceError,这个异常都会被捕捉,而且会使这个Promise被拒绝。

举例以下:

var p = new Promise(function (resolve, reject) {
    foo.bar();    // foo未定义
    resolve(2);
});

p.then(function (data) {
    console.log(data);    // 永远也不会到达这里
}, function (err) {
    console.log(err);    // err将会是一个TypeError异常对象来自foo.bar()这一行
});
foo.bar()中发生的JavaScript异常致使了Promise的拒绝,你能够捕捉并对其做出响应。
不是全部的thenable均可以信任

到目前为止,咱们讨论了使用Promise能够避免上述多种由控制反转致使的信任问题。可是,你确定也注意到了,Promise并无彻底摆脱回调,它只是改变了传递回调的位置。咱们并非把回调传递给foo(…)让第三方去执行,而是从foo(…)获得某个东西(Promise对象),而后把回调传递给这个东西。

可是,为何这就比单纯使用回调更值得信任呢?如何可以肯定返回的这个东西实际上就是一个可信任的Promise呢?

Promise对于这个问题已经有了解决方案,ES6实现的Promise的解决方案就是Promise.resolve(…)。

若是向Promise.resolve(…)传递一个非Promise,非thenable得当即值,就会获得一个用这个值填充的Promise。

举例以下:

var p1 = new Promise(function (resolve, reject) {
    resolve(2);
});

var p2 = Promise.resolve(2);

// 这里p1和p2的效果是同样的

而若是向Promise.resolve(…)传递一个真正的Promise,就只会返回同一个Promise。
举例以下:

var p1 = Promise.resolve(2);
var p2 = Promise.resolve(p1);

p1 === p2;    // true

更重要的是,若是向Promise.resolve(…)传递了一个非Promise的thenable值,前者就会试图展开这个值,并且展开过程当中会持续到提取出一个具体的非类Promise的最终值。

举例以下:

var p = {
    then: function (cb, errCb) {
        cb(2);
        errCb('haha');
    }
};

// 这能够工做,由于函数是一等公民,能够当作参数进行传递
p.then(function (data) {
    console.log(data);    // 2
}, function (err) {
    console.log(err);    // haha
});

这个p是一个thenable,但不是一个真正的Promise,其行为和Promise并不彻底一致,它同时触发了成功回调和拒绝回调,它是不可信任的。

尽管如此,咱们仍是均可以把这样的p传给Promise.resolve(…),而后就会获得指望中的规范化后的安全结果:

Promise.resolve(p)
    .then(function (data) {
        console.log(data);    // 2
    }, function (err) {
        console.log(err);    // 永远不会到达这里
    });

由于前面讨论过,一个Promise只接受一次决议,若是屡次调用resolve(…)reject(…),后面的会被自动忽略。

Promise.resolve(…)能够接受任何thenable,将其解封为它的非thenable值。从Promise.resolve(…)获得的是一个真正的Promise,是一个能够信任的值。若是你传入的已是真正的Promise,那么你获得的就是它自己,因此经过Promise.resolve(…)过滤来得到可信任性彻底没有坏处。

综上,咱们明确了,使用Promise处理异步能够解决回调函数控制反转带来的一系列信任问题。
很好,咱们又向前迈了一步。

Step3 - 生成器Gererator

在Step1中,咱们肯定了用回调表达异步流程的两个关键问题:

  • 基于回调的异步不符合大脑对任务步骤的规范方式
  • 因为控制反转,回调并非可信任的

在Step2中,咱们详细介绍了Promise是如何把回调的控制反转又反转过来,恢复了可信任性。

如今,咱们把注意力转移到一种顺序、看似同步的异步流程控制表达风格,这就是ES6中的生成器(Gererator)

可迭代协议和迭代器协议

了解Generator以前,必须先了解ES6新增的两个协议:可迭代协议和迭代器协议。

可迭代协议

可迭代协议运行JavaScript对象去定义或定制它们的迭代行为,例如(定义)在一个for…of结构中什么值能够被循环(获得)。如下内置类型都是内置的可迭代对象而且有默认的迭代行为:

  • Array
  • Map
  • Set
  • String
  • TypedArray
  • 函数的Arguments对象
  • NodeList对象

注意,Object不符合可迭代协议。

为了变成可迭代对象,一个对象必须实现@@iterator方法,意思是这个对象(或者它原型链prototype chain上的某个对象)必须有一个名字是Symbol.iterator的属性:

属性
[Symbol.iterator] 返回一个对象的无参函数,被返回对象符合迭代器协议

当一个对象须要被迭代的时候(好比开始用于一个for…of循环中),它的@@iterator方法被调用而且无参数,而后返回一个用于在迭代中得到值的迭代器。

迭代器协议

迭代器协议定义了一种标准的方式来产生一个有限或无限序列的值。
当一个对象被认为是一个迭代器时,它实现了一个next()的方法而且拥有如下含义:

属性
next 返回一个对象的无参函数,被返回对象拥有两个属性
  1. done(boolean)
  • 若是迭代器已经通过了被迭代序列时为true。这时value可能描述了该迭代器的返回值
  • 若是迭代器能够产生序列中的下一个值,则为false。这等效于连同done属性也不指定。
  1. value
  • 迭代器返回的任何JavaScript值。done为true时能够忽略。 |

使用可迭代协议和迭代器协议的例子:

var str = 'hello';

// 可迭代协议使用for...of访问
typeof str[Symbol.iterator];    // 'function'

for (var s of str) {
    console.log(s);    // 分别打印 'h'、'e'、'l'、'l'、'o'
}

// 迭代器协议next方法
var iterator = str[Symbol.iterator]();

iterator.next();    // {value: "h", done: false}
iterator.next();    // {value: "e", done: false}
iterator.next();    // {value: "l", done: false}
iterator.next();    // {value: "l", done: false}
iterator.next();    // {value: "o", done: false}
iterator.next();    // {value: undefined, done: true}

咱们本身实现一个对象,让其符合可迭代协议和迭代器协议:

var something = (function () {
    var nextVal;
    
    return {
        // 可迭代协议,供for...of消费
        [Symbol.iterator]: function () {
            return this;
        },
        
        // 迭代器协议,实现next()方法
        next: function () {
            if (nextVal === undefined) {
                nextVal = 1;
            } else {
                nextVal = (3 * nextVal) + 6;
            }
            
            return {value: nextVal, done: false};
        }
    };
})();

something.next().value;    // 1
something.next().value;    // 9
something.next().value;    // 33
something.next().value;    // 105
用Generator实现异步

若是咱们用Generator改写上面回调嵌套的例子会是什么样的呢?见代码:

function getKey () {
    $.ajax({
        type: 'get',
        url: 'http://localhost:3000/apiKey',
        success: function (data) {
            key = data;
            it.next(key);
        }
        error: function (err) {
            console.log(err);
        }
    });
}

function getToken (key) {
    $.ajax({
        type: 'get',
        url: 'http://localhost:3000/getToken',
        data: {
            key: key
        },
        success: function (data) {
            loginData = data;
            it.next(loginData);
        }
        error: function (err) {
            console.log(err);
        }
    });
}

function getData (loginData) {
    $.ajax({
        type: 'get',
        url: 'http://localhost:3000/getData',
        data: {
            token: loginData.token,
            userId: loginData.userId
        },
        success: function (busiData) {
            it.next(busiData);
        }
        error: function (err) {
            console.log(err);
        }
    });
}



function *main () {
    let key = yield getKey();
    let LoginData = yield getToken(key);
    let busiData = yield getData(loginData);
    console.log('业务数据:', busiData);
}

// 生成迭代器实例
var it = main();

// 运行第一步
it.next();
console.log('不影响主线程执行');

咱们注意*main()生成器内部的代码,不看yield关键字的话,是彻底符合大脑思惟习惯的同步书写形式,把异步的流程封装到外面,在成功的回调函数里面调用it.next(),将传回的数据放到任务队列里进行排队,当JavaScript主线程空闲的时候会从任务队列里依次取出回调任务执行。

若是咱们一直占用JavaScript主线程的话,是没有时间去执行任务队列中的任务:

// 运行第一步
it.next();

// 持续占用JavaScript主线程
while(1) {};    // 这里是拿不到异步数据的,由于没有机会去任务队列里取任务执行

综上,生成器Generator解决了回调函数处理异步流程的第一个问题:不符合大脑顺序、线性的思惟方式。。

Step4 - Async/Await

上面咱们介绍了PromiseGenerator,把这二者结合起来,就是Async/Await

Generator的缺点是还须要咱们手动控制next()执行,使用Async/Await的时候,只要await后面跟着一个Promise,它会自动等到Promise决议之后的返回值,resolve(…)或者reject(…)均可以。

咱们把最开始的例子用Async/Await的方式改写:

let getKeyPromise = function () {
    return new Promsie(function (resolve, reject) {
        $.ajax({
            type: 'get',
            url: 'http://localhost:3000/apiKey',
            success: function (data) {
               let key = data;
               resolve(key);         
            },
            error: function (err) {
                reject(err);
            }
        });
    });
};

let getTokenPromise = function (key) {
    return new Promsie(function (resolve, reject) {
        $.ajax({
            type: 'get',
            url: 'http://localhost:3000/getToken',
            data: {
                key: key
            },
            success: function (data) {
                resolve(data);         
            },
            error: function (err) {
                reject(err);
            }
        });
    });
};

let getDataPromise = function (data) {
    let token = data.token;
    let userId = data.userId;
    
    return new Promsie(function (resolve, reject) {
        $.ajax({
            type: 'get',
            url: 'http://localhost:3000/getData',
            data: {
                token: token,
                userId: userId
            },
            success: function (data) {
                resolve(data);         
            },
            error: function (err) {
                reject(err);
            }
        });
    });
};

async function main () {
    let key = await getKeyPromise();
    let loginData = await getTokenPromise(key);
    let busiData = await getDataPromise(loginData);
    
    console.log('业务数据:', busiData);
}

main();

console.log('不影响主线程执行');

能够看到,使用Async/Await,彻底就是同步的书写方式,逻辑和数据依赖都很是清楚,只须要把异步的东西用Promise封装出去,而后使用await调用就能够了,也不须要像Generator同样须要手动控制next()执行。

Async/Await是Generator和Promise的组合,彻底解决了基于回调的异步流程存在的两个问题,多是如今最好的JavaScript处理异步的方式了。

总结

本文经过四个阶段来说述JavaScript异步编程的发展历程:

  1. 第一个阶段 - 回调函数,但会致使两个问题:
  • 缺少顺序性: 回调地狱致使的调试困难,和大脑的思惟方式不符
  • 缺少可信任性: 控制反转致使的一系列信任问题
  1. 第二个阶段 - PromisePromise是基于PromiseA+规范的实现,它很好的解决了控制反转致使的信任问题,将代码执行的主动权从新拿了回来。
  2. 第三个阶段 - 生成器函数Generator,使用Generator,可让咱们用同步的方式来书写代码,解决了顺序性的问题,可是须要手动去控制next(…),将回调成功返回的数据送回JavaScript主流程中。
  3. 第四个阶段 - Async/AwaitAsync/Await结合了PromiseGenerator,在await后面跟一个Promise,它会自动等待Promise的决议值,解决了Generator须要手动控制next(…)执行的问题,真正实现了用同步的方式书写异步代码。

咱们能够看到,每项技术的突破都是为了解决现有技术存在的一些问题,它是按部就班的,咱们在学习的过程当中,要真正去理解这项技术解决了哪些痛点,它为何会存在,这样会有益于咱们构建体系化的知识,同时也会更好的去理解这门技术。

最后,但愿你们能够经过这篇文章对JavaScript异步编程有一个更宏观的体系化的了解,咱们一块儿进步。

参考:

https://developer.mozilla.org...

关于

相关文章
相关标签/搜索