[译]编写更快、更好的JavaScript的13个技巧

3,736 阅读14分钟

10年前,亚马逊分享一个例子,每100毫秒的延迟都会是他们损失1%的销售收入,即在全年中,每增加1秒钟的加载时间将使该公司损失约16亿美元。同样,谷歌发现搜索页面的生成时间增加500毫秒,访问量将减少20%,潜在的广告收入也将减少五分之一。

我们中很少人可以像谷歌和亚马逊一样去处理这种大场面,但是,相同的原则也适用于更小规模的场景,速度更快的代码可以带来更好的用户体验,并且对业务更有利。特别是在web开发中,速度可能在与对手竞争中成为关键因素。在较快的网络上浪费的每一个毫秒,就会在较慢的网络上放大。

在本文中,我们将探讨13种提升JavaScript运行速度的实用技巧,无论你是写基于Node.js的服务端代码还是客户端的JavaScript代码。我已经提供了基于jsperf.com创建的性能测试用例。如果你想自己测试这些技巧,请确保点击这些链接。

Do It Less

最快的代码是那些从来不会运行的代码。

1. 删除无用的功能

开始着手优化已经写好的代码是一件很容易的事情,但是,对性能提升最大的方法往往来自于退后一步问问自己为什么我们的代码需要出现在这里。

在继续某项优化工作之前,问问自己你的代码是否真的需要做他现在所做的事情。这个功能里面的组件或者函数是否有必要?如果没有,请删掉它。这一步对提升代码速度非常重要,却很容易被忽略。

2. 避免无用的步骤

基准测试:jsperf.com/unnecessary…

在较小的规模上,一个函数运行过程中执行的每一步都有用么?举个例子,为了达到最终的效果,你的数据是否会陷于一个没有必要的圈中?下面的示例可能被简化了,但是,它能代表那些在较大代码量中很难被发现的问题。

'incorrect'.split('').slice(2).join('');  // converts to an array
'incorrect'.slice(2);                     // remains a string 

即使在简单的例子中,性能上的差异也是十分巨大的,运行某些代码比不运行任何代码要慢得多!尽管很少有人会犯上述错,但是面对更长、更复杂的代码,在获取结果的前加上一些毫无价值的步骤就会很容易。尽量避免它!

Do It Less often

如果你不能删除代码,问问你自己能不能减少做这件事情的频率呢?代码如此强大的原因之一是他可以使用我们轻松的完成重复的操作,但是,也更容易让我们的代码执行次数超过需要的次数。以下是一些需要注意的特殊情况。

3. 越早退出循环越好

基准测试:jsperf.com/break-loops…

在一个循环中找出不需要迭代完成的情况。举个例子,如果你正在寻找一个特殊值并且已经找到他了,那么剩下的迭代就已经不需要了。你应该通过使用break语句来中断正在执行中的循环:

for (let i = 0; i < haystack.length; i++) {
    if (haystack[i] === needle) {
        break;
    }
}

或者,如果,你需要只对循环中某些元素做操作时,你可以使用continue语句来跳过对其他元素进行操作。continue会终止当前迭代中的执行语句,立即跳转到下一个语句中:

for (let i = 0; i < haystack.length; i++) {
    if (!haystack[i] === needle) {
        continue;
    }
    doSomething();
}

值得注意的是,你也可以通过breakcontinue跳过嵌套的循环:

loop1: for (let i = 0; i < haystacks.length; i++) {
    loop2: for (let j = 0; j < haystacks[i].length; j++) {
        if (haystacks[i][j] === needle) {
            break loop1;
        }
    }
}

4. 仅仅初始化一次

基准测试:jsperf.com/pre-compute… (译者在mac下自测,使用/不使用闭包,使用/不使用全局变量,目前对性能影响差异不大)

在我们的应用中,我们将会调用数次下列方法:

function whichSideOfTheForce1(name) {
    const light = ['Luke', 'Obi-Wan', 'Yoda'];
    const dark = ['Vader', 'Palpatine'];

    return light.includes(name) ? 'light' : dark.includes(name) ? 'dark' : 'unknown';
}

whichSideOfTheForce1('Luke');
whichSideOfTheForce1('Vader');
whichSideOfTheForce1('Anakin');

这段代码,我们每次调用whichSideOfTheForce1时,都会重新创建2次数组,每次调用时都需要给我们的数组重新分配内存。

提供的数组的值是固定的,那最好的解决办法就是定义一次,然后在函数中调用它的引用。尽管我们也可以全局定义这2个数组变量,但是,这将允许他们在我们的函数外部被篡改。最好的解决方法是使用闭包,这就意味着他返回的是一个函数:

function whichSideOfTheForceClosure1(name) {
    const light = ['Luke', 'Obi-Wan', 'Yoda'];
    const dark = ['Vader', 'Palpatine'];

    return (name) => (light.includes(name) ? 'light' : dark.includes(name) ? 'dark' : 'unknown');
}
const whichSideOfTheForce2 = whichSideOfTheForceClosure1();

现在,我们的数组只会初始化一次了。再来看看下面的例子:

function doSomething(arg1, arg2) {
    function doSomethingElse(arg) {
        return process(arg);
    };
    return doSomethingElse(arg1) + doSomethingElse(arg2);
}

每次运行doSomething时,都会从头开始创建嵌套函数doSomethingElse。 闭包提供了解决方案, 如果我们返回一个函数,doSomethingElse仍然是私有的,但只会创建一次:

function doSomething(arg1, arg2) {
    function doSomethingElse(arg) {
        return process(arg);
    };
    return (arg1, arg2) => doSomethingElse(arg1) + doSomethingElse(arg2);
}

5. 控制代码的执行顺序来保证最小的运行次数

基准测试: jsperf.com/choosing-th…

如果仔细考虑函数中每一步的执行顺序,也可以帮助我们提高代码的执行效率。假设,我们有一个数组来存储上平的价格(美分),我们需要一个函数对商品的价格进行求和并返回结果(美元):

const cents = [2305, 4150, 5725, 2544, 1900];

这个函数有2件事情要做,转化单位和求和,但是这些动作的顺序很重要。如果优先处理转化单位,我们函数是这样的:

function sumCents(array) {
    return '$' + array.map(el => el / 100).reduce((x, y) => x + y);
}

在这个方法中,我们对数组的每一项都需要进行除法,如果改变执行的顺序,我们只需要进行一次除法:

function sumCents(array) {
    return '$' + array.reduce((x, y) => x + y) / 100;
}

优化性能的关键就是确保函数以最佳的顺序执行。

6. 了解代码的时间复杂度 O(n)

了解代码的时间复杂度是理解为什么某些方法比其他方法运行的更快,占用的内存更少的最佳方法之一。例如,你可以通过使用时间复杂一目了然的了解为什么二分搜索是效率最好的搜索算法之一,为什么快排是往往是最有效的排序算法。详细请自行了解时间复杂度

Do It Faster

代码速度优化收益最大的往往是前面2类。在本节中我们将讨论提高代码速度的几种方法,他们更多的是和代码优化相关,而不是剔除他或者减少运行的次数。

当然,这些优化也要减少代码的大小或者使其对编译器更友好,但是,表面上看你只更改了代码而已。

7. 多用内置函数

基准测试:jsperf.com/prefer-buil…

对于拥有编译器和底层语言经验的人来说,这是一件很明显的事情。但是,这里还是要把它作为一个基础规则来提一下,如果JavaScript有内置函数,请使用它。

编译器代码在设计时,就针对方法或者对象类型进行了性能优化。另外,内置方法的底层语言是C++。除非你的用例特别具体,否则,你自己的JavaScript代码很少能比现有内置代码快。

为了测试这个,我们自己来实现一个map方法

function map(arr, func) {
    const mapArr = [];
    for (let i = 0; i < arr.length; i++) {
        const result = func(arr[i], i, arr);
        mapArr.push(result);
    }
    return mapArr;
}

让我们来创建一个数组,里面包含了100个随机数字(1-100)。

const arr = [...Array(100)].map(e=>~~(Math.random()*100));

我们来执行一些简单操作(数字乘2)来比较二者的差异:

map(arr, el => el * 2);  // Our JavaScript implementation
arr.map(el => el * 2);   // The built-in map method

在我的测试中,我们自己实现的map方法比原生的Array.prototype.map慢65%。

8. 选择最佳的数据类型

基准测试1:set.add()vs array.push() jsperf.com/adding-to-a…

基准测试2:map.set() vs object['xx'] jsperf.com/adding-map-…

同样,最佳的性能也可能来自于选择合适的内置数据类型。JavaScript中内置的数据类型远远不止:NumberStringFunctionObject。很多不常见的数据类型如果在正确的场景中使用将会提供非常明显的优势。

SetMap在频繁添加和删除元素的情况下有明显的性能优势。

了解内置的对象类型,并尝试使用最适合你需要的对象类型,这对提升代码的性能非常有用。

9. 别忘了内存

JavaScript作为一种高级语言,它为你处理很多底层细节。内存管理就是其中一个。JavaScript使用一种称为垃圾回收(GC)的系统来释放内存,在不需要开发人员明确指示的情况下,就可以自动释放内存。

尽管内存管理在JavaScript中是自动的,但这并不意味着它是完美的。你也可以采取其他步骤来管理内存并减少内存泄漏的机会。

例如,SetMap有变体WeakSetWeakMap,他们持有对象的“弱”引用。他们通过确保其中的对象没有其他对象引用时触发垃圾回收,来确保不会出现内存泄漏。

在ES2017之后,你可以通过TypedArray对象来更好的控制内存的分配。例如,Int8Array可以放-128到127之间的值,仅仅占用一个字节。但是,值得注意的是,使用TypedArray的性能提升可能很小:将常规数组与Uint32Array进行比较写入性能略有改善,但读取性能却几乎没有改善。

对于底层编程语言有基本的了解可以帮助你编写更快、更好的JavaScript代码。

10. 尽可能使用单态

基准测试1:jsperf.com/monomorphic…

基准测试2:jsperf.com/impact-of-f…

如果我们设置const a = 2,则变量a可以被视为多态的(可以更改)。 相反,如果我们直接使用2,则可以认为是单态的(其值是固定的)。

当然,如果我们需要多次使用变量,则设置变量非常有用。 但是,如果你只使用一次变量,则完全避免设置变量会稍快一些。 采取简单的乘法功能:

// 函数定义
function multiply(x, y) {
    return x * y;
}

如果我们运行multiply(2, 3),他比直接运行下面的代码快1%:

// 定义2个变量作为multiply的参数
let x = 2, y = 3;
multiply(x, y);

这是一个小胜利,在大型代码中,性能提升往往是由大量小胜利组成的。

同样,在函数中使用参数可提供灵活性,但会降低性能。 如果不需要它们,就可以把它变成一个常量放在函数中,它会略微提高性能。因此,multiply的更快版本如下所示:

// 如果3是固定不变的时候,则直接作为函数中的一部分
function multiplyBy3(x) {
    return x * 3;
}

结合上述优化,在我的测试中性能提升约为2%。虽然改动的点比较小,但是如果可以在大型代码库中多次进行这种改进,就值得考虑了。

译者注,这里原文太绕了,大家看看代码里面的注释理解一下

a. 仅在值必须是动态的时才引入函数参数,否则,就写成函数内部的变量;

b. 仅在多次使用某一个值时才引入变量,否则,就直接写值;

11. 避免使用delete

基准测试1: jsperf.com/removing-va…

基准测试2: jsperf.com/delete-vs-m…

delete关键词的作用是用来删除对象中的某一个属性。也许你会觉得这个对于你的应用来说很有用,但是,希望你尽量别去用它。在v8引擎中,delete关键词消除了hidden class的优势,让对象变成了一个"慢对象"。

hidden class:由于 JavaScript 是一种动态编程语言,属性可进行动态的添加和删除,这意味着一个对象的属性是可变的,大多数的 JavaScript 引擎(V8)为了跟踪对象和变量的类型引入了隐藏类的概念。在运行时 V8 会创建隐藏类,这些类附加到每个对象上,以跟踪其形状/布局。这样可以优化属性访问时间

根据你的需求,可能仅仅将不需要的属性设置成undefined就够了。

const obj = { a: 1, b: 2, c: 3 };
obj.a = undefined;

我在网上看过一些建议,他们使用以下的功能去拷贝除去指定属性之外的对象:

const obj = { a: 1, b: 2, c: 3 };
const omit = (prop, { [prop]: _, ...rest }) => rest;
const newObj = omit('a', obj);

但是,在我的测试中,上面的函数比delete关键词还要慢。另外,它的可读性也很低。

或者,你可以考虑使用Map而不是Object,因为,Map.prototype.deletedelete也快很多。

Do It Later

如果你做不到上述3个方面的优化,你也可以试一试第四类优化,即使运行时间完全相同也会让你觉代码更快。这涉及重构代码,使整体性较小或要求较高的任务不会阻塞最重要的代码执行。

12. 使用异步代码避免线程堵塞

默认情况下,JavaScript是单线程的,并且会同步的执行代码。(实际上,浏览器代码可能正在运行多个线程来捕获事件并触发处理程序,但就编写JavaScript代码而言,它是单线程的)

同步执行对大多是JavaScript代码都适用,但是,如果我们需要执行的代码需要很长时间,但是,我们又不想堵塞其他更重要的代码执行。

我们就需要使用异步代码。像fetch()或者XMLHttpRequest()这些内置方法强制是异步执行的。值得注意的是,任何同步函数都可以异步化:如果你在执行耗时的同步操作,例如对大型数组中的每个项目执行操作,则可以使此代码异步化,这样就不会阻止其他代码的执行。

此外,在NodeJs中,很多模块都存在同步方法和异步方法两种,例如,fs.writeFile()fs.writeFileSync()。在正常情况下,请默认使用异步方法。

13. 使用Code Split

如果你在浏览器中写JavaScript,那么你应该优先确保你的页面展示的越快越好。“首屏渲染”是一个衡量浏览器渲染第一个有效界面时间的关键指标。

改善此问题的最佳方法就是通过JavaScript代码拆分。与其将所有代码都打包在一起,不如将其拆分成较小的块,这样就可以预加载更少的JavaScript代码。根据你是用的引擎不同,代码拆分的方法也不同。

Tree-Shaking是一个从代码库中剔除无用的代码的策略,你可以通过这篇文章tree-shaking来了解他。

总结

确保你的优化策略有效的最佳方案就是测试他们,我在文章中使用测试性能,你也可以试一试:

Chrome的开发者工具中的性能和网络面板是检查web应用的性能的好工具,同时,我还推荐使用Google的LightHouse

最后,尽管速度很重要,但是,速度并不是好代码的全部。可读性和可维护性也很重要,如果为了提升轻微的性能而导致需要花更多的时间去找BUG并修复它,事情将变得很不值得。

关于我

我是一个莫得感情的代码搬运工,每周会更新1至2篇前端相关的文章,有兴趣的老铁可以扫描下面的二维码关注或者直接微信搜索前端补习班关注。

精通前端很难,让我们来一起补补课吧!

好啦,翻译完毕啦,原文链接在此 13 Tips to Write Faster, Better-Optimized JavaScript