Quantcast
Channel: w3cplus
Viewing all articles
Browse latest Browse all 1557

【转载】JavaScript异步进化史

$
0
0

前言

JS 中最基础的异步调用方式是 callback,它将回调函数 callback传给异步 API,由浏览器或 Node 在异步完成后,通知 JS 引擎调用 callback。对于简单的异步操作,用 callback实现,是够用的。但随着负责交互页面和 Node 出现,callback方案的弊端开始浮现出来。 Promise 规范孕育而生,并被纳入 ES6 的规范中。后来 ES7 又在 Promise 的基础上将 async函数纳入标准。此为 JavaScript 异步进化史。

同步与异步

通常,代码是由上往下依次执行的。如果有多个任务,就必需排队,前一个任务完成,后一个任务才会执行。这种执行模式称之为:同步(synchronous)。新手容易把计算机用语中的同步,和日常用语中的同步弄混淆。如,“把文件同步到云端”中的同步,指的是“使...保持一致”。而在计算机中,同步指的是任务从上往下依次执行的模式。比如:

A();
B();
C();

在这段代码中,ABC是三个不同的函数,每个函数都是一个不相关的任务。在同步模式,计算机会先执行 A任务,再执行 B任务,最后执行 C任务。在大部分情况,同步模式都没问题。但是如果 B任务是一个耗时很长的网络请求,而 C任务恰好是展现新页面,就会导致网页卡顿。

更好解决方案是,将 B任务分成两个部分。一部分立即执行网络请求的任务,另一部分在请求回来后的执行任务。这种一部分立即执行,另一部分在未来执行的模式称为异步。

A();
// 在现在发送请求 
ajax('url1',function B() {
// 在未来某个时刻执行
})
C();
// 执行顺序 A => C => B

实际上,JS 引擎并没有直接处理网络请求的任务,它只是调用了浏览器的网络请求接口,由浏览器发送网络请求并监听返回的数据。JavaScript 异步能力的本质是浏览器或 Node 的多线程能力。

callback

未来执行的函数通常也叫 callback。使用 callback的异步模式,解决了阻塞的问题,但是也带来了一些其他问题。在最开始,我们的函数是从上往下书写的,也是从上往下执行的,这种“线性”模式,非常符合我们的思维习惯,但是现在却被 callback打断了!在上面一段代码中,现在它跳过 B任务先执行了 C任务!这种异步“非线性”的代码会比同步“线性”的代码,更难阅读,因此也更容易滋生 BUG。

试着判断下面这段代码的执行顺序,你会对“非线性”代码比“线性”代码更难以阅读,体会更深。

A();

ajax('url1', function(){
    B();

    ajax('url2', function(){
        C();
    }
    D();

});
E();
// A => E => B => D => C

这段代码中,从上往下执行的顺序被 Callback打乱了。我们的阅读代码视线是A => B => C => D => E,但是执行顺序却是A => E => B => D => C,这就是非线性代码带来的糟糕之处。

通过将ajax后面执行的任务提前,可以更容易看懂代码的执行顺序。虽然代码因为嵌套看起来不美观,但现在的执行顺序却是从上到下的“线性”方式。这种技巧在写多重嵌套的代码时,是非常有用的。

A();
E();

ajax('url1', function(){
    B();
    D();

    ajax('url2', function(){
        C();
    }

});
// A => E => B => D => C

上一段代码只有处理了成功回调,并没处理异常回调。接下来,把异常处理回调加上,再来讨论代码“线性”执行的问题。

A();

ajax('url1', function(){
    B();

    ajax('url2', function(){
        C();
    },function(){
        D();
    });

},function(){
    E();

});

加上异常处理回调后,url1的成功回调函数 B和异常回调函数 E,被分开了。这种“非线性”的情况又出现了。

在 Node 中,为了解决的异常回调导致的“非线性”的问题,制定了错误优先的策略。Node 中 callback的第一个参数,专门用于判断是否发生异常。

A();

get('url1', function(error){
    if(error){
        E();
    }else {
        B();

        get('url2', function(error){
            if(error){
                D();
            }else{
                C();
            }
        });
    }
});

到此,callback引起的“非线性”问题基本得到解决。遗憾的是,使用 callback嵌套,一层层if else和回调函数,一旦嵌套层数多起来,阅读起来不是很方便。此外,callback一旦出现异常,只能在当前回调函数内部处理异常。

Promise

在 JavaScript 的异步进化史中,涌现出一系列解决 callback弊端的库,而 Promise 成为了最终的胜者,并成功地被引入了 ES6 中。它将提供了一个更好的“线性”书写方式,并解决了异步异常只能在当前回调中被捕获的问题。

Promise 就像一个中介,它承诺会将一个可信任的异步结果返回。首先 Promise 和异步接口签订一个协议,成功时,调用resolve函数通知 Promise,异常时,调用reject通知 Promise。另一方面 Promise 和 callback也签订一个协议,由 Promise 在将来返回可信任的值给thencatch中注册的 callback

// 创建一个 Promise 实例(异步接口和 Promise 签订协议)
var promise = new Promise(function (resolve,reject) {
ajax('url',resolve,reject);
});

// 调用实例的 then catch 方法 (成功回调、异常回调与 Promise 签订协议)
promise.then(function(value) {
// success
}).catch(function (error) {
// error
})

Promise 是个非常不错的中介,它只返回可信的信息给 callback。它对第三方异步库的结果进行了一些加工,保证了 callback一定会被异步调用,且只会被调用一次。

var promise1 = new Promise(function (resolve) {
// 可能由于某些原因导致同步调用
resolve('B');
});
// promise依旧会异步执行
promise1.then(function(value){
    console.log(value)
});
console.log('A');
// A B (先 A 后 B)



var promise2 = new Promise(function (resolve) {
// 成功回调被通知了2次
setTimeout(function(){
    resolve();
},0)
});
// promise只会调用一次
promise2.then(function(){
    console.log('A')
});
// A (只有一个)

var promise3 = new Promise(function (resolve,reject) {
// 成功回调先被通知,又通知了失败回调
setTimeout(function(){
    resolve();
    reject();
},0)

});
// promise只会调用成功回调
promise3.then(function(){
    console.log('A')
}).catch(function(){
    console.log('B')
});
// A(只有A)

介绍完 Promise 的特性后,来看看它如何利用链式调用,解决异步代码可读性的问题的。

var fetch = function(url){
    // 返回一个新的 Promise 实例
    return new Promise(function (resolve,reject) {
        ajax(url,resolve,reject);
    });
}

A();
fetch('url1').then(function(){
    B();
    // 返回一个新的 Promise 实例
    return fetch('url2');
}).catch(function(){
    // 异常的时候也可以返回一个新的 Promise 实例
    return fetch('url2');
    // 使用链式写法调用这个新的 Promise 实例的 then 方法    
}).then(function() {
    C();
    // 继续返回一个新的 Promise 实例...
})
// A B C ...

如此反复,不断返回一个 Promise 对象,再采用链式调用的方式不断地调用。使 Promise 摆脱了 callback层层嵌套的问题和异步代码“非线性”执行的问题。

Promise 解决的另外一个难点是 callback只能捕获当前错误异常。Promise 和 callback不同,每个 callback只能知道自己的报错情况,但 Promise 代理着所有的 callback,所有 callback的报错,都可以由 Promise 统一处理。所以,可以通过catch来捕获之前未捕获的异常。

Promise 解决了 callback的异步调用问题,但 Promise 并没有摆脱 callback,它只是将 callback放到一个可以信任的中间机构,这个中间机构去链接我们的代码和异步接口。

异步(async)函数

异步(async)函数是 ES7 的一个新的特性,它结合了 Promise,让我们摆脱 callback的束缚,直接用类同步的“线性”方式,写异步函数。

声明异步函数,只需在普通函数前添加一个关键字 async即可,如async function main(){}。在异步函数中,可以使用await关键字,表示等待后面表达式的执行结果,一般后面的表达式是 Promise 实例。

async function main{
    // timer 是在上一个例子中定义的
    var value = await timer(100);
    console.log(value); // done (100ms 后返回 done)
}

main();

异步函数和普通函数一样调用 main()。调用后,会立即执行异步函数中的第一行代码 var value = await timer(100)。等到异步执行完成后,才会执行下一行代码。

除此之外,异步函数和其他函数基本类似,它使用try...catch来捕捉异常。也可以传入参数。但不要在异步函数中使用return来返回值。

var  timer = new Promise(function create(resolve,reject) {
    if(typeof delay !== 'number'){
        reject(new Error('type error'));
    }
    setTimeout(resolve,delay,'done');
});

async function main(delay){
    try{
        var value1 = await timer(delay);
        var value2 = await timer('');
        var value3 = await timer(delay);
    }catch(err){
        console.error(err);
        // Error: type error
        //   at create (<anonymous>:5:14)
        //   at timer (<anonymous>:3:10)
        //   at A (<anonymous>:12:10)
    }
}
main(0);

异步函数也可以被当作值,传入普通函数和异步函数中执行。但是在异步函数中,使用异步函数时要注意,如果不使用await,异步函数会被同步执行。

async function main(delay){
    var value1 = await timer(delay);
    console.log('A')
}

async function doAsync(main){
    main(0);
    console.log('B')
}

doAsync(main);
// B A

这个时候打印出来的值是 B A。说明 doAsync函数并没有等待 main的异步执行完毕就执行了 console。如果要让 consolemain的异步执行完毕后才执行,我们需要在main前添加关键字await

async function main(delay){
    var value1 = await timer(delay);
    console.log('A')
}

async function doAsync(main){
    await main(0);
    console.log('B')
}

doAsync(main);
// A B

由于异步函数采用类同步的书写方法,所以在处理多个并发请求,新手可能会像下面一样书写。这样会导致url2的请求必需等到url1的请求回来后才会发送。

var fetch = function (url) {
    return new Promise(function (resolve,reject) {
        ajax(url,resolve,reject);
    });
}

async function main(){
    try{
        var value1 = await fetch('url1');
        var value2 = await fetch('url2');
        conosle.log(value1,value2);
    }catch(err){
        console.error(err)
    }
}

main();

使用Promise.all的方法来解决这个问题。Promise.all用于将多个Promise实例,包装成一个新的 Promis e实例,当所有的 Promise 成功后才会触发Promise.allresolve函数,当有一个失败,则立即调用Promise.allreject函数。

var fetch = function (url) {
    return new Promise(function (resolve,reject) {
        ajax(url,resolve,reject);
    });
}

async function main(){
    try{
        var arrValue = await Promise.all[fetch('url1'),fetch('url2')];
        conosle.log(arrValue[0],arrValue[1]);
    }catch(err){
        console.error(err)
    }
}

main();

目前使用 Babel 已经支持 ES7 异步函数的转码了,大家可以在自己的项目中开始尝试。

本文转载@fishDIV.IO的《JavaScript 异步进化史 》一文。


Viewing all articles
Browse latest Browse all 1557

Trending Articles