问题的提出

JavaScript 作为一门主要在浏览器中使用的编程语言,我们已经再熟悉不过了,那么 JavaScript 究竟是什么?它是如何在浏览器环境中工作的?在 JavaScript 中存在大量的异步事件处理,他们又是如何完成的?

如果你参考官方对 JavaScript 语言的定义,你大概会看到类似下面这样的描述:

a single-threaded non-blocking asynchronous concurrent runtime
单线程非阻塞异步同时进行的运行时

What?JavaScript 是如何实现异步处理机制的?这样的解释似乎并没有什么帮助。就像大部分脚本式编程语言一样,程序员编写的代码需要解释器对其进行解释执行,所有的代码将会一步一步按顺序来执行。那么当 JavaScript 遇到耗时较长的操作时,例如读取 I/O 或发送网络请求,浏览器就会卡在那里,等待操作返回结果,然后再继续向下执行。可当前使用浏览器预览网页时,我们似乎并没有感觉到这样长时间的卡顿,体验相对是很流畅的,JavaScript 是如何做到这些的?如何绕开这些耗时的处理过程?

答案显而易见是异步处理,及将这些耗时的操作设置为异步操作,这样就可以在处理诸如网络请求的同时,还能继续流畅地执行下面的代码。

嗯……这似乎解释了一些事情,但好像还不是很清楚 JavaScript 究竟做了什么?对于有过其他编程经验的人可能会想到,通常并行处理的任务都会交给程序中新开启的线程去处理,不同的线程可以独立的并行执行,相互之间不会发生冲突。这是一个很好的解决办法,但是等等,JavaScript …… single-threaded,单线程。哦,好吧,JavaScript 生来就被设计成了单线程的模式,一方面是为了减少语言复杂性,另一方面也是防止多线程操作 DOM 是会产生的冲突,因此只允许一个线程。所以说 JavaScript 只能一行一行代码的执行,所以,我们的问题依然没有得到满意的答案。

如果我们去问 JavaScript,它究竟是如何工作的?它大概会回答:

I have a call stack, an event loop, a callback queue, some other APIs and stuff
我有一个调用栈,一个事件循环,一个回调序列,还有一些其他的 API 之类的。

又是一个问号,鉴于 JavaScript 是运行在浏览器中的,例如 Chrome 的 V8 引擎专门用于解释执行 JavaScript 代码,我们能从它的文档中看到些什么。嗯……一个堆和一个栈,这就是全部了。好像我们绕了一大圈,又回到了原点,依然一无所知。

好了,我们不绕圈子了,下面来真正了解一下 JavaScript 运行时机制的真面目。

事件循环 Event Loop

首先上一个图(图片来自参考资料),这个图大体上说明了一切。

就是这些部分的联合运转组成的 JavaScript 的运行时。先不要着急,我们一步一步来说明。

调用栈 Call Stack

程序员对 Stack 这个概念应该是再熟悉不过了,一般情况程序的执行和函数调用都是使用调用栈(call stack)这个栈结构来实现的。程序每次调用一个函数,会先将当前运行环境保存并暂停,然后将需要调用的函数放入调用栈中,开始执行,当调用结束后,该函数出栈,程序返回调用函数的位置继续向下执行。
调用栈的知识请自行参考相关内容

正如上一节中我们提到的现象,发送一个网络请求一类的事情,被称为阻塞(blocking),正是由于调用栈的这种特性,单线程执行,导致非常差的用户体验。

现在,我们知道单独的调用栈是不能够完成异步机制的,这时就需要借助事件循环机制了。

下面,我们以 setTimeout(callback, 5000) 为例,来了解事件循环机制。

Web APIs 和任务队列 Task Queue

当我们在程序中调用 setTimeout 函数时,就按正常的流程一样,setTimeout 进入调用栈,单线程开始执行。

setTimeout 执行时,就是异步处理开始的关键,我们知道,setTimeout 不属于 ECMAScript 中的内容,而是 BOM 包含的内容。当 JavaScript 执行 setTimeout 时,它的内部将执行一个 WebAPI timer(),timer 是一个计时器函数,此时,JavaScript 在调用 WebAPI,就是将此时的任务交付给浏览器处理,在浏览器内部,将单独开辟一个线程,来执行 WebAPI 所承担的工作任务。由于 JavaScript 将计时的工作交给了浏览器的其他线程处理,该任务也就不再会阻塞调用栈了,程序将继续向下执行,继续进行函数调用的出栈入栈,执行下面的代码。

那么 timer 函数怎么样了?当时间到了之后,它是如何处理回调函数的?这就要用到任务队列 task queue 了。

当 timer 函数执行完成,即设定的时间到了,好,那么 WebAPI 会将回调函数放入到任务队列之中,至此异步任务执行结束。就是这么简单。如果有多个异步任务,则他们完成后的回调函数将依次进入任务队列,等待下一步的处理。(这里有涉及到了队列的数据结构,具体内容可参考相关资料。想不到 JavaScript 执行的过程中就涉及到两大主要的数据结构了,哈哈

到了现在这一步,我们知道了所谓的异步处理就是调用 WebAPI,将所有耗时的操作都交给浏览器的独立线程去处理,在任务执行后将返回的结果和回调函数放入任务队列中,等待下一步的执行。

那么下一步……

事件循环 Event Loop

现在,回调函数已经在任务队列中排好队了,等待 JavaScript 处理。这里有一条执行原则:

当 JavaScript 执行完调用栈中的所有代码,即调用栈已经为空,那么事件循环机制将检查任务队列中是否有排队等待被执行的回调函数,如果有,事件循环将任务队列排在队首的回调函数推入调用栈中,由 JavaScript 开始执行,执行完毕后,如果调用栈又一次空了,则再从任务队列队首中检查是否有回调函数,以此类推。

至此,整个 JavaScript 异步处理机制就完成了。事件循环和任务队列就是 WebAPI 向 JavaScript 通信的机制,整个循环和 JavaScript 有机地结合在一起,完成一次次的异步处理,使得 JavaScript 在运行过程中提供更好的用户体验。

我们是以 setTimeout 为例来进行讲解的,对于 DOM 事件也是同样的道理,注册事件的回调函数被交给了 WebAPI 处理,当触发如鼠标点击之类的事件时,WebAPI 就会将回调函数放入任务队列中,等待事件循环的调用。

总结

本文简要介绍了 JavaScript 中异步处理的机制和事件循环的过程。当然,这些只是最基本的内容,还有许多细节问题没有讨论。希望这些内容能够帮助你进一步理解 JavaScript,在遇到一些问题时,能有些思路。在以后对 JavaScript 的学习中,有豁然开朗感觉。

后记

本文主要是参考参考资料中的内容整理和叙述的,如果有遗漏或不清楚的地方,可以访问参考资料链接,了解更多内容。如有不足之处,请见谅,欢迎建议交流。

参考资料

Philip Roberts: Help, I’m stuck in an event-loop.

发表评论

电子邮件地址不会被公开。 必填项已用*标注