评论

js异步编程

本文介绍js中处理异步的方式,通过对比了解各自的原理以及优缺点,帮助我们更好的使用这些强大的异步处理方式。

前言

我们都知道,JS是单线程执行的,天生异步。在开发的过程中会遇到很多异步的场景,只用回调来处理简单的异步逻辑,当然是可以,但是逻辑逐渐复杂起来,回调的处理方式显得力不从心。

接下来会介绍js中处理异步的方式,通过对比了解各自的原理以及优缺点,帮助我们更好的使用这些强大的异步处理方式。

回调

基本用法

回调函数作为参数传进方法中,在合适的时机被调用。

比如调用ajax,或是使用定时器:


// ajax请求

$.ajax({

    url: '/ajax/hdportal_h.jsp?cmd=xxx',

    error: function(err) {

        console.log(err)

    },

    success: function(data) {

        console.log(data)

    }

})



// 定时器的回调

setTimeout(function callback() {

    console.log('hi')

}, 1000)

回调的问题

1. 回调地狱

  • 过深的嵌套,形成回调地狱

  • 使得代码难以阅读和调试

  • 层层嵌套,代码间耦合严重,牵一发而动全身

2.信任缺失,错误处理无法保证

控制反转,回调函数的调用是在请求函数内部,无法保证回调函数一定会被正确调用,回调本身没有错误处理机制,需要额外设计。

可能存在以下问题:

  1. 调用回调过早

  2. 调用回调过晚

  3. 调用回调次数太多或者太少

  4. 未能把所需的参数成功传给你的回调函数

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

Promise

基本用法

  1. Promise对象的状态不受外界影响。Promise对象代表一个异步操作,有三种状态:pending(进行中)、fulfilled(已成功)和rejected(已失败)

  2. 一旦状态改变,就不会再变,任何时候都可以得到这个结果。Promise对象的状态改变,只有两种可能:从pending变为fulfilled和从pending变为rejected。


new Promise((resovle, reject) => {

    setTimeout(() => {

        resovle('hello promise')

    }, 1000)

}).then(res => {

    console.log(res)

}).catch(err => {

    console.log(err)

})

Promise与回调的区别

Promise 不是对回调的替代。 Promise 在回调代码和将要执行这个任务的异步代码之间提供了一种可靠的中间机制来管理回调

Promise 并没有完全摆脱回调。它们只是改变了传递回调的位置。我们并不是把回调传递给处理函数,而是从处理函数得到Promise,然后把回调传给这个Promise

Promise 保证了行为的一致性,使其变得可信任,我们传递的回调会被正确的执行

Promise如何解决信任缺失问题?

调用时机上,不会调用过早,也不会调用过晚

根据PromiseA+规范,then中的回调会在 then 方法被调用的那一轮事件循环之后的新执行栈中执行。

这个事件队列可以采用“宏任务(macro-task)”机制或者“微任务(micro-task)”机制来实现。

所以提供给then的回调也总会在JavaScript事件队列的当前运行完成后,再被调用,即异步调用。


var p = Promise.resolve('p');



console.log('A');

p.then(function () {

    p.then(function () {

        console.log('E');

    });

    console.log('C');

})

.then(function () {

    console.log('D');

});

console.log('B');

运行这段代码,会依次打印出ABCED

这里要注意两个点:

  1. 会先执行同步代码,再执行then中的代码

  2. then执行回调时,打印D的代码晚于打印E的代码

调用次数上,不会出现回调未调用,也不会出现调用次数太多或者太少

一个Promise注册了一个成功回调和拒绝回调,那么Promise在决议的时候总会调用其中一个。

即使是在决议后调用then注册的回调函数,也会被正确调用,所以不会出现回调未调用的情况。

Promise只能被决议一次。如果处于多种原因,Promise创建代码试图调用多次resolve(…)或reject(…),或者试图两者都调用,那么这个Promise将只会接受第一次决议,忽略任何后续调用,所以调用次数不会太多也不会太少。

错误处理上,不会吞掉可能出现的错误或异常

如果在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()这一行

});

Promise中的then

then方法的设计是promise中最重要的部分之一,可以看promise/A+规范中对then方法的描述

then方法会返回一个新的promise,因此可以链式调用,下面的代码会打印出6


var p = Promise.resolve(0);

p.then(function (data) {

    return 1;

}).then(function (data) {

    return data + 2;

}).then(function (data) {

    return data + 3;

}).then(function (data) {

    console.log(data);

});

如果在then中主动返回一个promise,依旧会返回一个新的promise,只是这个promise的状态“跟随”主动返回的pormise


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

    resolve('p1');

});

var p2 = new Promise(function (resolve, reject) {

    resolve('p2');

});

var p3 = p2.then(function (data) {

    return p1;

});



console.log(p3 === p1); // false

p3.then(function (data) {

    console.log(data); // p1

});

静态方法

Promise.resolve()

Promise.resolve(value)方法返回一个以给定值解析后的 Promise 对象。

但如果这个值是个 thenable(即带有 then 方法),返回的 promise 会“跟随”这个 thenable的对象,采用它的最终状态;否则以该值为成功状态返回 promise 对象。

Promise.reject()

Promise.reject(reason)方法返回一个用reason拒绝的Promise。


// 以下两个 promise 是等价的

var p1 = new Promise( (resolve,reject) => {

    resolve( "Oops" );

});

var p2 = Promise.resolve( "Oops" );



var p1 = new Promise( (resolve,reject) => {

    reject( "Oops" );

});

var p2 = Promise.reject( "Oops" );

Promise.all()

Promise.all方法用于将多个 Promise 实例,包装成一个新的 Promise 实例


const p = Promise.all([p1, p2, p3]);

p.then(function (posts) {

    // ...

}).catch(function(reason){

    // ...

});

p的状态由p1、p2、p3决定,分成两种情况。

(1)只有p1、p2、p3的状态都变成fulfilled,p的状态才会变成fulfilled,此时p1、p2、p3的返回值组成一个数组,传递给p的回调函数。

(2)只要p1、p2、p3之中有一个被rejected,p的状态就变成rejected,此时第一个被reject的实例的返回值,会传递给p的回调函数。

Promise.race()

Promise.race方法同样是将多个 Promise 实例,包装成一个新的 Promise 实例。


const p = Promise.race([p1, p2, p3]);

p.then(function (posts) {

    // ...

}).catch(function(reason){

    // ...

});

只要p1、p2、p3之中有一个实例率先改变状态,p的状态就跟着改变。那个率先改变的 Promise 实例的返回值,就传递给p的回调函数

Generator

名词解释

迭代器 (Iterator)

  • 迭代器是一种对象,它具有一些专门为迭代过程设计的专有接口,所有迭代器对象都有一个 next 方法,每次调用都返回一个结果对象。

  • 结果对象有两个属性,一个是 value,表示下一个将要返回的值;另一个是 done,它是一个布尔类型的值,当没有更多可返回数据时返回 true。

  • 迭代器还会保存一个内部指针,用来指向当前集合中值的位置,每调用一次 next() 方法,都会返回下一个可用的值。

可迭代对象 (Iterable)

  • 可迭代对象具有 Symbol.iterator 属性,是一种与迭代器密切相关的对象。

  • Symbol.iterator 通过指定的函数可以返回一个作用于附属对象的迭代器。

  • 在 ECMCScript 6 中,所有的集合对象(数组、Set、及 Map 集合)和字符串都是可迭代对象,这些对象中都有默认的迭代器。

生成器 (Generator)

  • 生成器是一种返回迭代器的函数,通过 function 关键字后的 * 号来表示。

  • 此外,由于生成器会默认为 Symbol.iterator 属性赋值,因此所有通过生成器创建的迭代器都是可迭代对象。

for-of 循环

for-of 循环每执行一次都会调用可迭代对象的迭代器接口的 next() 方法,并将迭代器返回的结果对象的 value 属性储存在一个变量中,循环将持续执行这一过程直到返回对象的属性值为 true。

生成器的一般使用形式


function *foo() {

    var x = yield 2

    var y = x * (yield x + 1)

    console.log( x, y )

    return x + y

}



var it = foo()



it.next() // {value: 2, done: false}

it.next(3) // {value: 4, done: false}

it.next(3) // 3 9, {value: 12, done: true}

遍历器对象的next方法的运行逻辑如下:

(1)遇到yield表达式,就暂停执行后面的操作,并将紧跟在yield后面的那个表达式的值,作为返回的对象的value属性值。

(2)下一次调用next方法时,再继续往下执行,直到遇到下一个yield表达式。

(3)如果没有再遇到新的yield表达式,就一直运行到函数结束,直到return语句为止,并将return语句后面的表达式的值,作为返回的对象的value属性值。

(4)如果该函数没有return语句,则返回的对象的value属性值为undefined。

需要注意的是,yield表达式后面的表达式,只有当调用next方法、内部指针指向该语句时才会执行,因此等于为 JavaScript 提供了手动的“惰性求值”(Lazy Evaluation)的语法功能。

异步迭代生成器


function foo() {

    setTimeout(() => {

        it.next('success') // 恢复*main()

        // it.throw('error') // 向*main()抛出一个错误

    }, 2000);

}



function *main() {

   try {

       var data = yield foo()

       console.log(data)

   } catch(e) {

       console.log(e)

   }

}



var it = main()

it.next() // 这里启动!

本例中我们在 *main() 中发起 foo() 请求,之后暂停;又在 foo() 中相应数据恢复 *mian() 继续运行,并将 foo() 的运行结果通过 next() 传递出来。

我们在生成器内部有了看似完全同步的代码(除了 yield 关键字本身),但隐藏在背后的是,在 foo(…)内的运行可以完全异步。并且在异步代码中实现看似同步的错误处理(通过try…catch)在可读性和合理性方面也都是一个巨大的进步。

Generator + Promise

通过promise来管理异步流程


function foo() {

    return new Promise(function(resolve, reject){

        setTimeout(() => {

            resolve('fai');

        }, 2000);

    });

}



function *main() {

    try {

        var data = yield foo()

        console.log(data)

    } catch(e) {

        console.error(e)

    }

}



var it = main();

var p = it.next().value;   // p 的值是 foo()



// 等待 promise p 决议

p.then(

    function(data) {

        it.next(data);  // 将 data 赋值给 yield

    },

    function(err) {

        it.throw(err);

    }

)

  1. *mian() 中执行 foo() 发起请求,返回promise

  2. 根据promise 决议结果,根据结果选择继续运行迭代器或抛出错误

如何执行有多处yield的Generator 函数?


function foo(name) {

    return new Promise(function(resolve, reject){

        setTimeout(() => {

            resolve('hello ' + name);

        }, 2000);

    });

}



var gen = function* (){

    var r1 = yield foo('jarvis');

    var r2 = yield foo('hth');

    console.log(r1);

    console.log(r2);

};



var g = gen();



// 手动执行

g.next().value.then(function(data){

    g.next(data).value.then(function(data){

        g.next(data);

    });

});

手动执行的方式,其实就是用then方法,层层添加回调函数。理解了这一点,就可以写出一个自动执行器

自动执行Generator 函数


function foo(name) {

    return new Promise(function(resolve, reject){

        setTimeout(() => {

            resolve('hello ' + name);

        }, 2000);

    });

}



var gen = function* (){

    var r1 = yield foo('jarvis');

    var r2 = yield foo('hth');

    console.log(r1);

    console.log(r2);

};



function run(gen){

    var g = gen();

  

    function next(data){

      var result = g.next(data);

      if (result.done) return result.value;

      result.value.then(function(data){

        next(data);

      });

    }

  

    next();

}



run(gen);

只要保证yield后面总是返回promise,就能用run函数自动执行Generator 函数

Async/Await

async 函数的一般使用形式

async 函数是什么?

其实就是 promise+自动执行的Generator 函数的语法糖。类似于我们上面的实现


function foo(p) {

    return fetch('http://my.data?p=' + p)

}



async function main(p) {

    try {

        var data = await foo(p)

        return data

    } catch(e) {

        console.error(e)

    }

}



main(1).then(data => console.log(data))

与 Generator 函数不同是,* 变成了async、yeild变成了await,同时我们也不用再定义 run(…) 函数来实现 Promise 与 Generator 的结合。

async 函数执行的时候,一旦遇到 await 就会先返回,等到异步操作完成,再接着执行函数体内后面的语句,并且最终返回一个 Promise 对象。

正常情况下,await 命令后面是一个 Promise 对象。如果不是,会被转成一个立即 resolve 的 Promise 对象。

await 命令后面的 Promise 对象如果变为 reject 状态,则 reject 的参数会被 catch 方法的回调函数接收到。

async 函数的使用注意点

  1. 前面已经说过,await 命令后面的 Promise 对象,运行结果可能是 rejected,所以最好把 await 命令放在 try…catch 代码块中。

  2. 多个 await 命令后面的异步操作,如果不存在继发关系,最好让它们同时触发。

  3. await 命令只能用在 async 函数之中,如果用在普通函数,就会报错。


//getFoo 与 getBar 是两个互相独立、互不依赖的异步操作



// 错误写法

let foo = await getFoo();

let bar = await getBar();



// 正确写法一

let [foo, bar] = await Promise.all([getFoo(), getBar()]);



// 正确写法二

let fooPromise = getFoo();

let barPromise = getBar();

let foo = await fooPromise;

let bar = await barPromise;

async 函数比Promise好在哪?

  1. 类同步写法,使得在写复杂逻辑时,可以用一种顺序的方式来书写,大大降低了理解的难度。

  2. 错误处理上,可以用try catch来捕获,同时处理同步和异步错误。

总结

JavaScript异步编程的发展历程有以下四个阶段:

  1. 回调函数:

有两个问题,回调地狱和信任缺失,回调地狱的坏处主要是代码阅读性和可维护性差,同时不好对异步逻辑进行封装。信任缺失主要体现在调用的时机,调用的次数,对异常的处理上缺乏一致性。

  1. Promise

基于PromiseA+规范的实现解决了控制反转带来的信任问题。

  1. Generator

使用生成器函数Generator,我们得以用同步的方式来书写异步的代码,解决了顺序性的问题,这是一种重大的突破。但是使用比较繁琐,需要手动去调用next(…)去控制流程和传参。

  1. Async/Await

Async/Await结合了Promise和Generator,并实现了自动执行生成器函数逻辑。使得使用者通添加少量关键字就可以用同步的方式书写异步代码,大大提高了开发效率和代码可维护性。

可以看到,目前Async/Await方式可以说是处理异步的终极解决方案,在项目中应该优先使用这种方式。

最后一次编辑于  2019-06-11  
点赞 8
收藏
评论
登录 后发表内容