JS的工作原理:抽象语法树,解析及其优化


我们都知道,Web项目的JavaScript代码可能会增长到巨大的规模。 并且代码越大,浏览器加载它的时间就越长。 但是,这里的问题不仅在于通过网络传输数据的时间。 程序加载后,仍然需要对其进行解析,编译为字节码并最终执行。 今天,我们提请您注意JavaScript生态系统系列第14部分的译文。 即,我们将讨论JS代码的解析,抽象语法树的构建方式以及程序员如何影响这些过程,从而提高其应用程序速度。

图片

编程语言如何


在讨论抽象语法树之前,让我们先介绍一下编程语言的工作方式。 无论使用哪种语言,都必须始终使用某些带有源代码的程序,并将其转换为包含用于计算机的特定命令的内容。 解释器或编译器均充当此类程序。 无论您使用解释性语言(JavaScript,Python,Ruby)还是编译(C#,Java,Rust)编写代码,纯文本代码都将始终经过解析阶段,即将纯文本转换为数据结构称为抽象​​语法树(AST)。

抽象语法树不仅提供源代码的结构化表示,而且在语义分析中也起着至关重要的作用,在此期间,编译器将验证软件结构的正确性及其元素的正确使用。 形成AST并执行检查后,此结构用于生成字节码或机器码。

使用抽象语法树


抽象语法树不仅用于解释器和编译器。 在计算机世界中,它们在许多其他领域中很有用。 静态代码分析是最常见的应用程序之一。 静态分析器不执行传递给它们的代码。 但是,尽管如此,他们仍需要了解程序的结构。

假设您想开发一种工具来查找代码中经常发生的结构。 这种工具的报告将有助于重构并减少代码重复。 可以使用通常的字符串比较来完成此操作,但是这种方法非常原始,其功能将受到限制。 实际上,如果要创建类似的工具,则无需编写自己的JavaScript解析器。 此类程序有许多开源实现,它们与ECMAScript规范完全兼容。 例如-Esprima和Acorn。 还有一些工具可以帮助处理解析器生成的内容,即,与抽象语法树一起使用。

另外,抽象语法树还广泛用于编译器的开发中。 假设您决定开发一个将Python代码转换为JavaScript代码的编译器。 一个类似的项目可以基于以下想法:使用编译器基于Python代码创建抽象语法树,然后将其转换为JavaScript代码。 可能在这里您会想知道这怎么可能。 关键是抽象语法树只是在某种编程语言中表示代码的另一种方法。 在将代码转换为AST之前,它看起来像普通的文本,编写时遵循构成语言的某些规则。 解析后,此代码将变成一个树形结构,其中包含与程序源代码相同的信息。 结果,不仅可以执行从源代码到AST的转换,还可以执行逆转换,从而将抽象语法树变成程序代码的文本表示。

解析JavaScript


让我们谈谈抽象语法树是如何构建的。 例如,考虑一个简单的JavaScript函数:

function foo(x) {    if (x > 10) {        var a = 2;        return a * x;    }    return x + 10; } 

解析器将创建一个抽象语法树,如下图所示。


抽象语法树

请注意,这是解析器结果的简化表示。 真正的抽象语法树看起来要复杂得多。 在这种情况下,我们的主要目标是首先了解源代码在执行之前会变成什么。 如果您有兴趣查看真正的抽象语法树的外观,请使用AST Explorer网站。 为了为某个JS代码片段生成AST,将其放置在页面上的相应字段中就足够了。

也许在这里您将有一个问题,为什么程序员需要知道JS解析器的工作方式。 最后,解析和执行代码是浏览器任务。 从某种意义上说,你是对的。 下图显示了一些著名的Web项目在执行JS代码的过程中执行各个步骤所需的时间。

仔细看一下这张图,也许您会在这里看到一些有趣的东西。


执行JS代码所花费的时间

看吗 如果没有,请再次查看。 实际上,我们谈论的是这样一个事实,平均而言,浏览器花费15-20%的时间来解析JS代码。 这不是一些条件数据。 这是有关以某种方式使用JavaScript的实际Web项目的工作的统计信息。 也许15%的数字对您来说似乎并不大,但请相信我,这很多。 典型的一页应用程序加载大约0.4 MB的JavaScript代码,而浏览器需要大约370 ms来解析此代码。 同样,您可以说没有什么可担心的。 是的,仅此而已。 但是,请不要忘记这只是解析代码并将其转换为AST所需的时间。 这不包括执行代码所花费的时间,也不是解决页面加载所伴随的其他任务所花费的时间,例如HTML和CSS处理以及页面渲染任务。 而且,我们仅谈论桌面浏览器。 在移动系统的情况下仍然更糟。 特别是,在移动设备上对相同代码的解析时间可能比在台式机上的解析时间长2-5倍。 看下图。


在各种设备上解析1 MB JS代码的时间

这是在各种移动和台式设备上解析1 MB JS代码所需的时间。

另外,Web应用程序不断变得越来越复杂,并且越来越多的任务正在转移到客户端。 所有这些旨在改善用户使用网站的体验,以使这些感觉更接近用户与传统应用程序进行交互时所经历的感觉。 很容易弄清楚这对网络项目有多大影响。 为此,只需在浏览器中打开开发人员工具,然后转到一些现代站点,看看在准备工作页面时花了多少时间来解析代码,编译以及浏览器中发生的所有其他事情。


使用浏览器中的开发人员工具进行网站分析

不幸的是,移动浏览器没有这样的工具。 但是,这并不意味着无法分析网站的移动版本。 在这里,诸如DeviceTiming之类的工具将为我们提供帮助。 使用DeviceTiming,您可以测量在托管环境中解析和执行脚本所花费的时间。 这是由于本地脚本在由辅助代码形成的环境中的放置而导致的,这导致以下事实:每次从各种设备加载页面时,我们就有机会在本地测量解析和代码执行的时间。

解析优化和JS引擎


JS引擎做了很多有用的事情,以避免不必要的工作并优化代码处理过程。 这里有一些例子。

V8引擎支持流脚本和代码缓存。 在这种情况下,流式传输被理解为以下事实:系统参与解析异步加载的脚本,并且脚本的执行被延迟在一个单独的线程中,该脚本从代码开始加载之时就开始这样做。 这导致以下事实:解析几乎与脚本加载完成同时结束,这使准备工作页面所需的时间减少了大约10%。

通常,每次访问页面时,JavaScript代码都会编译为字节码。 但是,该字节码在用户导航到另一页后会丢失。 这是由于以下事实:在编译时,已编译的代码高度依赖于系统的状态和上下文。 为了改善这种情况,Chrome 42引入了对字节码缓存的支持。 由于这项创新,编译后的代码可以存储在本地,因此,当用户返回到已经访问过的页面时,无需下载,解析和编译脚本即可为工作做好准备。 这样,Chrome可以节省大约40%的解析和编译时间。 另外,对于移动设备,这可以节省电池电量。

在Opera浏览器中使用的Carakan引擎已经被V8取代了很长时间,它可以重用已经处理过的脚本的编译结果。 不需要将这些脚本连接到同一页面,甚至不需要从同一域中加载它们。 实际上,这种缓存技术非常有效,可以让您完全放弃编译步骤。 她依赖于典型的用户行为方案,以及人们如何使用Web资源。 即,当用户在使用Web应用程序时遵循特定的操作序列时,将加载相同的代码。

FireFox使用的SpiderMonkey解释器不会连续缓存所有内容。 它支持一个监视系统,该系统计算对特定脚本的调用次数。 根据这些指标,确定需要优化的代码部分,即具有最大负载的部分。

当然,某些浏览器开发人员可能会决定他们的产品根本不需要缓存。 因此,Safari浏览器的领先开发者Masei Stachovyak表示Safari不参与缓存已编译的字节码。 考虑了缓存的可能性,但尚未实现,因为代码生成花费的时间不到程序总执行时间的2%。

这些优化不会直接影响JS中源代码的解析。 在其应用过程中,在某些情况下,将竭尽所能以完全跳过此步骤。 不管解析速度有多快,它仍然需要一些时间,并且完全没有解析可能就是完美优化的例子。

减少Web应用程序的准备时间


正如我们在上面发现的那样,最大程度地减少对脚本解析的需求是很好的,但是您不能完全摆脱它,所以让我们来谈谈如何减少准备Web应用程序的时间。 实际上,可以做很多事情。 例如,您可以最大程度地减少应用程序中包含的JS代码的数量。 为工作准备页面的小代码可以更快地被解析,并且比大量的代码花费更少的时间来执行。

为了减少代码量,您可以仅将页面上真正需要的内容组织在页面上,而不是一些庞大的代码,其中绝对包括整个Web项目所需的所有内容。 因此,例如, PRPL模式促进了这种加载代码的方法。 或者,您可以检查依赖项,看看它们中是否有多余的东西,这样只会导致不合理的代码库增长。 实际上,我们在这里谈到了一个值得单独讨论的大话题。 返回解析。

因此,本材料的目的是讨论使Web开发人员能够帮助解析器更快地完成其工作的技术。 存在这样的技术。 现代JS解析器使用启发式算法来确定是否有必要尽快执行某些代码,或者是否需要稍后执行。 基于这些预测,解析器或者使用渴望的解析算法来完全分析代码片段,或者使用惰性解析算法。 通过全面的分析,您将了解需要尽快编译的功能。 在此过程中,解决了三个主要任务:构建AST,创建可见性区域层次结构以及查找语法错误。 另一方面,惰性分析仅用于尚不需要编译的函数。 这不会创建AST,也不会搜索错误。 通过这种方法,仅创建可见区域的层次结构,与需要尽快执行的处理功能相比,节省了大约一半的时间。

实际上,这个概念并不新鲜。 即使像IE9这样的过时浏览器也支持这种优化方法,尽管现代系统当然已经取得了长足的进步。

让我们来看一个说明这些机制操作的示例。 假设我们有以下JS代码:

 function foo() {   function bar(x) {       return x + 10;   }   function baz(x, y) {       return x + y;   }   console.log(baz(100, 200)); } 

与前面的示例一样,代码落入解析器,解析器执行其解析并形成AST。 结果,解析器表示由以下主要部分组成的代码(我们将不关注foo函数):

  • 声明一个带有一个参数( x )的bar函数。 该函数有一个return命令,它返回x与10相加的结果。
  • 声明一个带有两个参数( xy )的baz函数。 她还有一个return命令,她返回xy相加的结果。
  • 使用两个参数-100和200调用baz函数。
  • 使用一个参数调用console.log函数,该参数是先前调用的函数返回的值。

这是它的外观。


在不应用优化的情况下解析示例代码的结果

让我们谈谈这里发生了什么。 解析器可以看到bar函数的声明, baz函数的声明, baz函数的调用以及console.log函数的调用。 显然,解析这段代码后,解析器将遇到一个任务,该任务的执行不会影响该程序的结果。 这是关于功能bar的分析。 为什么分析此功能不切实际? 事实是,至少在所提供的代码片段中, bar函数从未被调用过。 这个简单的例子似乎牵强,但许多实际应用程序具有大量从未调用的功能。

在这种情况下,无需解析bar函数,我们只需记录它已声明但未在任何地方使用。 同时,该函数的实际解析在必要时在执行之前完成。 自然地,在执行延迟分析时,您需要检测函数的主体并记录其声明,但这就是工作的终点。 对于这样的功能,由于系统不具有计划执行该功能的信息,因此不必形成抽象语法树。 此外,没有分配堆内存,这通常需要大量的系统资源。 简而言之,拒绝解析不必要的功能会导致代码性能显着提高。

结果,在前面的示例中,真实的解析器将形成类似于以下方案的结构。


通过优化分析示例代码的结果

请注意,解析器记下了有关功能bar的声明的注释,但并未对其进行进一步的分析。 系统不分析功能代码。 在这种情况下,函数的主体是返回简单计算结果的命令。 但是,在大多数实际应用中,功能代码可能更长,更复杂,其中包含许多返回命令,条件,循环,变量声明命令和嵌套函数。 如果从不调用此类函数,则解析所有这些都是浪费时间。

上面描述的概念没有什么复杂的,但是其实际实现并非易事。 在这里,我们研究了一个非常简单的示例,实际上,当确定程序中是否需要某些代码时,有必要分析函数,循环,条件运算符和对象。 通常,我们可以说解析器需要处理和分析程序中的所有内容。

例如,这是在JavaScript中实现模块的一种非常常见的模式:

 var myModule = (function() {   //      //    })(); 

大多数现代的JS解析器都认可这种模式;对他们而言,这表明需要对位于模块内部的代码进行完全分析。

但是,如果解析器始终使用惰性解析怎么办? 不幸的是,这不是一个好主意。 事实是,使用这种方法,如果需要尽快执行某些代码,我们将遇到系统运行缓慢的问题。 解析器将执行一次懒惰解析,此后它将立即开始完全分析尽快完成的工作。 与解析器立即开始完全解析最重要的代码时相比,这将导致速度降低约50%。

代码优化,同时考虑其分析功能


既然我们已经了解了解析器内部的情况,现在该考虑可以采取什么措施来帮助他们了。 我们可以编写代码,以便在需要时执行功能解析。 大多数解析器都了解一种模式。 它表示为功能被括在方括号中。 这样的设计几乎总是告诉解析器该功能需要立即拆卸。 如果解析器检测到一个右括号,则紧随其后的是函数声明,它将立即开始解析函数。 当描述需要尽快执行的功能时,我们可以通过应用此技术来帮助解析器。

假设我们有一个函数foo

 function foo(x) {   return x * 10; } 

由于此代码段中没有明确指示该功能计划立即执行,因此浏览器将仅执行其惰性解析。 但是,我们有信心很快就会需要此功能,因此可以诉诸下一个技巧。

首先,将函数保存在变量中:

 var foo = function foo(x) {   return x * 10; }; 

请注意,我们将初始函数名称保留在function关键字和左括号之间。 不能说这是绝对必要的,但建议这样做,因为如果在函数运行时抛出异常,则可以在堆栈跟踪数据中看到函数的名称,而不是<anonymous>

经过上述更改后,解析器将继续使用延迟解析。 为了改变这一点,一个小细节就足够了。 该功能必须放在方括号中:

 var foo = (function foo(x) {   return x * 10; }); 

现在,当解析器在function关键字前面找到一个左括号时,它将立即开始解析此函数。

手动执行这种优化可能并不容易,因为为此您需要知道解析器将在哪种情况下执行延迟解析,以及在哪种情况下执行完整解析。 此外,要执行此操作,您需要花费时间来确定某个特定功能是否需要尽快准备就绪才能开始工作。

程序员肯定不会愿意承担所有这些额外的工作。 此外,与已经说过的一切一样重要,以这种方式处理的代码将更难以阅读和理解。 在这种情况下,像Optimize.js这样的特殊软件包已准备就绪,可以为我们提供帮助。 他们的主要目标是优化JS源代码的初始启动时间。 他们执行静态代码分析并对其进行修改,以便将需要尽快执行的功能括在方括号中,从而导致浏览器立即解析它们并为执行做好准备。

因此,假设我们在编程时没有真正考虑任何事情,并且我们有以下代码片段:

 (function() {   console.log('Hello, World!'); })(); 

它看起来很正常,可以按预期工作,并且执行迅速,因为解析器在function关键字的前面找到了左括号。 到目前为止一切顺利。 , , , :

 !function(){console.log('Hello, World!')}(); 

, , . , - .

, , . , , , . , , , . , , . Optimize.js. Optimize.js, :

 !(function(){console.log('Hello, World!')})(); 

, . , . , , , — .


, JS- — , . ? , , , , . , , , , JS- , . , , , -, . - . , , . , , , , . , JS- , , V8 , , . .


, -:

  • . .
  • , .
  • , , , JS-. , , .
  • DeviceTiming , .
  • Optimize.js , , .

总结


, , SessionStack , , -, . , . — . , — , -, , , .

亲爱的读者们! - JavaScript-?

Source: https://habr.com/ru/post/zh-CN415269/


All Articles