JS异步与性能(一)

1,400 阅读10分钟

前言

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

事件循环

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

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

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

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

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

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

回调

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、D

在线性(顺序)地追踪这段代码的过程中,我们不得不从一个函数跳到下一个,再跳到下一个,在整个代码中跳来跳去以“查看”流程。而且别忘了,这还是简化的形式,只考虑了最优情况。我们都知道,真实的异步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的总结,更优的异步解决方案。