深入理解 JavaScript 运行机制

5,607 阅读8分钟

想要理解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权威指南》