你的浏览器不支持canvas

Enjoy life!

javascript - 高级定时器

Date: Author: JM

本文章采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可。

一、简述定时器的机制

看以下例子:

var btn = document.getElementById("my-btn");
btn.onclick = function(){
 setTimeout(function(){
  document.getElementById("message").style.visibility = "visible";
 }, 250);
 //其他代码
}; 

*上述代码的解析:

  • 先给按钮设置了一个事件处理程序,事件处理程序里设置了一个 250ms 后调用的定时器。
  • 点击按钮后,先把 onclick 事件处理程序添加到任务队列中。
  • 该事件处理程序执行后,再设置定时器,250ms后,指定的代码才被添加到队列中等待执行。
  • 如果队列为空,即:没有其他代码等待执行,那么定时器内的代码就会执行; 如果队列不为空,即:有其他代码等待执行,那么定时器内的代码就会等到队列中的代码执行完后执行。
  • 总结:
    • 定时器只是用来计划代码在未来的某个时间执行。
    • 定时器所指定的时间间隔表示何时将定时器的代码添加到队列,而不是何时实际执行代码
    • js中没有任何代码是立刻执行的,除非进程是空闲的,而不管它们是如何添加到队列中。

二、重复的定时器 - setInterval()

2.1 setInterval()问题的阐述

  • setInterval():每隔指定的时间就执行一次代码。说明了它创建的定时器确保了定时器代码规则地插入队列中。
  • 但这个创建定时器的方式也存在问题:
    • 定时器代码可能在代码再次被添加到队列之前还没有完成执行,结果导致定时器代码连续运行好几次,而之间没有任何停顿。
  • JavaScript 引擎能避免这个问题:
    • 当使用 setInterval()时,仅当没有该定时器的任何其他代码实例时,才将定时器代码添加到队列中。
    • 这确保了定时器代码加入到队列中的最小时间间隔为指定间隔。

2.2 setInterval()问题展示的例子

  • setInterval()问题:
    • 某些间隔会被跳过。
    • 多个定时器的代码执行之间的间隔可能会比预期的小。
  • 先看以下例子:
    • 假设,某个 onclick 事件处理程序使用 setInterval()设置了一个 200ms 间隔的重复定时器。
    • 如果事件处理程序花了 300ms 多一点的时间完成,同时定时器代码也花了差不多的时间,就会同时出现跳过间隔且连续运行定时器代码的情况。

relationship-map

  • 上图分析:
    • 第 1 个定时器是在 205ms 处添加到队列中的,但是直到过了 300ms 处才能够执行。
    • 当执行这个定时器代码时,在 405ms 处又给队列添加了另外一个副本。
    • 在下一个间隔,即 605ms 处,第一个定时器代码仍在运行,同时在队列中已经有了一个定时器代码的实例。
    • 结果是,在这个时间点上的定时器代码不会被添加到队列中。
    • 结果在 5ms 处添加的定时器代码结束之后,405ms 处添加的定时器代码就立刻执行。

2.2 setInterval()问题解决方法

  • 解决上述两个问题的方法:用如下模式使用链式 setTimeout() 调用。
    • 这个模式链式调用了 setTimeout(),每次函数执行的时候都会创建一个新的定时器。
    • 第二个 setTimeout()调用使用了 arguments.callee 来获取对当前执行的函数的引用,并为其设置另外一个定时器。
    • 这样做的好处:
      • 在前一个定时器代码执行完之前,不会向队列插入新的定时器代码,确保不会有任何缺失的间隔
      • 它可以保证在下一次定时器代码执行之前,至少要等待指定的间隔,避免了连续的运行
setTimeout(function(){
 //处理中
 setTimeout(arguments.callee, interval);
}, interval); 

三、定时器应用 - Yielding Processes

3.1 长时间运行脚本的制约

  • 长时间运行脚本的制约如果代码运行超过特定的时间或者特定语句数量就不让它继续执行
  • 如果代码达到了这个限制,会弹出一个浏览器错误的对话框:告诉用户某个脚本会用过长的时间执行,询问是允许其继续执行还是停止它。
  • 定时器是绕开此限制的方法之一。

3.2 脚本长时间运行问题产生的原因

  • 脚本长时间运行的问题产生的原因:
    • 过长的、过深嵌套的函数调用。
    • 进行大量处理的循环。

3.3 数组分块技术(array chunking)

  • 长时间运行的循环通常遵循以下模式:
for (var i=0, len=data.length; i < len; i++){
 process(data[i]);
} 
  • 这个模式的问题在于 要处理的项目的数量在运行前是不可知 的。【 数组中的项目数量直接关系到执行完该循环的时间长度。】
    • 例子:如果完成 process()要花 100ms,只有 2 个项目的数组可能不会造成影响,但是 10 个的数组可能会导致脚本要运行一秒钟才能完成。
  • 由于 JavaScript 的执行是一个阻塞操作,脚本运行所花时间越久,用户无法与页面交互的时间也越久。
  • 在展开循环前,需先考虑以下两个问题:
    1. 该处理是否必须同步完成?
      • 如果这个数据的处理会造成其他运行的阻塞,那么最好不要改动它。
      • 如果你对这个问题的回答确定为“否”,那么将某些处理推迟到以后是个不错的备选项。
    2. 数据是否必须按顺序完成?
      • 通常,数组只是对项目的组合和迭代的一种简便的方法而无所谓顺序。
      • 如果项目的顺序不是非常重要,那么可能可以将某些处理推迟到以后。
  • 当你发现某个循环占用了大量时间,同时对于上述两个问题,你的回答都是“否”,那么你就可以使用定时器分割这个循环。
  • 这是一种叫做数组分块array chunking)的技术,小块小块地处理数组,通常每次一小块。
    • 基本的思路:为要处理的项目创建一个队列,然后使用定时器取出下一个要处理的项目进行处理,接着再设置另一个定时器。
function chunk(array, process, context){
    setTimeout(function(){
        // 取出下一个条目并处理
        var item = array.shift();
        
        // 通过 call()调用的 process()函数,这样可以设置一个合适的执行环境(如果必须)
        process.call(context, item);
        
        // 若还有条目,再设置另一个定时器
        if (array.length > 0){
          // 可以根据你的需要更改这个间隔大小,不过 100ms 在大多数情况下效果不错。
          setTimeout(arguments.callee, 100);
        }
    }, 100);
} 
  • chunk(array, process, context)函数分析:
    • chunk(array, process, context)接受三个参数:
      • array:要处理的项目的数组。
      • process:用于处理项目的函数。
      • context:可选的运行该函数的环境。
    • 在数组分块模式中,array 变量本质上就是一个 “待办事宜”列表,它包含了要处理的项目。
    • 使用 shift() 方法可以获取队列中下一个要处理的项目,然后将其传递给某个函数。
    • 如果在队列中还有其他项目,则设置另一个定时器,并通过 arguments.callee 调用同一个匿名函数。
  • 补充:
    • 由于数组是引用类型,如果你想保持原数组不变,则应该将该数组的克隆传递给 chunk()【即:某数组.concat()】。
    • 数组分块的重要性在于它可以将多个项目的处理在执行队列上分开,在每个项目处理之后,给予其他的浏览器处理机会运行,这样就可能避免长时间运行脚本的错误。
    • 一旦某个函数需要花 50ms 以上的时间完成,那么最好看看能否将任务分割为一系列可以使用定时器的小任务。

四、定时器应用 - 函数节流

4.1 函数节流介绍

  • 浏览器中某些计算和处理要比其他的昂贵很多。
  • 例如:DOM 操作比起非 DOM 交互需要更多的内存和 CPU 时间。
    • 连续尝试进行过多的 DOM 相关操作可能会导致浏览器挂起,有时候甚至会崩溃。
    • 尤其在 IE 中使用 onresize 事件处理程序的时候容易发生,当调整浏览器大小的时候,该事件会连续触发。
    • onresize 事件处理程序内部如果尝试进行 DOM 操作,其高频率的更改可能会让浏览器崩溃。
  • 为了绕开这个问题,你可以 使用定时器对该函数进行节流
  • 函数节流基本思想:某些代码不可以在没有间断的情况连续重复执行
    • 第一次调用函数,创建一个定时器,在指定的时间间隔之后运行代码。
    • 当第二次调用该函数时,它会清除前一次的定时器并设置另一个。
    • 如果前一个定时器已经执行过了,这个操作就没有任何意义。
    • 然而,如果前一个定时器尚未执行,其实就是将其替换为一个新的定时器。
    • 目的是只有在执行函数的请求停止了一段时间之后才执行。
  • 函数节流基本模式:
var processor = {
 timeoutId: null,
 
 //实际进行处理的方法
 performProcessing: function(){
 //实际执行的代码
 },
 
 // 初始化任何处理所必须调用的
 process: function(){
 // 第一步是清除存好的 timeoutId,来阻止之前的调用被执行。 
 clearTimeout(this.timeoutId);
 
 // 保存 this 的引用
 var that = this;
 
 this.timeoutId = setTimeout(function(){
  // 创建一个新的定时器调用 performProcessing()
  that.performProcessing();
 }, 100);  
 }
};

//尝试开始执行
processor.process();
  • 时间间隔设为了 100ms,这表示最后一次调用 process() 之后至少 100ms 后才会调用 performProcessing()
  • 所以如果 100ms 之内调用了 process() 共 20 次,performanceProcessing()仍只会被调用一次。

4.2 throttle()函数

  • 使用 throttle() 函数来简化上述模式,这个函数可以自动进行定时器的设置和清除。

  • throttle(fn, context)

    • fn::要执行的函数。
    • context:函数在哪个作用域中执行。
function throttle(method, context) {
 clearTimeout(method.tId);
 
 method.tId= setTimeout(function(){
  method.call(context);
 }, 100);
} 
  • 上述代码分析:
    • 首先清除之前设置的任何定时器。
      • 定时器 ID 是存储在函数的 tId 属性中的,第一次把方法传递给 throttle()的时候,这个属性可能并不存在。
    • 接下来,创建一个新的定时器,并将其 ID 储存在方法的 tId 属性中。
    • 如果这是第一次对这个方法调用 throttle()的话,那么这段代码会创建该属性。
    • 定时器代码使用 call() 来确保方法在适当的环境中执行。
    • 如果没有给出第二个参数,那么就在全局作用域内执行该方法。

4.3 throttle()的应用

  • 节流在 resize 事件中是最常用的。
  • 如果你基于该事件来改变页面布局的话,最好控制处理的频率,以确保浏览器不会在极短的时间内进行过多的计算。
  • 例如,假设有一个 div 元素需要保持它的高度始终等同于宽度。
window.onresize = function(){
 var div = document.getElementById("myDiv");
 div.style.height = div. offsetWidth + "px";
};
  • 上述代码分析:
    • 这段非常简单的例子有两个问题可能会造成浏览器运行缓慢。
      • 首先,要计算 offsetWidth 属性,如果该元素或者页面上其他元素有非常复杂的 CSS 样式,那么这个过程将会很复杂。
      • 其次,设置某个元素的高度需要对页面进行回流来令改动生效。【如果页面有很多元素同时应用了相当数量的 CSS 的话,这又需要很多计算。】
  • 解决方法 – throttle()
function resizeDiv(){
 var div = document.getElementById("myDiv");
 div.style.height = div.offsetWidth + "px";
}

window.onresize = function(){
 throttle(resizeDiv);
}; 

补充:只要代码是周期性执行的,都应该使用节流,但是你不能控制请求执行的速率。这里展示的 throttle()函数用了 100ms 作为间隔,你当然可以根据你的需要来修改它。


对于本文内容有问题或建议的小伙伴,欢迎在文章底部留言交流讨论。