js最小化浏览器_「译」解析、抽象语法树(ast) +如何最小化解析时间的5个技巧...

前言

该系列课程会在本周陆续更新完毕,主要讲解的都是工作中可能会遇到的真实开发中比较重要的问题以及相应的解决方法。通过本系列的课程学习,希望能对你日常的工作带来些许变化。当然,欢迎大家关注我,我将持续分享哪些前端层面核心的知识点,希望能给同处前端的你带来一点点收获。关于JavaScript引擎的理解欢迎大家阅读我写的《「译」JavaScript是如何工作的:引擎,运行时间和调用栈的概览》,《面试官:我们来聊聊Chrome中的V8隐藏类吧》,《高手进阶之史上最全JS内存管理策略剖析》,《浏览器事件循环必知必会10点》等等

1、概览

我们都知道处理大体积JavaScript代码会使事情变得很糟糕。大体积代码不仅需要通过网络传输,而且还需要解析、编译成字节码,机器码,最后执行。在之前的文章中,我们讨论了JS引擎、运行时和调用堆栈(call stack)等主题,以及主要由谷歌Chrome和NodeJS使用的V8引擎。它们在整个JavaScript执行过程中都发挥着至关重要的作用。我们今天介绍的主题同样重要:我们将看到大多数JavaScript引擎如何将文本解析为机器可识别的内容,以及之后会发生什么。通过深入学习这些内容,将为同处web前端界的你带来明显的优势。

2、编程语言是如何工作的

先退一步看看编程语言都是如何工作的。不管你使用什么编程语言,你总是需要一些软件,它可以获取源代码,让计算机实际做一些事情。该所谓的软件既可以是解释器,也可以是编译器。无论你使用的是解释型语言(JavaScript、Python、Ruby)还是编译型语言(c#、Java、Rust),都有一个共同的部分:将源代码作为纯文本解析为名为抽象语法树(abstract syntax tree, AST)的数据结构。AST不仅以结构化的方式显示源代码,而且在语义分析中扮演着关键角色,在语义分析中,编译器验证程序和语言元素的正确性并使用。继而,AST用于生成实际的字节码或机器码

3、AST的应用

AST不仅用于语言解释器和编译器。它们在计算机世界中有多种应用。

使用它们最常见的方法之一是进行静态代码分析。静态分析器不执行输入的代码。但是,他们仍然需要理解代码的结构。例如,你可能想要实现一个工具,该工具可以找到公共代码结构,以便你可以重构它们以减少重复代码。

你也许可以通过使用字符串比较来实现这一点,但是这种方式将是非常基础而且见效慢。当然,如果你对实现这样的工具感兴趣,你也不需要编写自己的解析器。有许多与Ecmascript规范完全兼容的开源实现方案。比如,Esprima和Acorn。

还有许多工具可以帮助解析器生成输出ASTs。ASTs还广泛用于实现代码转换程序。例如,你可能希望实现一个将Python代码转换为JavaScript的转换器。基本思想是使用Python转换器生成AST,然后使用AST生成JavaScript代码。

你可能会问,这怎么可能。原理就是ASTs是一种表示某种语言的不同方式。在解析之前,它被表示为遵循一些规则的文本,这些规则构成了一种语言。在解析之后,它被表示为一个树形结构,其中包含与输入文本完全相同的信息。因此,我们总是可以做相反的步骤,回到文本表示。

译者注:以前使用Babel将markdown文件代码转化为前端可执行的JS代码就是通过AST转化来完成的。和前端markdown解析器“bisheng”保持一致。关于bisheng可参考文末文献。

4、JavaScript解析

先看看AST是如何构建的。以一个简单的JavaScript函数作为例子:

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

解析器将生成以下AST:

107e3f4ff5816172d9ab2adf4e6a74d6.png

需要注意,出于可视化目的,这是解析器生成结果的简化版本。实际的AST要复杂得多。然而,这里的目的是要了解在源代码执行之前,首先会发生什么。如果你想查看实际的AST是什么样子,可以检查AST Explorer。它是一个在线工具,你可以在其中传递一些JavaScript并输出代码的AST。

你可能会问,为什么我需要知道JavaScript解析器是如何工作的。毕竟,让它工作应该是浏览器的责任。你说得对。下图显示了JavaScript执行过程中不同步骤的总时间分配。仔细看看你是否发现了什么有趣的东西。

0c4a3ffacb77d3b566babb4220b9e64c.png

仔细看看。平均而言,浏览器解析JavaScript大约需要总执行时间的15%到20%。这里没有具体的数字。这些是来自真实应用程序和以某种方式使用JavaScript的网站的统计数据。15%对你来说可能不是很多,但事实是,确实很多。典型的SPA加载0.4 mb左右的JavaScript,浏览器需要大约370ms来解析它。再一次,你可能会说,好吧,没那么多。它本身并不多。请记住,这只是将JavaScript代码解析为AST所需要的时间。这并不包括执行本身,也不包括在页面加载(如CSS和HTML渲染)期间发生的任何其他进程。这些都只涉及桌面。一旦我们进入移动领域,事情很快就会变得更加复杂。在手机上花在解析上的时间通常是在台式机上的2到5倍

21b103a8802bfaf5eefaf2bcb3f542d6.png

上图显示了1MB JavaScript包在不同类的移动和桌面设备上的解析时间。

而且,随着越来越多的业务逻辑进入客户端,以获得更类似于原生应用的用户体验,web应用程序正变得越来越复杂。你可以很容易地知道这对你的应用程序/网站有会多大的影响。你所需要做的就是打开浏览器开发工具,用它测量在页面完全加载之前在解析、编译和浏览器中发生的所有其他事情上所花费的时间。

697a4fc0a28644dca5d0363056d566fb.png

遗憾的是,移动浏览器上没有开发工具。不过不用担心。这并不意味着你对此无能为力。这就是DeviceTiming这样的工具存在的原因。它可以帮助你在受控环境中测量脚本的解析和执行时间。它的工作原理是用检测代码包装本地脚本,这样每次从不同的设备访问页面时,就可以在本地度量解析和执行时间

好的方面是JavaScript引擎做了很多来避免冗余的工作,并得到了更好的优化。下面是一些主要浏览器引擎所做的事情。

例如,V8执行脚本流(script streaming)和代码缓存。脚本流意味着一旦下载开始,异步(async)脚本和延迟(defered)脚本将在单独的线程上解析。这表明在下载脚本之后,解析几乎立即完成。它的结果是页面加载速度快了10%。

JavaScript代码通常在每次页面访问时被编译成字节码。然而,一旦用户导航到另一个页面,这个字节码就会被丢弃。这是因为编译后的代码在很大程度上取决于编译时机器的状态和上下文。这就是为什么Chrome 42引入字节码缓存的原因。它是一种本地存储已编译代码的技术,因此当用户返回到同一页面时,可以跳过下载、解析和编译等所有步骤。这使得Chrome可以节省40%的解析和编译时间。此外,这也可以节省移动设备的电池寿命

在Opera中,Carakan引擎可以重用最近编译的另一个程序的编译器输出。没有要求代码必须来自相同的页面甚至域。这种缓存技术实际上非常有效,可以完全跳过编译步骤。它依赖于典型的用户行为和浏览场景:每当用户在应用程序/网站中跟随某个用户的操作时,都会加载相同的JavaScript代码。然而,Carakan引擎早已被谷歌的V8所取代。

Firefox使用的SpiderMonkey引擎不会缓存所有内容。它可以过渡到监视阶段,在这个阶段中,它计算执行给定脚本的次数。根据这个计数,它确定代码的哪些部分是处于被频繁执行的序列中的,需要优化。

显然,有些人决定什么都不做。Safari的首席开发人员Maciej Stachowiak表示,Safari不会对编译后的字节码进行任何缓存。这是他们考虑过的问题,但是他们还没有实现,因为代码生成不到总执行时间的2%。

我们可以做很多事情来改善应用程序的初始加载时间。比如最小化所传输的JavaScript数量:更少的脚本、更少的解析和更少的执行。要做到这一点,我们只能在特定的路由上传输所需的代码,而不是一次性传递一大团东西。例如,PRPL模式宣扬这种类型的代码传输。或者,我们可以检查我们的依赖关系,看看是否有什么冗余的东西只会使代码库膨胀。然而,这些事情是其他的主题。

译者注:比如单页应用中的react-loadable; external掉三方类库如react, react-dom等

这篇文章的目标是讨论作为web开发人员,我们可以做些什么来帮助JavaScript解析器更快地完成它的工作。还有,现代JavaScript解析器使用启发式(heuristics)来确定某段代码是立即执行,还是在将来推迟执行。基于这些启发式,解析器将进行即时或延迟解析

1)即时解析运行:需要立即编译函数。它主要做三件事:构建AST、构建范围层次结构和查找所有语法错误。

2)延迟解析:只在不需要编译的函数上使用。它不构建AST,也不查找所有语法错误。它只构建范围层次结构,与即时解析运行相比节省了大约一半的时间。

显然,这不是一个新概念。即使像IE9这样的浏览器也支持这种类型的优化,尽管与现在的解析器的工作方式相比,这种优化方式还很初级。

我们通过一个例子来看它是如何工作的。假设我们有一些JavaScript,它有以下代码片段:

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

就像之前的例子一样,代码被输入到语法分析器中,语法分析器进行语法分析并输出AST。

1)foo的函数声明,它接受一个参数(x),它有一个返回语句。函数返回x+10的结果。

2)bar的函数声明,它接受两个参数(x和y),它有一个返回语句。函数返回x+y的结果。

3)使用两个参数100和200对baz进行函数调用。

4)对console.log执行一个函数调用,其中一个参数是上一个函数调用的结果。

72204f23ba010af812486893f1bb839b.png

那么刚刚发生了什么? 解析器看到foo函数的声明、bar函数的声明、baz函数的调用和console.log函数的调用。但是在这里要停顿一下,解析器做了一些完全无关的额外工作。就是foo函数的解析。为什么这无关紧要? 因为函数foo从来没有被调用过(或者至少在那个时候没有)。这是一个简单的示例,看起来可能有些不同寻常,但在许多实际应用程序中,许多声明的函数从未被调用

在这里,我们不去解析foo函数,因为可以观察到它是在没有指定它做什么的情况下声明的(不会被调用执行)。实际的解析在必要时进行,也就是在函数执行之前。当然,延迟解析仍然需要找到函数的整个主体并为其声明,但仅此而已。它不需要语法树,因为它还没有被执行。另外,它不会从堆中分配内存,而且堆通常会占用相当多的系统资源。简而言之,跳过这些步骤会带来很大的性能改进。在前面的示例中,解析器实际上会执行如下操作。

9a6e36a456d4e1b440857f6432f35e22.png

注意,foo函数声明得到了确认,但仅此而已。没有对函数体本身做更多的处理。在这个例子中,函数体只是一个返回语句。但是,与大多数实际应用程序一样,它可以更大,包含多个返回语句、条件语句、循环、变量声明,甚至嵌套函数声明。这完全是在浪费时间和系统资源,因为这个函数永远不会被调用。

译者注:比如上面bar函数不会被调用,所以returnStatement不会被解析

这是一个相当简单的概念,但实际上,它的实现并不简单。这里我们展示了一个例子,这当然不是唯一的例子。整个方法适用于函数、循环、条件、对象等。基本上,所有需要解析的东西。例如,下面是一个非常常见的JavaScript模块实现模式。

var myModule = (function() { // 整个模块的逻辑 // 返回模块对象})();

大多数现代JavaScript解析器都能识别这种模式,这是一个标识(立即执行函数),表示应该立刻解析其中的代码。

为什么解析器不总是延迟解析呢?这是因为,如果延迟代码解析,那么代码需要立即执行,这反而会让代码更慢。因为首先它需要一次延迟解析,之后再经过一次立即解析。这会导致相对于一次立即解析50%的延迟时间。

现在我们已经对后台发生的事情有了基本的了解,是时候考虑如何优化解析器了。我们可以用这样一种方式来编写代码,让函数在正确的时间被解析。

大多数解析器都能识别一种模式:将函数括在括号中(立即执行表达式)。对于解析器来说,这几乎总是一个快速执行的信号,即函数将立即执行。如果解析器看到一个开括号,紧接着是一个函数声明,它将快速地解析这个函数。我们可以通过显式地声明立即执行的函数来帮助解析器提升效率

假设我们有一个名为foo的函数。

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

由于没有明显的迹象表明函数将立即执行,浏览器将执行延迟解析。然而,我们确信这不是我们想要的结果,所以我们可以做两件事。

首先,我们将函数存储在一个变量中:

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

注意,我们在function关键字和函数参数前的左括号之间保留了函数的名称。这不是必需的,但是建议这样做,因为在抛出异常的情况下,堆栈错误信息将包含函数的实际名称,而不仅仅是显示错误。

解析器仍然执行惰性解析。可以通过添加一个小细节来防止这种情况:将函数括在圆括号中。

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

此时,当解析器看到function关键字前面的左括号时,它将立即执行一次即时解析。

这可能导致很难手动管理,因为我们需要知道在哪些情况下解析器将决定延迟或即时地解析代码。此外,我们还需要花时间考虑是否立即调用某个函数。我们当然不想这么做。更重要的是,这将使我们的代码更难阅读和理解。

为了帮助我们做到这一点,像 Optimize.js 这样的工具可以提供帮助。它们的唯一目的是优化JavaScript源代码的初始加载时间。它们对代码进行静态分析,并以这样一种方式修改代码:首先将需要执行的函数被括在圆括号中,以便浏览器能够快速解析它们并为执行做好准备。

我们像往常一样编码有一段代码是这样的:

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

一切看起来都很好,如预期的那样工作,而且速度很快,因为在函数声明之前有一个左括号。太好了。当然,在投入生产之前,我们需要缩小代码以节省字节。以下代码是minifier的输出:

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

似乎好了,代码像以前一样工作,但还是少了点什么。minifier去掉了包围函数的括号,而是在函数前面添加了一个感叹号。这意味着解析器将跳过此操作并执行惰性解析。在上面,为了能够执行这个函数,它将在惰性函数之后立即执行一个即时解析。这使得我们的代码运行得更慢。幸运的是,我们有像Optimize.js 这样的工具,它们为我们做了大量的工作。通过Optimize.js 传递缩小后的代码会产生如下输出:

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

现在才达到我们的目的,我们有了两个方面的优点:代码被缩小,解析器正确地识别哪些函数需要即时地解析,哪些函数需要延迟地解析。

5、预编译(Precompilation)

但是为什么我们不能在服务器端完成所有这些工作呢?毕竟,最好只做一次,并将结果提供给客户端,而不是每次都强迫每个客户端完成同样的工作。关于引擎是否应该提供一种执行预编译脚本的方法以避免在浏览器中浪费时间的问题,一直有讨论。

本质上,我们的想法是有一个服务器端工具,它可以生成字节码,而我们只需要通过传输并在客户端执行它。然后我们会看到启动时间上的一些明显差异。这听起来很诱人,但其实没那么简单。这可能会产生相反的效果,因为它更大,而且出于安全原因,很可能需要对代码进行签名和处理。例如,V8团队正在内部避免重新解析,这样预编译实际上可能没有那么有利。

6、让用户更快访问你的应用的几个方法

1)检查你的依赖,去除所有不需要的东西。

2)将代码分割成更小的块,而不是加载一个大的bundle。

3)尽可能延迟JavaScript的加载,你可以只加载当前路由所需的代码片段。

4)使用开发工具和DeviceTiming来找出瓶颈所在。

5)使用像 Optimize.js 这样的工具来帮助解析器决定什么时候应该即时解析,什么时候应该延迟解析。

参考资料:

资源

  • https://blog.sessionstack.com/how-javascript-works-parsing-abstract-syntax-trees-asts-5-tips-on-how-to-minimize-parse-time-abfcf7e8a0c8
  • https://en.wikipedia.org/wiki/Abstract_syntax_tree
  • https://medium.com/@jotadeveloper/abstract-syntax-trees-on-javascript-534e33361fc7
  • https://medium.com/reloading/javascript-start-up-performance-69200f43b201
  • https://timkadlec.com/2014/09/js-parse-and-execution-time/
  • https://www.youtube.com/watch?v=Fg7niTmNNLg
  • https://github.com/liangklfangl/react-article-bucket
  • https://astexplorer.net/
  • https://github.com/danielmendel/DeviceTiming
  • https://github.com/nolanlawson/optimize-js
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值