JS中的异步操作

JS中异步编程的方法有:

  • 回调函数
  • 事件监听
  • 发布/订阅
  • promise
  • generator(ES6)
  • async/await(ES7)

回调函数

回调是异步编程中最基础的方法。举例一个简单的回调:在f1执行完之后再执行f2

var func1=function(callback){
    console.log(1);
    (callback && typeof(callback)==='function') && callback();
}
func1(func2);
var func2=function(){
    console.log(2);
}

异步回调中最常见的形式可能就是Ajax了:

$.ajax({
    url:"/getmsg",
    type: 'GET',
    dataType: 'json',
    success: function(ret) {
        if (ret && ret.status) {
            //
        }
    },
    error: function(xhr) {
        //
    }
})

事件监听

通过事件机制,实现代码的解耦。js处理DOM交互就是采用的事件机制,我们这儿只是实现一些自定义的事件而已。JS中已经很好的支持了自定义事件,如:

//新建一个事件
var event=new Event('Popup::Show');
//dispatch the event
elem1.dispatchEvent(event)

//listen for this event
elem2.addEventListener('Popup::Show',function(msg){},false)

发布-订阅模式

在系统中存在一个"信号中心",当某个任务执行完成后向信号中心"发布"(publish)一个信号,其他任务可以向信号中心"订阅"(subscribe)这个信号,从而知道什么时候自己可以开始执行。简单实现如下:

//发布-订阅
//有个消息池,存放所有消息
let pubsub = {};
(function(myObj) {
    topics = {}
    subId = -1;
    //发布者接受参数(消息名称,参数)
    myObj.publish = function(topic, msg) {
            //如果发布的该消息没有订阅者,直接返回
            if (!topics[topic]) {
                return
            }
            //对该消息的所有订阅者,遍历去执行各自的回调函数
            let subs = topics[topic]
            subs.forEach(function(sub) {
                sub.func(topic, msg)
            })
        }
    //订阅者接受参数:(消息名称,回调函数)
    myObj.subscribe = function(topic, func) {
        //如果订阅的该事件还未定义,初始化
        if (!topics[topic]) {
            topics[topic] = []
        }
        //使用不同的token来作为订阅者的索引
        let token = (++subId).toString()
        topics[topic].push({
                token: token,
                func: func
            })
        return token
    }
    myObj.unsubscribe = function(token) {
        //对消息列表遍历查找该token是哪个消息中的哪个订阅者
        for (let t in topics) {
            //如果某个消息没有订阅者,直接返回
            if (!topics[t]) {
                return }
            topics[t].forEach(function(sub,index) {
                if (sub.token === token) {
                    //找到了,从订阅者的数组中去掉该订阅者
                    topics[t].splice(index, 1)
                }
            })
        }
    }
})(pubsub)

let sub1 = pubsub.subscribe('Msg::Name', function(topic, msg) {
    console.log("event is :" + topic + "; data is :" + msg)
});
let sub2 = pubsub.subscribe('Msg::Name', function(topic, msg) {
    console.log("this is another subscriber, data is :" + msg)
});
pubsub.publish('Msg::Name', '123')

pubsub.unsubscribe(sub2)
pubsub.publish('Msg::Name', '456')

其中存储消息的结构用json可以表示为:

topics = {
    topic1: [{ token: 1, func: callback1 }, { token: 2, func: callback2 }],
    topic2: [{ token: 3, func: callback3 }, { token: 4, func: callback4 }],
    topic3: []
}

消息池的结构是发布订阅模式与事件监听模式的最大区别。当然,每个消息也可以看做是一个个的事件,topics对象就相当于一个事件处理中心,每个事件都有各自的订阅者。所以事件监听其实就是发布订阅模式的一个简化版本。而发布订阅模式的优点就是我们可以查看消息中心的信息,了解有多少信号,每个信号有多少订阅者。

再说一说观察者模式

很多情况下,我们都将观察者模式和发布-订阅模式混为一谈,因为都可用来进行异步通信,实现代码的解耦,而不再细究其不同,但是内部实现还是有很多不同的。

  1. 整体模型的不同:发布订阅模式是靠信息池作为发布者和订阅者的中转站的,订阅者订阅的是信息池中的某个信息;而观察者模式是直接将订阅者订阅到发布者内部的,目标对象需要负责维护观察者,也就是观察者模式中订阅者是依赖发布者的。

  2. 触发回调的方式不同:发布-订阅模式中,订阅者通过监听特定消息来触发回调;而观察者模式是发布者暴露一个接口(方法),当目标对象发生变化时调用此接口,以保持自身状态的及时改变。

观察者模式很好的应用是MVC架构,当数据模型更新时,视图也发生变化。从数据模型中将视图解耦出来,从而减少了依赖。但是当观察者数量上升时,性能会有显著下降。我们同样可以自己实现:

//观察者模式
var Subject=function(){
    this.observers=[];
}
Subject.prototype={
    subscribe:function(observer){
        this.observers.push(observer);
    },
    unsubscribe:function(observer){
        var index=this.observers.indexOf(observer);
        if (index>-1) {
            this.observers.splice(index,1);
        }
    },
    notify:function(observer,msg){
        var index=this.observers.indexOf(observer);
        if (index>-1) {
            this.observers[index].notify(msg)
        }
    },
    notifyAll:function(msg){
        this.observers.forEach(function(observe,msg){
            observe.notify(msg)
        })
    }
}
var Observer=function(){
    return {
        notify:function(msg){
            console.log("received: "+msg);
        }
    }
}
var subject=new Subject();
var observer0=new Observer();
var observer1=new Observer();
var observer2=new Observer();
var observer3=new Observer();
subject.subscribe(observer0);
subject.subscribe(observer1);
subject.subscribe(observer2);
subject.subscribe(observer3);
subject.notifyAll('all notified');
subject.notify(observer2,'asda');

promise

为解决回调函数噩梦而提出的写法,将回调函数的横向加载变成纵向加载。

  • 对象状态不受外界影响。三种状态:pending,resolved,rejected。只有异步操作的结果才能改变状态
  • 状态一旦改变,就不会再变。

用Promise对象实现Ajax操作的例子

var getJSON=function(url){
    var promise=new Promise(function(resolve,reject){
        var client=new XMLHttpRequest();
        client.open("GET",url);
        client.onreadystatechange=handler;
        client.responseType="json";
        client.setRequestHeader("Accept","application/json");
        client.send();
        function handler(){
            if(this.readyState!=4){
                return;
            }
            if(this.status==200){
                resolve(this.response);
            }else{
                reject(new Error(this.statusText));
            }
        }
    });
    return promise;
}

getJSON('/posts.json').then(function(json){
    console.log('Contents: '+json);
},function(error){
    console.error(error)
})

再举一个需要多层回调的例子:假设每个步骤都是异步,并且依赖上一个步骤的结果,使用setTimeout来模拟异步操作。

//输入n,表示该函数执行时间,结果为n+200,并且用于下一步的输入
function takeLongTime(n){
    return new Promise(resolve=>{
        setTimeout(()=>resolve(n+200),n)
    })
}

function step1(n){
    console.log(`step1 with ${n}`);
    return takeLongTime(n);
}

function step2(n){
    console.log(`step2 with ${n}`);
    return takeLongTime(n);
}

function step3(n){
    console.log(`step3 with ${n}`);
    return takeLongTime(n);
}

如果使用Promise的方式将其3个步骤处理为链式操作,每一步都返回一个promise对象,将输出的结果作为下一步新的输入:

function dolt(){
    console.time('dolt');
    const time1=300;
    step1(time1)
    .then(time2=>step2(time2))
    .then(time3=>step3(time3))
    .then(result=>{
        console.log(`result is ${result}`);
        console.timeEnd('dolt')
    });
}
dolt();
//输出结果为
step1 with 300
step2 with 500
step3 with 700
result is 900
dolt: 1516.713ms

实际耗时跟我们计算的延迟时间300+500+700=1500ms差不多。但是对于长的链式操作来说,看起来是一堆then方法的堆砌,代码冗余,语义也不清楚,而且还是靠着箭头函数才使得代码略微简短一些。Promise还有一个痛点,就是传递参数太麻烦,尤其是需要传递多参数的情况下。

Generator函数

generator是一个封装的异步任务,在需要暂停的地方,使用yield语句注明。如

function* gen(x){
    let y=yield x+2;
    return y;
}
let g=gen(1);
g.next();
//返回 {value: 3, done: false}
g.next();
//返回 {value: undefined, done: true}

调用generator函数返回的是内部的指针对象,调用next方法就会移动内部指针。Generator函数之所以能被用来处理异步操作,因为它可以暂停执行和恢复执行、函数体内外的数据交换和错误处理机制。

针对前面多任务的例子,使用generator实现:

function* dolt(){
    console.time('dolt');
    const time1=300;
    const time2=yield step1(time1);
    const time3=yield step2(time2);
    const result=yield step3(time3);
    console.log(`result is ${result}`);
    console.timeEnd('dolt');
}

但是 Generator 函数的执行必须靠执行器

function spawn(genF) {
  return new Promise(function(resolve, reject) {
    var gen = genF();
    function step(nextF) {
      try {
        var next = nextF();
      } catch(e) {
        return reject(e); 
      }
      if(next.done) {
        return resolve(next.value);
      } 
      Promise.resolve(next.value).then(function(v) {
        step(function() { return gen.next(v); });      
      }, function(e) {
        step(function() { return gen.throw(e); });
      });
    }
    step(function() { return gen.next(undefined); });
  });
}
spawn(dolt);

async/await

async函数基于Generator又做了几点改进:

  • 内置执行器,将Generator函数和自动执行器进一步包装。
  • 语义更清楚,async表示函数中有异步操作,await表示等待着紧跟在后边的表达式的结果。
  • 适用性更广泛,await后面可以跟promise对象和原始类型的值(Generator中不支持)

很多人都认为这是异步编程的终极解决方案,由此评价就可知道该方法有多优秀了。它基于Promise使用async/await来优化then链的调用,其实也是Generator函数的语法糖。 async 会将其后的函数(函数表达式或 Lambda)的返回值封装成一个 Promise 对象,而 await 会等待这个 Promise 完成,并将其 resolve 的结果返回出来。

await得到的就是返回值,其内部已经执行promise中resolve方法,然后将结果返回。使用async/await的方式重写前面的回调任务:

async function dolt(){
    console.time('dolt');
    const time1=300;
    const time2=await step1(time1);
    const time3=await step2(time2);
    const result=await step3(time3);
    console.log(`result is ${result}`);
    console.timeEnd('dolt');
}

dolt();

功能还很新,属于ES7的语法,但使用Babel插件可以很好的转义。另外await只能用在async函数中,否则会报错。

【参考】
async 函数的含义和用法
JavaScript 异步编程解决方案笔记
用ES6 Generator替代回调函数

推荐阅读更多精彩内容

  • 异步编程对JavaScript语言太重要。Javascript语言的执行环境是“单线程”的,如果没有异步编程,根本...
    呼呼哥阅读 4,987评论 5 22
  • 本文首发在个人博客:http://muyunyun.cn/posts/7b9fdc87/ 提到 Node.js, ...
    牧云云阅读 1,309评论 0 3
  • 弄懂js异步 讲异步之前,我们必须掌握一个基础知识-event-loop。 我们知道JavaScript的一大特点...
    DCbryant阅读 1,618评论 0 4
  • 一.非阻塞和异步 借用知乎用户严肃的回答在此总结下,同步和异步是针对消息通信机制,同步代表一个client发出一个...
    Daniel_adu阅读 1,201评论 0 8
  • 官方中文版原文链接 感谢社区中各位的大力支持,译者再次奉上一点点福利:阿里云产品券,享受所有官网优惠,并抽取幸运大...
    HetfieldJoe阅读 5,269评论 9 20