Skip to content

💤

《你不知道的 JavaScript》

性能测试与调优

本部分的前四章都是关于(异步与并发)编码模式的性能,第五章是关于宏观程序架构级的性能。这一章要讨论的主题则是微观性能,关注点在单个表达式和语句。

最能引发普遍好奇心的领域之一(真的,有些开发者可能会沉迷于此)就是分析和测试编写一行或一块代码的多个选择,然后确定哪一种更快。

我们将要来探讨这些问题中的一部分,但从一开始你就要明白,本章的目的不是为了满足对微观性能调优的沉迷,比如某个 JS 引擎上运行++a是不是会比a++快。本章更重要的目标是弄清楚哪些种类的 JS 性能更重要,哪些种类则无关紧要,以及如何区分。

但是,在得出结论之前,首先需要探讨如何最精确可靠地测试 JS 性能,因为我们的知识库中充满了大量的错误概念和观念。需要筛选掉所有垃圾以得到清晰的概念。

性能测试

好,现在是时候消除一些误解了。我敢打赌,如果被问到如何测试某个运算的速度(执行时间),绝大多数 JS 开发者都会从类似下面的代码开始:

javascript
var start = new Date().getTime() // 或者Date.now()

// 进行一些操作

var end = new Date().getTime()

console.log('Duration:', end - start)

如果这大致上就是你首先想到的,请举手。嗯,我想就是如此。这种方案有很多错误,不过别难过,我们会找到正确方法的。

这个测量方式到底能告诉你什么呢?理解它做了什么以及关于这个运算的执行时间不能提供哪些信息,就是学习如何正确测试 JS 性能的关键所在。

如果报告的时间是 0,可能你会认为它的执行时间小于 1ms。但是,这并不十分精确。有些平台的精度并没有达到 1ms,而是以更大的递增间隔更新定时器。比如,Windows(也就是 IE)的早期版本上的精度只有 15ms,这就意味着这个运算的运行时间至少需要这么长才不会被报告为 0 !

还有,不管报告的时长是多少,你能知道的唯一一点就是,这个运算的这次特定的运行消耗了大概这么长时间。而它是不是总是以这样的速度运行,你基本上一无所知。你不知道引擎或系统在这个时候有没有受到什么影响,以及其他时候这个运算会不会运行得更快。

如果时长报告是 4 呢?你能更加确定它的运行需要大概 4ms 吗?不能。它消耗的时间可能要短一些,而且在获得 start 或 end 时间戳之间也可能有其他一些延误。

更麻烦的是,你也不知道这个运算测试的环境是否过度优化了。有可能 JS 引擎找到了什么方法来优化你这个独立的测试用例,但在更真实的程序中是无法进行这样的优化的,那么这个运算就会比测试时跑得慢。

那么,能知道的是什么呢?很遗憾,根据前面提出的内容,我们几乎一无所知。这样低置信度的测试几乎无力支持你的任何决策。这个性能测试基本上是无用的。更坏的是,它是危险的,因为它可能提供了错误的置信度,不仅是对你,还有那些没有深入思考条件就带来测试结果的人员。

重复

“好吧,”你现在会说,“那就用一个循环把它包起来,这样整个测试的运行时间就会更长一些了。”如果重复一个运算 100 次,然后整个循环报告共消耗了 137ms,那你就可以把它除以 100,得到每次运算的平均用时为 1.37ms,是这样吗?

并不完全是这样。

简单的数学平均值绝对不足以对你要外推到整个应用范围的性能作出判断。迭代 100 次,即使只有几个(过高或过低的)的异常值也可以影响整个平均值,然后在重复应用这个结论的时候,你还会扩散这个误差,产生更大的欺骗性。

你也可以不以固定次数执行运算,转而循环运行测试,直到达到某个固定的时间。这可能会更可靠一些,但如何确定要执行多长时间呢?你可能会猜测,执行时间应该是你的运算执行的单次时长的若干倍。错。

实际上,重复执行的时间长度应该根据使用的定时器的精度而定,专门用来最小化不精确性。定时器的精度越低,你需要运行的时间就越长,这样才能确保错误率最小化。15ms 的定时器对于精确的性能测试来说是非常差劲的。要最小化它的不确定性(也就是出错率)到小于 1%,需要把你的每轮测试迭代运行 750ms。而 1ms 定时器时只需要每轮运行 50ms 就可以达到同样的置信度。

但是,这只是单独的一个例子。要确保把异常因素排除,你需要大量的样本来平均化。你还会想要知道最差样本有多慢,最好的样本有多快,以及最好和最差情况之间的偏离度有多大,等等。你需要知道的不仅仅是一个告诉你某个东西跑得有多快的数字,还需要得到某个可以计量的测量值告诉你这个数字的可信度有多高。

还有,你可能会想要把不同的技术(以及其他方面)组合起来,以得到所有可能方法的最佳平衡。

这仅仅是个开始。如果你过去进行性能测试的方法比我刚才提出的还要不正式的话,好吧,那么可以说你完全不知道:正确的性能测试。

Benchmark.js

任何有意义且可靠的性能测试都应该基于统计学上合理的实践。此处并不打算撰写一章关于统计学的内容,所以我要和如下术语挥手作别:标准差、方差、误差幅度。如果你不知道这些术语的意思 —— 我回大学上了一门统计学课程,但对这些还是有点糊涂 —— 那么实际上你还不够资格编写自己的性能测试逻辑。

幸运的是,像 John-David Dalton 和 Mathias Bynens 这样的聪明人了解这些概念,并编写了一个统计学上有效的性能测试工具,名为 Benchmark.js。因此,对于这个悬而未决的问题,我的答案就是:“使用这个工具就好了。”

我并不打算复述他们的整个文档来介绍 Benchmark.js 如何运作。他们的 API 很不错,你应该读一读。还有一些很棒的文章介绍了更多的细节和方法,比如这里这里

但为了简单展示一下,下面介绍应该如何使用 Benchmark.js 来运行一个快速的性能测试:

javascript
function foo() {
  // 要测试的运算
}

var bench = new Benchmark(
  'foo test', // 测试名称
  foo, // 要测试的函数
  {
    //..  更多配置
  }
)

bench.hz // 每秒运行次数
bench.stats.moe // 出错边界
bench.stats.variance // 方差
// ..

除了这里我们介绍的一点内容,关于 Benchmark.js 的使用还有很多要学的。但是,关键在于它处理了为给定的一段 JS 代码建立公平、可靠、有效的性能测试的所有复杂性。如果你想要对你的代码进行功能测试和性能测试,这个库应该最优先考虑。

这里我们展示了测试一个像 X 这样的单个运算的使用方法,不过很可能你还想要比较 X 和 Y。通过在一个 suite(Benchmark.js 组织特性)中建立两个不同的测试很容易做到这一点。然后,可以依次运行它们,比较统计结果,得出结论,判断 X 和 Y 哪个更快。

Benchmark.js 当然可以用在浏览器中测试 JS(参见“jsPerf.com”),它也可以在非浏览器环境中运行(Node.js 等)。

Benchmark.js 有一个很大程度上还未开发的潜在用例,就是你可以将其用于开发或测试环境中,针对应用中 JS 的关键路径部分运行自动性能回归测试。这和你可能在部署之前运行的单元测试套件类似,你也可以与之前的版本进行性能测试比较,以监控应用性能是提高了还是降低了。

setup/teardown

在前面的代码片段中,我们忽略了“额外选项”{ .. } 对象。这里有两个选项是我们应该讨论的:setup 和 teardown。

这两个选项使你可以定义在每个测试之前和之后调用的函数。

有一点非常重要,一定要理解,setup 和 teardown 代码不会在每个测试迭代都运行。最好的理解方法是,想像有一个外层循环(一轮一轮循环)还有一个内层循环(一个测试一个测试循环)。setup 和 teardown 在每次外层循环(轮)的开始和结束处运行,而不是在内层循环中。

为什么这一点很重要呢?设想你有一个像这样的测试用例:

javascript
a = a + 'w'
b = a.charAt(1)

然后,你建立了测试 setup 如下:

javascript
var a = 'x'

你的目的可能是确保每个测试迭代开始的 a 值都是 "x"。但并不是这样!只有在每一轮测试开始时 a 值为 "x",然后重复 + "w" 链接运算会使得 a 值越来越长,即使你只是访问了位置 1 处的字符 "w"。

对某个东西,比如 DOM,执行产生副作用的操作的时候,比如附加一个子元素,常常会刺伤你。你可能认为你的父元素每次都清空了,但是,实际上它被附加了很多元素,这可能会严重影响测试结果。

环境为王

对特定的性能测试来说,不要忘了检查测试环境,特别是比较任务 X 和 Y 这样的比对测试。仅仅因为你的测试显示 X 比 Y 快,并不能说明结论 X 比 Y 快就有实际的意义。举例来说,假定你的性能测试表明 X 运算每秒可以运行 10 000 000 次,而 Y 每秒运行 8 000 000 次。你可以说 Y 比 X 慢了 20%。数学上这是正确的,但这个断言并不像你想象的那么有意义。

让我们更认真地思考这个结果:每秒 10 000 000 次运算就是每毫秒 10 000 次运算,每微妙 10 次。换句话说,单次运算需要 0.1μs,也就是 100ns。很难理解 100ns 到底有多么短。作为对比,据说人类的眼睛通常无法分辨 100ms 以下的事件,这要比 X 运算速度的 100ns 慢一百万倍了。

即使最近的科学研究表明可能大脑可以处理的最快速度是 13ms(大约是以前结论的 8 倍),这意味着 X 的运算速度仍然是人类大脑捕获一个独立的事件发生速度的 125 000 倍。X 真的非常非常快。

不过更重要的是,我们来讨论一下 X 和 Y 的区别,即每秒 2 000 000 次运算差距的区别。如果 X 需要 100ns,而 Y 需要 80ns,那么差别就是 20ns,这在最好情况下也只是人类大脑所能感知到的最小间隙的 65 万分之一。

我要说的是什么呢?这些性能差别无所谓,完全无所谓!

但是稍等,如果这些运算将要连续运行很多次呢?那么这个差别就会累加起来,对不对?

好吧,那我们要问的就是,这个运算 X 要一个接一个地反复运行多次的可能性有多大呢,得运行 650 000 次才能有一点希望让人类感知到。更可能的情况是,它得在一个紧密循环里运行 5 000 000~10 000 000 次才有意义。

你脑子里的计算机科学家可能抗议说,这是可能的;但你脑子里那个现实的的你会更大声说还是应该检查一下这个可能性到底有多大。即使在很少见的情况下是有意义的,但在绝大数情况下它却是无关紧要的。

对于微小运算的绝大多数测试结果,比如 ++x 对比 x++ 的神话,像出于性能考虑应该用 X 代替 Y 这样的结论都是不成立的。

引擎优化

你无法可靠地推断,如果在你的独立测试中 X 比 Y 要快上 10μs,就意味着 X 总是比 Y 要快,就应该总是使用 X。性能并不是这样发挥效力的。它要比这复杂得多。举例来说,设想一下(纯粹假设)你对某些微观性能行为进行了测试,比如这样的比较:

javascript
var twelve = '12'
var foo = 'foo'

// 测试1
var X1 = parseInt(twelve)
var X2 = parseInt(twelve)

// 测试2
var Y1 = Number(twelve)
var Y2 = Number(twelve)

如果理解与Number(..) 相比parseInt(..)做了些什么,你可能会凭直觉以为parseInt(..)做的工作可能更多,特别是在 foo 用例下。或者你可能会直觉认为它们的工作量在 foo 用例下应该相同,两个都应该能够在第一个字符 f 处停止。

哪种直觉是正确的呢?老实说,我不知道。不过,对于我举的这个例子,哪个判断正确并不重要。测试结果可能是什么?这里我再次单纯假设,并没有实际进行过测试,也没必要那么做。

让我们假装测试结果返回的是从统计上来说完全相同的 X 和 Y。那么你能够确定你关于 f 字符的直觉判断是否正确吗?不能。

在我们假设的情况下,引擎可能会识别出变量 twelve 和 foo 在每个测试中只被使用了一次,因此它可能会决定把这些值在线化。那么它就能识别出Number( "12" )可以直接替换为 12。对于parseInt(..),它可能会得出同样的结论,也可能不会。

也有可能引擎的死代码启发式去除算法可能会参与进来,它可能意识到变量 X 和 Y 并没有被使用,因此将其标识为无关紧要的,故而在整个测试中实际上什么事情都没有做。所有这些都只是根据单个测试所做的假设的思路。现代引擎要比我们凭直觉进行的推导复杂得多。它们会实现各种技巧,比如跟踪记录代码在一小段时期内或针对特别有限的输入集的行为。

如果引擎由于固定输入进行了某种优化,而在真实程序中的输入更加多样化,对优化决策影响很大(甚至完全没有)呢?或者如果引擎看到测试由性能工具运行了数万次而进行优化,但是在真实程序中只会运行数百次,而这种情况下引擎认为完全不值得优化呢?

我们设想的所有这些优化可能性在受限的测试中都有可能发生,而且在更复杂的程序中(出于各种各样的原因),引擎可能不会进行这样的优化。也可能恰恰相反,引擎可能不会优化这样无关紧要的代码,但是在系统已经在运行更复杂的程序时可能会倾向于激进的优化。

这里我要说明的就是,你真的不能精确知道底下到底发生了什么。你能进行的所有猜想和假设对于这样的决策不会有任何实际的影响。

这是不是意味着无法真正进行任何有用的测试呢?绝对不是!

这可以归结为一点,测试不真实的代码只能得出不真实的结论。如果有实际可能的话,你应该测试实际的而非无关紧要的代码,测试条件与你期望的真实情况越接近越好。只有这样得出的结果才有可能接近事实。

像 ++x 对比 x++ 这样的微观性能测试结果为虚假的可能性相当高,可能我们最好就假定它们是假的。

jsPerf.com

尽管在所有的 JS 运行环境下,Benchmark.js 都可用于测试代码的性能,但有一点一定要强调,如果你想要得到可靠的测试结论的话,就需要在很多不同的环境(桌面浏览器、移动设备,等等)中测试汇集测试结果。

比如,针对同样的测试高端桌面机器的性能很可能和智能手机上 Chrome 移动设备完全不同。而电量充足的智能手机上的结果可能也和同一个智能手机但电量只有 2% 时完全不同,因为这时候设备将会开始关闭无线模块和处理器。

如果想要在不止一个环境下得出像“X 比 Y 快”这样的有意义的结论成立,那你需要在尽可能多的真实环境下进行实际测试。仅仅因为在 Chrome 上某个 X 运算比 Y 快并不意味着这在所有的浏览器中都成立。当然你可能还想要交叉引用多个浏览器上的测试运行结果,并有用户的图形展示。

有一个很棒的网站正是因这样的需求而诞生的,名为 jsPerf。它使用我们前面介绍的 Benchmark.js 库来运行统计上精确可靠的测试,并把测试结果放在一个公开可得的 URL 上,你可以把这个 URL 转发给别人。

每次测试运行的时候,测试结果就会被收集并持久化,累积的测试结果会被图形化,并展示到一个页面上以供查看。

在这个网站上创建测试的时候,开始需要先填写两个测试用例,但是你可以按需增添加任意多的测试。你还可以设定在每个测试循环开始时运行的 setup 代码,以及每个测试循环结束时运行的 teardown 代码。

NOTE

可以通过一个技巧实现只用一个测试用例(如果需要测试单个方法的性能,而不需要对比的话),就是在首次创建的时候在第二个测试输入框填入占位符文字,然后编辑测试并把第二个测试清空,也就是删除了它。你总是可以在以后增加新的测试用例。

可以定义初始页面设置(导入库、定义辅助工具函数、声明变量,等等)。还有选项可以在需要的时候定义 setup 和 teardown 行为,参见上边 benchmark.js 小节。

完整性检查

jsPerf 是一个很好的资源,但认真分析的话,出于本章之前列出的多种原因,公开发布的测试中有大量是有缺陷或无意义的。

考虑:

javascript
// 用例1
var x = []
for (var i = 0; i < 10; i++) {
  x[i] = 'x'
}
// 用例2
var x = []
for (var i = 0; i < 10; i++) {
  x[x.length] = 'x'
}
// 用例3
var x = []
for (var i = 0; i < 10; i++) {
  x.push('x')
}

这个测试场景的一些需要思考的现象如下。

  • 对开发者来说,极常见的情况是:把自己的循环放入测试用例,却忘了 Benchmark.js 已经实现了你所需的全部重复。非常有可能这些情况下的 for 循环完全是不必要的噪音。

  • 每个测试用例中 x 的声明和初始化可能是不必要的。回忆一下之前的内容,如果x = []放在 setup 代码中,它并不会在每个测试迭代之前实际运行,而是只在每轮测试之前运行一次。这意味着 x 将会持续增长到非常大,而不是 for 循环中暗示的大小 —— 10。

    所以,其目的是为了确定测试只局限于 JS 引擎如何处理小数组(大小为 10)吗?目的可能是这样,而如果确实是的话,你必须考虑这是否过多关注了微秒的内部实现细节。

    另一方面,测试的目的是否包含数组实际上增加到非常大之后的环境?与真实使用情况相比,JS 处理大数组的行为是否适当和精确呢?

  • 目的是否是找出x.lengthx.push(..)对向数组 x 添加内容的操作的性能的影响有多大?好吧,这可能是有效的测试目标。但话说回来,push(..)是一个函数调用,所以它当然要比[..]访问慢。可以证明,用例 1 和 2 要比用例 3 公平得多。

以下是另一个例子,展示了典型的不同类型对比的缺陷:

javascript
// 用例1
var x = ['John', 'Albert', 'Sue', 'Frank', 'Bob']
x.sort()
// 用例2
var x = ['John', 'Albert', 'Sue', 'Frank', 'Bob']
x.sort(function mySort(a, b) {
  if (a < b) return -1
  if (a > b) return 1
  return 0
})

这里,很明显测试目标是找出自定义的比较函数mySort(..)比内建默认比较函数慢多少。但是,通过把函数mySort(..)指定为在线函数表达式,你已经创建了一个不公平 / 虚假的测试。这里,第二个用例中测试的不只是用户自定义 JS 函数,它还在每个迭代中创建了一个新的函数表达式。

如果运行一个类似的测试,但是将其更新为将创建一个在线函数表达式与使用预先定义好的函数对比,如果发现在线函数表达式创建版本要慢 2% ~ 20%,你会不会感到吃惊?!

除非你这个测试特意要考虑在线函数表达式创建的代价,否则更好更公平的测试就是将mySort(..)的声明放在页面 setup 中——不要把它放在测试 setup 中,因为那会在每一轮不必要地重新声明 —— 只需要在测试用例中通过名字引用它:x.sort(mySort)

根据前面的例子,还有一个陷阱是隐式地给一个测试用例避免或添加额外的工作,从而导致“拿苹果与橘子对比”的场景:

javascript
// 用例1
var x = [12, -14, 0, 3, 18, 0, 2.9]
x.sort()

// 用例2
var x = [12, -14, 0, 3, 18, 0, 2.9]

x.sort(function mySort(a, b) {
  return a - b
})

除了前面提到的在线函数表达式陷阱,第二个用例的mySort(..)可以工作。因为你给它提供的是数字,但如果是字符串的话就会失败。第一个用例不会抛出错误,但它的行为不同了,输出结果也不同!这应该很明显,但是两个测试用例产生不同的输出几乎肯定会使整个测试变得无效!

不过,在这种情况下,除了不同的输出之外,内建的比较函数sort(..)实际上做了mySort()没有做的额外工作,包括内建的那个把比较值强制类型转化为字符串并进行字典序比较。第一段代码结果为[-14, 0, 0, 12, 18, 2.9, 3],而第二段代码结果(基于目标而言可能更精确)为[-14, 0, 0, 2.9, 3, 12, 18]

所以,这个测试是不公平的,因为对于不同的用例,它并没有做完全相同的事情。你得到的任何结果都是虚假的。

同样的陷阱可能会更加不易察觉:

javascript
// 用例1
var x = false
var y = x ? 1 : 2

// 用例2
var x
var y = x ? 1 : 2

这里,目的可能是测试对 Boolean 值进行强制类型转换对性能的冲击:如果 x 表达式并不是 Boolean 运算符,? : 就会进行强制类型转换(参见本书的“类型和语法”部分)。所以,你显然可以接受如下事实:第二个用例中有额外的类型转换工作要做。

那么不易察觉的问题是什么呢?在第一个用例中设定了 x 的值,而在另一个中则没有设定,所以实际上你在第一个用例中做了在第二个用例中没有做的事。要消除这个潜在的(虽然很小的)影响,可以试着这样:

javascript
// 用例1
var x = false
var y = x ? 1 : 2

// 用例2
var x = undefined
var y = x ? 1 : 2

现在两种情况下都有赋值语句了。所以你想要测试的内容(有无对 x 的类型转换)很可能就更加精确地被独立出来并被测试到了。

写好测试

我看看能不能讲清楚我在这里想要说明的更重要的一点。

要写好测试,需要认真分析和思考两个测试用例之间有什么区别,以及这些区别是有意还是无意的。

有意的区别当然是正常的,没有问题,可我们太容易造成会扭曲结果的无意的区别。你需要非常小心才能避免这样的扭曲。还有,你可能有意造成某个区别,但是,对于这个测试的其他人来说,你的这个意图可能不是那么明显,所以他们可能会错误地怀疑(或信任!)你的测试。如何解决这样的问题呢?

编写更好更清晰的测试。但还有,花一些时间来编写文档(使用 jsPerf.com 上的 Description 字段和 / 或代码注释)精确表达你的测试目的,甚至对于那些微小的细节也要如此。找出那些有意的区别,这会帮助别人和未来的你更好地识别出那些可能扭曲测试结果的无意区别。

通过在页面或测试 setup 设置中预先声明把与测试无关的事情独立出来,使它们移出测试计时的部分。

不要试图窄化到真实代码的微小片段,以及脱离上下文而只测量这一小部分的性能,因为包含更大(仍然有意义的)上下文时功能测试和性能测试才会更好。这些测试可能也会运行得慢一点,这意味着环境中发现的任何差异都更有意义。

微性能

到目前为止,我们一直在围绕各种微性能问题讨论,并始终认为沉迷于此是不可取的。现在我要花费一点时间直面这个问题。

在考虑对代码进行性能测试时,你应该习惯的第一件事情就是你所写的代码并不总是引擎真正运行的代码。在第 1 章讨论编译器语句重排序问题时,我们简单介绍过这个问题。不过这里要说的是,有时候编译器可能会决定执行与你所写的不同的代码,不只是顺序不同,实际内容也会不同。

来考虑下面这段代码:

javascript
var foo = 41

;(function () {
  ;(function () {
    ;(function (baz) {
      var bar = foo + baz
      // ..
    })(1)
  })()
})()

可能你会认为最内层函数中的引用 foo 需要进行三层作用域查找。本系列的“作用域和闭包”部分讨论了词法作用域是如何工作的。事实上,编译器通常会缓存这样的查找结果,使得从不同的作用域引用 foo 实际上并没有任何额外的花费。

但是,还有一些更深入的问题需要思考。如果编译器意识到这个 foo 只在一个位置被引用而别处没有任何引用,并且注意到这个值只是 41 而从来不会变成其他值呢?

JS 可能决定完全去掉 foo 变量,将其值在线化,这不是很可能发生也可以接受的吗?就像下面这样:

javascript
;(function () {
  ;(function () {
    ;(function (baz) {
      var bar = 41 + baz
      // ..
    })(1)
  })()
})()

NOTE

当然,这里编译器有可能也对 baz 进行类似的分析和重写。

当你把 JS 代码看作对引擎要做什么的提示和建议,而不是逐字逐句的要求时,你就会意识到,对于具体语法细节的很多执着迷恋已经烟消云散了。

另一个例子:

javascript
function factorial(n) {
  if (n < 2) return 1
  return n * factorial(n - 1)
}
factorial(5) // 120

啊,很不错的老式阶乘算法!你可能认为 JS 就像代码这样运行。但说实话,只是可能,我真的也不确定。

但作为一件趣事,同样的代码用 C 编写并用高级优化编译的结果是,编译器意识到调用factorial(5)可以直接用常量值 120 来代替,完全消除了函数的调用!

另外,有些引擎会进行名为递归展开的动作,在这里,它能够意识到你表达的递归其实可以用循环更简单地实现(即优化)。JS 引擎有可能会把前面的代码重写如下来运行:

javascript
function factorial(n) {
  if (n < 2) return 1
  var res = 1
  for (var i = n; i > 1; i--) {
    res *= i
  }
  return res
}
factorial(5) // 120

现在,我们设想一下,在前面的代码片段中你还担心n * factorial(n-1)n *=factorial(--n)哪个运行更快。甚至可能你还进行了性能测试来确定哪个更好。但你忽略了这个事实:在更大的上下文中,引擎可能并不会运行其中任何一行代码,因为它可能会进行递归展开!

说到----n对比n--经常被作为那些通过选择--n版本来优化的情况进行引用,因为从理论上说,在汇编语言级上它需要处理的工作更少。

对现代 JS 来说,这一类执迷基本上毫无意义。这就属于你应该让引擎来关心的那一类问题。你应该编写意义最明确的代码。比较下面的三个 for 循环:

javascript
// 选择1
for (var i = 0; i < 10; i++) {
  console.log(i)
}

// 选择2
for (var i = 0; i < 10; ++i) {
  console.log(i)
}

// 选择3
for (var i = -1; ++i < 10; ) {
  console.log(i)
}

即使你认为理论上第二个或第三个选择要比第一个选择性能高那么一点点,这也是值得怀疑的。第三个循环更令人迷惑,因为使用了++i先递增运算,你就不得不把 i 从 -1 开始计算。而第一个和第二个选择之间的区别实际上完全无关紧要。

完全有可能一个 JS 引擎看到了一个使用i++的位置,并意识到它可能将其安全地替换为等价的++i,这意味着你花费在决定采用哪一种方案上的时间完全被浪费了,而且产出还毫无意义。

这里是另一个常见的愚蠢的执迷于微观性能的例子:

javascript
var x = [ .. ];
// 选择1
for (var i=0; i < x.length; i++) {
 // ..
}

// 选择2
for (var i=0, len = x.length; i < len; i++) {
 // ..
}

理论上说,这里应该在变量 len 中缓存 x 数组的长度,因为表面上它不会改变,来避免在每个循环迭代中计算x.length的代价。

如果运行性能测试来比较使用x.length和将其缓存到 len 变量中的方案,你会发现尽管理论听起来没错,但实际的可测差别在统计上是完全无关紧要的。

实际上,在某些像 v8 这样的引擎中,可以看到,预先缓存长度而不是让引擎为你做这件事情,会使性能稍微下降一点。不要试图和 JS 引擎比谁聪明。对性优化来说,你很可能会输。

不是所有的引擎都类似

各种浏览器中的不同 JS 引擎可以都是“符合规范的”,但其处理代码的方法却完全不同。JS 规范并没有任何性能相关的要求,好吧,除了 ES6 的“尾调用优化”,这部分将在“尾调用优化”一节介绍。

引擎可以自由决定一个运算是否需要优化,可能进行权衡,替换掉运算次要性能。对一个运算来说,很难找到一种方法使其在所有浏览器中都运行得较快。

在一些 JS 开发社区有一场运动,特别是在那些使用 Node.js 工作的开发者中间。这场运动是要分析 v8 JS 引擎的特定内部实现细节,决定编写裁剪过的 JS 代码来最大程度地利用 v8 的工作模式。通过这样的努力,你可能会获得令人吃惊的高度性能优化。因此,这种努力的回报可能会很高。如下是 v8 的一些经常提到的例子

  • 不要从一个函数到另外一个函数传递 arguments 变量,因为这样的泄漏会降低函数实现速度。

  • 把 try..catch 分离到单独的函数里。浏览器对任何有 try..catch 的函数实行优化都有一些困难,所以把这部分移到独立的函数中意味着你持有不可优化的危害的同时,其周围的代码可以优化的。

不过,与其关注这些具体技巧,倒不如让我们在通用的意义上对 v8 独有的优化方法进行一次完整性检查。

确实要编写只需在一个 JS 引擎上运行的代码吗?即使你的代码目前只需要 Node.js,假定使用的 JS 引擎永远是 v8 是否可靠呢?有没有可能某一天,几年以后,会有 Node.js 之外的另一种服务器端 JS 平台被选中运行你的代码呢?如果你之前的优化如今对新引擎而言成了一种运行很慢的方法,要怎么办呢?

或者如果从现在开始你的代码总是保持运行在 v8 上,但是 v8 决定在某些方面修改其运算的工作方式,过去运行很快的方式现在很慢,或者相反,那又该怎么办?

这些场景并不仅仅只是理论。过去把多个字符串值放在一个数组中,然后在数组上调用join("")来连接这些值比直接用 + 连接这些值要快。这一点的历史原因是微妙的,涉及字符串值在内存中如何存储和管理这样的内部实现细节。

因此,那时的工业界广泛传播的最佳实践建议是:开发者应总是使用数组的join(..)方法。很多人遵从了这一建议。

但随着时间的发展,JS 引擎改变了内部管理字符串的方法,特别对 + 连接进行了优化。它们并没有降低join(..)本身的效率,而是花了更多精力提高 + 的使用,因为它仍然是广泛使用的。

NOTE

主要基于某些方法当前的广泛使用来标准化或优化这些特定方法的实践通常称为(比喻意义上的)“给已被牛踏出的路铺砖”(paving the cowpath)。

一旦新的处理字符串和连接的方法确定下来,很遗憾,所有那些使用数组join(..)来连接字符串的代码就成次优的了。

另一个例子:曾几何时,Opera 浏览器在如何处理原生封装的对象的封箱 / 开箱上与其他浏览器不同(参见本书的“类型和语法”部分)。同样,他们对开发者的建议是:如果需要访问 length 这样的属性或charAt(..)这样的方法,应使用 String 对象而不是原生字符串值。这个建议对那时候的 Opera 来说可能是正确的,但是它完全与同时代的其他主流浏览器背道而驰,因为后者都对原生字符串有特殊的优化而不是对其对象封装。

我想,即使是对于今天的代码,这些陷阱即便可能性不高,至少也是可能的。因此,我对在我的代码中单纯根据引擎实现细节进行的广泛性能优化非常小心,特别是如果这些细节只对于单个引擎成立的话。

反过来也有一些事情需要警惕:你不应当为了绕过某一种引擎难于处理的地方而改变一块代码。

历史上,IE 是导致许多这种挫折的领头羊,在老版本的 IE 中曾经有许多场景,在当时的其他主流浏览器中看起来没有太多麻烦的性能方面苦苦挣扎。我们刚刚讨论的字符串连接在 IE6 和 IE7 的年代就是一个真实的问题,那时候使用join(..)就可能要比使用 + 能得到更好的性能。

不过为了一种浏览器的性能问题而使用一种很有可能在其他所有浏览器上是次优的编码方式,很难说是正当的。即便这种浏览器占有了你的网站用户的很大市场份额,编写恰当的代码并仰仗浏览器最终在更好的优化机制上更新自己可能更实际。

“没什么是比临时的 hack 更持久的了。”很有可能你现在编写的用来绕过一些性能 bug 的代码可能比浏览器的性能问题本身存在得更长久。

在浏览器每五年才更新一次的时候,这是个很难作出的抉择。但是到了现在,浏览器更新的速度要快得多(尽管移动世界显然还落在后面),它们都彼此竞争着对 Web 功能进行越来越好的优化。

如果你遇到这样的情形,即一个浏览器有性能问题而其他浏览器没有,那就要确保通过随便什么可用的渠道把这个问题报告其开发者。多数浏览器都提供了开放的 bug 跟踪工具用于此处。

TIP

我建议只有在浏览器的性能问题确实引发彻底的中断性故障时才去绕过它,不要仅仅因为它让人讨厌就那么做。我也会非常小心地检查,以确定性能 hack 在其他浏览器上不会有显著的消极副作用。

大局

我们应该关注优化的大局,而不是担心这些微观性能的细微差别。

怎么知道什么是大局呢?首先要了解你的代码是否运行在关键路径上。如果不在关键路径上,你的优化就很可能得不到很大的收益。

有没有听过“这是过早优化”这样的警告?这来自于高德纳著名的一句话:“过早优化是万恶之源。”很多开发者都会引用这句话来说明多数优化都是“过早的”,因此是白费力气。和通常情况一样,事实要更加微妙一些。

这里是高德纳的原文(重点强调):

程序员们浪费了大量的时间用于思考,或担心他们程序中非关键部分的速度,这些针对效率的努力在调试和维护方面带来了强烈的负面效果。我们应该在,比如说 97% 的时间里,忘掉小处的效率:过早优化是万恶之源。但我们不应该错过关键的 3% 中的机会。

———— 计算机访谈 6 (1974 年 12 月)

我相信这么解释高德纳的意思是合理的:“非关键路径上的优化是万恶之源。”所以,关键是确定你的代码是否在关键路径上 —— 如果在的话,就应该优化!

甚至可以更进一步这么说:花费在优化关键路径上的时间不是浪费,不管节省的时间多么少;而花在非关键路径优化上的时间都不值得,不管节省的时间多么多。

如果你的代码在关键路径上,比如是一段将要反复运行多次的“热”代码,或者在用户会注意到的 UX 关键位置上,如动画循环或 CSS 样式更新,那你就不应该吝惜精力去采用有意义的、可测量的有效优化。

举例来说,考虑一下:一个关键路径动画循环需要把一个字符串类型转换到数字。当然有很多种方法可以实现(参见本书的“类型和语法”部分),但是哪一种,如果有的话,是最快的呢?

javascript
var x = '42' // 需要数字42

// 选择1:让隐式类型转换自动发生
var y = x / 2

// 选择2:使用parseInt(..)
var y = parseInt(x, 0) / 2

// 选择3:使用Number(..)
var y = Number(x) / 2

// 选择4:使用一元运算符+
var y = +x / 2

// 选项5:使用一元运算符|
var y = (x | 0) / 2

NOTE

我将把这个问题留给你作为练习。如果感兴趣的话,可以建立一个测试,检查这些选择之间的性能差异。

在考虑这些不同的选择时,就像别人说的,“其中必有一个是与众不同的”。parseInt(..)可以实现这个功能,但是它也做了更多的工作:它解析字符串而不是进行类型转换。你很可能会猜测parseInt(..)是一个比较慢的选择,应该避免,这是正确的。

当然,如果 x 可能是一个需要解析的值,比如"42px"(比如来自 CSS 样式查找),那parseInt(..)就确实是唯一合理的选择了!

Number(..)也是一个函数调用。从行为角度说,它和一元运算符 + 选择是完全一样的,但实际上它可能更慢一些,要求更多的执行函数的机制。当然,也可能 JS 引擎意识到了行为上的相同性,会帮你内联化Number(..)的行为(即+x)!

但是,请记住,沉迷于+xx | 0的对比在绝大多数情况下都是浪费时间。这是一个微观性能问题,是一个你不应该让其影响程序可读性的问题。

尽管程序关键路径上的性能非常重要,但这并不是唯一要考虑的因素。在性能方面大体相似的几个选择中,可读性应该是另外一个重要的考量因素。

尾部调用优化(Tail Call Optimization - TCO)

正如前面我们提到的,ES6 包含了一个性能领域的特殊要求。这与一个涉及函数调用的特定优化形式相关:尾调用优化(Tail Call Optimization,TCO)。

简单地说,尾调用就是一个出现在另一个函数“结尾”处的函数调用。这个调用结束后就没有其余事情要做了(除了可能要返回结果值)。

举例来说,以下是一个非递归的尾调用:

javascript
function foo(x) {
  return x
}

function bar(y) {
  return foo(y + 1) // 尾调用
}

function baz() {
  return 1 + bar(40) // 非尾调用
}

baz() // 42

foo(y + 1)bar(..)中的尾调用,因为在foo(..)完成后,bar(..)也完成了,并且只需要返回foo(..)调用的结果。然而,bar(40)不是尾调用,因为在它完成后,它的结果需要加上 1 才能由baz()返回。

不详细谈那么多本质细节的话,调用一个新的函数需要额外的一块预留内存来管理调用栈,称为栈帧。所以前面的代码一般会同时需要为每个baz()bar(..)foo(..)保留一个栈帧。

然而,如果支持 TCO 的引擎能够意识到foo(y + 1)调用位于尾部,这意味着bar(..)基本上已经完成了,那么在调用foo(..)时,它就不需要创建一个新的栈帧,而是可以重用已有的bar(..)的栈帧。这样不仅速度更快,也更节省内存。

在简单的代码片段中,这类优化算不了什么,但是在处理递归时,这就解决了大问题,特别是如果递归可能会导致成百上千个栈帧的时候。有了 TCO,引擎可以用同一个栈帧执行所有这类调用!

递归是 JS 中一个纷繁复杂的主题。因为如果没有 TCO 的话,引擎需要实现一个随意(还彼此不同!)的限制来界定递归栈的深度,达到了就得停止,以防止内存耗尽。有了 TCO,尾调用的递归函数本质上就可以任意运行,因为再也不需要使用额外的内存!

考虑到前面递归的factorial(..),这次重写成 TCO 友好的:

javascript
function factorial(n) {
  function fact(n, res) {
    if (n < 2) return res
    return fact(n - 1, n * res)
  }

  return fact(n, 1)
}

这个版本的factorial(..)仍然是递归的,但它也是可以 TCO 优化的,因为内部的两次fact(..)调用的位置都在结尾处。

NOTE

有一点很重要,需要注意:TCO 只用于有实际的尾调用的情况。如果你写了一个没有尾调用的递归函数,那么性能还是会回到普通栈帧分配的情形,引擎对这样的递归调用栈的限制也仍然有效。很多递归函数都可以改写,就像刚刚展示的factorial(..)那样,但是需要认真注意细节。

ES6 之所以要求引擎实现 TCO 而不是将其留给引擎自由决定,一个原因是缺乏 TCO 会导致一些 JS 算法因为害怕调用栈限制而降低了通过递归实现的概率。

如果在所有的情况下引擎缺乏 TCO 只是降低了性能,那它就不会成为 ES6 所要求的东西。但是,由于缺乏 TCO 确实可以使一些程序变得无法实现,所以它就成为了一个重要的语言特性而不是隐藏的实现细节。

ES6 确保了 JS 开发者从现在开始可以在所有符合 ES6+ 的浏览器中依赖这个优化。这对 JS 性能来说是一个胜利。

复习

对一段代码进行有效的性能测试,特别是与同样代码的另外一个选择对比来看看哪种方案更快,需要认真注意细节。

与其打造你自己的统计有效的性能测试逻辑,不如直接使用 Benchmark.js 库,它已经为你实现了这些。但是,编写测试要小心,因为我们很容易就会构造一个看似有效实际却有缺陷的测试,即使是微小的差异也可能扭曲结果,使其完全不可靠。

从尽可能多的环境中得到尽可能多的测试结果以消除硬件 / 设备的偏差,这一点很重要。jsPerf.com 是很好的网站,用于众包性能测试运行。

遗憾的是,很多常用的性能测试执迷于无关紧要的微观性能细节,比如x++对比++x。编写好的测试意味着理解如何关注大局,比如关键路径上的优化以及避免落入类似不同的 JS 实现细节这样的陷阱中。

尾调用优化是 ES6 要求的一种优化方法。它使 JS 中原本不可能的一些递归模式变得实际。TCO 允许一个函数在结尾处调用另外一个函数来执行,不需要任何额外资源。这意味着,对递归算法来说,引擎不再需要限制栈深度。