浏览器和Node中的JavaScript是如何工做的? 可视化解释

原文地址: How JavaScript works in browser and node?javascript

有很是多满怀激情的开发者,他们搞前端或者搞后端,为JavaScript奉献本身青春和血汗。JavaScript是一种很是容易理解语言,毫无疑问它是前端开发中一个很是关键的部分。可是和其余语言不一样的是, 它是单线程的,这就意味着,同一时间只能有一个代码片断在执行。由于代码执行是线性的,若是当中有任何代码执行很长时间,将会阻塞后面须要被执行的代码。所以有时候你会在Google Chrome中看到这样的界面:前端



当你在浏览器打开一个网站时,它会使用一个JavaScript执行线程。这个线程负责响应一切操做,好比页面滚动、页面渲染、监听DOM事件(好比用户点击按钮)等等。可是若是JavaScript执行被阻塞了,那浏览器就什么事情也作不了,即意味着浏览器会呈现为卡死,没法响应的现象。java

不信你就在控制台输入试试:node

while(true){}
// ...
复制代码

你会上面语句以后的任何代码都不会被执行,这个‘死循环’会霸占着系统资源, 让浏览器没法响应用户操做. 无限递归调用也会出现这种状况, 不过下文会介绍,Javascript引擎对调用栈长度进行限制,无限递归会抛出RangeError异常, 而不会无休止地运行。git



感谢现代浏览器,如今不是全部打开的标签页都依赖于一个JavaScript线程。而是每一个标签页或者域名都会有独立的JavaScript线程。这样每一个标签页之间不会互相阻塞。好比你能够在Chrome中打开多个标签页,在某个标签页下执行上面的死循环,你会发现只有执行了上面语句的标签卡死,其余不受影响。github



调用栈(Call Stack)

为了可视化JavaScript 如何执行程序,咱们首先要理解JavaScript运行时。编程


和其余编程语言同样,JavaScript运行时有一个栈(Stack)和一个堆(Heap)存储器。后端

上图来源于[Fhinkel](Confused about Stack and Heap?)文章,关于栈和堆之间的差别讲得比较清晰. 举个例子:浏览器

在Java或者C#中, 值类型(primitives原始类型)存储在栈中,而引用类型(reference)则存储在堆中。C++规范没有规定栈和堆的内存分配,而是使用自动存储(automatic)期动态存储(dynamic)期来做区分,局部变量是自动存储期,编译器会将它们存储在栈中。而动态分配的对象则一般保存在堆中。放在栈中的数据会在函数执行完毕后自动回收,而放在堆中的对象,若是没有释放就会形成内存泄露缓存


本文不会深刻解释Heap,你能够看这里. 在本文咱们感兴趣的是栈,栈是一个LIFO(后进先出)的数据结构,用来保存程序当前的函数执行上下文, 换句话说,它表示的是当前程序执行的位置. 每次开始执行一个函数,就会将该函数推入栈中,当函数返回时从栈中弹出。 当栈为空时表示没有程序正在执行。因此栈经常也称为‘调用栈’

function baz(){
   console.log('Hello from baz')
}
function bar() {
   baz();
}
function foo() {
   bar();
}
foo();
复制代码

所以, 当上面的程序加载进内存时,会开始执行第一个函数,即foo。 所以第一个栈元素就是foo(), 由于foo函数会调用bar函数,第二个栈元素就是bar(); 同理bar函数会调用baz,第三个栈元素就是baz(). 最后,baz调用console.log,最后一个栈元素就是console.log('Hello from baz')

栈会在函数执行完毕时(到达函数底部或者调用return)弹出。而后继续执行函数调用后续的语句:


每一个栈元素中,元素的状态也被称为栈帧(Stack Frame). 若是在函数调用抛出错误,JavaScript会输出栈跟踪记录(Stack trace),表示代码执行时的栈帧的快照。

function baz(){
   throw new Error('Something went wrong.');
}
function bar() {
   baz();
}
function foo() {
   bar();
}
foo();
复制代码

上面的程序,咱们在baz中抛出错误,JavaScript会打印出栈跟中记录,指出错误发生的地方和错误信息。


栈的大小不是无限的。例如Chrome就会限定栈的最大为16,000帧。因此无限递归会致使Chrome抛出Maximum Call Stack size exceeded:



事件循环与Web API

由于JavaScript是单线程的,因此它只有一个栈和堆。所以,若是其余程序想要执行一些东西,须要等待上一个程序执行完毕

对比其余语言,这多是一个糟糕的设计,可是JavaScript的定位就是通用编程语言,而不是用于很是复杂的场景

考虑这样一个场景。假设浏览器发送一个HTTP请求到服务器,加载图片并展现到页面。浏览器会卡死等待请求完成吗?显然不会,这样用户体验太差了

浏览器经过JavaScript引擎来提供JavaScript运行环境。好比Chrome使用V8 引擎。可是浏览器内部可不仅有JavaScript引擎。下面是浏览器的底层结构:



看起来很复杂,可是它也很好理解。JavaScript引擎须要和其余2个组件协做,即事件循环(EventLoop)回调队列(CallbackQueue),回调队列也被称为消息队列或任务队列。

除了JavaScript引擎,浏览器还包含了许多不一样的应用来作各类各样的事情,好比HTTP请求、DOM事件监听、经过setTimeout、setInterval延迟执行、缓存、数据存储等等。这些特性能够帮助咱们建立丰富的Web应用。

想一下,若是浏览器只使用同一个JavaScript线程来处理上面这些特性,用户体验会有多糟糕。由于用户即便只是简单的滚动页面,背后是须要处理不少事情的, 单个Javascript线程压根忙不过来。所以浏览器会使用低级的语言,好比C++,来执行这些操做,并暴露简洁的JavaScript API给开发者。这些API统称为Web API

这些Web API一般是异步的。这意味着,你能够命令这些API在'后台'(独立线程)去作一些事情,完成任务以后再通知Javascript运行时. 在此同时,Javascript引擎会继续执行剩下的JavaScript代码. 在命令这些API在后台作事情时,咱们一般须要给它们提供一个回调。这个回调的职责就是在Web API完成任务后执行JavaScript代码。让咱们将上述的全部东西整合起来理解一下:

当你调用一个函数时,它会被推动栈中。若是这个函数中包含了Web API调用,JavaScript会代理Web API的调用, 通知Web API执行任务,接着继续执行下一行代码直到函数返回。一旦函数到达return语句或者函数底部,这个函数就会从调用栈中弹出来。

与此同时,若是Web API在后台完成了它的工做,且有一个回调和这个工做绑定,Web API会将消息结果和回调进行绑定,并推入到消息队列中(或者称为回调队列).

事件循环, 就像一个无限循环,它的惟一工做是检查回调队列,一旦回调队列中有待处理的任务,就将该回调推送到调用栈。不过由于Javascript是单线程的, 事件循环一次只能推送一个回调到调用栈,栈将会执行回调函数,一旦调用栈为空,事件循环才会将下一个回调函数推送到调用堆

事件循环的伪代码大概以下:

while(true) {
   let task
   while(task = popCallbackQueue()) {// 弹出回调队列任务
      executeTask(task) // 执行任务, 这里面可能会触发新的Web API调用
   }

   if (hasAnyPendingTask()) {
      sleep() // 睡一觉,有新任务推送到回调队列时时再唤醒我哦
   } else {
      break // 终止程序, 没什么好干的拜拜了
   }
}
复制代码

咱们经过setTimeout Web API这个例子一步一步看看上述的一切是怎么运做的。setTimeout Web API主要用于延时执行一些操做,可是回调真正被执行, 须要等待当前程序执行完毕(即栈为空), 也就是说,setTimeout函数回调执行时间未必等于你指定的延时时间。setTimeout的语法以下:

setTimeout(callbackFunction, timeInMilliseconds);
复制代码

callbackFunction是一个回调函数,它将会在timeInMilliseconds以后执行. 咱们修改上面的代码来调用setTimeout:

function printHello() {
    console.log('Hello from baz');
}
function baz() {
    setTimeout(printHello, 3000);
}
function bar() {
    baz();
}
function foo() {
    bar();
}
foo();
复制代码

上面的代码延时调用了console.log. 栈仍是会像以前同样,如foo() => bar() => baz(), 当baz开始执行并到达setTimeout时,Javascript会将回调函数传递给Web API,而且继续执行下一行。 由于这里没有下一行了,栈会弹出baz,接着弹出bar和foo。

在这期间,Web API正在进行3s等待,当时间到达时,它会将回调推动回调队列中。 由于这时候调用栈为空,事件循环会将这个回调推动栈中,并执行这个回调。


🎉🎉Philip Robers建立了一个神奇的在线工具Loupe,来可视化Javascript的底层运行。上面的实例能够查看这个连接🎉🎉

因此说咱们Javascript是单线程的,可是不少Web API的执行是多线程的。也就是说Javascript的单线程指的是‘Javascript代码’的执行是单线程.


Node.js

经过Node.js咱们能够作更多的事情, 而不只限于浏览器的端。那么它是怎么运做的?

Node.js 和Chrome同样,一样使用Google的V8引擎来提供Javascript运行时. 它使用libuv(C++编写)来和V8的事件循环配合,扩展更多能够在后台执行的东西, 好比文件系统I/O, 网络I/O。Node的标准库API遵循了浏览器Web API的相似回调风格。

若是你比较了浏览器和node的结构图,你会发现二者很是类似。右侧的部分相似于Web API,一样包含事件队列(或者称为回调队列/消息队列)和事件循环。

V八、事件循环、事件队列都在单线程中运行,最右侧还有工做线程(Worker Thread)负责提供异步的I/O操做。这就是为何说Node.js拥有非阻塞的、事件驱动的异步I/O架构。

上面的内容都来源于Philip Roberts30min的高光演讲(五年前)

本文完

相关文章
相关标签/搜索