JavaScript(以下简称js)的语言执行环境是单线程(single thread)的,这是其用途而决定的,作为浏览器脚本语言,js的主要用途是与用户互动,以及操作DOM,在iOS开发上的理解就是操作UI(只有主线程才能对UI进行操作)。所以为了避免语言的复杂性,单线程成为了js的核心特质,将来也不会轻易改变。
但异步编程又非常重要,没有异步操作在进行耗时任务卡顿感会非常严重。所以目前JavaScript主要提供了三种异步方式:回调函数、事件监听、Promise对象。
这篇文章我们将使用三种方式实现同一个登录网络请求的Mock方法,来模拟js的异步执行。
回调函数
在iOS开发中,我们的一般做法就是在参数中指定回调函数,直观并且容易理解。在js中做法可能更加自由,例如微信小程序OpenApi,直接指定参数为一个对象,对象分别指定相关参数和回调:
wx.request({
url: 'test.php', //仅为示例,并非真实的接口地址
data: {
x: '',
y: ''
},
header: {
'content-type': 'application/json' // 默认值
},
success (res) {
console.log(res.data)
}
})
我们使用回调函数的方式实现异步回调,代码片段如下:
//声明
function request_callback(url, param, success, fail){
console.log('请求:' + url + '中...');
setTimeout(() => {
if (!error) {
success(response, param);
}else{
fail(error);
}
}, 2000);
}
//执行
request_callback(url, params,
(response, params) => {
console.log(response);
console.log('用户名:' + params.name);
console.log('密码:' + params.sec);
},
(error) => {
console.log(error);
}
);
事件监听
该实现借鉴jQuery的trigger写法,类似于iOS开发中的消息通知(一对多)
var listener;
function request_trigger(url, params){
console.log('请求:' + url + '中...');
setTimeout(() => {
if (!error) {
listener.trigger('success', response);
}else{
listener.trigger('fail', error);
}
}, 2000);
}
listener.on('success', (response, params) => {
console.log(response);
console.log('用户名:' + params.name);
console.log('密码:' + params.sec);
});
listener.on('fail', (error) => {
console.log(error);
});
Promise
比较官方的解释:Promise对象是一个代理对象(代理一个值),被代理的值在Promise对象创建时可能是未知的。它允许你为异步操作的成功和失败分别绑定相应的处理方法(handlers)。
个人理解,Promise就是对一个异步流程的封装,ES6为这种流程的封装提供了一种标准的方法,并取了一个好听的名字。Promise译为承诺,承诺那么就要有反馈,Promise就是规范了这种反馈的方式,由程序员决定反馈的时机。
//声明
function request_promise(url, params) {
console.log('请求:' + url + '中...');
//Promise 新建后就会立即执行
return new Promise((resolve, reject) => {
setTimeout(() => {
if (!error) {
resolve(response);
}else{
reject(error);
}
}, 2000);
});
}
//执行
request_promise(url, params)
.then((response) => {
console.log(response);
console.log('用户名:' + params.name);
console.log('密码:' + params.sec);
})
.catch((error) => {
console.log(error);
});
阮一峰大神在ES6入门中,举了一个使用Promise封装图片加载的例子,通过这个例子我们可以更深刻理解Promise对异步<流程>的封装,这种方式非常美妙:
function loadImageAsync(url) {
return new Promise(function(resolve, reject) {
const image = new Image();
image.onload = function() {
resolve(image);
};
image.onerror = function() {
reject(new Error('Could not load image at ' + url));
};
image.src = url;
});
}
Promise实现异步流程控制
解决异步回调,可是后台接口并不如我们前端程序员想的那么好用。如果我们要在请求登录之前,需要先请求init接口获取初始化数据,那代码会变成什么样?看下面这段代码:
/**
* 1. 请求init获取初始化数据
* 2. 请求登录
*/
request_callback(initUrl, params,
(response, params) => {
//嵌套
request_callback(url, params,
(response, params) => {
console.log(response);
console.log('用户名:' + params.name);
console.log('密码:' + params.sec);
},
(error) => {
console.log(error);
}
)
},
(error) => {
console.log(error);
}
)
这样的嵌套方式,简直就是灾难,使用Promise的链式语法,可以将上述代码重构成这样:
function requestInitUrl(url) {
return new Promise((resolve, reject) => {
console.log('请求' + url + '前需先请求' + initUrl);
console.log('请求:' + initUrl + '中...');
setTimeout(resolve, 4000, url);
});
}
function request(url, params) {
return new Promise((resolve, reject) => {
console.log('开始请求...');
setTimeout(resolve, 4000, url);
});
}
//执行
request(url, params)
.then(requestInitUrl)
.then(request_promise)
.then((response) => {
console.log(response);
console.log('用户名:' + params.name);
console.log('密码:' + params.sec);
})
Generator实现异步流程控制
通过上个Section代码的实现,看样子是解决了问题,但是我们却需要实现一个requestInitUrl函数来封装第一次init请求,并且第二次请求的参数和调用并不在一起,总感觉不是很优雅。值得开心的是,ES6中提供了新的解决方案Generator,上述代码可以重构成这样,无需再重新封装一个初始化请求的Promise对象:
function* fullRequest_generator(){
yield request_promise(initUrl)
.then((response)=>{
console.log('初始化' + response);
});
yield request_promise(url, params)
.then((response)=>{
console.log('登录' + response);
});
return 'finish';
}
//执行
var fr_g = fullRequest_generator();
//错误的调用方式
// fr_g.next();
// fr_g.next();
// fr_g.next();
执行后我们会发现,程序执行的结果并不是我们想要的异步控制,所有的请求其实是【同步】执行的。
所以,一个单纯的Generator并不能完成异步控制。Generator异步需要两个条件,一是每个yield表达式返回一个Promise对象,二是需要一个执行器。通过引入'co'模块,解决了异步执行的问题,将以上代码改为:
//执行
const co = require('co')
co(fr_g)
ps: co模块是著名程序员 TJ Holowaychuk 于 2013 年 6 月发布的一个小工具,用于 Generator函数的自动执行。
Async/Await
作为一个傲娇的程序员,引入别人的模块万一出问题还要去读源码,不是自己写的代码总有点不放心。所以在ES7中,提供了Generator函数的语法糖Async/Await解决了Generator异步执行的问题。Async函数的实现,就是将Generator函数和自动执行器,包装在一个函数里。我们将上述代码重构成如下:
async function fullRequest_async(){
console.log('开始任务');
await request_promise(initUrl)
.then((response)=>{
console.log('初始化' + response);
});
await request_promise(url, params)
.then((response)=>{
console.log('登录' + response);
});
console.log('结束任务');
}
//执行
fullRequest_async();
看到以上代码的实现,是不是感觉非常优雅美丽并且酷炫,我们不用单独声明流程函数,也不用引入执行器,就完成了异步任务的流程控制,并且提升了代码的可读性。(๑•̀ㅂ•́)و✧
如果要将任务结果抛出,则上述代码还可以重构为:
async function fullRequest_async(comp, fail){
console.log('开始任务');
try {
await request_promise(initUrl)
.then((response)=>{
console.log('初始化' + response);
});
await request_promise(url, params)
.then((response)=>{
console.log('登录' + response);
})
comp();
} catch (error) {
fail(error);
}
}
fullRequest_async(
()=>{
console.log('结束任务');
},
(err)=>{
console.log(err);
console.log('终止任务');
});
事件循环机制
讲了代码的实现方式,让我们简单探究下js底层对异步任务的处理机制
由于js的单线程机制,所以我们可以暂时不管线程这个概念,将所有即将执行的任务分为同步任务(synchronous),和异步任务(asynchronous)。同步任务指的是,在内存栈上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是,当某个事件完成触发而进入"任务队列"(task queue)的任务。而读取任务队列中任务的操作,永远是在栈中任务执行完成后的。
主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)。
一个例子:
var req = new XMLHttpRequest();
req.open('GET', url);
req.onload = function (){};
req.onerror = function (){};
req.send();
var req = new XMLHttpRequest();
req.open('GET', url);
req.send();
req.onload = function (){};
req.onerror = function (){};
如果用iOS开发的角度来看,我们是不确定onload
和onerror
回调时机的,但是在js的事件循环中,这两种写法是等价的
总结
从几种异步回调方式来看,不论是回调函数、事件还是Promise,免不了套娃的感觉。于我个人的开发习惯,比较喜欢使用类似微信小程序Api的回调方式,将参数和回调通过对象封装后传参的形式,这样的写法更加自由,清爽,并且容易控制(最主要的是作为一个iOSer毫无学习压力)。
事件监听的方式可以将回调的实现和调用分离开,达到解耦的目的,逻辑分离所带来的问题就是代码理解成本、维护成本的提高。
Promise封装了整个异步流程,相对于回调函数和事件,可以用更优雅的方式做更多的事情,例如多个异步任务的串行实现,但需要深刻理解Promise对象的实现机制,提高了学习成本。
ES6提供的Generator函数将异步代码使用同步代码的形式表现,但并不能直接解决异步流程控制,需要引入或实现执行器。
ES7提供的Async/Await语法糖,则进一步封装了整个流程,相比较Generator实现,无需实现执行器,task函数可直接执行,使代码书写更优雅,可读性更好。
所以,在实际开发中,如果无需负责流程控制的异步操作或者需要提供给外部使用的Api建议直接使用回调函数的方式进行封装。若需要对异步流程进行过控制,则将异步操作封装成Promise对象,用Async/Await的方式进行控制