JavaScript高效图形编程
上QQ阅读APP看书,第一时间看更新

1.4 优化JavaScript

严格来说,任何用于JavaScript的优化也适用于其他语言。到了CPU层,道理都是一样的:尽量少做工作。在JavaScript中,CPU层的工作和程序员距离太远,以至于很难确定到底CPU层进行了哪些工作。使用一些前人证实过可行的方法,一般来说对你的代码是有益处的,尽管只有通过实验测试才能明确证明。

1.4.1 查找表

高开销的计算可以预先进行,并将值存在一个查找表(lookup table)中,使用时给出简单的整型下标(index)就可以取出查找表中的值。只要查找表访问的代价比从头计算的代价低,你的应用程序就能因此获得更好的性能。比如,JavaScript的三角函数就可以利用查找表加速。在这节中,将用一个查找表取代Math.Sin()函数,并用它来构建一个图形动画的应用。

Math.sin()函数接受一个参数:角度(以弧度为单位),并返回一个−1~1之间的值。角度参数的有效范围是0~2π(约6.283 18)弧度。这个范围对索引一个查找表没什么帮助,因为只有6个可能的整数。与其这样,不如完全不用弧度,而是让查找表接受0~4 095的整数索引。这个粒度对大多数应用来说足够了,但你可以通过给参数steps设置更大的值来得到更精密的查找表。

fastSin()函数将2π弧度分为参数中定义的步数,并将每一步得到的结果保存在数组中。

图1-2为Math.sin()和查找表的性能测试结果的比较。

图1-2 Math.sin()和查找表的性能测试对比。数值越大,性能越好

大多数浏览器上的性能提高大约有20%,而Google Chrome上的提高幅度更大。如果查找表里面的值是由比Math.sin()更复杂的函数计算出来的,查找表方法的性能优势将更明显;因为不论计算值的时间多长,查找表的访问时间保持不变。

下面的应用使用fastSin()查找表来创建一个动画,其显示结果如图1-3所示。

图1-3 在一个动画应用中使用的sine查找表

下面的代码调用fastSin()函数创建一个sine的查找表,保存在变量sinTable[]中。

下面的drawGraph()函数通过更新许多1像素宽的div的高度和位置,画出一个正弦波。表1-1列出了相关参数。

表1-1 传递给drawGraph()的参数

下面的循环创建480个1像素宽的div元素。这些div被添加到$drawTarget中。

drawGraph()函数通过bars[]数组来引用这些div。

setInterval()函数以连续变化的参数,重复调用drawGraph(),创造出动画效果:

1.4.2 位操作、整数和二进制数

在JavaScript中,所有数都以浮点数形式表示。和C++和Java等语言不同,JavaScript语言中无法显示声明int和float类型。这个惊人的遗漏是由于JavaScript早期只是面向Web设计者和业余爱好者的简单语言。虽然JavaScript的单个数值类型帮程序员避免了许多数值类型错误,但毕竟整数更快,CPU更容易处理,在许多情况下是其他语言的首选数值类型。

 提示

ECMAScript规范中定义JavaScript的数值表示为“双精度64位的IEEE 754格式,即IEEE二进制浮点数算术标准”。其表示范围很广,大约从大数(±1.797 693 134 862 315 7 × 10308)到小数(±5×10−324)。不过需要注意的是:浮点数是有误差的,比如alert(0.1+0.2)会显示0.300 000 000 000 000 04,而不是0.3。

不过,仔细阅读ECMAScript标准会发现JavaScript有几个内部操作可以处理整数:

ToInteger

转为整数

ToInt32

转为有符号32位整数

ToUint32

转为无符号32位整数

ToUint16

转为无符号16位整数

你不能直接使用这些操作,而是在执行位操作时被自动调用,使得数字被预先转为合适的整型。虽然这些操作看起来和Web编程不相关,但实际上它们可用于优化。

 警告

位操作将数字转为32位整数,数字范围为−2 147 483 648~2 147 483 647。超过这个范围的数字也会被调整到这个范围。

1.二进制数的快速回顾

曾几何时,程序员经常要与二进制数打交道。使用彼时计算机所需的底层编程要求对二进制和十六进制有很好的理解。如今,二进制数很少被用在Web编程,但在硬件驱动和网络等领域仍有一席之地。

每个人都熟悉十进制数系统。在表1-2的第一行,从右到左每列所表示的权重从小到大是10的幂。将第二行的数字和对应的权重乘起来,并将所有乘积相加,就得到了最终数字为:

(3×1 000) + (9×1) = 3 009

表1-2 十进制数系统

二进制数系统也是类似的,不同的是每列的权重为2的幂,而不是10的幂。第二行中的数字只能是0或1,也称比特或位(bit)。二进制数简单的开关特性使其非常适合在数字电路中模拟。表1-3显示了十进制数69的二进制表示:

(1 × 64) + (1 × 4) + (1 × 1) = 69

表1-3 十进制数69的8位二进制数表示

二进制数如何取反?一般采用一个叫做补码的系统:

1.将二进制数中的每位取反,因此01000101变为10111010。

2.加1,因此10111010变为10111011(−69)。

最左边的比特叫做符号位,0代表正,1代表负。使用同样的步骤,我们可以从−69回到+ 69。

2.JavaScript的位操作

JavaScript的位操作在整数的二进制数字(或位)上进行。

位与(x&y):对操作数进行二进制与的操作,如果两个操作数的某一位都为1,将对应的结果位设为1。因此0x0007&0x0003的结果为0x003。此操作可用于检查一个对象是否有一组属性或标记。表1-4显示了一个宠物对象的标记。一个小型、年老、棕色的狗可以用64 + 16 + 8 + 2 = 90来标记。

表1-4 一个宠物对象的二进制标记

搜索一个有特定标记的宠物,只需要和搜索值进行位与操作。下面的代码搜索大型、年轻和白色的宠物(猫狗都可以):

整型有32位来表示不同的标记,而相比之下其他方法,如分开表示标记或其他类型的条件测试,要慢许多。比如:

&运算符也可达到类似取余运算符(%)的效果,也就是返回除法后的余数。下面的代码将保证变量value总是在0到7之间:

不过这种等价性只有在&后面的值是2的幂−1(1,3,6,15,31,...)时才成立。

位或(x|y):对操作数进行二进制或的操作,如果两个操作数的某一位至少有一个为1,将对应的结果位设为1。因此0x0007|0x0003的结果为0x0007。

位异或(x^y):对操作数进行二进制异或的操作,如果两个操作的某一位只有一个为1,将对应的结果位设为1。因此0x0000^0x0001的结果是0x0001,而0x0001^0x0001的结果是0x0000。这可以用于方便地切换变量:

每次执行toggle^=1;,toggle值将在1和0值之间转换(假设原来的值是1或0)。下面是等价的if-else代码:

或者:

位非(~x):对所有位进行取反。例如11100111将变为00011000。如果操作数是有符号整数(最左位为符号位),则~操作符等于取负减1(前面提过补码中取负对应各位取反加1)。

位左移(x<<numBits):对x的二进制向左移numBits位。所有位向左移,最左的位丢失,0填补最右的位。这等价于无符号整数的乘法 x*2^numBits。例如:

测试显示左移位运算和对应的乘法运算符(*)相比没有性能提升。

算术位右移(x>>numBits):对x的二进制向右移numBits位。除了(最左)符号位,所有位向右移,最右位丢失。这相当于有符号整数除法 x/2^numBits。例如:

测试显示右移位运算和对应的除法运算符(/)相比没有性能提升。

下面的代码看起来毫无用处:

但是它使得JavaScript调用其内部的整数转换函数,剔除数字的小数部分。这实际上是一个快速的Math.floor()函数。图1-4显示其在IE8、Google Chrome和Safari 5.0中都有速度提升。

图1-4 Math.floor()与位移(bitshift)的对比。数值越大,性能越好

逻辑位右移(x>>>y):很少用到,类似>>操作符,但符号位不保留而填补为0。对正数来说,这和>>操作符没两样;对负数来说,逻辑位右移的结果将成正数。例如:

3.循环展开:麻烦的真相

任何编程语言中的循环都会增加额外的开销。循环通常需要维护一个计数器和/或检查结束条件,这两者都花费时间。

移除循环开销将提供一些性能提升。一个典型的JavaScript循环如下:

如果替换成下面的代码,你可以完全去除循环开销:

不过,对只有8次迭代的循环,性能的提升不大。假设循环体是一个简单语句(如x++),循环展开可能会快300%,但只是在毫秒级的;3毫秒比1毫秒不会有很大的差别。如果循环体花费时间较长,那可能是0.100 003秒和0.100 001秒的差别,也不太值得去优化。

有两个因素决定了循环展开是否会带来可观的好处:

  • 循环迭代的次数。事实上,需要许多(比如上千)个迭代才能带来明显的区别。
  • 循环体开销和循环开销的比例。如果前者比后者的比例越大,性能提升越少。这是因为更多的时间是花费在循环体,而不是循环开销中。

要完全展开成千上百的迭代并不现实。现实的解决方案是使用达夫设备经典算法的变种,部分展开循环。比如,1 000个迭代的循环可以分成125个展开8次的迭代:

第一个while循环处理了不能被8整除的部分迭代。比如1 004次迭代需要1个4次(1 004%8)普通迭代的循环,然后跟着125个(parseInt(1 004/8))展开的8次迭代。下面是一个稍稍改进的版本:

 提示

达夫设备指的是由Tom Duff在1983年开发的一种循环展开的C语言优化技术。循环展开是汇编语言中常用的技术,细小的优化就可以在内存复制等领域发挥作用。具有优化功能的编译器也可能进行自动的循环展开。

对一个循环体很少的10 000次循环的迭代,这会得到很大的性能提升。图1-5显示了结果。那我们应该像这样优化所有循环吗?不。这个测试是不现实的:循环体只有一个局部变量自增的操作是比较少见的。

图1-5 展开一个循环体很少的10 000次循环。结果不错,但是不要激动。数值越大,性能越好

一个更好的测试是迭代一个数组并用数组内容调用函数。下面是一个更接近现实的应用:

图1-6显示了性能的提升。注意,一个更现实的循环体使得循环展开的作用大大降低。这就好像点了一个4 000卡路里的超级汉堡套餐,而希望低糖汽水可以帮助减肥。对于10 000次的迭代来说,图中的结果让人失望。

图1-6 展开一个循环体很少的10 000次循环。结果令人失望。数值越大越好

通过实验我们发现JavaScript循环实际上很高效,你需要在实际应用的背景下去测试循环展开这种优化技术,来实际测试它们的好处。