MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

JavaScript函数方法的性能测试

2021-02-253.9k 阅读

JavaScript函数方法性能测试基础

性能测试的重要性

在JavaScript开发中,尤其是当应用程序规模不断扩大,处理的数据量逐渐增多时,函数方法的性能就显得至关重要。性能良好的函数方法能够确保应用程序快速响应,提升用户体验。而性能不佳的函数方法可能导致页面卡顿、加载缓慢,甚至在极端情况下,使得应用程序无法正常运行。例如,在一个实时数据处理的Web应用中,如果处理数据的函数方法性能低下,就无法及时更新页面展示的数据,造成数据滞后,影响用户对应用的信任。

性能测试工具

  1. console.time() 和 console.timeEnd():这是JavaScript提供的最简单的性能测试工具。通过在函数执行前调用 console.time(),在函数执行后调用 console.timeEnd(),可以输出函数执行所花费的时间。例如:
console.time('testFunction');
function testFunction() {
    for (let i = 0; i < 1000000; i++) {
        // 一些简单操作
    }
}
testFunction();
console.timeEnd('testFunction');
  1. Performance.now()Performance.now() 方法返回一个高精度时间戳,单位为毫秒。它比 Date.now() 更精确,并且不受系统时钟调整的影响。可以通过记录函数执行前后的 Performance.now() 值,然后计算差值来获取函数执行时间。示例如下:
function measurePerformance() {
    const startTime = performance.now();
    for (let i = 0; i < 1000000; i++) {
        // 操作
    }
    const endTime = performance.now();
    console.log(`函数执行时间: ${endTime - startTime} 毫秒`);
}
measurePerformance();
  1. Benchmark.js:这是一个专门用于JavaScript性能测试的库。它提供了丰富的功能,能够进行复杂的性能测试,并生成详细的报告。使用时,首先需要安装 benchmark 库,例如通过npm安装:npm install benchmark。然后可以编写如下测试代码:
const Benchmark = require('benchmark');
const suite = new Benchmark.Suite;

function testFunction1() {
    for (let i = 0; i < 1000000; i++) {
        // 操作
    }
}

function testFunction2() {
    let i = 0;
    while (i < 1000000) {
        i++;
    }
}

suite
  .add('for循环', testFunction1)
  .add('while循环', testFunction2)
  .on('cycle', function(event) {
        console.log(String(event.target));
    })
  .on('complete', function() {
        console.log('最快的是'+ this.filter('fastest').map('name'));
    })
  .run({ 'async': true });

这个例子中,Benchmark.jsfor 循环和 while 循环的性能进行了测试,并输出了每个测试用例的执行时间以及最快的测试用例。

常见函数方法性能测试

数组操作方法性能

  1. push() 和 unshift()push() 方法用于在数组的末尾添加一个或多个元素,unshift() 方法则是在数组的开头添加元素。在性能方面,push() 通常比 unshift() 更快,因为在数组末尾添加元素只需要修改数组的长度并将新元素放在末尾位置,而在数组开头添加元素需要将原有的所有元素向后移动一位。
const arr = [];
const startPush = performance.now();
for (let i = 0; i < 100000; i++) {
    arr.push(i);
}
const endPush = performance.now();
console.log(`push方法执行时间: ${endPush - startPush} 毫秒`);

const arr2 = [];
const startUnshift = performance.now();
for (let i = 0; i < 100000; i++) {
    arr2.unshift(i);
}
const endUnshift = performance.now();
console.log(`unshift方法执行时间: ${endUnshift - startUnshift} 毫秒`);
  1. pop() 和 shift()pop() 用于删除并返回数组的最后一个元素,shift() 用于删除并返回数组的第一个元素。pop() 的性能通常优于 shift(),原因与 push()unshift() 类似,pop() 操作只需要修改数组长度并删除最后一个元素,而 shift() 操作需要将剩余的所有元素向前移动一位。
const arr3 = Array.from({ length: 100000 }, (_, i) => i);
const startPop = performance.now();
while (arr3.length > 0) {
    arr3.pop();
}
const endPop = performance.now();
console.log(`pop方法执行时间: ${endPop - startPop} 毫秒`);

const arr4 = Array.from({ length: 100000 }, (_, i) => i);
const startShift = performance.now();
while (arr4.length > 0) {
    arr4.shift();
}
const endShift = performance.now();
console.log(`shift方法执行时间: ${endShift - startShift} 毫秒`);
  1. forEach()、map() 和 for 循环forEach()map() 是数组的迭代方法,for 循环是传统的迭代方式。for 循环在性能上通常优于 forEach()map(),因为 for 循环没有函数调用的开销,直接通过索引访问数组元素。forEach()map() 则需要在每次迭代时调用回调函数,这增加了额外的性能开销。
const numbers = Array.from({ length: 100000 }, (_, i) => i + 1);

const startFor = performance.now();
let sumFor = 0;
for (let i = 0; i < numbers.length; i++) {
    sumFor += numbers[i];
}
const endFor = performance.now();
console.log(`for循环执行时间: ${endFor - startFor} 毫秒`);

const startForEach = performance.now();
let sumForEach = 0;
numbers.forEach((num) => {
    sumForEach += num;
});
const endForEach = performance.now();
console.log(`forEach方法执行时间: ${endForEach - startForEach} 毫秒`);

const startMap = performance.now();
const mappedNumbers = numbers.map((num) => num);
let sumMap = 0;
mappedNumbers.forEach((num) => {
    sumMap += num;
});
const endMap = performance.now();
console.log(`map方法执行时间: ${endMap - startMap} 毫秒`);

字符串操作方法性能

  1. 字符串拼接:在JavaScript中,有多种字符串拼接方式,如 + 运算符、concat() 方法和模板字面量。在性能方面,模板字面量通常是最快的,尤其是在拼接多个字符串时。+ 运算符在拼接少量字符串时性能尚可,但当拼接数量较多时,性能会急剧下降,因为每次拼接都会创建一个新的字符串对象。concat() 方法性能也不如模板字面量。
const parts = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'];

const startPlus = performance.now();
let resultPlus = '';
for (let i = 0; i < parts.length; i++) {
    resultPlus += parts[i];
}
const endPlus = performance.now();
console.log(`+ 运算符拼接执行时间: ${endPlus - startPlus} 毫秒`);

const startConcat = performance.now();
let resultConcat = '';
for (let i = 0; i < parts.length; i++) {
    resultConcat = resultConcat.concat(parts[i]);
}
const endConcat = performance.now();
console.log(`concat方法拼接执行时间: ${endConcat - startConcat} 毫秒`);

const startTemplate = performance.now();
const resultTemplate = `${parts.join('')}`;
const endTemplate = performance.now();
console.log(`模板字面量拼接执行时间: ${endTemplate - startTemplate} 毫秒`);
  1. indexOf() 和 includes()indexOf() 方法返回指定字符串在另一个字符串中首次出现的位置,如果不存在则返回 -1。includes() 方法用于判断一个字符串是否包含另一个字符串,返回布尔值。在性能上,includes() 通常比 indexOf() 略快,因为 indexOf() 需要返回位置信息,而 includes() 只需要返回简单的布尔值。
const longString = 'a'.repeat(1000000);
const searchString = 'a'.repeat(1000);

const startIndexOf = performance.now();
longString.indexOf(searchString);
const endIndexOf = performance.now();
console.log(`indexOf方法执行时间: ${endIndexOf - startIndexOf} 毫秒`);

const startIncludes = performance.now();
longString.includes(searchString);
const endIncludes = performance.now();
console.log(`includes方法执行时间: ${endIncludes - startIncludes} 毫秒`);

函数调用性能

  1. 普通函数调用与箭头函数调用:从性能角度来看,普通函数和箭头函数在调用上的性能差异微乎其微。然而,箭头函数在词法作用域和 this 绑定方面与普通函数有明显不同。例如,箭头函数没有自己的 this,它的 this 取决于外层作用域,而普通函数的 this 取决于函数的调用方式。但就单纯的函数调用性能测试而言,两者差别不大。
function normalFunction() {
    // 一些操作
}

const arrowFunction = () => {
    // 一些操作
};

const startNormal = performance.now();
for (let i = 0; i < 1000000; i++) {
    normalFunction();
}
const endNormal = performance.now();
console.log(`普通函数调用执行时间: ${endNormal - startNormal} 毫秒`);

const startArrow = performance.now();
for (let i = 0; i < 1000000; i++) {
    arrowFunction();
}
const endArrow = performance.now();
console.log(`箭头函数调用执行时间: ${endArrow - startArrow} 毫秒`);
  1. 函数柯里化性能:函数柯里化是将一个多参数函数转换为一系列单参数函数的技术。在性能方面,柯里化后的函数可能会因为额外的函数调用层而带来一些性能开销,但在某些场景下,柯里化可以提高代码的可复用性和可读性。例如,在需要固定部分参数的情况下,柯里化后的函数可以更方便地进行复用。
function add(a, b) {
    return a + b;
}

function curryAdd(a) {
    return function(b) {
        return a + b;
    };
}

const startNormalAdd = performance.now();
for (let i = 0; i < 1000000; i++) {
    add(i, i + 1);
}
const endNormalAdd = performance.now();
console.log(`普通加法函数执行时间: ${endNormalAdd - startNormalAdd} 毫秒`);

const curriedAdd = curryAdd(1);
const startCurryAdd = performance.now();
for (let i = 0; i < 1000000; i++) {
    curriedAdd(i);
}
const endCurryAdd = performance.now();
console.log(`柯里化加法函数执行时间: ${endCurryAdd - startCurryAdd} 毫秒`);

影响函数方法性能的因素

数据规模

数据规模对函数方法的性能影响巨大。随着数据量的增加,许多函数方法的执行时间会显著增长。例如,在对一个包含少量元素的数组进行排序时,sort() 方法可能瞬间完成,但当数组元素达到数百万个时,排序时间会大幅增加。因为排序算法的时间复杂度与数据规模相关,常见的排序算法如快速排序、归并排序等,虽然平均时间复杂度较好,但在数据规模增大时,仍然需要处理大量的数据比较和交换操作。

const smallArray = Array.from({ length: 10 }, (_, i) => Math.random());
const largeArray = Array.from({ length: 1000000 }, (_, i) => Math.random());

const startSmallSort = performance.now();
smallArray.sort();
const endSmallSort = performance.now();
console.log(`小数组排序执行时间: ${endSmallSort - startSmallSort} 毫秒`);

const startLargeSort = performance.now();
largeArray.sort();
const endLargeSort = performance.now();
console.log(`大数据规模排序执行时间: ${endLargeSort - startLargeSort} 毫秒`);

算法复杂度

不同的算法具有不同的时间复杂度和空间复杂度。例如,在查找算法中,线性查找的时间复杂度为 O(n),而二分查找的时间复杂度为 O(log n)。当数据规模较大时,二分查找的性能优势就会明显体现出来。在JavaScript中,虽然没有直接提供二分查找的原生方法,但可以通过自己实现来对比性能。

function linearSearch(arr, target) {
    for (let i = 0; i < arr.length; i++) {
        if (arr[i] === target) {
            return i;
        }
    }
    return -1;
}

function binarySearch(arr, target) {
    let left = 0;
    let right = arr.length - 1;
    while (left <= right) {
        const mid = Math.floor((left + right) / 2);
        if (arr[mid] === target) {
            return mid;
        } else if (arr[mid] < target) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }
    return -1;
}

const sortedArray = Array.from({ length: 1000000 }, (_, i) => i).sort((a, b) => a - b);
const target = 500000;

const startLinear = performance.now();
linearSearch(sortedArray, target);
const endLinear = performance.now();
console.log(`线性查找执行时间: ${endLinear - startLinear} 毫秒`);

const startBinary = performance.now();
binarySearch(sortedArray, target);
const endBinary = performance.now();
console.log(`二分查找执行时间: ${endBinary - startBinary} 毫秒`);

作用域链查找

JavaScript中的作用域链查找也会影响函数方法的性能。当函数访问一个变量时,会首先在自身作用域中查找,如果找不到则会沿着作用域链向上查找。如果作用域链层次较深,变量查找的时间就会增加。例如,在嵌套函数中访问外部函数的变量,就需要经过多层作用域链查找。

function outerFunction() {
    const outerVar = 10;
    function innerFunction() {
        return outerVar;
    }
    return innerFunction;
}

const inner = outerFunction();

const startScopeLookup = performance.now();
inner();
const endScopeLookup = performance.now();
console.log(`作用域链查找执行时间: ${endScopeLookup - startScopeLookup} 毫秒`);

垃圾回收机制

JavaScript的垃圾回收机制会对函数方法的性能产生影响。当函数创建大量临时对象,而这些对象在函数执行结束后不再被引用时,垃圾回收器需要花费时间来回收这些对象占用的内存。如果垃圾回收过于频繁或回收时间过长,就会导致应用程序的性能下降。例如,在一个循环中不断创建新的数组对象而不及时释放,就可能触发频繁的垃圾回收。

function createLotsOfArrays() {
    for (let i = 0; i < 100000; i++) {
        const arr = Array.from({ length: 100 }, (_, j) => j);
        // 数组没有被复用,执行完循环后就成为垃圾对象
    }
}

const startGarbageCollection = performance.now();
createLotsOfArrays();
const endGarbageCollection = performance.now();
console.log(`垃圾回收相关操作执行时间: ${endGarbageCollection - startGarbageCollection} 毫秒`);

性能优化策略

减少不必要的函数调用

  1. 避免在循环中调用函数:在循环内部调用函数会增加额外的性能开销,因为每次调用函数都需要进行函数调用栈的操作,包括参数传递、上下文切换等。如果循环中的某些操作可以提取到循环外部,应尽量这样做。例如,如果在循环中需要获取当前时间,而这个时间在循环过程中不会改变,可以在循环外部获取一次时间,然后在循环中使用这个变量。
// 不好的写法
for (let i = 0; i < 100000; i++) {
    const now = new Date();
    // 使用now进行操作
}

// 好的写法
const now = new Date();
for (let i = 0; i < 100000; i++) {
    // 使用now进行操作
}
  1. 使用函数缓存:如果一个函数的输入和输出是固定的,即相同的输入总是返回相同的输出,可以使用函数缓存来避免重复计算。可以通过一个对象来存储函数的计算结果,下次调用时先检查缓存中是否有对应的结果,如果有则直接返回,而不需要再次执行函数。
function expensiveFunction(a, b) {
    // 复杂计算
    return a + b;
}

const cache = {};
function cachedFunction(a, b) {
    const key = `${a}-${b}`;
    if (cache[key]) {
        return cache[key];
    }
    const result = expensiveFunction(a, b);
    cache[key] = result;
    return result;
}

优化算法和数据结构

  1. 选择合适的算法:根据具体的应用场景选择合适的算法至关重要。如前面提到的查找算法,在有序数组中使用二分查找比线性查找性能更好。在排序算法方面,如果数据规模较小,简单的插入排序可能性能不错;但对于大规模数据,快速排序、归并排序等更高效的算法则更合适。
  2. 优化数据结构:不同的数据结构适用于不同的场景。例如,如果需要频繁地在数组开头或结尾添加和删除元素,使用链表结构可能更合适,因为链表在这些操作上的时间复杂度为 O(1),而数组在开头添加或删除元素时需要移动大量元素,时间复杂度较高。在JavaScript中虽然没有原生的链表结构,但可以通过对象和指针来模拟链表。

减少作用域链查找深度

  1. 合理使用局部变量:尽量将需要在函数中多次使用的外部变量复制为局部变量。因为局部变量的查找速度比沿着作用域链查找外部变量更快。例如,在一个函数中多次访问全局对象的属性,可以将这个属性赋值给一个局部变量,然后在函数中使用这个局部变量。
// 不好的写法
function accessGlobalProperty() {
    for (let i = 0; i < 100000; i++) {
        console.log(window.someGlobalVariable);
    }
}

// 好的写法
function accessGlobalPropertyOptimized() {
    const localVar = window.someGlobalVariable;
    for (let i = 0; i < 100000; i++) {
        console.log(localVar);
    }
}
  1. 避免不必要的嵌套函数:嵌套函数会增加作用域链的深度,从而增加变量查找的时间。如果一个函数不需要访问外部函数的变量,应将其定义为全局函数或独立的模块函数,这样可以减少作用域链的层次。

关注垃圾回收

  1. 及时释放不再使用的对象:在函数执行过程中,当对象不再被使用时,应及时将其设置为 null,这样垃圾回收器就可以更快地回收这些对象占用的内存。例如,在一个函数中创建了一个大数组,在使用完后如果不再需要这个数组,就将其设置为 null
function createAndReleaseArray() {
    let bigArray = Array.from({ length: 100000 }, (_, i) => i);
    // 使用bigArray
    bigArray = null;
}
  1. 避免频繁创建临时对象:尽量复用已有的对象,而不是在每次执行某个操作时都创建新的对象。例如,在一个循环中需要创建字符串对象来拼接字符串,可以使用 StringBuilder 模式(虽然JavaScript没有原生的 StringBuilder,但可以通过数组和 join() 方法模拟),而不是每次都使用 + 运算符创建新的字符串对象。
// 不好的写法
let result = '';
for (let i = 0; i < 100000; i++) {
    result += i.toString();
}

// 好的写法
const parts = [];
for (let i = 0; i < 100000; i++) {
    parts.push(i.toString());
}
const resultOptimized = parts.join('');

通过对JavaScript函数方法的性能测试以及了解影响性能的因素,并采用相应的性能优化策略,可以显著提升JavaScript应用程序的性能,为用户提供更流畅、高效的体验。在实际开发中,应根据具体的应用场景和需求,灵活运用这些知识和技巧,不断优化代码性能。