当前位置: 首页 > 科技观察

JavaScript是如何工作的?

时间:2023-03-16 15:16:09 科技观察

JavaScript的运行原理是面试时经常被问到的问题,但根据以往的面试结果,这部分只有不到20%的人能看懂。大部分同学热衷于学习一些Vue,React这样的框架,一些新的API,而忽略了语言的基础,这是一个很不好的现象。今天,我就带大家回顾一下JavaScript真正的工作原理。它不涉及深入的源代码分析。我只是希望你能用最简单的描述来理解整个过程。主要分为以下几个部分:解释和编译型语言JavaScript引擎EcmaScript和JavaScript引擎的关系运行时环境为什么是单线程调用栈执行过程JavaScript语言解析过程之前大家可能听说过解释型和编译型语言,JavaScript是一种解释型编程语言,那么什么是解释型语言呢?编程语言是用来写代码的,代码是给人看的。计算机只能理解机器代码(01010101),不能理解语言代码。将我们能看懂的代码转换成计算机可读的机器码有两种方式:解释和编译。编译语言编译语言可以直接转换成计算机处理器可以执行的机器代码。运行编译语言需要一个“build”步骤,每次更新代码都得重新“build”。它们将比解释语言执行得更快、更有效。还可以更好地控制硬件,例如内存管理和CPU使用率。但是,完成整个编译步骤需要额外的时间,并且生成的二进制代码对平台有一定的依赖性。常见的编译型语言有C、C++、Erlang、Haskell、Rust和Go。解释型语言解释型语言是逐行解释和执行程序的每个命令的解释器。由于在运行时翻译代码的开销,解释型语言过去比编译型语言慢得多。不过,随着即时编译的发展,这个差距正在缩小。不过解释型语言比较灵活,一般可以动态植入,程序比较小。另外,由于源代码是由解释器自己执行的,所以代码本身是独立于平台的。常见的解释型语言有PHP、Ruby、Python和JavaScript。最后我们来看一下,谁来编译?谁来解释一下?谁来执行?编译型:由编译器编译,由系统执行。解释:解释器解释并执行。JavaScript引擎JavaScript是一种解释型编程语言,因此源代码在执行前不会编译成二进制代码。那么计算机是如何理解和执行纯文本脚本的呢?这是JavaScript引擎的工作,也就是我们上面提到的解释器。JavaScript引擎是执行JavaScript代码的计算机程序。基本上所有现代浏览器都有内置的JavaScript引擎。当我们的浏览器加载JavaScript文件时,JavaScript引擎会解析(将其转换为机器代码)并从上到下执行文件的每一行。每个浏览器都有自己的JavaScript引擎,其中最著名的是Google的V8。GoogleChrome和Node.js的JavaScript引擎都是V8。下面是一些其他常见的引擎:SpiderMonkey:由Firefox开发,第一个用于Firefox的JavaScript引擎。Chakra:由Microsoft开发,用于MicrosoftEdge。JavaScriptCore:由Apple为webkit类型的浏览器开发,例如Safari所有JavaScript引擎都会包含一个调用堆栈和一个堆:内存堆-这是内存分配发生的地方,一个非结构化的内存池,用于存储我们的应用程序需要的所有对象。调用栈——是我们的代码真正执行的地方EcmaScript和JavaScript引擎的关系ECMAScript指的是语言标准和JavaScript的语言版本,比如ES6表示语言(标准)的第6版。它是由一个促进JavaScript开发的委员会制定的。这个委员会指的是TechnicalCommitteeNo.39,我们一般简称为TC39。JavaScript引擎的核心是实现ECMAScript标准,同时也提供了一些额外的机制(比如V8提供的垃圾回收器)。一些最新的ECMAScript提案将在到达stage3或stage4后由JavaScript引擎实现。例如,v8会在它的博客上更新它的一些语言标准的实现:https://v8.dev/runtimeenvironmentJavaScript引擎不能孤立地运行。它需要良好的运行环境才能发挥更大的作用。比如Node.js就是一个JavaScript运行环境,各种浏览器也是JavaScript运行环境。这些运行时环境通常提供额外的功能,例如事件处理、网络请求API、回调队列或消息队列以及事件循环。那么JavaScript引擎如何在运行时环境中使用这些能力呢?我们以Chrome为例。Chrome是一个多进程架构。当我们打开浏览器时,会启动多个不同的进程来帮助浏览器为我们呈现页面:前向、后向、收藏等)、网络资源管理、下载等。插件流程:负责各个第三方插件的使用。每个第三方插件在使用时都会创建一个相应的进程。这样可以防止第三方插件崩溃影响整个浏览器。也方便使用沙盒模型隔离插件进程,提升浏览体验。设备稳定性。GPU进程:负责3D渲染和硬件加速渲染进程:浏览器会给每个窗口分配一个渲染进程,也就是我们常说的浏览器内核,可以防止单个页面崩溃影响整个浏览器。我们常说的浏览器内核,比如webkit内核,就是浏览器的渲染进程。从接收到下载的文件到渲染整个页面,浏览器渲染进程负责。浏览器内核是多线程的。在内核的控制下,各个线程相互协作,保持同步。一个浏览器内核通常由以下常驻线程组成:GUI渲染线程:负责渲染浏览器界面的HTML元素,当界面需要重绘(Repaint)或某些操作触发回流时,该线程会执行.定时触发线程:浏览器定时计数器不被JavaScript引擎统计,因为JavaScript引擎是单线程的,如果处于阻塞线程状态,会影响定时的准确性,所以比较方便使用单独的线程来计时和触发计时合理的解决方案。事件触发线程:当事件触发时,线程会将事件添加到待处理队列的末尾,等待JS引擎处理。这些事件可以是当前执行的代码块如定时任务,也可以是来自浏览器内核的其他线程如鼠标点击、AJAX异步请求等,但是由于JS的单线程关系,这些事件都得排队供JS引擎处理。异步http请求线程:连接XMLHttpRequest后,通过浏览器开启一个新的线程请求。当检测到状态变化时,如果设置了回调函数,异步线程会产生一个状态变化事件,放入JavaScript引擎的处理队列中进行处理。JavaScript引擎线程:解释和执行JavaScript代码。GUI渲染线程和JavaScript引擎是互斥的。当JavaScript引擎执行时,GUI线程会被挂起,GUI更新会被保存在一个队列中,当引擎线程空闲时立即执行。JavaScript是一种单线程编程语言,因此浏览器核心中只有一个JavaScript引擎线程。但是,在一个JavaScript运行环境中,可能存在多个JavaScript引擎线程,因为可能存在多个渲染进程。详见这篇文章:浏览器如何调度进程和线程?为什么是单线程那么为什么JavaScript不设计成多线程的呢?这不是更有效率吗?作为一种浏览器脚本语言,JavaScript的主要目的是与用户交互和操作DOM。这就决定了它只能是单线程的,否则会带来非常复杂的同步问题。例如,假设JavaScript同时有两个线程,一个线程向某个DOM节点添加内容,另一个线程删除该节点,那么浏览器应该以哪个线程为基础呢?因此,为了避免复杂性,JavaScript从一开始就是单线程的,这已经成为这门语言的核心特征,以后也不会改变。那么既然JavaScript本身就是设计成单线程的,为什么还要有WebWorker这样的多线程API呢?来看看WebWorker的核心特性:JS引擎在创建Worker时,向浏览器申请开启一个子线程(子线程由浏览器开启,完全由主线程控制,不能操作DOM)。Worker线程通过特定的方式进行通信(postMessageAPI,需要序列化对象来与线程进行特定数据的交互),所以WebWorker并没有违背JS引擎作为单线程的初衷,其主要目的是降低CPU-intensive计算类逻辑的负担。在单线程上运行代码非常容易,而且您不必处理多线程环境中出现的复杂场景——例如死锁。调用堆栈执行JavaScript是一种单线程编程语言,这意味着它有一个调用堆栈并且一次只能做一件事。调用栈是一种数据结构,基本上记录了我们在程序中的位置。如果我们执行一个函数,它会被放在栈顶。如果我们从一个函数返回,它会从栈顶弹出,这就是调用栈的运行方式。下面的动画很好地解释了整个过程:调用堆栈中的每个条目称为堆栈帧。当调用堆栈中的堆栈帧需要花费大量时间来处理时,就会发生卡顿,因为浏览器无法执行任何其他操作。JavaScript代码的执行流程我们已经从宏观上看到了JavaScript调用栈是如何执行的,那么每一段代码又是如何解析和执行的呢?下面以V8为例,看一段JavaScript代码的解析和执行过程。上图展示了V8的大致工作流程。绘图非常复杂。让我们简化它。其实核心模块就是以下三个:解析器(Parser):负责将JavaScript代码转换成AST抽象语法树。解释器(Ignition):负责将AST转换为字节码,收集编译器需要的优化编译信息。编译器(TurboFan):使用解释器收集的信息将字节码转换成优化的机器码。执行JavaScript代码时,解析器首先将源代码解析成AST抽象语法树,解释器将AST转换成字节码,边解释边执行。然后编译器根据解释器反馈的信息对字节码进行优化编译,最终生成优化后的机器码。这是V8的一般工作流程。词法分析和语法分析我们常说的词法分析和语法分析过程发生在解析器(Parser)执行阶段。词法分析是将字符序列转换为标记序列的过程。所谓token就是源文件中的一串不能再分割的字符,类似于英文中的单词或者中文中的单词。一般来说,编程语言中的记号包括:常量(整数、小数、字符、字符串等)、运算符(算术运算符、比较运算符、逻辑运算符)、分隔符(逗号、分号、括号等)、保留单词、标识符(变量名、函数名、类名等)等。比如下面的代码:const公众号='微信公众号名';经过词法分析后会转化为如下的token:const(保留字)公众号(变量名)=(赋值运算符)'微信公众号名'(字符串常量)语法分析将这些token转化为AST语法规则:{"type":"Program","start":0,"end":23,"body":[{"type":"VariableDeclaration","start":0,"end":23,“声明”:[{“类型”:“VariableDeclarator”,“开始”:6,“结束”:22,“id”:{“类型”:“标识符”,“开始”:6,“结束”:9,"name":"公众号"},"init":{"type":"Literal","start":12,"end":22,"value":"微信公众号名","raw":"'微信公众号名'"}}],"kind":"const"}],"sourceType":"module"}在生成AST的同时也会为代码生成一个执行上下文。在解析过程中,函数体中声明的所有变量和函数参数都被放入作用域中。如果是普通变量,默认值为undefined,如果是函数声明,则指向实际的函数对象。字节码和机器码有了AST和执行上下文,解释器就会把AST转换成字节码执行,那么字节码和机器码有什么区别呢?机器码(machinecode),机器语言指令的学名,有时也称为本机代码(NativeCode),是计算机的CPU可以直接解释的数据(计算机只知道0和1)。字节码(bytecode)是包含可执行程序的二进制文件,由一系列OP码(operationcode)/数据对组成。字节码是一种中间码,比机器码更抽象,需要经过解释器翻译才能成为机器码的中间码。与机器码相比,字节码不仅占用内存少,而且生成字节码的速度非常快,提高了启动速度。那么什么时候使用机器码呢?我们在文章开头提到,随着即时编译的发展,解释型语言和编译型语言的运行速度差距正在缩小。同时采用了解释执行和编译执行两种方式。这种混合方式被称为JIT(即时编译),V8就使用了这种技术。在解释器执行字节码的过程中,如果有热点代码,比如一段代码被反复执行,这就称为热点代码,那么后台编译器会把热点的字节码编译成高效的机器码,然后再执行这段优化后的代码时,只需要执行编译后的机器码,大大提高了代码的执行效率。最后,当然,如果你想了解更详细的执行机制,可以看看V8源码。这篇文章主要带你了解各种概念,让你了解运行一段JavaScript背后的工作原理。如果您想更深入地了解,请查看以下文章:。https://zhuanlan.zhihu.com/p/383959486https://www.zhihu.com/question/268303059https://blog.devgenius.io/how-javascript-works-behind-the-scenes-88c546173f32