JS 语法性能调优实战|8月更文挑战

404 阅读7分钟

为什么我们如此关注性能

javascript 作为一门解释性语言,性能优化是 JS 工程师避不开的一个重要话题。为什么 我们如此关注 JS 性能问题呢?

  1. 当代用户对于应用体验的要求普遍提高了。若干年前,对于一个页面的等待时间,普遍在 7s;而现在,据相关统计,50%以上的访问者希望访问的目标网站加载时间在 2s 以上,也就意味着页面的一个卡顿或白屏时间超过2s,该网站就会流失至少 50% 的用户。
  2. 应用的瓶颈依然在 JS 上。现代主流的三大框架,基本的思路都是采用数据驱动视图,而数据的操作是依赖于 js,所以 js 的性能优秀就变得尤为重要了。

JS 性能对比工具

Benchmark.js 是一个以统计为基础的稳固的基准化分析工具。github 地址:github.com/bestiejs/be… 。比如想知道 indexOf 和 filter 哪个方法耗时更短,只需要在你的项目中 npm install benchmark,根据 Benchmark.js 的语法编写一段测试程序:

var Benchmark = require('benchmark');
var suite = new Benchmark.Suite;

suite
  .add('indexOf', function() {
    var fruits = ['apple', 'orange', 'grape'];
    fruits.indexOf('mango'); // -1
  })
  .add('filter', function() {
    var fruits = ['apple', 'orange', 'grape'];

    fruits.filter(function(value) {
      return value === 'mango';
    });
  })
  .on('cycle', function(event) {
    console.log(String(event.target));
  })
  .on('complete', function() {
    console.log('Fastest is ' + this.filter('fastest').map('name'));
  })
  .run({ 'async': true });

在用 node 命令单独执行该文件,就可以得到这两个方法的对比结果,显然 indexOf 的耗时比较短。

indexOf x 35,355,389 ops/sec ±0.36% (87 runs sampled)
filter x 7,499,308 ops/sec ±1.03% (88 runs sampled)
Fastest is indexOf

根据测试的结果,我们可以在选择函数方法上进行取舍,对代码语法进行一些针对性的优化。

Jsbench 这个网站 [jsbench.me/] 是基于 Benchmark.js 实现的,我们可以在这个网站上进行一些性能对比。

语法优化实战

下面是基于在 Jsbench 网站上的性能对比结果,总结的一系列 js 语法优化的小技巧。

以下数据结论全部是在 Chrome 上运行得出,不同浏览器得到的结果可能存在差异,所以以下结论以理论分析得出的结论为准。 ops/s 单位代表每秒操作数,值越大代表性能越好。

1. 慎用!全局变量

1.png

  • 全局变量的定义是在全局执行上下文中,是作用域链的最顶端。根据 js 作用域链的特性,每次在函数内使用全局变量都会需要从局部向上查找到最顶层,消耗了不必要的时间。
  • 在程序不退出的情况下,因为全局变量的定义导致全局执行上下文一直存在于上下文执行栈中,造成了内存空间的浪费。
  • 全局变量容易被篡改,且不好排查。

2. 全局变量的缓存

2.png 如果无法避免使用全局变量,可以在每次使用全局变量的函数内部将全局变量缓存起来,从而减少查找时间。

3. 避免使用构造函数来创建数组和对象

尽量使用隐式创建。这里以创建数组为例:

3.png

若使用构造函数创建时指定数组长度,两种创建方式耗时相差不大。

4.png

因为使用 new Array() 创建,系统会新生成一个对象,没生成一个对象会耗费资源去构造他的属性和方法;隐式创建 [] 是一个数据原型,是对象的子集。

若在一定需要使用 new Array() 创建数组的场景下,创建时指定数组长度也能够大大减少耗时.

image.png

4. 数字转字符串

最好使用+号拼接的形式转化变量为字符串。性能由高至低: + ' ' > String() > .toString()

从理论上来看,使用编译时就能使用的内部操作(例如 + 运算符)要比运行时使用的用户操作要快。String() 属于内部函数,所以速度很快,而 .toString() 要查询 Object 原型中的函数,所以速度逊色一些。

但不同浏览器的内核不同,导致得出的结论也会有所差别,上图是 Chrome ,下图是 Safari ,很明显可以看出在 Chrome 中 .toString() 的性能是要好过 String() 的。

image.png

image.png 所以在选择方法时,我们更应该是针对实际场景进行选择。例如 String() 能将nullundefined 转换为字符串,toString() 能直接对数字进行进制转换。

5. 选择最优的循环方法

forEach, for, for...of..., for...in..., map 五种循环方式,其中 for...in...map 的性能明显低于其他方式。

不过它们都有属于自己的使用场景,for...in... 更适合用来遍历对象,而 map 能够直接返回一个数组,在对数组进行加工返回新数组的场景下使用起来十分便利。

在使用 for 循环时,把数组的 length 存放到一个变量上,避免 for 循环在运行的过程中重复获取数组的length,从而降低性能。

image.png

6. 节点添加优化

节点的添加操作必然会引起回流和重绘。document.createdocumentfragment() 方法创建了一虚拟的节点对象,是一段新的文档片段,该对象包含所有属性和方法。这样可以合并节点的添加操作,减少 DOM 的重排和重绘。虚拟 DOM 就是采用了这种

13.png

7. 事件委托

把事件绑定在祖先节点,由于有事件冒泡,当事件触发时根据 event 对象的 target 属性可以知道具体事件是在那个子元素发生的。从而执行不同的行为。这样就不必每个子节点都绑定事件。

12.png

8. 尽量使用原生方法

这里以 jQuery 为例。这类框架的理念是:The Write Less, Do More。但通过简洁代码实现相同功能是需要付出一些代价的。

时间上:

  • 需要执行额外的兼容性操作,这些不服务于业务的兼容性操作会消耗额外的时间。
  • 浏览器内核能解析的只是原生 API,在原生 API 上层封装过的框架一定会增加解析时间。
  • 很多框架的 API 是定义在原型上,原型链查找也是需要消耗时间的。 空间上:
  • 申明额外的变量如 jQuery 中的全局变量 $,会消耗占空间,内存占用变大。
  • 实例化对象数量增加,存在堆空间中,内存占用变大。
  • 引用额外的库,消耗网络请求时间。

14.png

9. 远离闭包陷阱

闭包是比较特殊的函数,通过闭包可以使函数外部作用域访问到函数内部变量。但就是因为这种特性,闭包可能导致被引用的函数内部变量不会被回收,从而导致内存泄漏引起性能问题。 这里要着重申明,闭包只是会让内存常驻,但不一定会引起内存泄漏,这两者之间没有必然的联系。一般造成内存泄漏都是因为闭包的不合理使用,如:变量循环引用。

function outer() {
    let a = b;
    function block() {
        console.log(a);
    }
    
    b = {
        inner: function() {
            console.log('test');
        }
    }
}

for(let i = 0; i < 10000; i++){
    outer();
}

如上述代码,block 函数中引用了 a 变量,故形成闭包,inner 函数共享 outer 函数提供的闭包作用域,虽然 inner 函数中没有直接使用到 a 变量,但相当于 inner 函数隐式持用的 a 变量,因为闭包的原因,a 变量无法释放,且内存中 a 变量会指向上一次赋值的 b 对象,b 对象中的 inner 又持有 a,这样造成了一个循环引用的局面,导致内存泄漏。

其实解决这个问题的方案也很简单,在 outer 函数的最后添加上 a = null 解除对上一次 b 变量的引用,手动释放即可。

尾言

总结了这么多优化的技巧供开发者参考,并不是希望开发者在开发的过程中唯性能论。性能固然很重要,但很多业务的需求往往没有非常高的要求,一个优秀的软件产品也不是仅仅通过性能一个维度来进行评判的。开发者需要做的是在开发效率,代码可维护性,业务特性,性能优化等多个方面进行权衡,在预期的开发周期内开发出可靠,优秀的产品。