本系列其他译文请看[JS工作机制 - 小白1991的专栏 - 掘金 (juejin.cn)] (juejin.cn/column/6988…
本章阅读指数:3
本章的优化原理可以学习一下,但是优化的方式有一些现在可能用不到了。使用webpack已经能够很好的解决代码冗余,无效代码,代码分割的问题了
概述
众所周知,把所有的JS代码写到一个大文件中是非常头疼的。这些代码不仅要在网络中传输,还要被解析,编译成字节码,最后才执行。
我们之前讨论了JS引擎,运行时,调用栈,以及V8。这一章,我们讨论一下:JS引擎如何把文本解析成机器可以理解的东西,以及作为web工程师,如何利用这些知识。
编程语言是如何工作的
不管使用什么样的编程语言,都需要一些软件来执行源代码,让计算机真正的做事情。这些软件,不是编译器就是解释器。不管是采用解释语言(JavaScript, Python, Ruby)还是编译语言(C#, Java, Rust),都少不了一个环节:把文本一样的源代码,转换成抽象语法树(AST)。AST不仅仅是源码的数据结构表征,同样也是语法分析的重要基础。语法分析是编译器来验证程序和语言元素的正确性和使用的合法性。之后,AST被用类生成真实的字节码或者机器码。
AST 应用
AST不仅仅拿来编译和解释。在计算机世界里,有很多应用。常见的一个是静态代码分析。静态分析不会执行代码,但是他们需要理解代码的结构。
假如,你想做个一个工具,去查找通用的代码结构,这样你可以进行重构,减少代码冗余。你也可以使用字符串比较来实现,但是这种方案非常的基础和局限。
如果你有兴趣实现这么一个工具,也不用自己实现解析器。现在有很多适配ES6规范的开源的解析器,比如Esprima 和 Acorn的搭配。也有很多工具吗,可以帮助解析器的输出代码,叫ASTs--在代码转义时被广泛应用。
例如你想实现一个转义器,把Python转换到JS。你可以使用一个Python转义器,生成一个AST,然后再把AST生成JS代码。怎么做到的呢?本质上讲AST只是一些语言的不同的表征方式。在转换之前,它表现为文本,该文本遵守着构成语言的一些语法规则。转换之后,它就变现为一个树结构,树结构包含的信息跟输入文本是一样的。因此,我们也可以反着做,再把AST转成代码文本。
JavaScript 转换
我们看看AST是怎么构建的。
function foo(x) {
if (x > 10) {
var a = 2;
return a * x;
}
return x + 10;
}
这段代码会生成下面的AST.
这是图是一个简化的版本。真实的AST会复杂一些,可以通过 AST Explorer查看真实的AST。
为什么需要知道JS转换的工作原理呢。毕竟,这些都是浏览器的工作。
我们先看一下图,这是我们JS执行各个步骤所耗费的时间。仔细看看,有一些有意思的事情。
我们发现,JS的转换阶段花费了15% 到 20% 的时间。这些数据来自于真实的网站统计。一个典型的单页程序会加载大约 0.4M 的 JavaScript 代码,然后消耗掉浏览器大概 370ms 的时间来进行解析。 看起来不是跟多,但是要只知道这只是把JS转换成AST的时间。这不包含执行或者渲染页面的其他事情,比如CSS和HTML的渲染。这还只是在桌面端。在移动设备商,就更复杂了 手机上进行转换的时间往往是桌面端的2到5倍
上图显示了1MBJS代码在不同平台上解析的时间
而且,如今Web应用的复杂度日益增高,客户端承载了更多的业务逻辑,也引入了类似原生的用户体验。打开DEV Tools看一看,在页面完全加载期间各个阶段比如解析,编译的时间,你应该很容易理解以上所述给你的应用带来的影响有多大。
不幸的是,手机浏览器没有dev tools。但不用担心,你可以使用DeviceTiming,它可以让你在一个受控环境中确定解析和执行的时间。它的工作原理是,用一些工具代码包装一下本地代码,这样每一次你的页面在不同设备商被点击,可以本地测量解析和运行时间。
JS引擎做了一些事情,精简工作,进行优化。以下为主流浏览器使用的技术。 V8处理脚本流和代码缓存。脚本流意味着一旦脚本开始下载,async 和 deferred 的脚本在单独的线程中进行解析。解析会在脚本下载完成时立即完成。这会提升 10% 的页面加载速度。
每一次页面访问,JS代码往往被编译成字节码。但是一旦用户导航到其他页面,字节码就失效了。这是因为编译代码依赖了很多状态和机器上下文。在这一步,Chrome 42引入了字节码缓存。这个技术在本地保存编译后的代码,这样当用户回到之前的这样,就不用去下载,解析和编译了。这在解析和编译上节省了40%的时间,也节省了手机设备的电池声明周期。
Opera浏览器中, Carakan 引擎可以复用另一个程序最近编译过的输出,而且不要求是相同页面甚至是同源的程序。这个缓存技术非常有效,而且可以跳过整个编译步骤。它依赖于用户行为和浏览器场景:每当用户在程序/网站上遵循特定的用户浏览习惯,则会加载相同的 JavaScript 代码。不过,Carakan 早就被谷歌 V8 引擎所取代
Firefox使用的SpiderMonkey引擎不会缓存任何东西。它可以过渡到一个监听阶段,可以统计给定脚本执行的次数,然后决定那一部分的是热代码,并需要被优化。
不过,也有啥都不做的。Safari 首席开发者 Maciej Stachowiak 指出 Safari 不缓存编译的字节码。他们可能已经想到了缓存技术但并没付诸实施,因为生成代码的耗时小于总运行时间的 2%。
不过这些优化对于源码的转换本身,没有直接的作用,他们只是尽量去跳过这一步。
我们可以做一些事情,提升初次加载的速度。我们可以最小化JS数量:减少脚本,减少解析,减少执行。要做到这一步,就需要一个按需加载而不是一次性加载个大文件。例如PRPL 模式即表示该种代码传输类型。或者,可以检查依赖然后查看是否有无用、冗余的依赖导致代码库的膨胀。然而,这些东西需要很大的篇幅来进行讨论。
本文讨论的目标,是如何提升JS转换的速度。现代的JS转化器使用探索式判断,一段代码是否是立即执行或者将要执行,基于此转换器将选择立即或者延迟转换,然后立即解析马上需要被编译的函数。主要做三个事情:构建AST,构建层级作用域,查找所有的语法错误。另一方面,当函数还不需要编译的时候,就使用延迟加载。此时不会构建AST,也不会查找语法错误。只是构建了作用域层级,这样相对于立即解析会节省大约一半的时间。
这并非是一个新概念。甚至IE9都支持这种类型的优化,虽然它支持的方式很简陋
看一个例子
function foo() {
function bar(x) {
return x + 10;
}
function baz(x, y) {
return x + y;
}
console.log(baz(100, 200));
}
像前一个例子,把代码输入解析器进行语法分析然后输出 AST。这样表述如下:
foo 函数接受一个参数(X)。它有一个返回值。这个函数返回了X加1O的结果。
bar函数接受两个参数(X和Y)。有一个返回值,X和Y相加的值。
调用 baz 函数传入 100 和 2。
调用 console.log 参数为之前函数调用的返回值。
发生了什么?解析器看到了一个foo函数的声明,一个bar函数的声明,bar函数的调用,和console.log函数的调用。但是解析器也做了一些额外的无关紧要的工作---解析了foo函数。但是foo函数没有被调用。在实际的使用中,是有很多函数被声明了却从没有被调用。
foo函数被声明了,但是没有指明用途。所以没有解析foo函数,只在函数执行前才开始解析。懒解析依然需要查找到函数体,然后声明它。因为不会马上执行,所有也不需要语法树。另外,也不会从堆上分配内存--这会耗费相当大的资源。简单来说,就是跳过这些步骤,提升性能。
所以,解析器实际做的是这些事情:
解析器仅仅知道foo函数已经声明了。但是没有进入到函数体内。这个案例中,函数体只是一个返回值。但是实际使用的时候,会复杂得多,包含多个返回值,条件判断,循环,变量声明甚至嵌套的函数声明。但是因为方法没有被调用,因此这些都是极大的性能浪费。
这是个简单概念,但是真实的实现很复杂。不局限于以上示例。整个方法还可以应用于函数,循环,条件语句,对象等等。一般情况下,所有代码都需要解析。
例如,以下是一个实现 JavaScript 模块的相当常见的模式。
function foo() {
function bar(x) {
return x + 10;
}
function baz(x, y) {
return x + y;
}
console.log(baz(100, 200));
}
大多数的现代JS解析器可以认出这种模式,这种模式表示它包含的代码需要立刻被解析。
为什么解析器不经常是懒解析?因为一旦立马要执行的代码被懒解析了,就会很慢。这么做将会运行一次懒解析之后进行另一个立即解析。和立即解析相比,这么做会降低 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;
});
解析器看到了我们加上去的左边小括弧,这样就可以立即开始解析。
让解析器知道在何种情况下懒解析或者立即解析代码,手动做起来是非常困难的。 我们需要思考指定的函数是否需要被立刻执行。这样做既麻烦,也会让代码不好阅读。因此可以使用Optimize.js 这样的工具来帮忙。 该工具只是用来优化 JavaScript 源代码的初始加载时间。他们对代码运行静态分析,然后通过使用括号封装需要立即运行的函数以便浏览器立即解析并准备运行它们。
我们可以这么写代码:
(function() {
console.log('Hello, World!');
})();
看起来不错。但是我们要知道,在发布之前,我们需要最小化代码。那么这段代码就变成了这样:
!function(){console.log('Hello, World!')}();
看起来一切正常。代码如期运行。然而好像少了什么。压缩工具移除了封装函数的括号代之以一个感叹号。这意味着解析器会跳过该代码且将会运行懒解析。 为了运行该函数,解析器会在懒解析之后进行立即解析。这会导致代码运行变慢。幸运的是,可以利用 Optimize.js 来解决此类问题。 Optimize.js 压缩过的代码会输出如下代码
!(function(){console.log('Hello, World!')})();
现在,充分利用了各自的优势:压缩代码且解析器正确地识别懒解析和立即解析的函数。
预编译
但是为何不在服务端进行这些工作呢?毕竟,相比强制让每一个客户每次都做整个工作,让服务器只做一次,然后传递给客户端看起来好得多。 有一个进行中的方案,讨论引擎是否需要提供一个方式去预编译代码,这样浏览器就不用浪费时间了。这个方案提供一个服务端工具,去生成字节码,我们只需要把字节发传递给客户端,让客户端执行就行了。 然后可以在启动时看到一些不同。看起来很不错,但是实际要复杂很多。这可能带来一些负面的效果,因为它将会很庞大且由于安全原因很有可能需要进行签名和处理。比如V8团队,正在努力在内部减少重复解析,这样预编译带来的优势就没那么大了。
提升性能的一些建议
- 检查依赖,抛弃一切不需要的东西
- 把代码分割到更小的chunk中,而不是一次性加载一个大文件块
- 尽可能延迟加载JS,只加载当前需要代码
- 使用dev tools 和DeviceTiming去查找到瓶颈所在
- 使用像 Optimize.js 的工具来帮助解析器选择立即解析或者懒解析以加快解析速度。