13 советов, как сделать код на JavaScript качественнее и быстрее

Способы оптимизации кода: от простых до роскошных.

10 лет назад компания Amazon подсчитала, что 100 миллисекунд задержки стоили ей 1% выручки с продаж. Аналогичным образом в Google обнаружили, что дополнительные 500 миллисекунд на генерацию поисковых страниц сократили их трафик на 20%, на столько же урезав потенциальный доход от рекламы. Оказывается, скорость действительно решает всё. Мы публикуем перевод статьи британского разработчика Брета Кэмерона, в которой он дает 13 практических советов для увеличения скорости работы JavaScript-кода.

Меньше — лучше

Самый быстрый код — это код, который никогда не будет запущен

1. Удалите ненужные функции

Можно сразу приступить к оптимизации написанного кода, но часто наибольший прирост производительности достигается, если сделать шаг назад и спросить себя: нужен ли тот или иной сегмент на самом деле? Прежде чем перейти к оптимизации, спросите себя: должна ли ваша программа делать всё, что она делает? Необходимы ли все эти возможности, компоненты и функции? Если нет, удалите ненужное. Этот шаг невероятно важен для повышения скорости работы вашего кода, но про него легко забыть.

2. Избегайте ненужных шагов

Оценочный тест

Начав детально пересматривать код, спросите себя: каждый ли сделанный шаг необходим для успешного выполнения функции и достижения нужного результата? Например, не проходят ли данные через ненужные циклы? Следующий пример намеренно упрощен, но такое сложнее обнаружить в большой кодовой базе:

'incorrect'.split('').slice(2).join('');  // преобразование в массив

'incorrect'.slice(2);                     // остается текстовой строкой 

Даже в этом простом примере разница в производительности огромна: маленький работающий код быстрее большого неработающего. Хотя вышеописанную ошибку допускают немногие, в большом коде легко проглядеть лишние шаги, если нужный результат достигается. Избегайте их.

Реже — лучше

Если вы не можете удалить фрагмент кода, постарайтесь выполнять его реже

Ваш код работает в том числе за счет простого повторения операций, но тут легко переборщить и начать выполнять их чаще необходимого. Вот несколько конкретных примеров, на которые стоит обратить внимание.

3. Циклы должны завершаться как можно раньше

Оценочный тест

Обращайте внимание, есть ли необходимость выполнять каждую итерацию цикла. Например, если вы ищете конкретное значение и находите его, нет смысла в дальнейших итерациях, лучше прервать выполнение цикла оператором break:

for (let i = 0; i < haystack.length; i++) {

  if (haystack[i] !== needle) break;

}

Или, если нужно произвести операции с определенными элементами цикла, вы можете пропустить другие операции с помощью оператора continue, который останавливает выполнение операторов в текущей итерации и немедленно переходит к следующей:

for (let i = 0; i < haystack.length; i++) {

  if (!haystack[i] === needle) continue;

  doSomething();

}

Стоит также помнить, что вложенные циклы можно разрывать с помощью меток. Это позволяет привязать оператор break или continue к определенному циклу:

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. Предвычисляйте, если возможно

Оценочный тест

Возьмем следующую функцию, в нашей программе мы хотим её вызвать несколько раз:

function whichSideOfTheForce(name) {

  const light = ['Luke', 'Obi-Wan', 'Yoda']; 

  const dark = ['Vader', 'Palpatine'];

  

  return light.includes(name) ? 'light' : 

    dark.includes(name) ? 'dark' : 'unknown';

};



whichSideOfTheForce('Yoda');   // на вопрос «На какой ты стороне Силы?» возвращает ответ "light"

whichSideOfTheForce('Anakin'); // на вопрос «На какой ты стороне Силы?» возвращает ответ "unknown"

Проблема этого кода в том, что каждый раз, когда мы вызываем функцию whichSideOfTheForce, мы создаем новый объект. При каждом вызове функции под наши массивы без необходимости выделяется память. Учитывая, что «светлые» и «темные» значения статичны, лучшим решением было бы объявить эти переменные один раз, а затем ссылаться на них при вызове whichSideOfTheForce. Мы могли бы определить наши переменные в глобальной области видимости, но тогда они могли бы изменяться за пределами нашей функции. Лучшее решение — задействовать замыкание, чтобы функция вернула своё значение:

function whichSideOfTheForce2(name) {

  const light = ['Luke', 'Obi-Wan', 'Yoda'];

  const dark = ['Vader', 'Palpatine'];

  return name => light.includes(name) ? 'light' :

    dark.includes(name) ? 'dark' : 'unknown';

};

Теперь массивы «света» и «тьмы» будут создаваться всего один раз. То же самое относится к вложенным функциям. Например:

function doSomething(arg1, arg2) {

  function doSomethingElse(arg) {

    return process(arg);

  };

  return doSomethingElse(arg1) + doSomethingElse(arg2);

}

Каждый раз, когда мы запускаем doSomething, вложенная функция создается с нуля. Замыкание поможет и здесь. Если мы возвращаем функцию, doSomethingElse остается закрытой, но создается всего один раз:

function doSomething(arg1, arg2) {

  function doSomethingElse(arg) {

    return process(arg);

  };

  return (arg1, arg2) => doSomethingElse(arg1) + doSomethingElse(arg2);

}

5. Минимизируйте количество операций в коде

Оценочный тест

Зачастую скорость работы кода можно повысить, если тщательно продумать порядок операций в функции. Представим себе, что у нас есть массив цен на продукты в центах и нам нужна функция для суммирования элементов и возврата результата в долларах:

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

Функция должна делать две вещи: конвертировать центы в доллары и суммировать элементы, но важно соблюсти порядок операций. Для конвертации в доллары мы могли бы использовать следующую функцию:

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. Изучите О-нотацию

Знакомство с О-нотацией — один из лучших способов понять, почему одни функции исполняются быстрее и занимают меньше места в памяти, чем другие. Например, с помощью О-нотацией можно понять, почему бинарный поиск — один из самых эффективных алгоритмов поиска, а быстрая сортировка (quicksort) — один из самых эффективных алгоритмов сортировки данных.

По сути, О-нотация помогает применять и понимать описанные в этой статье способы оптимизации. Это глубокая тема, поэтому, если вам интересно узнать больше, я рекомендую прочитать мою статью об О-нотации или статью, где я разбираю четыре варианта решения задач на собеседовании в Google в контексте затраченного времени и пространственной сложности алгоритма.

Ускоряйте исполнение

Наибольший прирост в скорости работы кода наблюдается в первых двух категориях оптимизации: делать меньше и делать реже. В этом разделе мы рассмотрим несколько способов оптимизации кода без его сокращения или уменьшения количества запусков. На самом деле, даже такая оптимизация позволит уменьшить размер кода или сделает его более удобным для компилятора, что уменьшит размер скомпилированного кода. На первый взгляд, вы меняете код, а не удаляете его, поэтому этот раздел про скорость.

7. Используйте встроенные инструменты

Оценочный тест

Для тех, кто имеет опыт работы с компиляторами и низкоуровневыми языками, этот момент может показаться очевидным. Если нужные вам инструменты уже содержатся в самом JavaScript «из коробки», используйте именно их.

Код компилятора оптимизирован с точки зрения производительности конкретных методов и типов объектов. Кроме того, базовым языком для JavaScript служит C++. Если у вас не очень специфичный случай, вероятность того, что ваша собственная реализация JavaScript превзойдет существующие методы, крайне мала.

Чтобы проверить, создадим собственную JavaScript-реализацию Array.prototype.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));

Даже если мы хотим выполнить простую операцию, например, умножить каждое целое число в массиве на два, мы всё равно увидим разницу в производительности:

map(arr, el => el * 2);  // наша JavaScript-реализация

arr.map(el => el * 2);   // встроенная реализация

В моих тестах новая функция map JavaScript оказалась примерно на 65% медленнее, чем Array.prototype.map. Чтобы посмотреть исходный код реализации Array.prototype.map движка V8, кликните сюда. А чтобы провести тесты самому, кликните по ссылке на оценочный тест.

8. Используйте объекты, наиболее подходящие для конкретной задачи

Что эффективнее:

добавлять значения в коллекцию Set или в массив с помощью push()?

добавлять записи в коллекцию Map или в обычный объект?

Как и в предыдущих пунктах, наилучшая производительность достигается за счет выбора соответствующего объекта для решения текущей задачи. Встроенные в JavaScript объекты хорошо дополняют примитивные типы данных: числа, строки, функции, объекты и так далее. При использовании в верном контексте многие из этих менее распространенных объектов могут дать значительные преимущества в производительности.

В других статьях я писал о том, что использовать Set может быть быстрее, чем Array, а Map — чем регулярные объекты. Set и Map — это коллекции, которые используют ключи, они могу пригодиться, если вы регулярно добавляете или удаляете значения. Познакомьтесь со встроенными типами объектов и всегда используйте лучший инструмент из доступных, так вы сможете сделать код быстрее.

9. Не забудьте про память

Будучи высокоуровневым языком, JavaScript учитывает нюансы более низких уровней. Один из таких нюансов — управление памятью. JavaScript использует систему сбора «мусора» для высвобождения данных из памяти (если только явно не прописать в коде, что эти данные всё ещё нужны).

Хотя управление памятью в JavaScript выполняется автоматически, это не означает, что оно безупречно. Есть дополнительные шаги, которые можно предпринять для управления памятью и снижения вероятности утечек памяти.

Например, у функций Set и Map есть так называемые слабые вариации (WeakSet и WeakMap). Они содержат «слабые» ссылки на объекты. Они не используют тип enumerable, но они предотвращают утечку памяти, так как позволяют отправлять в «мусор» не упомянутые в коде значения.

Вы также можете лучше контролировать распределение памяти, используя объекты TypedArray, представленные в обновлении JavaScript ES2017. Например, Int8Array может принимать значения от –128 до 127 и имеет размер всего в один байт. Стоит отметить, однако, что прирост производительности при использовании объектов TypedArray может быть очень мал: сравнение обычного массива и Uint32Array показывает небольшое улучшение производительности записи, но незначительное или нулевое улучшение производительности чтения (спасибо Крису Ху за оба теста).

Поняв низкоуровневый язык программирования, вы сможете быстрее и лучше писать код на JavaScript. Об этом я пишу подробнее в своей статье «Как C++ может помочь JavaScript-разработчикам».

10. Используйте мономорфные операции, если возможно

Оценочный тест 1: мономорфные операции vs полиморфные

Оценочный тест 2: один аргумент функции vs два

Если присвоить переменной a значение 2 с помощью const, то её можно считать полиморфной (ее можно изменить). Напротив, если мы будем непосредственно использовать числовое значение 2, то его можно считать мономорфным (его значение фиксировано).

Конечно, полезно присваивать переменным значения, если нам нужно использовать их несколько раз. Если вы используете переменную только один раз, немного быстрее будет, если не устанавливать переменную вовсе. Возьмем простую функцию умножения:

function multiply(x, y) {

  return x * y;

};

Если запустить функцию multiply(2, 3), она завершится примерно на 1% быстрее, чем если запустить такой код:

let x = 2, y = 3;

multiply(x, y);

Это довольно мало. Но в большой кодовой базе подобные маленькие победы складываются в большую. Таким же образом использование аргументов в функциях дает гибкость в ущерб производительности. Опять же, аргументы — это неотъемлемая часть программирования. Но если они вам не нужны, вы получите преимущество в производительности, если не будете их использовать. Еще более быстрая версия нашей функции умножения может выглядеть так:

function multiplyBy3(x) {

  return x * 3;

}

Как я отметил ранее, улучшение производительности невелико (в моих тестах около 2%). Но если такое улучшение может быть использовано многократно в большой кодовой базе, то стоит подумать об этом. Как правило, вводить аргументы стоит тогда, когда значение динамическоe, а переменные вводятся только тогда, когда будут использоваться несколько раз.

11. Избегайте оператора delete

Оценочный тест 1: удаление свойств из объекта vs неопределенные свойства

Оценочный тест 2: оператор delete vs Map.prototype.delete

Оператор delete используется для удаления содержания из объекта. Может показаться, что он вам необходим, но если вы можете обойтись без него, то сделайте это. В движке V8 есть паттерн скрытых классов, и delete лишает вас преимуществ этого паттерна, делая объект обычным и медленным. А операции с медленными объектами — всё верно — выполняются медленнее.

В зависимости от ваших потребностей можно определить нежелательное свойство как неопределенное и, возможно, этого будет достаточно:

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. Кроме того, такие функции менее удобочитаемые, чем delete obj.a или obj.a = undefined.

В поисках альтернативы подумайте, можно ли использовать Map вместо объекта, так как Map.prototype.delete работает быстрее, чем оператор delete.

Откладывайте исполнение

Если вы не можете уменьшить код, ускорить его или реже его выполнять, есть четвертый способ оптимизации кода.

Код будет казаться более быстрым, даже если на самом деле время его выполнения не изменится. Это такая реструктуризация кода, в которой не такие критичные для всей структуры задачи не блокируют самые важные процессы.

12. Используйте асинхронный код для предотвращения блокировки потока

По умолчанию JavaScript является однопоточным языком и запускает свой код синхронно, шаг за шагом. В большинстве случаев это работает хорошо, но если у нас есть события, которые занимают много времени, не стоит блокировать или задерживать исполнение более важного кода.

Решение можно найти через асинхронный код. Некоторые встроенные инструменты работают так по умолчанию (вроде fetch() или XMLHttpRequest()), но стоит также отметить, что любую синхронную функцию можно сделать асинхронной. Если у вас длительная (синхронная) операция, например, операция над каждым из элементов в большом массиве, то такой код можно сделать асинхронным, чтобы он не блокировал выполнение другого кода. Если вы новичок в асинхронном коде JavaScript, ознакомьтесь с моей статьей «Что обещает JavaScript».

Кроме того, многие модули (например, файловая система Node.js) имеют асинхронные и синхронные варианты некоторых функций (например, fs.writeFile() и fs.writeFileSync()). В обычных условиях придерживайтесь стандартного асинхронного подхода.

13. Используйте разделение кода

Если вы используете JavaScript на стороне клиента, ваши приоритеты должны быть сосредоточены в визуальном быстродействии. Главный оценочный критерий — это First Contentful Paint (FCP), время, за которое появляется первый полезный для пользователя контент в DOM-элементах.

Разделение кода — один из лучших способов улучшить ситуацию. Вместо того, чтобы подавать ваш JavaScript-код одним большим файлом, подумайте о том, чтобы разделить его на более мелкие фрагменты. Как вы будете разбивать код, зависит от того, используете ли вы один из фреймворков (React, Angular, Vue) или обходитесь стандартными средствами самого JavaScript.

Tree shaking — связанная с описанным кодом тактика статического анализа всего кода и исключения того, что на самом деле не используется. Чтобы узнать больше, я рекомендую эту статью от Google. Не забывайте оптимизировать свой код!

Заключение

Тестирование — лучший способ проверить, удалось ли вам оптимизировать код. В этой статье я привожу примеры кода, используя https://jsperf.com, но можно проверять и меньшие сегменты коды здесь:

http://jsben.ch

https://jsbench.me

— Ваша собственная консоль, через функции console.time() и console.timeEnd().

Что касается проверки производительности веб-приложений, отличной отправной точкой являются разделы Chrome Dev Tools про сети и производительность. Также рекомендую расширение Lighthouse от Google.

Наконец, хочу сказать, что, хоть скорость и важна, хороший код должен быть не только быстрым. Удобочитаемость и легкость сопровождения также исключительно важны, вряд ли нужно чуть-чуть увеличивать скорость, если такое улучшение приводит к увеличению времени на поиск и исправление ошибок.

Если вы новичок, надеюсь, этот материал открыл вам глаза на некоторые способы повышения производительности кода. А если вы опытный разработчик, надеюсь, что эта статья послужила полезным дополнением к вашим знаниям.

Краткий пересказ от Yandex GPT