[前端工坊]浅谈Web编程中的异步调用的发展演变

文章来自微信公众号:前端工坊(fe_workshop),不定期更新有趣、好玩的前端相关原创技术文章。 如果喜欢,请关注公众号:前端工坊
版权归微信公众号所有,转载请注明出处。
作者:京东金融-移动研发部-前端开发工程师 张恒

作为Web工程师,相信大家在开发项目的过程中,都存在与服务器端的通信,如登录验证、获取用户信息、获取应用数据等都需要通过调用后端的API来进行操作,而实现这一操作的正是异步调用;
这篇文章旨在通过一些异步调用的概念和相应的代码演示,尽量详细地介绍异步调用的实现、各种异步编程的使用方式和区别,以及他们的发展演变;

一、AJAX

在Web应用的开发过程中,为了实现良好的交互体验,我们都会使用 ajax 的方式与后端通信,实现无刷新数据提取和快速展现,极大地提升了用户体验;
ajax 的全称是Asynchronous JavaScript and XML,Asynchronous 即异步,它有别于传统web开发中采用的同步的方式。
ajax 的原理简单来说就是通过 XmlHttpRequest 对象来向服务器发异步请求,从服务器获得数据,然后用JavaScript来操作DOM而更新页面,这其中 XMLHttpRequestajax 的核心机制,
通过这种异步技术,JavaScript可以及时向服务器提出请求和处理响应,而不阻塞用户,从而达到无刷新页面的效果。

相信广大的Web工程师们对此已经耳熟能详,我就不在这里细讲了,如果你是刚入行前端并且不了解此概念,可以移步ajax
但是必须提到的是XmlHttpRequest 对象有一个属性 onreadystatechange 用于当异步请求状态改变时触发事件执行后续动作,
这也就是本文要讲的异步调与回调处理;对于单个的异步请求及其回调结果处理实际上没太大问题,但当碰到某些复杂场景,需要多次异步调用接口,并且后一个的调用需要前一个异步调用的返回结果作为参数时,
由于是异步形式,不能像同步编程那样编写代码,咱们就不得不嵌套编写,而当嵌套层过多就会出现难以阅读和维护代码。

举个例子,在一个Web App中,需要获取用户的某篇博客的所有跟帖,这时我们就需要有如下的APIs;

1、获取用户会话的token(也可能是一开始进入博客通过登录返回的)

{ 
    status:'success',  
    data:{
        token: '******'
    }
}

2、通过token获取用户详细信息

{
    status:'success',  
    data:{
        userInfo: {
            id: 10001,
            name:'test',
            email:'[email protected]'
        }
    }
}

3、通过userId获取用户文章列表

{
  status:'success',  
  data:[
    {
        id: 1,
        title:'my first article',
        content:'a long content will be here...',
        date:'2018-02-28'
    },
    {
        id: 2,
        title:'my second article',
        content:'a long content will be here...',
        date:'2018-02-28'
    },
  ]
}

4、通过博客id获取所有用户评论

{
 status:'success',  
 data:[
   {
       id: 1,
       userId:,10005,
       comment:'it's an great article...',
       date:'2018-02-28'
   },
   {
       id: 2,
       userId:,10008,
       comment:'it's very useful article for me, thanks blogger...',
       date:'2018-03-01'
   },
 ]
}

接下来我们就通过code去实现这样一个逻辑,首定义一个异步调用的公用方法:

function ajaxRequest(url,successHandler){
    var xhr;
    if (window.XmlHttpRequest) {
        xhr = new XmlHttpRequest();
    }else if (window.ActiveXObject) {
        try {
          xhr = new ActiveXObject("Microsoft.XMLHTTP");
        }
        catch (e) {
          try {
              xhr = new ActiveXObject("msxml2.XMLHTTP");
          }
          catch (ex) { }
        }
    }
    
    xhr.onreadystatechange = function () {
        if (xhr.readyState == 4) {
            if (xhr.status == 200) {
                successHandler(xhr.responseText);
            }
        }
    }
        
    xhr.open("GET", url);
    xhr.send();
}

使用ajax实现获取用户评论的逻辑则如下所示:

/*获取评论*/
ajaxRequest('your-host/api/get-token',function(res1){
    var token = res1.data.token;
    ajaxRequest('your-host/api/get-user?token='+token,function(res2){
        var userId = res2.data.userInfo.id;
        ajaxRequest('your-host/api/get-article?userId='+userId,function(res3){
            var artcleId=res3.data[0].id;
            ajaxRequest('your-host/api/get-comments?artcleId='+artcleId,function(res4){
                var comments = res4.data;
                console.log(comments);
            });
        })
    })
})

OK,上面的代码是不是让人头晕,如果碰到某些更复杂的逻辑,就会出现更多的嵌套回调,这即称为'回调地狱(callback hell)';
我们可以稍加重构以提高可阅读性:

function getToken(callback){
    ajaxRequest('your-host/api/get-token',function(res1){
        callback(res1.data.token);
    });
}

function getUserByToken(token,callback){
    ajaxRequest('your-host/api/get-user?token='+token,function(res2){
            callback(res2.data.userInfo.id);
    });
}

function getArticlesByUserId(userId,callback){
    ajaxRequest('your-host/api/get-article?userId='+userId,function(res3){
            callback(res3.data[0].id);
    });
}

function getCommentsByArtcleId(artcleId,callback){
    ajaxRequest('your-host/api/get-comments?artcleId='+artcleId,function(res4){
            callback(res4.data);
    });
}

/*获取评论*/
getToken(function(token){
    getUserByToken(token,function(userId){
        getArticlesByUserId(userId,function(artcleId){
            getCommentsByArtcleId(artcleId,function(comments){
                console.log(comments);
            });
        });
    });
});

上面的代码看着是不是稍微清晰了一些,不过函数里面调函数的方式仍然丑陋,下面我们将介绍另一种异步调用方式Promise。

二、Promise

Promise 对象是一个代理对象(代理一个值),被代理的值在Promise对象创建时可能是未知的。
它允许你为异步操作的成功和失败分别绑定相应的处理方法(handlers)。 这让异步方法可以像同步方法那样返回值,
但并不是立即返回最终执行结果,而是一个能代表未来出现的结果的promise对象,如果你不了解Promise,
可以移步Promise查看详细说明。

一个 Promise对象有且仅有三种状态:

* pending:初始状态,既不是成功,也不是失败状态
  * fulfilled:意味着操作成功完成
  * rejected:意味着操作失败

pending状态的 Promise 对象可能触发fulfilled 状态并传递一个值给相应的状态处理方法,也可能触发失败状态(rejected)并传递失败信息。
当其中任一种情况出现时,Promise 对象的 then 方法绑定的处理方法(handlers )就会被调用(then方法包含两个参数:onfulfilled 和 onrejected,
它们都是Function类型。当Promise状态为fulfilled时,调用 then 的 onfulfilled 方法,当Promise状态为rejected时,调用 then 的 onrejected 方法,
所以在异步操作的完成和绑定处理方法之间不存在竞争),限于样例代码限制,在上面的例子中我并没有对请求异常做处理,在实际项目中读者朋友可以自行加上处理。
因为 Promise.prototype.then和Promise.prototype.catch方法返回promise 对象, 所以它们可以被链式调用。

还是以上的场景为例子来看Promise实现的异步调用的代码片段,如下所示:

function getToken(){
   return new Promise(function(resolve,reject){
       ajaxRequest('your-host/api/get-token',function(res1){
           resolve(res1.data.token);
       });
   }); 
}

function getUserByToken(token){
    return new Promise(function(resolve,reject){
        ajaxRequest('your-host/api/get-user?token='+token,function(res2){
            resolve(res2.data.userInfo.id);
        });
    });
}

function getArticlesByUserId(userId){
    return new Promise(function(resolve,reject){
        ajaxRequest('your-host/api/get-article?userId='+userId,function(res3){
            resolve(res3.data[0].id);
        });
    });
}

function getCommentsByArtcleId(artcleId){
    return new Promise(function(resolve,reject){
        ajaxRequest('your-host/api/get-comments?artcleId='+artcleId,function(res4){
            resolve(res4.data);
        });
    });
}

/*获取评论*/
getToken().then(function(token){
    return getUserByToken(token);
}).then(function(userId){
    return getArticlesByUserId(userId);
}).then(function(artcleId){
    return getCommentsByArtcleId(artcleId);
}).then(function(comments){
    console.log(comments);
});

从上面获取comments的代码可以看出,后一个方法的调用总是在前一个异步调用完成后,通过前一个结果作为参数去执行下一个请求,
一步一步往后执行直到所有异步请求都执行完成,这个过程不仅代码结构上清晰了许多,而且从编程风格上看也能看出些类同步编码的影子。
下面介绍一个更接近同步编程的风格的异步编码方式生成器函数Generator。

三、Generator

Generator即生成器,它是生成器函数(Function*)返回的一个对象,是ES2015中提供的一种异步编程解决方案;
而生成器函数有两个特征,一是函数名前带星号,二是内部执行语句前有关键字 yield,调用一个生成器函数并不会马上执行它里面的语句,而是返回一个这个生成器的迭代器对象。当这个迭代器的 next() 方法被首次调用时,
其内的语句会执行到第一个出现yield的位置为止,yield 后紧跟迭代器要返回的值。或者如果用的是 yield*(多了个星号),则表示将执行权移交给另一个生成器函数(当前生成器暂停执行)。
next() 方法返回一个对象,这个对象包含两个属性:valuedonevalue 属性表示本次 yield 表达式的返回值,done 属性为布尔类型,表示生成器后续是否还有 yield 语句,即生成器函数是否已经执行完毕并返回。
调用 next() 方法时,如果传入了参数,那么这个参数会作为上一条执行的 yield 语句的返回值。看一个简单的例子:

function* genFun(){
    yield 'initial';
    var anotherVal=yield 'Hello';
    yield anotherVal;
}

var gObj=genFun();
console.log(gObj.next());// 执行 yield 'initial';,返回 'initial',{value:'initial',done:false}
console.log(gObj.next());// 执行 yield 'Hello',返回 'Hello',{value:'Hello',done:false
console.log(gObj.next('World'));// 将'World'赋给上一条 yield 'Hello'的左值anotherVal,即执行 anotherVal='World',返回'World',{value:'World',done:false}
console.log(gObj.next());// 执行完毕,{value:undefined,done:true}

在上面的例子中,如果第三个 next() 的调用是在给anotherVal赋值,这样执行之后返回的 value 即为传入的参数,如果不传参数,则返回的 value 为undefined,且此时的 done 还是 false,这里需要注意。
当在生成器函数中显式 return 时,会导致生成器立即变为完成状态,即调用 next() 方法返回的对象的 donetrue。如果 return 后面跟了一个值,那么这个值会作为当前调用 next() 方法返回的 value 值。请看如下代码:

function* yieldAndReturn() {
  yield "Y";
  return "R";//显式返回处,可以观察到 done 也立即变为了 true
  yield "unreachable";// 不会被执行了
}

var gen = yieldAndReturn()
console.log(gen.next()); // { value: "Y", done: false }
console.log(gen.next()); // { value: "R", done: true }
console.log(gen.next()); // { value: undefined, done: true }

了解了Generator的简单概念之后,那它到底与本文核心内容有什么关联呢?OK,咱们还是以上面的场景来使用Generator方式实现(异步调用api的几个方法公用上面的),代码片段如下:

function* myGen(){
  var token = yield getToken();
  var userId = yield getUserByToken(token);
  var articleId = yield getArticlesByUserId(userId);
  var comments = yield getCommentsByArtcleId(articleId);
  console.log(comments);  
}

var gen = myGen();
gen.next().value.then(function(res1){
  gen.next(res1).value.then(function(res2){
      gen.next(res2).value.then(function(res3){
        gen.next(res3).value.then(function(res4){
            gen.next(res4);
            console.log('executing done');
        });
      });
  });
});

从上面的代码中,咱们可以看到生成器函数 myGen里面的语句就跟平时写同步代码一样类似,只是多了关键字 yield,这即是Generator的关键之处,用同步的编码方式,处理异步逻辑。
但同时我们也看到后半部分代码的执行跟之前的Promise几乎一样,一连串的 then 语句看起来还是不怎么美观,咱们可以对它进行再一次封装:

function genRunner(){
    var gen = myGen();
    
    function run(result){
        if(result.done) {
            return;
        }
        result.value.then(function(res){
            run(gen.next(res));
        });
    }
    run(gen.next());
}

genRunner();

通过封装一个函数执行器,通过在函数内部循环调用自身来执行Generator函数内部的所有yield 语句,这样的代码阅读起来就更加清晰且优雅了!

四、async/await

async 是ES2017引入的一种函数形式,可以使用它加在 function 前来声明定义异步函数,使用它能给异步编程带来极大的便利,从code形式上看就跟编写同步代码一样。当一个async 函数被调用时,它返回一个 Promise 对象。
async 函数返回一个值时,Promise 将用返回的值 resolved。 当async 函数抛出异常或某个值时,Promise将被抛出的值 rejected
async 函数可以包含 await 表达式,带有 await 的语句会暂停async 函数的执行并等待传递的Promise的解析,然后再恢复async 函数的执行并返回解析后的值。
async/await 函数的目的是简化同步使用 Promise 的行为,并对一组 Promise 执行某些行为,就像 Promises 类似于结构化回调一样,async/await 相当于 GeneratorPromise 的集合体。

先来看一个简单的例子:

function fakeRequest() {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve('second output');
    }, 500);
  });
}

async function asyncCall() {
  console.log('first output');
  var result = await fakeRequest();
  console.log(result);
  console.log('last output');
}

asyncCall();

/*执行后的输出*/
//first output
//second output
//last output

从上面的代码写法以及async 函数内部的执行结果可以看出,这简直就是同步调用的同步编程风格和执行顺序,有没有?其实如上所述,await 的语句会暂停async 函数的执行并等待传递的Promise的解析,
因此才会有console.log('last output');再最后输出,如果在async 函数体外面在写一个执行代码,则会先于await 结果输出;

咱们还是以最初的场景为例,使用async/await 的方式来实现一遍,看看代码风格上的差异:

async function getComments(){
  var token = await getToken();
  var userId = await getUserByToken(token);
  var articleId = await getArticlesByUserId(userId);
  var comments = await getCommentsByArtcleId(articleId);
  console.log(comments);  
}

从代码风格上看是不是跟Generator函数基本一样,只是把星号去掉,前面加了async ,函数体内语句中把 yield 换成来 await;但是调用执行函数时则完全不一样了,
Generator函数需要额外定义执行函数器,通过不断调用 next() 来完成调用获取结果,而async 函数自带来执行函数器,只要调用函数即会执行,因此使用上也方便来许多。

## 总结
咱们再回顾一下文章内容,首先通过最传统的 ajax 方式异步调用和回调函数处理;然后加入Promise对象,通过链式调用使代码编写更加有条理性;
之后又引入了新的异步编程解决方案 Generator ,其函数内部的编码方式与同步写法及其类似,只是 Generator 的执行权交由了另外一个函数,其执行方式仍然需要不断的调用 next() 而略显繁琐;
最后引入了ES2017新标准中收录的新函数 async,通过与await 相结合,使其异步调用的编码实现基本跟同步编码相差无几,且非常易于理解和提高了代码的维护行。
好了,到这里也该是文章结束的时候了,虽然篇幅不长,并且描述文字也不多,但还是希望阅读之后的朋友们能有所收获;由于写作仓促,文中难免出现错误或描述不清的地方,希望朋友们能谅解,并欢迎指正。

注:文中所有的代码都没对异常进行处理,如果你在实际项目中使用,请记得加上异常和错误处理逻辑!

参考资源

[前端工坊]浅谈Web编程中的异步调用的发展演变

相关推荐