深入理解Javascript定时器是如何工作的

[译]JavaScript中的定时器是如何工作的?

如有问题,欢迎指正

原文链接:http://ejohn.org/blog/how-javascript-timers-work/

在一个基础阶段,理解JavaScript定时器的工作原理的是非常重要的。通常它们看起来不那么直观,因为它们处于单线程中。让我们从我们将接触的这三个函数开始,它们是用来构造和操作计时器的。

  • var id = setTimeout(fn, delay) – 启动一个定时器,它将调用参数中的函数在延迟时间之后。这个函数将将返回一个独一无二的ID用来被指定以取消这个定时器。
  • var id = setInterval(fn, delay) – 和setTimeout()相似,但将持续地调用参数中的函数每间隔一个延迟时间,除非它被取消。
  • clearInterval(id)clearTimeout(id) – 传入一个定时器ID(即上述函数传回的ID)并停止定时器回调。

为了理解定时器内部是如何工作的,还有一个重要的概念需要理解就是:定时器的延迟时间是无法保证的。由于浏览器中的所有JavaScript都在单个线程上执行,因此异步事件(例如鼠标点击和计时器)只会在执行中出现打开时运行。这最好以图表来解释,如下图:

Timers

这张图中包含了大量的信息要研究,但理解它会让你对异步JavaScript执行是如何工作有一个更深刻的认识。这张图是一维的,在垂直方向我们能看到图中标着事件,以毫秒为单位。蓝色的盒子代表着JavaScript正在被执行的部分。例如第一块JavaScript大概执行18毫秒,鼠标点击那块大约11毫秒等等。

因为JavaScript一次只能执行一段代码(由于它的单线程特性),所以每个代码块都“阻塞”了其他异步事件的进度。这意味着当一个异步事件发生时(如鼠标点击,计时器触发或是XMLHttpRequest完成),它会排队等待稍后执行。(实际中排队是如何进行的,不同的浏览器情况不同,因此这里我们考虑的时候将它理想化)。

首先,在第一个JavaScript块中,启动了两个计时器,分别是10毫秒的setTimeout()和10毫秒的setInterval()。虽然我们可以清楚地看到它是何时何地计时器被触发,但实际上这发生在第一个代码块完成之前。但是,请注意,它并没有马上被执行(它不能这样做,因为线程的原因)。相反地,延迟功能被排队,以便在下一个可以被执行的时刻执行。

此外,在第一个JavaScript块中,我们看到发生了一个鼠标点击事件。于是与这个异步事件关联的JavaScript回调(由于我们不知道用户何时会执行一个动作,因此我们认为它是异步的)不能立即执行。因此和初始的定时器类似,它被排队等待执行。

在JavaScript完成执行浏览器的初始块之后立即提出了问题:接下来是什么等待被执行?此时鼠标点击程序和计时器回调都在等待。于是浏览器选择了一个(鼠标点击回调)并立刻执行了它。于是计时器将等到下一次可能执行的时间以便执行。

注意当鼠标点击程序在执行时,第一个间隔回调也执行了。与定时器一样,其处理程序也排队等待执行。但是注意,当间隔再次被触发时(同时计时器程序正在执行),这次将删除处理程序。如果在一大块代码正在被执行的时候将所有的间隔回调排队等待执行,那么当这一大块代码完成后将是一大堆间隔执行程序,并且它们之间没有延迟。相反,在给更多的间隔处理程序排队前,浏览器往往只是等待直到没有更多的间隔处理程序在排队。

事实上,我们可以看到,这是第三个间隔回调触同时间隔处理程序本身正在被执行的情况。从中我们可以发现一个事实,就是:间隔程序并不在意正在执行的是什么,它们会不加区别地排队,即使这意味着回调之间的时间会被牺牲。

最终,在第二个间隔回调被执行后,我们可以看到这里没有东西等待JavaScript引擎去执行。这意味着浏览器现在等待新的异步事件发生。这发生在50毫秒标记处,也就是间隔再次被触发。但这一次没有东西阻塞它的执行,所以它立刻被触发了。

让我们从下面的例子中更好的理解setTimeout()setInterval()之间的区别。

/* Some long block of code… */
setTimeout(arguments.callee, 10);
 }, 10);

setInterval(function(){
/* Some long block of code… */
 }, 10);

这两段代码乍一看在功能上看起来似乎是一样的,但实际上不是的。值得注意的是,在前一个回调执行之后,setTimeout()至少会有10毫秒的延迟。(它可能会更多,但至少不会少),而setTnterval()会尝试每10毫秒执行一次回调,而不考虑何时执行最后一次回调。

到目前为止我们学了很多,让我们总结一下:

  • JavaScript引擎只有一个线程,也就是单线程,迫使异步事件排队等待执行。
  • setTimeout()setInterval()在如何执行异步代码上有根本的不同。
  • 如果一个计时器被阻塞无法立即执行,它将被推迟到下一个可能执行的时刻(这将导致它比期望的延迟时间更长)。
  • 如果它将花费足够长的时间(超出了指定的推迟时间),间隔将会一个接着一个地无间隔执行。

所有的这些都是非常重要的知识,了解JavaScript引擎工作的原理,尤其是大量的异步事件发生的情况,为构建一个高级应用程序打下良好的基础。

深入理解JavaScript运行机制

深入理解JavaScript运行机制

前言

  • 本文是写作在给团队新人培训之际,所以其实本文的受众是对JavaScript的运行机制不了解或了解起来有困难的小伙伴。也就是说,其实真正的原理和本文阐述的并不完全符合,就如中学课本和大学课本一样,大学老师会告诉你高中的一些东西是在某些理想情况下得到的结论,本文同理。
  • 本文的目的是希望大家阅读之后能对JavaScript的运行机制有一个比较直观比较快的认识,但更重要的是自己动手实践,只有实践才能真正发现问题和得到提升:)
  • 收到了大家的支持和反馈,非常感谢:)

想要理解JavaScript的运行机制,需要分别深刻理解以下几个点:

  • JavaScript的单线程机制
  • 任务队列(同步任务和异步任务)
  • 事件和回调函数
  • 定时器
  • Event Loop(事件循环)

JavaScript的单线程机制

JavaScript的一个语言特性(也是这门语言的核心)就是单线程。什么是单线程呢?简单地说就是同一时间只能做一件事,当有多个任务时,只能按照一个顺序一个完成了再执行下一个。

JavaScript的单线程与它的语言用途是有关的。作为一门浏览器脚本语言,JavaScript的主要用途是完成用户交互、操作DOM。这就决定了它只能是单线程,否则会导致复杂的同步问题。

设想JavaScript同时有两个线程,一个线程需要在某个DOM节点上添加内容,而另一个线程的操作是删除了这个节点,那么浏览器应该以谁为准呢?

所以为了避免复杂性,JavaScript从诞生起就是单线程。

为了提高CPU的利用率,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以这个标准并没有改变JavaScript单线程的本质。

任务队列

一个接一个地完成任务也就意味着待完成的任务是需要排队的,那么为什么会需要排队呢?

通常排队有以下两种原因:

  • 任务计算量过大,CPU处于忙碌状态;
  • 任务所需的东西为准备好所以无法继续执行,导致CPU闲置,等待输入输出设备(I/O设备)。> 比如有的任务你需要Ajax获取到数据才能往下执行

由此JavaScript的设计者也意识到,这时完全可以先运行后面已经就绪的任务来提高运行效率,也就是把等待中的任务先挂起放到一边,等得到需要的东西再执行。就好比接电话时对方离开了一下,这时正好有另一个来电,于是你便把当前通话挂起,等那个通话结束后,再连回之前的通话。

所以也就出现了同步和异步的概念,任务也被分成了两种,一种是同步任务(Synchronous),另一种是异步任务(Asynchronous)。

  • 同步任务:需要执行的任务在主线程上排队,一个接一个,前一个完成了再执行下一个
  • 异步任务:没有马上被执行但需要执行的任务,存放在“任务队列”(task queue)中,“任务队列”会通知主线程什么时候哪个异步任务可以执行,然后这个任务就会进入主线程并被执行。> 所有的同步执行都可以看作是没有异步任务的异步执行

具体来说,异步执行如下:

  • 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。

    也就是所有能被马上执行的任务都在主线程上排好了队,一个接一个的被执行。

  • 主线程之外,还存在一个“任务队列”(task queue)。只要异步任务有了运行结果,就在“任务队列”之中放置一个事件。

    也就是说每个异步任务准备好了就会立一个唯一的flag,这个flag用来标识对应的异步任务。

  • 一旦“执行栈”中的所有同步任务执行完毕,系统就会读取“任务队列”,看看里面有哪些事件。那些对应的异步任务,就结束等待装袋,进入执行栈开始被执行。

    也就是主线程把之前的任务做完了之后,就会来看“任务队列”中的flag,来把对应的异步任务打包来执行。

  • 主线程不断重复以上三步。

    只要主线程空了,就会去读取“任务队列”。这个过程会被不断重复,这就是JavaScript的运行机制。

事件和回调函数

事件

“任务队列”是一个事件的队列(也可以理解成是消息的队列),IO设备完成一项任务,就会在“任务队列”中添加一个时间,表示相关的异步任务可以进入“执行栈”。接着主线程读取“任务队列”,查看里面有哪些事件。

“任务队列”中的事件,除了IO设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等)。只要指定过回调函数,这些事件发生时就会进入“任务队列”,等待主线程读取。

回调函数

所谓“回调函数”(callback),就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。

“任务队列”是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,“任务队列”上第一位的事件就自动进入主线程。但是,如果包含“定时器”,主线程首先要检查一下执行时间,某些事件只有到了规定的时间,才能返回主线程。

Event Loop

主线程从“任务队列”中读取事件,这个过程是循环不断的,所以整个的运行机制又称为“Event Loop”(事件循环)

为了更好地理解Event Loop,下面参照Philip Roberts的演讲中的一张图。

Event Loop

上图中,主线程在运行时,产生了heap(堆)和stack(栈),栈中的代码调用各种外部API,并在“任务队列”中加入各种事件(click,load,done)。当栈中的代码执行完毕,主线程就会读取“任务队列”,并依次执行那些事件所对应的回调函数。

执行栈中的代码(同步任务),总是在读取“任务队列”(异步任务)之前执行。

var req = new XMLHttpRequest();
req.open('GET', url);
req.onload = function (){};
req.onerror = function (){};
req.send();

上面的代码中的req.send方法是Ajax操作向服务器发送数据,它是一个异步任务,意味着只有当前脚本的所有代码执行完,系统才会去读取“任务队列”。所以,它与以下的写法是等价的。

var req = new XMLHttpRequest();
req.open('GET', url);
req.send();
req.onload = function (){};
req.onerror = function (){};

也就是说,指定回调函数的部分(onload和onerror),在send()方法的前面或后面是无关紧要的,因为它们属于执行栈的一部分,系统总是执行完它们才会去读取“任务队列”。

定时器

除了放置异步任务的事件,“任务队列”还可以放置定时事件,即指定某些代码在多少时间之后执行。这叫做定时器(timer)功能,也就是定时执行的代码。

SetTimeout()setInterval()可以用来注册在指定时间之后单次或重复调用的函数,它们的内部运行机制完全一样,区别在于前者指定的代码是一次性执行,后者会在指定毫秒数的间隔里重复调用:

setInterval(updateClock, 60000); //60秒调用一次updateClock()

因为它们都是客户端JavaScript中重要的全局函数,所以定义为Window对象的方法。

但作为通用函数,其实不会对窗口做什么事情。

Window对象的setTImeout()方法用来实现一个函数在指定的毫秒数之后运行。所以它接受两个参数,第一个是回调函数,第二个是推迟执行的毫秒数。 setTimeout()setInterval()返回一个值,这个值可以传递给clearTimeout()用于取消这个函数的执行。

console.log(1);
setTimeout(function(){console.log(2);}, 1000);
console.log(3);

上面代码的执行结果是1,3,2,因为setTimeout()将第二行推迟到1000毫秒之后执行。

如果将setTimeout()的第二个参数设为0,就表示当前代码执行完(执行栈清空)以后,立即执行(0毫秒间隔)指定的回调函数。

setTimeout(function(){console.log(1);}, 0);
console.log(2)

上面代码的执行结果总是2,1,因为只有在执行完第二行以后,系统才会执行“任务队列”中的回调函数。

总之,setTimeout(fn,o)的含义是,指定某个任务在主线程最早可得的空闲时间执行,也就是尽可能早地执行。它在“任务队列”的尾部添加一个事件,因此要等到同步任务和“任务队列”现有的事件都处理完,才会的到执行。

HTML5标准规定了setTimeout()的第二个参数的最小值(最短间隔),不得低于4毫秒,如果低于这个值,就会自动增加。

需要注意的是,setTimeout()只是将事件插入了“任务队列”,必须等到当前代码(执行栈)执行完,主线程才会去执行它指定的回调函数。要是当前代码耗时很长,有可能要等很久,所以并没有办法保证回调函数一定会在setTimeout()指定的时间执行。

由于历史原因,setTimeout()setInterval()的第一个参数可以作为字符串传入。如果这么做,那这个字符串会在指定的超时时间或间隔之后进行求值(相当于执行eval())。

关于深入理解定时器的工作原理,这里推荐阅读jQuery的作者John Resig的一篇文章: http://ejohn.org/blog/how-javascript-timers-work/

我自己也翻译了这篇文章,如有问题,欢迎指正:http://guoxunique.com/2016/12/07/how-javascript-timers-work/

参考阮一峰老师的博文 http://www.ruanyifeng.com/blog/2014/10/event-loop.html

参考《JavaScript权威指南》

What you don’t know about console

本文参考:

https://segmentfault.com/a/1190000006721606
http://www.html5cn.org/article-9690-1.html

通常用法

大家都会有用log,但很少有人能够很好地利用console.error或console.warn等将输出到控制台的信息进行分类整理。他们功能区别不大,意义在于将输出到控制台的信息进行归类,或者说让它们更加语义化。

  • 打印字符串:console.log("普通信息");
  • 打印提示消息:console.info("提示性信息");
  • 打印警告消息:console.warn("警示信息");
  • 打印错误消息:console.error("错误信息");
  • 打印调试信息:console.debug("debug");

高级用法

查看所有方法

console除了上面的几个最常见的方法之外还有什么方法呢?log除了能打印字符串外,还能打印对象,我们可以利用console.log打印自己。

在Console输入:

console.log(console);

输出:

Console.log(console)

于是我们可以看到有这么多的console方法,但这不等于每个浏览器都能实现。所以说,这个特性是非标准的,请尽量不要在生产环境中使用它。

但是我们可以在开发环境中,合理的利用这些方法来帮助我们开发。

清理控制台

当我们在控制台调试时,有时候会需要清理控制台中的内容。浏览器和命令行clear一样,提供了一个清理函数console.claer()

console.clear()

当然我们也可以用chromecommand line api来清理控制台。

clear()

又或者可以使用按键:Mac上的cmd+k,Win上的ctrl+l(我使用的是chrome浏览器)。

分组

当代码非常长,或者我们需要把一个函数,或者一个文件中的函数等区分出来。我们可以使用分组来实现,这也是将分类管理的思想发挥到极致。这适合在开发一个规模很大模块很多很复杂的Web APP时,将各自的log信息分组到以各自命名空间为名称的组里面。

在Console输入:

console.group("group1");
console.log("aaa");
console.log("bbb");
console.groupEnd();

console .group("group2");
console.log("ccc");
console.log("ddd");
console.groupEnd();

输出:

console.group()

如果希望输出信息为折叠的,可以使用console.groupCollapsed,用法同console.group

查看对象信息

有时候我们需要打印出对象信心,可以使用console.log来进行简单的输出。

在Console输入:

var Obj = {
    proprety1: value1,
    property2: value2,
    property3: value3
};
console.log(Obj);

于是你会发现这个输出信息并不利于我们观察,于是我们可以使用console.table来帮助我们清楚的显示关联数组信息

var data = [
    {
        "proprety1": "value1",
        "property2": "value2"
    },
    {
        "proprety1": "value3",
        "property2": "value4"
    }
];
console.table(data);

输出:

console.table()

以及有的时候后端传回来一大串数据,直接console.log或是通过抓包工具查看或许都会让人晕头转向,这个时候也是console.table发挥作用的时候,以表格的形式呈现数据,一目了然。

但是如果想要看到详细的对象信息,我们可以使用console.dir,将一个JavaScript对象的所有属性和属性值显示成一个可交互的列表,它还能打印出函数等。

console.dir(clear);

console.dir将DOM节点以JavaScript对象的形式输出到控制台,而console.log是直接将该DOM节点以DOM树的结构进行输出,与在元素审查时看到的结构是一致的。

console.dir(document.body);
console.log(document.body);

如果你想要查看某个节点中的HTML代码,可以用console.dirxml来查看页面中对应元素的html/xml内容。

HTML代码:

<div id="flag">

message

</div>

JavaScript代码:

var flag = document.getElementById("flag");
console.dirxml(flag);

性能测试

有时候当我们完成了一段代码我们想要知道这段代码的性能如何。这是我们可以使用console.timeconsole.timeEnd,他们可以记录代码运行所花费的时间。

console.time("test");
(function () {
    for(var i = 0; i < 10; i++) {
        var sum = (function () {
            var flog = 0;
            for(var i = 0; i < 10; i++) {
                flog+=i;
            }
        })();
    }
})();
console.timeEnd("test");

所以这个性能测试的本质就是个计时器,无独有偶,还有一对叫做console.profileconsole.profileEnd

console.profile("test");
(function () {
    for(var i = 0; i < 10; i++) {
        var sum = (function () {
            var flog = 0;
            for(var i = 0; i < 10; i++) {
                flog+=i;
            }
        })();
    }
})();
console.profileEnd("test");

其输出结果会显示在profile中。

当想要查看CPU使用相关信息时,可以使用console.profile配合console.profileEnd来完成这个需求。
这一功能可以通过UI界面来完成,Chrome开发者工具里面有个tab便是Profile。使用方法和console.time基本一样,其实time开发者工具里也有个tab就是timeline。

此外,如果还想要知道运行时的结果栈,可以使用console.trace

在Console输入:

function add(num) {
    if (0 < num) {
        console.trace("现在num的值为", num);
        return num + add(num - 1);
    } else {
        return 0;
    }
}

var a =3;
add(3);

输出:

判断真假

平时我们在写代码时经常需要判断一下当前值的真假情况,使用if或者三元表达式来达到目的。我们现在也可以使用console.assert来判断,它会先对传入的表达式进行断言,只有表达式为false时,才会返回一个console.error的结果到控制台。

console.assert(1 == 1);
console.assert(1 == 0);
console.assert(!(1 == 0));

统计次数

有时候我们需要统计一个函数被调用了几次,我们通常会增加一个变量count来记录,然后在控制台中查看。这样相当的麻烦,我们可以使用console.count函数来帮忙我们记录次数,并输出。

function hi(name) {
console.count(name);
return "hi " + name;
}   

for(var i = 0; i < 10; i++) {
    if(i < 4) {
        hi("person");
    } else {
        hi("god");
    }
}

$

我们知道PHP中满屏都是$。而在Chrome里,$用处同样是多且方便。

2+2//回车,再
$_+1//回车得5

上面的$_需要领悟其奥义才能使用得当,而$0~$4则代表了最近5个你选择过的DOM节点。
这是什么意思呢?在页面右击检查(审查元素),然后在弹出的DOM节点树上点选,这些被点选的节点会被记录下来,而$0则会返回最近一次点选的DOM节点,以此类推,$1返回的是上上次点选的DOM节点,最多保存了5个,如果不够5个,则会返回undefined。

另外值得称赞的是,Chrome控制台中原生支持类jQuery的选择器,也就是说你可以用$加上熟悉的CSS选择器来选择DOM节点。

$("body");
$("div");

而$(selector)返回的实际上是满足选择条件的首个DOM元素。
深入一步,其实$(selector)是原生JavaScript document.querySeletor()的封装。
同时另一个命令$$(selector)返回的是所有满足选择条件的元素的一个集合,是对document.querySelectorAll()的封装。

$x(path)

将所匹配的节点放在一个数组里返回

$x("//p");
$x("//p[a]");

$x(path)匹配节点
$x("//p")匹配所有的p节点,$x("//p[a]");匹配所有子节点包含a的p节点

copy

copy(document.body)

然后你就可以Ctrl+V了。
注意:它不依附于任何全局变量比如Window,所以其实在JS代码里是访问不了这个copy方法的,所以从代码层面来调用复制功能也就无从谈起。

Keys & values

关于键值对,前者返回传入对象所有属性名组成的数组,后者返回所有属性值组成的数组。例如:

var tfboy={name:'wayou',gender:'unknown',hobby:'opposite to the gender'};
keys(tfboy);
values(thboy);

Keys&values

monitor & unmonitor

monitor(function),它接收一个函数名作为参数,比如function a,每次a被执行了,都会在控制台输出一条信息,里面包含了函数的名称a以及执行时传入的参数。而unmonitor(function)便是用来停止这一监听。

function sayHello(name){
    alert('hello,'+name);
}
monitor(sayHello);
sayHello('damonare');
sayHello(‘tjz');
unmonitor(sayHello);

monitor&unmonitor

debug & undebug

debug同样也是接收一个函数名作为参数。当该函数执行时自动断下来以供调试,类似于在该函数的入口处打了个断点,可以通过debugger来做到,同时也可以通过在Chrome开发者工具里找到相应源码然后手动打断点。而undebug则是接触该断点。

一个酷炫的黑魔法

console.log家族还提供了一个API:第一个参数可以带一些格式化命令,比如%c,\n;

console.log('%chello world', 'background-image:-webkit-gradient( linear, left top, right top, color-stop(0, #f22), color-stop(0.15, #f2f), color-stop(0.3, #22f), color-stop(0.45, #2ff), color-stop(0.6, #2f2),color-stop(0.75, #2f2), color-stop(0.9, #ff2), color-stop(1, #f22) );color:transparent;-webkit-background-clip: text;font-size:5em;');

console.log第一个参数带格式化命令

当然图片也是可以的,大家可以自行尝试,修改上述代码即可。

另外,console.log()接收不定参数,参数间用逗号分隔,最终输出会将它们以空白字符连接。

console.log接收不定参数

总结

console中有很多对我们调试代码有帮助的函数,在开发环境中使用console来调试代码,可以让我们的测试更加便利。