JavaScript 工作原理 —— 引擎、运行时和调用栈概述
潘忠显 / 2021-04-01
“JavaScript 工作原理”系列文章是翻译和整理自 SessionStack 网站的 How JavaScript works。因为博文发表于2017年,部分技术或信息可能已经过时。本文英文原文链接,作者 Alexander Zlatkov,翻译 潘忠显。
随着 JavaScript 越来越流行,其在不同技术栈中都得到了一定程度的支持,比如前端(front-end)、后端(back-end)、混编 App(Hybrid App)、嵌入式设备等技术栈。
GitHut stats 中显示:GitHub上的活跃仓库 (Active Repository) 和总推送数 (Total Pushs) 而言,JavaScript排名第一。在其他维度的统计中,JavaScript 排名也相对靠前。点击查看最新的GitHub编程语言统计数据。
如果项目越来越依赖 JavaScript,开发人员只有深刻地理解其运行原理、利用语言及其生态提供的能力,才能构建出色的软件。
事实上,很多开发人员每天都在使用 JavaScript,但却不了解背后发生了什么。
概述
几乎每个人都听过 V8 引擎这一概念,大多数人也知道 JavaScript 是单线程的、使用回调队列。
本文将介绍这些概念,并解释 JavaScript 的实际运行方式。通过了解这些细节,您将能够正确利用所提供的 API,编写出更好的、非阻塞的应用程序。
如果您是JavaScript的新手,本文将帮助您了解:与其他语言相比,JavaScript 为何如此“古怪”。
如果您是一位经验丰富的 JavaScript 开发人员,希望本文会为您带来 JavaScript Runtime 实际工作方式的新见解。
JavaScript 引擎
Google V8 引擎是一个流行的 JavaScript 引擎例子,被用于 Chrome 和 Node.js,可以简单的描述为:
引擎包含两个主要组件:
- 内存堆(Memory Heap):分配内存的地方
- 调用栈(Call Stack):执行代码栈帧的地方
运行时 (The Runtime)
几乎所有的 JavaScript 的开发者都会使用浏览器的 API,比如 setTimeout
。但引擎不提供这些API,这些API是从哪里来的呢?
其实除了引擎之外,还有浏览器提供的额外的 Web API(比如 DOM、AJAX、setTimeout 函数等),还有广为人知的“事件循环(event loop)”和“回调队列(callback queue)”:
调用栈 (The Call Stack)
JavaScript 是一种单线程编程语言,这意味着它只有单独一个调用栈。因此,同一时间只能干一件事件。
调用栈实际上是记录 程序运行到何处 的一种数据结构,栈能做的只有两件事:
- 如果我们进入一个函数,我们会将函数的上下文放在栈顶
- 如果我们从函数中返回,我们会将函数的上下文从栈中pop出来
我们来看一个例子,代码如下:
function multiply(x, y) {
return x * y;
}
function printSquare(x) {
var s = multiply(x, x);
console.log(s);
}
printSquare(5);
当引擎刚开始执行这段代码时,调用栈是空的,然后会按以下步骤,进行入栈和出栈:
调用栈中每一个条目(灰色填充),被称之为栈帧 (Stack Frame)。
当一个异常被抛出时,栈跟踪就被构造出来,它基本上就是异常发生时,调用栈的状态。比如运行下面这段代码:
function foo() {
throw new Error('SessionStack will help you resolve crashes :)');
}
function bar() {
foo();
}
function start() {
bar();
}
start();
在Chome中运行这段代码(假设代码存在foo.js文件中),会造成下边的栈跟踪:
栈溢出(Blowing the stack) 概念是说调用栈尺寸超出了最大限制。这种情况很容易发生,尤其是在使用递归而又没有认真的测试。比如这段代码:
function foo() {
foo();
}
foo();
当引擎执行这段代码时,先从函数 foo()
开始,然而 foo()
递归调用自己却没有终止条件。因此每一步执行,相同的栈帧会被压入调用栈,栈越来越高:
当调用栈中的函数调用个数超过了调用栈的实际大小,浏览器就会采取抛出错误的行为,类似于:
单线程上运行代码处理起来会相对简单,因为您无需处理因多线程环境引起的一些复杂场景,最常见的如死锁。
另一方面,单线程也有很多限制。因为只有一个调用栈,当某一个函数非常慢的时候,会发生什么情况呢?
并发 & 事件循环
当调用栈中有函数花费大量的时间,会发生什么情况。比如,你想在浏览器中使用 JavaScript 进行复杂的图像转换。
也许你会问:为什么这会是个问题?当调用栈有函数在执行,浏览器就被阻塞了,干不了其他事情。这意味着浏览器不能渲染,也不能运行其他代码,只是卡在那儿,会影响应用的流畅度。
另外,一旦浏览器开始处理调用栈中的大量的任务,就会停止响应很长一段时间。大部分浏览器会通过抛出错误的方式,询问你是否想结束网页。
这不是最好的用户体验,对吧?那么我们如何在不阻塞UI、也不让浏览器无响应的情况下,执行重度代码呢?解决方法就是:异步回调(asynchronous callbacks)。
这个将会在“JavaScript 工作原理”系列文章《V8引擎透视 + 5个编写优化代码的技巧》一文中详细解释。