JavaScript异步编程

3,451 阅读14分钟
原文链接: segmentfault.com

前言

从我们一开始学习JavaScript的时候就听到过一段话:JS是单线程的,天生异步,适合IO密集型,不适合CPU密集型。但是,多数JavaScript开发者从来没有认真思考过自己程序中的异步到底是怎么出现的,以及为什么会出现,也没有探索过处理异步的其他方法。到目前为止,还有很多人坚持认为回调函数就完全够用了。

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

本文也会同步到我的个人网站

正文

Step1 - 回调函数

回调函数大家肯定都不陌生,从我们写一段最简单的定时器开始:

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

定时器里面的匿名函数就是一个回调函数,因为在JS中函数是一等公民,所以它可以像其他变量一样作为参数进行传递。这样看来,通过回调函数来处理异步挺好的,写着也顺手,为什么要用别的方法呢?

我们来看这样一个需求:

http-1

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

  1. 调用秘钥接口,获取key
  2. 携带key调用登录接口,获取token和userId
  3. 携带token和userId调用业务接口,获取数据

可能上述步骤和实际业务中的有些出入,但是却可以用来说明问题,请大家谅解。

我们写一段代码来实现上述需求:

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)

为什么会出现这种现象?

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

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

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

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

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

A和B发生于现在,在JavaScript主程序的直接控制之下,而C会延迟到将来发生,并且是在第三方的控制下,在本例中就是函数$.ajax(...)。从根本上来说,这种控制的转移通常不会给程序带来很多问题。

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

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

既然是无法控制的第三方在执行你的回调函数,那么就有可能存在以下问题,当然通常情况下是不会发生的:

  1. 调用回调过早
  2. 调用回调过晚
  3. 调用回调次数太多或者太少
  4. 未能把所需的参数成功传给你的回调函数
  5. 吞掉可能出现的错误或异常
  6. ......

这种控制反转会导致信任链的完全断裂,如果你没有采取行动来解决这些控制反转导致的信任问题,那么你的代码已经有了隐藏的Bug,尽管我们大多数人都没有这样做。

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

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

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

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

Step2 - Promise

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

至于Promise是什么,大家肯定都有所了解,这里是PromiseA+规范,ES6的Promise也好,jQuery的Promise也好,不同的库有不同的实现,但是大家遵循的都是同一套规范,所以,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中,我们确定了用回调表达异步流程的两个关键问题:

  1. 基于回调的异步不符合大脑对任务步骤的规范方式
  2. 由于控制反转,回调并不是可信任的

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

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

可迭代协议和迭代器协议

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

可迭代协议

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

  1. Array
  2. Map
  3. Set
  4. String
  5. TypedArray
  6. 函数的Arguments对象
  7. NodeList对象

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

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

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

当一个对象需要被迭代的时候(比如开始用于一个for...of循环中),它的@@iterator方法被调用并且无参数,然后返回一个用于在迭代中获得值的迭代器。

迭代器协议

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

属性
next 返回一个对象的无参函数,被返回对象拥有两个属性:
1. done(boolean)
- 如果迭代器已经经过了被迭代序列时为true。这时value可能描述了该迭代器的返回值
- 如果迭代器可以产生序列中的下一个值,则为false。这等效于连同done属性也不指定。
2. 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

上面我们介绍了Promise和Generator,把这两者结合起来,就是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. 第一个阶段 - 回调函数,但会导致两个问题:

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

我们可以看到,每项技术的突破都是为了解决现有技术存在的一些问题,它是循序渐进的,我们在学习的过程中,要真正去理解这项技术解决了哪些痛点,它为什么会存在,这样会有益于我们构建体系化的知识,同时也会更好的去理解这门技术。

最后,希望大家可以通过这篇文章对JavaScript异步编程有一个更宏观的体系化的了解,我们一起进步

参考:

  1. developer.mozilla.org...