作者:KevinS你看到的第一个JavaScript通常不是前端程序员写的。相反,它很可能是由像webpack这样的工具生成的一个包,而且可能是一个相当大的包,其中包括一个UI框架,例如React,各种polyfill(用于模拟旧浏览器库中的新平台功能),以及在npm上找到的各种包。浏览器的JavaScript引擎面临的第一个挑战是将大量文本转换为可在虚拟机上执行的指令。因为它需要解析代码,而用户正在等待JavaScript进行交互,所以它必须要快。在高层次上,JavaScript引擎像其他语言编译器一样解析代码。首先,输入文本流被分成称为标记的块。每个标记代表语法结构中一个有意义的单元,类似于自然语言中的单词和标点符号。然后将这些标记送入自上而下的解析器,该解析器生成表示程序的树结构。语言设计者和编译器工程师喜欢把这种树结构称为AST(抽象语法树)。然后可以分析生成的AST以生成称为字节码的虚拟机指令列表。生成AST的过程是JavaScript引擎更直接的工作之一。它也可能很慢。还记得本文开头提到的那一堆代码吗?JavaScript引擎必须在用户开始与站点交互之前解析整个包并构建语法树。大多数代码对于初始页面加载可能不是必需的,其中一些甚至可能根本不会执行!幸运的是,编译器工程师发明了各种技巧来加快速度。首先,一些引擎在后台线程中解析代码,释放主UI线程用于其他计算。其次,现代引擎会通过使用一种称为“惰性解析”或“惰性编译”的技术尽可能地延迟在内存中创建语法树。它是这样工作的:如果引擎发现一个函数定义可能在一段时间内不会被执行,它就会对函数体进行快速的“丢弃”解析。这种一次性分析会发现代码中可能隐藏的所有语法错误,但不会生成AST。稍后,当第一次调用该函数时,再次解析代码。这一次,引擎将生成执行所需的完整AST和字节码。在JavaScript世界中,有时执行两次比执行一次更快!然而,最好的优化是让我们完全绕过所有耗时的处理。对于JavaScript编译,这意味着完全跳过解析步骤。一些JavaScript引擎尝试缓存生成的字节码,以便在用户将来重新访问该站点时重新使用。没那么简单。JavaScript包可能会随着网站的更新而频繁更改,浏览器必须仔细权衡序列化字节码的成本与缓存带来的性能提升。运行时字节码现在我们有了字节码,我们可以开始执行它了。在今天的JavaScript引擎中,解析期间生成的字节码首先被发送到称为解释器的虚拟机。解释器有点像用软件实现的CPU。它一次查看一个字节码指令并决定执行哪条实际机器指令以及下一条执行哪条指令。JavaScript编程语言的结构和行为在名为ECMA-262的文档中定义。结构部分称为“语法”,行为部分称为“语义”。编程语言的语义几乎都是由用伪代码编写的算法来定义的。假设我们是执行有符号右移运算符(>>)的编译器工程师,这里是规范(以下脚本引用自ECMA-262):GetValue(lref)。令rref为评估AdditiveExpression的结果。令rval为?GetValue(rref).让lnum是?屏蔽除rnum的最低有效5位以外的所有位,即计算rnum和0x1F。返回对lnum进行符号扩展右移shiftCount位的结果。传播最高有效位。结果是一个带符号的32位整数。前六个步骤将操作数(>>两边的值)转换为32位整数,然后执行实际的移位操作。但如果算法完全按照规范中的描述实现,生成的解释器将非常慢。我们以从JavaScript对象中获取属性值的简单操作为例。从概念上讲,JavaScript中的对象就像字典。每个属性都以字符串名称作为键。对象也可以有原型对象。如果对象没有给定字符键的条目,则需要在原型中查找该键。重复此操作,直到找到所需的关键字或到达原型链的末尾。每次要从对象中获取属性值时,这都会导致大量工作。JavaScript引擎中用于加速动态属性查找的策略称为内联缓存。内联缓存最初是在1980年代为Smalltalk语言开发的。基本思想是可以将先前属性查找操作的结果直接存储在生成的字节码指令中。为了理解它是如何工作的,让我们闭上眼睛,把JavaScript引擎想象成一个充满魔力的大型库。当我们走进去的时候,我们注意到里面塞满了飞来飞去的书(即物体)。每个对象都有一个可识别的形状,这决定了其属性的存储位置。假设我们正在根据书单中记录的一系列字节码指令执行一个程序。下一条指令告诉我们从某个对象中获取名为x的属性的值。您抓取该对象,找出x的存储位置,并发现它已存储在该对象的第二个数据槽中。你会注意到所有具有相同形状的对象在它们的第二个数据槽中都有一个x属性。拿出你的笔,在字节码表上做个笔记,标记对象的形状和x属性的位置。下次您看到此标记时,只需检查对象的形状即可。如果形状与您在字节码注释中标记的形状匹配,则无需检查对象即可确切知道数据的位置。这样你就实现了一个单态内联缓存。但是,如果对象的形状与我们的字节码注释不匹配怎么办?这时候,你可以通过做一个小表格,把你看到的每一个形状记录为一行来解决这个问题。每次看到一个新形状时,它都会作为一行添加到表格中。这实现了多态内联缓存。它不像单态缓存那么快,而且会占用更多的书单空间,但如果行数不多,它的效果会很好。如果最终生成的表太大,删掉并打个注释,提醒自己不要担心这条指令的inlinecache。在编译器术语中,实现了一个巨态调用点。一般来说,单态代码非常快,多态代码差不多快,而多态代码往往很慢。单态:像飓风一样快多态:像兔子一样快多态:像乌龟一样慢还是两次,这个“软件CPU”的强大执行速度还是可以接受的。但对于“热代码”(运行数百、数千甚至数百万次的函数),我们真正想要的是直接在实际硬件上执行机器指令。这时候就需要即时(JIT)编译。当解释器执行JavaScript函数时,会收集有关函数调用频率和调用参数的各种统计信息。如果函数经常使用相同类型的参数执行,引擎可能会将函数的字节码转换为机器码。我们再回到之前想象中的JavaScript引擎,也就是充满魔力的库。当程序开始执行时,您应该从标有标签的架子上取下字节码手册。对于每个函数,大约有一行。当您按照每一行的说明进行操作时,您可以记录每行执行的次数。还要注意执行指令时遇到的物体的形状。在这一点上,您是分析解释器。当您查看下一行字节码时,您会注意到该字节码是“热”的,因为您已经执行了数十次并想加快它的速度。你有两个助手准备为你翻译。第一个助手可以快速将字节码转换为机器码。他生成的代码质量好、简洁,但效率不如预期。第二个助手工作更仔细,虽然会花费更长的时间,但是生成的代码经过高度优化以尽可能快。在编译器方面,我们将这些不同的助手称为JIT编译层。不同的引擎有不同的层数,这取决于它们所做的权衡和取舍。您决定将字节码发送到第一个助手的位置。经过一段时间的处理,并仔细记下笔记,他将制作一份包含机器指令的新书籍清单,并将它们与原始字节码版本一起放在正确的书架上。下次需要执行该功能时,可以使用这个更快的指令集。问题是,Assistant在翻译我们的书单时做了很多假设。也许他认为变量总是包含一个整数。如果这些假设无效会怎样?这时候,就必须进行所谓的救市行动。拿出原始的字节码书,找出应该开始执行的指令。把机器码本列表发给二助理,又开始前面的流程。BeyondInfinity今天的高性能JavaScript引擎已经远远超过了90年代NetscapeNavigator和InternetExplorer中相对简单的解释器,并且还在不断发展。JavaScript语言正在逐渐添加新功能。优化了常见的编码模式。WebAssembly也已经成熟,并且正在开发更丰富的标准模块库。作为开发人员,我们可以期待现代JavaScript引擎能够快速、高效地执行,只要我们控制包的大小并确保我们不会让性能关键型代码过于动态化。
