阅读 4704

我了解到的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
clipboard.png

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

二、 二个前提知识点

2.1 浏览器内核的多线程

clipboard.png
clipboard.png

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

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

2.2 事件循环机制

clipboard.png
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
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
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
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) www.jianshu.com/p/12b9f73c5…
2) www.jianshu.com/p/fe5f17327…
3) mengera88.github.io/2017/05/18/…
4) www.cnblogs.com/nullcc/p/58…

文章分类
前端
文章标签