再了解JS之前,我们先要分清楚编译型语言和解释型语言的差异,以及解释器/引擎的概念,这对于后续深入JS运行机制,以及代码中的调优非常重要。
1. 编译型和解释型语言的特性
编译型语言是指在程序开发完毕后,通过相关工具把源代码一次性的编译成一个个可以独立执行的字节码文件或者其他二进制中间码文件,这种编译是整体编译。
比如Java代码通过javac编译成一个个class文件,再给到JVM转为机器码让CPU执行。
由于代码是在运行前就做好了编译工作,因此这种编译也叫静态编译。而解释型语言一般都被视为脚本语言,所谓脚本是指该语言不具备开发操作系统的能力(当然,这并不意味着编译型语言就具备开发操作系统的能力)。脚本通常是用于嵌入到某个环境中执行,这种环境称为宿主环境,就JS而言,它是浏览器脚本语言,运行在浏览器环境下。再比如Python、NodeJS也是属于脚本语言,它们也有自己的宿主环境,譬如Node环境。在这些宿主环境中,代码的解析执行都是通过环境内置的解释器来完成的。解释器其实也是把源代码解释编译为字节码或者二进制中间码,再进一步转为机器码给到CPU执行。只不过解释器不生成额外的字节码文件,这些字节码或二进制中间码都是存在内存中的。还需要注意的一点是,解释器一般都是逐行解释翻译源代码再让CPU执行,由于不事先生成独立的编译文件,因此这种解释编译也称为动态编译或运行时编译。
💁 编译型语言和解释型语言的差异!
编译型语言和解释型语言最大的差异就在于两者在把源代码进行解释或者编译为字节码或二进制中间码的过程中,其处理时机以及处理方式的不一样。除此外几乎没有什么分别。在早期,人们普遍认为编译型语言的运行速度速度高于解释型语言。因为是静态编译,程序运行前就已经被高度优化好了,而解释型语言的运行速度则比较低下,因为是运行时编译,代码逐行解释执行比较耗时。然而,随着技术的发展,解释型语言和编译型语言的边界已经变得越来越模糊。各种解释器被高度优化,并引入了JIT之类的即时编译技术。现代的解释型语言执行效率已经有了质的提升,甚至有的脚本语言执行效率优于某些编译型语言。
❗ 为什么代码需要编译为字节码或者二进制中间码?
无论是编译型语言还是解释型语言,我们总会看到,他们都在通过不同的手段把源代码转换或者编译为字节码或者二进制中间码,这是为什么呢?因为我们日常使用的语言,大多都是高级语言。高级语言是在底层语言例如C、C++的基础上做了一层封装抽象。底层语言一般都很复杂,涉及到硬件控件、内存管理等一系列复杂的问题。而高级语言几乎都把这些复杂的问题给我们处理好了,比如自动垃圾回、内存管理等、并且提供了大量简易的API给我们使用,不同的高级语言设计的用途也不一样。例如浏览器脚本JS用于操控浏览器行为、数据库查询SQL用于快速高效的查询数据库等。总之其目的都是简化开发者的负担,提升开发效率。但是我们的源代码都是计算机不可识别直接执行的,因此需要转换或者编译为计算机CPU能执行的机器码。而字节码或者二进制中间码都是通过虚拟机和解释器等技术手段针对我们的源代码进行高度优化所产生的。其目的就是为了高度优化、动态调整代码、提升代码健壮性、确保代码质量和运行效率。
❗ 字节码/二进制机器码依然不能被机器直接执行,为什么不直接编译为机器码?
不同的操作系统平台,其硬件架构并不是一致的,CPU所能执行的指令,无论是算术运算、逻辑运算、输入输出都是事先预设的。每个平台下的CPU的指令集都有差异。而字节码是一种无关平台架构的中间码,这种跨平台性的设计可以更快更优的把代码转为机器码。虽然浏览器中JS引擎、JVM等虚拟机或者解释器都做了跨平台的设计和适配,但依旧不采用直接将源码转为对应平台的机器码的方式,本质核心依旧是为了对源代码进行最大程度的进行动态优化(比如热点代码转换等),即使是前面提到的JVM,它也会在运行时,将字节码转为机器码,并在这个过程中进行优化调整。如果直接将源代码转为机器码,除了导致编译转换适配的复杂性、增加代码的运行耗时、还会挤压了代码运行前的高度优化的空间。
2. 引擎、解释器、编译器、解析器
解释型语言都是通过解释器执行,但实际上解释器一般由多个部件构成,比如代码解析器、即时编译器等,它们组合在一起完成代码解析、优化、编译等工作。之所以叫解释器,只不过是一个大众化语义化的叫法,是对这些部件所做工作的一个统称。我们也可以把这些部件统一称呼为其他形容词、例如引擎等等。浏览器内置了JS引擎,用于解析执行JS代码。各个主流浏览器的JS引擎不一样,例如:
Chrome的V8引擎、Firfox的 SpiderMonkey引擎、Safari的Nitro引擎;最著名的当属V8,V8内部有解析器、解释器、即使编译器、垃圾回收器等部件
引擎一般都是基于C、C++开发的,因为这两门语言是低级语言,它们可以操作内存的分配和释放、以及操控硬件、调用操作系统API(I/O、网络等操作),以此实现和计算机系统交互的目的。
3. JS引擎的工作流程
需要注意的是,前面讲到过,在计算机底层来说执行代码的是硬件CPU。也就是说JS引擎本身实际上并不执行代码、也不与网页交互,它的职责主要是把JS源码处理转成CPU可以直接执行的机器码交给CPU执行,因此JS引擎的工作流程中的执行代码指的是把源码转为机器码的过程而其他工作。其他工作如渲染、绘制等,是通过JS引擎和其他线程以及浏览器内部的其他组件一起协调完成。
这一点要特别注意。因为后续会涉及到浏览器渲染主线程的工作机制。
下面来简单拆解一下JS引擎的工作流程(这里就不考虑不同的引擎的实现了,以V8为例)
大体的流程分为两步,代码解析和代码执行两个阶段,每个阶段内都有着严格的步骤
1️⃣ 代码解析阶段
- 代码加载
浏览器通过网络请求,把JS代码加载到内存中
- 词法分析
V8内部的解析器将源代码分成各个tokens-标记,比如关键字、运算符、标识符等
在这个阶段,如果遇到非法字符不符合JS标准语法的问题,引擎会立即抛出错误,阻断线程。这一点,无论在浏览器或Node环境都是一样的,因为JS是单线程设计。该阶段抛出的错误一般是语法错误SyntaxError
- 语法分析
V8把词法分析阶段形成的tokens-标记,进一步解析成抽象语法树(简称AST)。这种树结构,有利于加快引擎对代码的理解和优化。
AST-抽象语法树:
这是一种将代码解析成嵌套节点的树形结构,主要是描述代码的语法和语义。在现代化的前端开发生态中,一些知名的工具、库比如Babel、Eslint在进行代码转换或分析时都会涉及到JS代码转换,它们往往也会形成AST,它们采用EStree规范进行抽象解析。 EStree 是一个社区标准,基于ECMA语法规则,用于定义AST的结构。
引擎并不采用Estree,而是采用自己的规范进行解析形成AST。但是其实现和目的都是差不多的。
这里要补充一点,V8并不是通篇执行AST解析的,而是根据内部的按需策略,生成特定的AST,有点类似模块按需加载的感觉,其目的是为了加快代码执行,快速启动程序。这里只是了解一下即可,引擎内部实现异常复杂。
- 生成字节码
AST形成后,引擎内部的编译器会把AST转位字节码或二进制中间码,为代码执行做好准备。
2️⃣ 执行阶段
- 解释执行
解释器将字节码逐条翻译成机器码,这一步相对较慢,因为代码需要逐条翻译
- 即时编译
引擎内部还采用了JIT编译器,通过即时编译技术,将频繁运执行的热点代码,转位机器码缓存起来,下次执行时直接给到CPU,进一步提升代码执行效率
3️⃣ 垃圾回收
垃圾回收是JS引擎非常重要的优化机制,简单来说,就是把内存的无用数据被清理掉,保证程序运行时有足够的内存空间进行数据的再分配。的这里暂不涉及,在了解更多的高级概念后再说明。
4. 预编译
在引擎的工作流程中,还有非常重要的一个阶段,这个阶段涉及到JS核心特性,因此值得单独深入讲解。在执行上下文一章中将深入说明。
5. JS单线程设计
1️⃣语法层面:
单线程是JS语言的一个重要特性,ECMA规范中明确定义了JS没有线程级别的语法支持,也没有相关创建、管理线程的API。也就是说在早期设计时就规定了JS是线性调用的。
为什么设计单线程?
设计之初,JS就是为浏览器网页添加交互、动态更新内容。而这些操作会涉及到修改DOM和CSSOM。因此,需要一个线性的环境来确保代码的执行顺序,避免多个线程同时修改DOM、CSSOM引发冲突。
如果是多线程,还需要额外设计线程锁等复杂机制,然而这针对网页操作这不是不必要的,因此早期设计时,就规定了JS是单线程,从而避免复杂的同步问题。
2️⃣浏览器和引擎层面:
除了语言层面的设计因素,浏览器和引擎层面的实现也是一个重要因素,JS主要运行在浏览器环境,虽然现代浏览器是多进程、多线程架构的,但是浏览器规定JS引擎只能运行在渲染主线程上。现代JS引擎,如V8都是遵循单线程为核心而设计的。V8内部使用了一个调用栈来管理函数调用,确保程序单线程执行。
5.1 单线程是否阻塞❗❗
首先明确一点,无论是在浏览器环境还是Node环境,JS单线程是不会阻塞的。造成它不阻塞的原因,并非JS本身。而是得益于宿主环境的几乎完美的单线程异步不阻塞运行机制。两个环境的不阻塞机制的实现有一定差异,但原理都差不多,前端层面着重理解浏览器环境下的机制,可移步至WEB浏览器章节深入阅读。
5.2 调用栈概述
调用栈是一个复杂的数据结构,V8用它来管理函数调用顺序和执行上下文,每次执行任务,引擎都会把其上下文压入栈中,栈中任务未执行完时,其他任务将无法处理,任务执行完毕后、上下文弹出栈(销毁)。
这里补充一些经典场景:
栈溢出、超出最大调用栈大小、死循环均和调用栈有关。后续详解,需要具备语言基础才能理解
5.3 JS是否可以设计为多线程
技术层面来讲,V8完全可以设计多调用栈,但是这违背了JS语言规范和浏览器规定。假设JS设计为多线程,那么引擎就得设计为多调用栈,因为一个线程需要对应一个调用栈用来管理对应的数据变量和函数,这会产生很多复杂的问题,同时语言层面和浏览器层面的设计都要做大规模变更。因此,JS单线程本质几乎已经注定。
随着浏览器的发展,用户的需要,单线程的设计限制了JavaScript在运行复杂计算或处理大量数据时的并发性能,为了利用现代计算机多核CPU的计算能力,HTML5 提出了web worker标准,标准规定JS可以开启新的子线程用于执行需要大量计算的任务,比如这种实时语音、视频会议等场景,其本质是借助浏览器的多线程能力,实现JS多线程而不阻塞页面,但worker标准规定,子线程中JS不能操作DOM等核心对象,所以本质上web worker并没有改变JS单线程的核心特性。
另外:webWorker中不能使用XMLHttpRequest,因为XHR是基于DOM以及WINDOW。但是FetchAPI可以,因为Fetch是基于浏览器的。