JS异步与性能(一)

前言

看了《你不知道的javascript》上卷以及中卷以后,本身的一些总结。javascript

事件循环

JavaScript 引擎并非独立运行的,它运行在宿主环境中,对多数开发者来讲一般就是Web 浏览器。处理程序中多个块的执行,且执行每块时调用JavaScript 引擎,这种机制被称为事件循环html

先经过一段伪代码了解一下这个概念:html5

// eventLoop是一个用做队列的数组
// (先进,先出)
var eventLoop = [ ];
var event;
// “永远”执行
while (true) {
    // 一次tick
    if (eventLoop.length > 0) {
        // 拿到队列中的下一个事件
        event = eventLoop.shift();
        // 如今,执行下一个事件
        try {
            event();
        }
        catch (err) {
            reportError(err);
        }
    }
}
复制代码

你能够看到,有一个用while 循环实现的持续运行的循环,循环的每一轮称为一个tick。 对每一个tick 而言,若是在队列中有等待事件,那么就会从队列中摘下一个事件并执行。这 些事件就是你的回调函数。java

必定要清楚,setTimeout(..) 并无把你的回调函数挂在事件循环队列中。它所作的是设定一个定时器。当定时器到时后,环境会把你的回调函数放在事件循环中,这样,在将来某个时刻的tick 会摘下并执行这个回调。jquery

若是这时候事件循环中已经有20 个项目了会怎样呢?你的回调就会等待。它得排在其余项目后面——一般没有抢占式的方式支持直接将其排到队首。这也解释了为何setTimeout(..) 定时器的精度可能不高。大致说来,只能确保你的回调函数不会在指定的 时间间隔以前运行,但可能会在那个时刻运行,也可能在那以后运行,要根据事件队列的状态而定。ajax

回调

listen("click", function handler(evt){
    setTimeout( function request(){
        ajax( "http://some.url.1", function response(text){
            if (text == "hello") {
                handler();
            }
            else if (text == "world") {
                request();
            }
        } );
    }, 500) ;
} );
复制代码

你极可能很是熟悉这样的代码。这里咱们获得了三个函数嵌套在一块儿构成的链,其中每一个函数表明异步序列(任务,“进程”)中的一个步骤。这种代码经常被称为回调地狱(callback hell),有时也被称为毁灭金字塔(pyramid of doom,得名于嵌套缩进产生的横向三角形状)。数据库

  • 线性跟踪
doA( function(){
    doB();
    doC( function(){
        doD();
    } )
    doE();
} );
doF();
复制代码

执行顺序是?数组

A、F、B、C、E、Dpromise

在线性(顺序)地追踪这段代码的过程当中,咱们不得不从一个函数跳到下一个,再跳到下一个,在整个代码中跳来跳去以“查看”流程。并且别忘了,这仍是简化的形式,只考虑了最优状况。咱们都知道,真实的异步JavaScript程序代码要混乱得多,这使得这种追踪的难度会成倍增长。浏览器

咱们的顺序阻塞式的大脑计划行为没法很好地映射到面向回调的异步代码。这就是回调方式最主要的缺陷:对于它们在代码中表达异步的方式,咱们的大脑须要努力才能同步得上。

  • 信任问题

这是回调驱动设计最严重(也是最微妙)的问题。它以这样一个思路为中心:有时候ajax(..)(也就是你交付回调continuation 的第三方)不是你编写的代码,也不在你的直接控制下。多数状况下,它是某个第三方提供的工具。

// A
ajax( "..", function(..){
    // C
} );
// B
复制代码

咱们把这称为控制反转(inversion of control),也就是把本身程序一部分的执行控制交给某个第三方。在你的代码和第三方工具(一组你但愿有人维护的东西)之间有一份并无明确表达的契约。

  • 回调设计

为了更优雅地处理错误,有些API 设计提供了分离回调(一个用于成功通知,一个用于出错通知):

function success(data) {
    console.log( data );
}
function failure(err) {
    console.error( err );
}
ajax( "http://some.url.1", success, failure );
复制代码

还有一种常见的回调模式叫做“error-first 风格”(有时候也称为“Node风格”,由于几乎全部Node.jsAPI都采用这种风格),其中回调的第一个参数保留用做错误对象(若是有的话)。若是成功的话,这个参数就会被清空/置假(后续的参数就是成功数据)。不过,若是产生了错误结果,那么第一个参数就会被置起/ 置真(一般就不会再传递其余结果):

function response(err,data) {
    // 出错?
    if (err) {
        console.error( err );
    }
    // 不然认为成功
    else {
        console.log( data );
    }
}
ajax( "http://some.url.1", response );
复制代码

setTimeout

教科书里面的setTimeout 定义很简单。

setTimeout() 方法用于在指定的毫秒数后调用函数或计算表达式。普遍应用场景:定时器,轮播图,动画效果,自动滚动等等。可是setTimeout真的有那么简单吗?

测试题

for (var i = 1;i <= 5;i ++) {
    setTimeout(function timer() {
        console.log(i)
    },i * 1000)
}
复制代码

答案:以一秒的频率连续输出五个6。

解答

  • 做用域

这里我引用《你不知道的javascript》中的一个比喻,能够把做用域链想象成一座高楼,第一层表明当前执行做用域,楼的顶层表明全局做用域。咱们在查找变量时会先在当前楼层进行查找,若是没有找到,就会坐电梯前往上一层楼,若是仍是没有找到就继续向上找,以此类推。到达顶层后(全局做用域),可能找到了你所需的变量,也可能没找到,但不管如何查找过程都将中止。

  • 任务队列

事件循环只有一个,但任务队列可能有多个,任务队列可分为宏任务(macro-task)和微任务(micro-task)。XHR回调、事件回调(鼠标键盘事件)、setImmediate、setTimeout、setInterval、indexedDB数据库操做等I/O以及UI rendering都属于宏任务(也有文章说UI render不属于宏任务,目前尚未定论),process.nextTick、Promise.then、Object.observer(已经被废弃)、MutationObserver(html5新特性)属于微任务。注意进入到任务队列的是具体的执行任务的函数。好比上述例子setTimeout()中的timer函数。另外不一样类型的任务会分别进入到他们所属类型的任务队列,好比全部setTimeout()的回调都会进入到setTimeout任务队列,全部then()回调都会进入到then队列。当前的总体代码咱们能够认为是宏任务。事件循环从当前总体代码开始第一次事件循环,而后再执行队列中全部的微任务,当微任务执行完毕以后,事件循环再找到其中一个宏任务队列并执行其中的全部任务,而后再找到一个微任务队列并执行里面的全部任务,就这样一直循环下去。

测试题2

console.log('global');
setTimeout(function () {
    new Promise(function (resolve) {
        console.log('timeout1_promise')
        resolve()
    }).then(function () {
        console.log('timeout1_then')
    });
    console.log('timeout1');
},2000);

for (var i = 1;i <= 5;i ++) {
    setTimeout(function() {
        console.log(i)
    },i*1000)
    console.log(i)
}

setTimeout(function () {
    console.log('timeout2')
}, 1000);
复制代码

咱们来一步一步分析以上代码:

首先执行总体代码,“global”会被第一个打印出来。这是第一个输出。

执行到第一个setTimeout时,发现它是宏任务,此时会新建一个setTimeout类型的宏任务队列并派发当前这个setTimeout的回调函数到刚建好的这个宏任务队列中去,而且轮到它执行时要延迟2秒后再执行。

代码继续执行走到for循环,发现是循环5次setTimeout(),那就把这5个setTimeout中的回调函数依次派发到上面新建的setTimeout类型的宏任务队列中去,注意,这5个setTimeout的延迟分别是1到5秒。此时这个setTimeout类型的宏任务队列中应该有6个任务了。再执行for循环里的console.log(i),很简单,直接输出1,2,3,4,5,这是第二个输出。

再继续走,执行到第二个setTimeout,发现是宏任务,派发它的回调到上面setTimeout类型的宏任务队列中去。

第一轮事件循环的宏任务执行完成(总体代码能够看作宏任务)。

开始第二轮事件循环:执行setTimeout类型队列(宏任务队列)中的全部任务。发现都有延时,但延时最短的是for循环中第一次循环push进来的那个setTimeout和第二个setTimeout,它们都只延时1s。它们会被同时执行,但前者先被push进来,因此先执行它!它的做用就是打印变量i,在当前做用域找变量i,木有!去它上层做用域(这里是全局做用域)找,找到了,但此时的i早已经是6了。(为啥不是5,那你得去补补for循环的执行流程了~)因此这里第三个输出是延时1s后打印出6。紧接着执行第二个setTimeout,它会打印出"timeout2",这是第四个输出。

延迟一秒后,宏队列当中前后有第一个setTimeout和for循环当中的setTimeout,上面有说到Promise.then是微任务,那么这里会生成一个Promise.then类型的微任务队列,这里的then回调会被push进这个队列中。第五个和第六个输出为“timeout1_promise”,“timeout1”,以后执行微任务队列,第七个输出为“timeout1_then”。以后执行宏队列,第八个输出”6”;

后续就每隔一秒输出”6”,执行三次,所有代码执行完毕。

执行结果以下:

测试题3

改动一下代码,要它以一秒的频率分别输出1,2,3,4,5。

利用setTimeout第三个参数

for (var i=1; i<=5; i++) {
  setTimeout( function timer(i) {
    console.log(i);    
   }, i*1000,i);
}
复制代码

测试题4

setTimeout(function () {
    func1();
}, 0);
func2();
复制代码

setTimeout,setInterval都存在一个最小延迟的问题,虽然你给的delay值为0,可是浏览器执行的是本身的最小值。HTML5标准是4ms,但并不意味着全部浏览器都会遵循这个标准,包括手机浏览器在内,这个最小值既有可能小于4ms也有可能大于4ms。在标准中,若是在setTimeout中嵌套一个setTimeout, 那么嵌套的setTimeout的最小延迟为10ms。

setInterval

setInterval有一个很重要的应用是javascript中的动画。

举个例子,假设咱们有一个正方形div,宽度为100px, 如今想让它的宽度在1000毫秒内增长到300px——很简单,算出每毫秒内应该增长的像素,再按每毫秒为周期调用setInterval实现增加。

var div = $('div')[0];
var width = parseInt(div.style.width, 10);

var MAX = 300, duration = 1000;
var inc = parseFloat( (MAX - width) / duration );

function animate (id) {
    width += inc;
    if (width >= MAX) {
        clearInterval(id);
        console.timeEnd("animate");
    }
    div.style.width = width + "px";
}

console.time("animate");
var timer = setInterval(function () {
    animate(timer);
}, 0)
复制代码

执行结果以下:

代码中利用console.time来计算时间所花费的时间——实际上花的时间是明显大于1000毫秒的,为何?由于上面说到最小周期至少应该是4ms,因此每一个周期的增加量应该是每毫秒再乘以四。

var inc = parseFloat( (MAX - width) / duration ) * 4;
复制代码

执行结果以下:

若是你有心查看jquery的动画源码的话,你能发现源码的时间周期是13ms,13ms 大概是一个在各浏览器上使动画表现接近一致的值。若是最求流畅的动画效果来讲,每秒(1000毫秒)应该是60帧,这样算下来每帧的时间应该是16.7毫秒,在这里我把每帧定义为完成一个像素增量所花的时间,也就是16毫秒(毫秒不容许存在小数)是让动画流畅的最佳值。

不管你如何优化setInterval,偏差是始终存在的。但其实在HTML5中,有一个实践动画的最佳途径requestAnimationFrame。这个函数能保证能以每帧来执行动画函数。好比上面的例子就能够改写为:

//init some values
var div = $('div')[0].style;
var height = parseInt(div.height, 10);
var seconds = 1;

//calc distance we need to move per frame over a time
var max = 300;
var steps = (max- height) / seconds / 16.7;

//16.7ms is approx one frame (1000/60)

//loop
function animate (id) {
    height += steps; //use calculated steps
    div.height = height + "px";

    if (height < max) {
        requestAnimationFrame(animate);
    }
}

animate();
复制代码

这种状况下一般会有多个计时器同时运行,若是同时大量计时器同时运行的话,会引发一些个问题,好比如何回收这些计时器?jquery的做者John Resig建议创建一个管理中心,它给出的一个很是简单的代码以下:

var timers = {                               
  timerID: 0,                                           
  timers: [],                                           
  add: function(fn) {                            
    this.timers.push(fn);
  },
  start: function() {                             
    if (this.timerID) return;
    (function runNext() {
      if (timers.timers.length > 0) {
        for (var i = 0; i < timers.timers.length; i++) {
          if (timers.timers[i]() === false) {
            timers.timers.splice(i,1);
            i--;
          }
        }
        timers.timerID = setTimeout(runNext, 0);
      }
    })();
  },
  stop: function() {                                  
    clearTimeout(this.timerID);
    this.timerID = 0;
  }
};
复制代码

注意看中间的start方法:他把全部的定时器都存在一个timers队列(数组)中,只要队列长度不为0,就轮询执行队列中的每个子计时器,若是某个子计时器执行完毕(这里的标志是返回值是false),那就把这个计时器踢出队列。继续轮询后面的计时器。

上面描述的整个一轮轮询就是runNext,而且递归轮询,一遍一遍的执行下去timers.timerID = setTimeout(runNext, 0)直到数组为空。

感谢阅读至此,后面会更新promise的总结,更优的异步解决方案。

相关文章
相关标签/搜索