我了解到的JavaScript异步编程

一、 一道面试题

前段时间面试,考察比较多的是js异步编程方面的相关知识点,如今,正好轮到自己分享技术,所以想把js异步编程学习下,做个总结。
下面这个demo 概括了大多数面试过程中遇到的问题:

for(var i = 0; i < 3; i++) {
   setTimeout(function() {
       console.log('timeout' + i);
   })
}
 
new Promise(function(resolve) {
    console.log('promise1');
    for(var i = 0; i < 1000; i++) {
        i == 99 && resolve();
    }
    console.log('promise2');
}).then(function() {
    console.log('then1');
})

console.log('global1');

通过验证可以得知这个demo的结果为:

clipboard.png

可是为什么会是这样的结果,我们可能需要先了解下下面两个知识点

二、 二个前提知识点

2.1 浏览器内核的多线程

clipboard.png

浏览器的内核是多线程的,他们在内核的控制下互相配合以保持同步,一个浏览器至少实现三个常驻的线程:javascript引擎线程,GUI渲染线程,浏览器事件触发线程。

1)js引擎,基于事件驱动单线程执行的,js引擎一直等待着任务队列中任务的到来,然后加以处理,浏览器无论什么时候都只有一个JS线程在运行JS程序。
2)GUI线程,当界面需要重绘或由于某种操作引发回流时,该线程就会执行。它和JS引擎是互斥的。
3)浏览器事件触发线程,当一个事件被触发时,该线程会把事件添加到待处理队列的队尾,等待js引擎的处理,这些事件可来自JavaScript引擎当前执行的代码块如,setTimeOut, 也可以来自浏览器内核的其他线程如鼠标点击,AJAX异步请求等,但由于JS的单线程关系,所有这些事件都得排队等待JS引擎处理。

2.2 事件循环机制

clipboard.png

1)任务队列又分为macro-task(宏任务)与micro-task(微任务),
在最新标准中,它们被分别称为task与jobs。

2)macro-task大概包括:script(整体代码), setTimeout, setInterval, setImmediate, I/O, UI rendering。

3)micro-task【先执行】大概包括: process.nextTick, Promise, Object.observe(已废弃), MutationObserver(html5新特性)

setTimeout/Promise等我们称之为任务源。而进入任务队列的是他们指定的具体执行任务。

事件循环的顺序,决定了JavaScript代码的执行顺序。它从script(整体代码)开始第一次循环。之后全局上下文进入函数调用栈。直到调用栈清空(只剩全局),然后执行所有的micro-task。当所有可执行的micro-task执行完毕之后。循环再次从macro-task开始,找到其中一个任务队列执行完毕,然后再执行所有的macro-task,这样一直循环下去。

通过这个事件循环的顺序,我们就知道,为什么上面提到的面试题为什么是这样的输出结果了。
接下来我们看下三类异步编程的实现。

三、三类异步编程实现

3.1 回调函数

demo1:

// 一个简单的封装
function want() {
    console.log('这是你想要执行的代码');
}

function fn(want) {
    console.log('这里表示执行了一大堆各种代码');

    // 其他代码执行完毕,最后执行回调函数
    want && want();
}

fn(want);

demo2:

//callback hell

doSomethingAsync1(function(){
    doSomethingAsync2(function(){
        doSomethingAsync3(function(){
            doSomethingAsync4(function(){
                doSomethingAsync5(function(){
                    // code...
                });
            });
        });
    });
});

可以发现一个问题,在回调函数嵌套层数不深的情况下,代码还算容易理解和维护,一旦嵌套层数加深,就会出现“回调金字塔”的问题,就像demo2那样,如果这里面的每个回调函数中又包含了很多业务逻辑的话,整个代码块就会变得非常复杂。从逻辑正确性的角度来说,上面这几种回调函数的写法没有任何问题,但是随着业务逻辑的增加和趋于复杂,这种写法的缺点马上就会暴露出来,想要维护它们实在是太痛苦了,这就是“回调地狱(callback hell)”。

回调函数还有一个问题就是我们在回调函数之外无法捕获到回调函数中的异常,一般我们用try catch来捕捉异常,我们尝试下捕捉回调中的异常

clipboard.png

可以看到,不能捕捉到callback中的异常。

3.2 事件监听(事件发布/订阅)

事件监听是一种非常常见的异步编程模式,它是一种典型的逻辑分离方式,对代码解耦很有用处。通常情况下,我们需要考虑哪些部分是不变的,哪些是容易变化的,把不变的部分封装在组件内部,供外部调用,需要自定义的部分暴露在外部处理。从某种意义上说,事件的设计就是组件的接口设计。
1)jQuery事件监听

    $('#btn').on('myEvent', function(e) {
        console.log('There is my Event');
    });
    $('#btn').trigger('myEvent');

2)发布/订阅模式

    var PubSub = function(){
        this.handlers = {}; 
    };
    PubSub.prototype.subscribe = function(eventType, handler) {
        if (!(eventType in this.handlers)) {
            this.handlers[eventType] = [];
        }
        this.handlers[eventType].push(handler); //添加事件监听器
        return this;//返回上下文环境以实现链式调用
    };
    PubSub.prototype.publish = function(eventType) {
        var _args = Array.prototype.slice.call(arguments, 1);
        for (var i = 0, _handlers = this.handlers[eventType]; i < _handlers.length; i++) {
            _handlers[i].apply(this, _args);//遍历事件监听器
        }
        return this;
    };
    var event = new PubSub;//构造PubSub实例
    event.subscribe('list', function(msg) {
        console.log(msg);
    });
    event.publish('list', {data: ['one,', 'two']});
    //Object {data: Array[2]}

这种模式实现的异步编程,本质上还是通过回调函数实现的,所以3.1中提到的回调嵌套和无法捕捉异常的问题还是存在的,接下来我们看ES6提供的Promise对象,是否解决这两个问题。

3.3 Promise对象

ES 6中原生提供了Promise对象,Promise对象代表了某个未来才会知道结果的事件(一般是一个异步操作),并且这个事件对外提供了统一的API,可供进一步处理。
使用Promise对象可以用同步操作的流程写法来表达异步操作,避免了层层嵌套的异步回调,代码也更加清晰易懂,方便维护,也可以捕捉异常。

一个简单例子:

function fn(num) {
  return new Promise(function(resolve, reject) {
    if (typeof num == 'number') {
      resolve();
    } else {
      reject();
    }
  })
  .then(function() {
    console.log('参数是一个number值');
  })
  .then(null, function() {
    console.log('参数不是一个number值');
  })
}
fn('haha');
fn(1234);

为什么Promise 可以这样实现异步编程,在这我们简单分析下Promise实现过程:
1)极简Promise雏形

// 极简promise雏形
function Promise(fn) {
  var value = null,
    callbacks = [];  //callbacks为数组,因为可能同时有很多个回调

  this.then = function (onFulfilled) {
    callbacks.push(onFulfilled);
  };

  function resolve(value) {
    callbacks.forEach(function (callback) {
      callback(value);
    });
  }

  fn(resolve);
}
  • 如果promise内部的函数是同步函数,我们要加入一些处理,保证在resolve执行之前,then方法已经注册完所有的回调;
  • 通过setTimeout机制,将resolve中执行回调的逻辑放置到JS任务队列末尾,以保证在resolve执行时,then方法的回调函数已经注册完成.

2)加入延时处理

// 极简promise雏形,加入延时处理
function Promise(fn) {
  var value = null,
    callbacks = [];  //callbacks为数组,因为可能同时有很多个回调

  this.then = function (onFulfilled) {
    callbacks.push(onFulfilled);
  };

  function resolve(value) {
    setTimeout(function() {
        callbacks.forEach(function (callback) {
            callback(value);
        });
    }, 0)
  }

  fn(resolve);
}
  • 如果Promise异步操作已经成功,这时,在异步操作成功之前注册的回调都会执行,但是在Promise异步操作成功这之后调用的then注册的回调就再也不会执行了,这显然不是我们想要的

3)加入状态判断

// 极简promise雏形,加状态判断
function Promise(fn) {
  var state = 'pending',
      value = null,
      callbacks = [];

  this.then = function (onFulfilled) {
      if (state === 'pending') {
          callbacks.push(onFulfilled);
          return this;
      }
      onFulfilled(value);
      return this;
  };

  function resolve(newValue) {
      value = newValue;
      state = 'fulfilled';
      setTimeout(function () {
          callbacks.forEach(function (callback) {
              callback(value);
          });
      }, 0);
  }

  fn(resolve);
}

4)链式promise

// 极简promise雏形,链式promise
function Promise(fn) {
  var state = 'pending',
      value = null,
      callbacks = [];

  this.then = function (onFulfilled) {
      return new Promise(function (resolve) {
          handle({
              onFulfilled: onFulfilled || null,
              resolve: resolve
          });
      });
  };

  function handle(callback) {
      if (state === 'pending') {
          callbacks.push(callback);
          return;
      }
      //如果then中没有传递任何东西
      if(!callback.onResolved) {
          callback.resolve(value);
          return;
      }

      var ret = callback.onFulfilled(value);
      callback.resolve(ret);
  }
  
  function resolve(newValue) {
      if (newValue && (typeof newValue === 'object' || typeof newValue === 'function')) {
          var then = newValue.then;
          if (typeof then === 'function') {
              then.call(newValue, resolve);
              return;
          }
      }
      state = 'fulfilled';
      value = newValue;
      setTimeout(function () {
          callbacks.forEach(function (callback) {
              handle(callback);
          });
      }, 0);
  }

  fn(resolve);
}

四、四个扩展点

4.1 Promise常用的应用场景:ajax

利用Promise的知识,对ajax进行一个简单的封装。看看会是什么样子:

//demo3 promise封装ajax
var url = 'https://hq.tigerbrokers.com/fundamental/finance_calendar/getType/2017-02-26/2017-06-10';
function getJSON(url) {
  return new Promise(function(resolve, reject) {
    var XHR = new XMLHttpRequest();
    XHR.open('GET', url, true);
    XHR.send();

    XHR.onreadystatechange = function() {
        if (XHR.readyState == 4) {
            if (XHR.status == 200) {
                try {
                    var response = JSON.parse(XHR.responseText);
                    resolve(response);
                } catch (e) {
                    reject(e);
                }
            } else {
                reject(new Error(XHR.statusText));
            }
        }
    }
  })
}
getJSON(url).then(resp => console.log(resp));

除了串行执行若干异步任务外,Promise还可以并行执行异步任务。

当有一个ajax请求,它的参数需要另外2个甚至更多请求都有返回结果之后才能确定,那么这个时候,就需要用到Promise.all来帮助我们应对这个场景。

4.2 Promise.all

Promise.all接收一个Promise对象组成的数组作为参数,当这个数组所有的Promise对象状态都变成resolved或者rejected的时候,它才会去调用then方法。

// demo4 promise.all
var url = 'https://hq.tigerbrokers.com/fundamental/finance_calendar/getType/2017-02-26/2017-06-10';
var url1 = 'https://hq.tigerbrokers.com/fundamental/finance_calendar/getType/2017-03-26/2017-06-10';

function renderAll() {
  return Promise.all([getJSON(url), getJSON(url1)]);
}

renderAll().then(function(value) {
  console.log(value); //将得到一个数组,里面是两个接口返回的值
})

结果:

clipboard.png

有些时候,多个异步任务是为了容错。比如,同时向两个URL读取用户的个人信息,只需要获得先返回的结果即可。这种情况下,用Promise.race()实现。

4.3 Promise.race

与Promise.all相似的是,Promise.race都是以一个Promise对象组成的数组作为参数,不同的是,只要当数组中的其中一个Promsie状态变成resolved或者rejected时,就可以调用.then方法了

// demo5 promise.race
function renderRace() {
  return Promise.race([getJSON(url), getJSON(url1)]);
}

renderRace().then(function(value) {
  console.log(value);
})

这里then()传的value值将是接口返回比较快的接口数据,另外一个接口仍在继续执行,但执行结果将被丢弃。

结果:

clipboard.png

4.4 Generator 函数

Generator函数是协程在ES 6中的实现,最大特点就是可以交出函数的执行权(暂停执行)。
注意:在node中需要开启--harmony选项来启用Generator函数。
整个Generator函数就是一个封装的异步任务,或者说是异步任务的容器。异步操作需要暂停的地方,都用yield语句注明。

看个简单的例子:

function* gen(x){
    var y = yield x + 2;
    return y;
}

var g = gen(1);
var r1 = g.next(); // { value: 3, done: false }
console.log(r1);
var r2 = g.next() // { value: undefined, done: true }
console.log(r2);

需要注意的是Generator函数的函数名前面有一个"*"。
上述代码中,调用Generator函数,会返回一个内部指针(即遍历器)g,这是Generator函数和一般函数不同的地方,调用它不会返回结果,而是一个指针对象。调用指针g的next方法,会移动内部指针,指向第一个遇到的yield语句,上例就是执行到x+2为止。
换言之,next方法的作用是分阶段执行Generator函数。每次调用next方法,会返回一个对象,表示当前阶段的信息(value属性和done属性)。value属性是yield语句后面表达式的值,表示当前阶段的值;done属性是一个布尔值,表示Generator函数是否执行完毕,即是否还有下一个阶段。

对Generator函数,只有一个感性认知,没有实践过,所以就先介绍到这了,后面还有ES7新的知识点async await,看了下网上的资料,理解得还不够,希望后面自己接触得更多再来这里补上,未完待续...

参考资料:

  1. http://www.jianshu.com/p/12b9f73c5a4f
  2. http://www.jianshu.com/p/fe5f173276bd
  3. https://mengera88.github.io/2017/05/18/Promise%E5%8E%9F%E7%90%86%E8%A7%A3%E6%9E%90/
  4. http://www.cnblogs.com/nullcc/p/5841182.html
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,198评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,663评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,985评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,673评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,994评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,399评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,717评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,407评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,112评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,371评论 2 241
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,891评论 1 256
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,255评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,881评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,010评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,764评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,412评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,299评论 2 260

推荐阅读更多精彩内容