JavaScript的第十四条工作原理——解析、语法抽象树和最小化解析时间的5个技巧本系列持续更新中,Github地址请查看这里。这是JavaScript工作原理的第十四章。概述我们都知道在运行大量JavaScript代码时性能会有多糟糕。代码不仅需要通过网络传输,还需要解析、编译成字节码,最后运行。之前的文章讨论了JS引擎、运行时和调用堆栈以及广泛用于GoogleChrome和NodeJS的V8引擎等主题。它们都对JavaScript的运行起着重要的作用。今天的主题也很重要:了解大多数JavaScript引擎如何将文本解析为机器可理解的代码,转换后会发生什么,以及开发人员如何利用这些知识。编程语言原则首先,让我们回顾一下编程语言原则。无论使用什么编程语言,往往都需要一些软件对源代码进行处理,让计算机能够看懂。该软件可以是解释器或编译器。无论是使用解释型语言(JavaScript、Python、Ruby)还是编译型语言(C#、Java、Rust),它们都有一个共同点:源代码被作为纯文本解析成语法抽象树的数据结构(AST)。AST不仅以结构化的方式呈现源代码,而且在语义分析中也起着重要作用,编译器在语义分析中检查以验证程序和语言元素在句法上的使用是否正确。之后,AST用于生成实际的字节码或机器码。AST程序AST不仅用于语言解释器和编译器,在计算机世界中还有其他用途。最常见的用途之一是静态代码分析。静态代码分析不运行输入代码。但是,他们仍然需要了解代码的结构。例如,实现一个工具来查找可用于代码重构以减少重复代码的通用代码结构。也许您可以使用字符串比较来做到这一点,但这些工具会相当简单且有限。当然,如果你有兴趣实现这样一个工具,你大可不必自己写解析器,有很多开源项目可以完美兼容Ecmascript规范。Esprima和Acorn是黄金搭档。还有其他工具可用于帮助解析器输出代码,即AST。AST广泛用于代码转换。例如,您可能想要实现一个转换器以将Python代码转换为JavaScript。大意是先用Python代码转换器生成AST,再用AST生成JavaScript代码。你可能会觉得难以置信。事实上,AST只是语言部分的不同表示。在解析之前,它显示为遵循构成语言的某些语法规则的文本。解析后,它呈现为树状结构,包含与输入文本几乎相同的信息。所以也可以反向解析并返回到文本。JavaScript解析让我们看一下AST的构造。以下面这个简单的JavaScript函数为例:functionfoo(x){if(x>10){vara=2;返回*x;}returnx+10;}解析器生成以下AST。请注意,为了便于说明,这是解析器输出的简化版本。实际的AST更复杂。但是,这里的意思是理解运行源代码之前的第一步。您可以访问ASTExplorer来查看实际的AST树。这是一个在线工具,您可以在其中编写JavaScript代码,网站会输出目标代码的AST。您可能会问为什么我必须学习JavaScript解析器的工作原理。不管怎样,浏览器负责运行JavaScript代码。你是对的一点点。下图展示了JavaScript运行过程中不同阶段的耗时情况。睁大你的眼睛,也许你能发现一些有趣的东西。你找到了吗?通常,浏览器花费大约15%到20%的总运行时间来解析JavaScript。我没有这些数字的具体统计数据。这些统计数据来自实际程序和网站中的各种JavaScript使用手势。现在也许15%看起来不是很多,但相信我,这已经很多了。一个典型的单页程序会加载大约0.4M的JavaScript代码,然后消耗大约370ms的浏览器时间来解析。也许你又会说,这还不算多。它本身并不需要太多时间。但请记住,这只是将JavaScript代码转换为AST所需的时间。它不包括运行时本身或其他进程,如页面加载期间的CSS和HTML呈现。这只是桌面浏览器面临的问题。移动浏览器的情况更为复杂。一般来说,手机上的移动浏览器解析代码的时间是桌面浏览器的2-5倍。上图显示了不同的移动和桌面浏览器解析1MBJavaScript代码所花费的时间。另外,为了获得更原生的用户体验,越来越多的业务逻辑堆积在前端,Web程序也变得越来越复杂。网页程序越来越肥,几乎动弹不得。您可以轻松想象对Web应用程序的性能影响。只需打开浏览器开发人员工具并使用该工具来测量在页面完全加载之前浏览器中发生的解析、编译和其他事情所花费的时间。不幸的是,移动浏览器没有用于分析的开发人员工具。不用担心。由于DeviceTiming工具。它可用于帮助检测受控环境中脚本的解析和运行时间。它通过注入代码来包装本机代码,以便在从不同设备访问时可以在本地测量解析和运行时间。好消息是JavaScript引擎做了很多工作来避免冗余工作并提高效率。主要浏览器使用以下技术。例如,V8实现了脚本流和代码缓存技术。脚本流意味着当脚本开始下载时,异步和延迟脚本在单独的线程中被解析。这意味着一旦脚本下载完成,解析就会完成。这将页面加载速度提高了10%。每当访问页面时,JavaScript代码通常会编译成字节码。但是,当用户访问另一个页面时,字节码就会失效。这是因为编译后的代码在很大程度上依赖于编译期间机器的状态和上下文。Chrome42带来了字节码缓存。该技术将编译后的代码缓存在本地,这样当用户返回同一页面时,将跳过所有下载、解析和编译等步骤。这为Chrome节省了大约40%的代码解析和编译时间。此外,这也将节省手机的电池电量。在Opera中,Carakan引擎可以重用另一个程序最近编译的输出。不需要代码在同一页面或同一域名下。这种缓存技术非常高效,可以完全跳过编译步骤。它依赖于典型的用户行为和浏览场景:只要用户在程序/网站上遵循特定的用户浏览习惯,就会加载相同的JavaScript代码。不过,Carakan早已被谷歌的V8引擎取代。Firefox使用的SpiderMonkey引擎不使用任何缓存技术。它可以过渡到监控阶段,记录脚本运行的次数。在此计算的基础上,它推导出可以优化的常用代码部分。显然,有些人选择什么都不做。Safari首席开发人员MaciejStachowiak指出,Safari不会缓存已编译的字节码。他们可能想到了缓存技术但没有实施它们,因为生成代码只用了不到总运行时间的2%。这些优化不会直接影响JavaScript源代码的解析时间,但会尽可能完全避免。毕竟,有聊胜于无。有许多方法可用于减少程序的初始加载时间。最小化加载的JavaScript数量:更少的代码意味着更少的解析时间和更少的运行时间。为此,您可以使用特殊方法来传输必要的代码,而不是一次加载一大块代码。例如,PRPL模式代表这种类型的代码传输。或者,您可以检查依赖项并查看是否有任何无用的、冗余的依赖项使您的代码库膨胀。不过,这些东西需要很大的篇幅来讨论。本文的目标是开发人员如何帮助加速JavaScript解析器。现代JavaScript解析器使用启发式(heuristics)来决定是立即运行给定的一段代码还是推迟到将来的某个时间执行。基于这些试探法,解析器执行立即解析或惰性解析。立即解析运行需要立即编译的函数。它主要做三件事:构建AST、构建作用域层次结构以及检查任何语法错误。虽然惰性解析只运行未编译的函数,但它不会构建AST并检查任何语法错误。仅构建作用域层次结构,与立即解析相比节省了大约一半的时间。显然,这不是一个新概念。即使与IE9一样古老的浏览器也支持这种优化,尽管与现代解析器的工作方式相比是一种粗糙的方式。举个栗子。假设您有以下代码片段:functionfoo(){functionbar(x){returnx+10;}函数baz(x,y){返回x+y;}console.log(baz(100,200));}和前面的代码类似,代码输入parser进行解析,然后输出AST。这表示如下:声明bar函数接收一个x参数。有一个返回语句。该函数返回x和10相加的结果。声明baz函数有两个参数(x和y)。有一个返回语句。函数函数x和y相加结果。调用传入100和2的baz函数。使用上一个函数调用的返回值调用console.log。所以发生了什么事?解析器看到bar函数声明,baz函数声明,调用bar函数,调用console.log函数。但是,解析器执行完全不相关的额外工作来解析bar函数。为什么它无关紧要?因为函数bar永远不会被调用(或者至少不会在相应的时间点被调用)。这只是一个简单的例子,可能有点不寻常,但在许多现实生活中的程序中,许多函数声明从未被调用过。bar函数在这里不做解析,它是在没有说明用途的情况下声明的。仅在需要时在函数运行之前进行实际解析。惰性解析仍然只需要找到整个函数体并声明它。它不需要语法树,因为它不会被处理。此外,它不从内存堆中分配内存,这会消耗大量系统资源。简而言之,跳过这些步骤可以获得巨大的性能提升。所以在前面的例子中,解析器实际上会这样解析:注意这只是验证函数bar声明。不进入bar函数的主体。在当前情况下,函数体只有一个简单的返回语句。然而,与现代世界中的大多数程序一样,函数体可能更大,包含多个返回语句、条件语句、循环、变量声明甚至嵌套函数声明。这完全是对时间和系统资源的浪费,因为该函数从未被调用过。这实际上是一个相当简单的概念,但实现起来却非常困难。不限于以上示例。整个方法还可以应用于函数、循环、条件语句、对象等。一般来说,所有的代码都需要解析。例如,以下是实现JavaScript模块的一种相当常见的模式。varmyModule=(function(){//整个模块的逻辑//返回模块对象})();大多数现代JavaScript解析器都可以识别此模式,并指示需要立即解析其中的代码。那么为什么不是所有的解析器都使用惰性解析呢?如果你懒惰地解析一些必须立即运行的代码,它会减慢你的代码。需要运行一个惰性解析,然后再运行另一个立即解析。运行速度比立即解析慢50%。现在我们对解析器的底层原理有了一个大致的了解,是时候考虑如何帮助提高解析器的解析速度了。可以以在正确时间解析函数的方式编写代码。这是大多数解析器都认可的模式:使用圆括号来包装函数。这告诉解析器需要立即函数。如果解析器看到一个左括号后跟一个函数声明,它会立即解析该函数??。通过显式声明立即函数可以帮助解析器加快解析速度。假设有一个foo函数functionfoo(x){returnx*10;}因为没有明显的提示需要立即运行该函数,所以浏览器会进行惰性解析。但是,我们确定这不是真的,因此可以运行两个步骤。首先,将函数存储为变量。varfoo=functionfoo(x){returnx*10;};注意函数关键字和函数参数的左括号之间的函数名称。这不是必需的,但建议这样做,因为当抛出异常时,堆栈跟踪将包含实际的函数名称而不是
