Помню, как занимался проектом на Node.js, который обрабатывал большие объемы данных. Во время выполнения небольших тестов все было хорошо, но стоило мне развернуть его в реальной среде, как процесс затягивался на целую вечность. Задачи, которые должны были завершаться за несколько минут, длились час, в результате чего я смотрел на терминал и сомневался в правильности своего выбора.
В конце концов я решил, что с меня хватит. Я погрузился в профилирование Node, изучил документацию в поисках советов по производительности и опробовал все возможные приемы. Результат? Мне удалось сократить часовой процесс примерно до 20 минут — трехкратное ускорение. В этой статье я подробно расскажу вам о том, как я этого добился, а также о некоторых лучших практиках, которые предотвратят задержку работы JavaScript-кода.
1. Начните с профилирования (не угадывайте — измеряйте!)
Прежде чем приступать к оптимизации кода, важно найти реальные узкие места. Часто вы думаете, что виной всему определенный цикл или функция, но потом обнаруживаете, что истинная причина замедления работы кроется в другом месте.
Сила console.time и console.timeEnd
Вот быстрый способ создать базовый тайминг в Node или браузере:
console.time('myProcess');
// Ваш код или вызов функции
myExpensiveFunction();
console.timeEnd('myProcess');
Этот код выведет, сколько времени занял myProcess. Полноценные профилировщики, конечно, могут представить более подробную информацию, но указанная операция позволит убедиться в эффективности блока кода в плане занимаемого времени.
Встроенный профилировщик Node.js
Если вам нужны более подробные сведения, попробуйте воспользоваться встроенным профилировщиком Node. Запустите следующий код:
node --prof yourScript.js
Затем обработайте лог с помощью:
node --prof-process isolate-0xXXXXXXXXXXXX-v8.log
Вы увидите разбивку по функциям, которые используют больше всего времени процессора. Именно так я обнаружил, что причина большей части задержек в моем коде крылась в функции парсинга.
2. Используйте потенциал встроенных и нативных методов
Один из самых простых способов ускорить код JavaScript — использовать нативные методы и встроенные функции. Как правило, они написаны на оптимизированном языке C/C++ (например, в движке V8) и выполняются быстрее, чем пользовательские циклы.
Пример: замена ручных циклов на методы массивов
Неэффективный цикл:
const bigArray = [/* a million items */];
let total = 0;
for (let i = 0; i < bigArray.length; i++) {
total += bigArray[i];
}
Оптимизация с помощью reduce:
const bigArray = [/* a million items */]; const total = bigArray.reduce((acc, val) => acc + val, 0);
reduce не только более лаконичен, но и «под капотом» он может оказаться более оптимизированным, чем разворачиваемый вручную цикл. То же самое касается таких методов, как map, filter и find.
3. Используйте мощь структур данных Set и Map для быстрого поиска
Одна из самых распространенных проблем с производительностью возникает тогда, когда мы многократно проверяем принадлежность к массивам. Например:
const itemsToCheck = ['apple', 'banana', 'orange'];
const bigList = /* огромный массив с тысячами строк */;
for (const item of itemsToCheck) {
if (bigList.includes(item)) {
// какие-либо действия
}
}
bigList.includes(item) может занимать O(n) времени на каждый вызов, если bigList велик. Если вы задействуете этот прием в плотном цикле, то у вас возникнут проблемы. Вместо этого используйте Set:
const bigSet = new Set(/* same huge array */);
for (const item of itemsToCheck) {
if (bigSet.has(item)) {
// Среднее время проверки O(1)
}
}
Переход от поиска по массиву к Set или объектно-ориентированному поиску часто приводит к значительному увеличению производительности.
4. Сократите количество манипуляций с DOM в коде фронтенда
Если ваш код работает в браузере, манипуляции с DOM могут быть довольно затратными. Модификация DOM вызывает пересчет макета и операции перерисовки, которые, как известно, очень медленные. Каким правилом руководствуюсь лично я? Выполняю пакетные изменения или манипулирую с DOM единожды после определенного количества вычислений, а не выполняю это поэлементно в цикле.
// Вместо добавления нескольких элементов DOM в рамках цикла:
for (let i = 0; i < 1000; i++) {
const newDiv = document.createElement('div');
newDiv.textContent = `Item ${i}`;
document.body.appendChild(newDiv);
}
// Используйте подход DocumentFragment или построение строк:
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
const newDiv = document.createElement('div');
newDiv.textContent = `Item ${i}`;
fragment.appendChild(newDiv);
}
document.body.appendChild(fragment);
В одном из моих прежних React-проектов избегание частых прямых обращений к DOM позволило сэкономить несколько секунд на рендеринге больших списков.
5. Используйте воркеры для параллельной обработки
В JavaScript цикл событий однопоточный, но вы можете задействовать несколько ядер с помощью веб-воркеров (в браузере) или потоков воркеров (в Node). Это особенно полезно при выполнении задач, требующих большого количества ресурсов процессора, таких как обработка изображений, вычисления больших наборов данных или криптографические операции.
Поток воркеров в Node (пример):
// worker.js
const { parentPort } = require('worker_threads');
parentPort.on('message', (data) => {
// вычисление, требующее интенсивного использования процессора
const result = data.map(num => num ** 2);
parentPort.postMessage(result);
});
// main.js
const { Worker } = require('worker_threads');
function runWorker(data) {
return new Promise((resolve, reject) => {
const worker = new Worker('./worker.js');
worker.postMessage(data);
worker.on('message', (res) => resolve(res));
worker.on('error', (err) => reject(err));
worker.on('exit', (code) => {
if (code !== 0) reject(new Error(`Worker stopped with exit code ${code}`));
});
});
}
(async () => {
const bigDataArray = Array.from({ length: 1e6 }, () => Math.random());
const processed = await runWorker(bigDataArray);
console.log('Processed length:', processed.length);
})();
Разделяя «тяжелые» задачи по нескольким потокам воркеров, вы сможете эффективно использовать больше ядер процессора и ускорить общее время выполнения.
6. Кэшируйте результаты (применяйте мемоизацию)
Если у вас есть функции, которые выполняют дорогостоящие вычисления или получают данные из внешнего API, запускать их многократно с одними и теми же входными данными нецелесообразно. Вместо этого кэшируйте или мемоизируйте результаты.
Вот простой шаблон мемоизации в JavaScript:
function memoize(fn) {
const cache = new Map();
return function(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key);
}
const result = fn(...args);
cache.set(key, result);
return result;
};
}
// Пример использования
function expensiveCalculation(x, y) {
// допустим, имеется гигантский цикл или требуются интенсивные математические вычисления
return x ** y;
}
const memoizedCalc = memoize(expensiveCalculation);
console.log(memoizedCalc(2, 10)); // вычисляется один раз
console.log(memoizedCalc(2, 10)); // мгновенное получение из кэша
У меня была ситуация, когда я снова и снова парсил одну и ту же конфигурацию на основе JSON. Мемоизация этой функции парсинга привела к мгновенному ускорению при последующих вызовах.
7. Оптимизируйте циклы (и не пренебрегайте типизированными массивами)
Иногда без циклов не обойтись, особенно для непосредственных, низкоуровневых задач. Но даже в этом случае стиль выбранного цикла может иметь значение.
- Циклы
forмогут быть немного быстрее, чем методы более высокого уровня, если вам нужна только скорость и не нужны дополнительные возможностиmapилиreduce. - Типизированные массивы могут принести огромный прирост производительности для числовых данных. Они хранят данные в непрерывной области памяти; это похоже на то, как работают с массивами языки более низкого уровня, например C.
const buffer = new ArrayBuffer(8 * 1000000); // место для 1 миллиона чисел с плавающей запятой
const floatArray = new Float64Array(buffer);
for (let i = 0; i < floatArray.length; i++) {
floatArray[i] = Math.random();
}
Типизированные массивы особенно полезны при выполнении сложных числовых операций или работе с двоичными данными. Они позволяют значительно сократить накладные расходы по сравнению с обычными массивами чисел в JS.
8. Избегайте горячей замены глобальных переменных
Обращение к глобальным переменным и их обновление может замедлить работу кода, поскольку заставляет JavaScript искать переменные в областях видимости более высокого уровня. Передавайте переменные как параметры функций и возвращайте результаты. Это также повышает ясность кода.
Медленный подход:
let globalCounter = 0;
function countUpTo(n) {
for (let i = 0; i < n; i++) {
globalCounter++;
}
return globalCounter;
}
Быстрый (и более четкий) подход:
function countUpTo(n) {
let localCounter = 0;
for (let i = 0; i < n; i++) {
localCounter++;
}
return localCounter;
}
const result = countUpTo(1_000_000);
Когда я переместил несколько переменных из глобального пространства имен в свой скрипт, прирост производительности был скромным, но заметным. Это также позволило избежать странных ошибок, когда глобальные переменные случайно менялись в разных частях кода.
9. Постоянно обновляйте среду выполнения (Node, браузеры)
Движки JavaScript (V8, SpiderMonkey и т. д.) часто обновляются и оптимизируют производительность. Убедитесь в том, что используете последнюю стабильную версию Node.js, если вы работаете на стороне сервера, и призывайте пользователей обновлять браузеры, если работаете на фронтенде. Обновление Node с версии 12 до версии 16/18 может принести определенный прирост производительности без изменения единой строки кода.
10. Минифицируйте и пакетируйте код (если работаете с приложениями фронтенда)
Для приложений фронтенда можно сократить время загрузки за счет пакетирования и минификации кода. Меньший размер файлов позволяет ускорить передачу данных по сети и время анализа. Такие инструменты, как Webpack, Rollup, esbuild и Vite, делают это автоматически.
# Пример с esbuild:
npx esbuild app.js --bundle --minify --outfile=bundle.js
Как только я начал пакетировать код фронтенда, я заметил не только ускорение загрузки, но и улучшение поведения кэширования для возвращающихся посетителей. Это обязательное условие для производственных приложений.
11. Не блокируйте цикл событий — используйте асинхронные паттерны
Однопоточная природа JavaScript означает следующее: если вы делаете что-то блокирующее (например, выполняете огромный цикл или синхронную операцию ввода-вывода), вы задерживаете все остальное. Используйте асинхронные методы или потоки, либо же разбивайте большие задачи на более мелкие фрагменты, чтобы цикл событий оставался отзывчивым.
Пример блокировки:
// Все остальное ждет, пока мы досчитаем до 1 миллиарда.
for (let i = 0; i < 1_000_000_000; i++) {
// ...
}
Пример без блокировки:
let i = 0;
function doChunk() {
const end = Math.min(i + 10000, 1_000_000_000);
for (; i < end; i++) {
// ...
}
if (i < 1_000_000_000) {
setImmediate(doChunk); // постановка в очередь следующего фрагмента
}
}
doChunk();
Разделение цикла на фрагменты позволяет циклу событий обрабатывать другие обратные вызовы или ввод-вывод между этими операциями. Это не обязательно сократит общее время работы процессора, но предотвратит сценарий «зависания», который разочаровывает пользователей и может иметь решающее значение для некоторых приложений.
12. Используйте итерации и повторения
Настройка производительности не работает по принципу «раз и готово». Мой финальный код не был идеальным с самого начала; я использовал профилирование, исправил самое узкое место, затем снова профилировал и повторял, пока прирост не стал меньше.
- Профилирование: выясните, на что действительно тратится время.
- Выполняйте оптимизацию: применяйте соответствующий хак (кэширование, оптимальные структуры данных, распараллеливание и т. д.).
- Проводите тесты и проверки: убедитесь, что вы действительно улучшили скорость (и не нарушили функциональность).
- Дорабатывайте: при необходимости продолжайте работу до тех пор, пока не достигнете целевого уровня производительности или не заметите снижающийся эффект.
В моем случае каждая итерация позволяла сэкономить все больше и больше времени, пока я не получил скрипт, который работал в 3 раза быстрее, чем исходный. Каков самый важный урок, который я усвоил? Нужно сосредоточиваться на реальных узких местах. Вы можете потратить часы на микрооптимизацию не того участка кода и не заметить ощутимых преимуществ.
Заключение
Процесс ускорения JavaScript не обязательно должен пугать или казаться слишком сложным. Начните с профилирования кода, а затем планомерно наращивайте эффективность:
- Измеряйте код с помощью
console.time, профилировщика Node или браузерных DevTools.
- Используйте встроенные функции и нативные методы.
- Переходите к Set и Map, когда вам нужен быстрый поиск.
- Пакетируйте обновления DOM и следите за крупными пересчетами положения в коде фронтенда.
- Используйте воркеры для параллельных задач.
- Кэшируйте повторяющиеся результаты, чтобы избежать повторных вычислений.
- Оптимизируйте циклы, а для числовых данных используйте типизированные массивы.
- Ограничьте использование глобальных переменных и определяйте область видимости функций.
- Следите за последними версиями сред выполнения.
- Минифицируйте и пакетируйте активы фронтенда для ускорения загрузки.
- Избегайте блокировки цикла событий синхронными задачами.
- Итерируйте и продолжайте профилирование, пока не достигнете оптимального результата.
Я следовал всем этим советам, и мой Node-скрипт с большим объемом данных стал контролируемо выполняться за 20 минут (а раньше это занимало целый час!). Надеюсь, мои рекомендации избавят вас от тех же головных болей, которые были у меня, и помогут создавать более быстрые и эффективные JavaScript-приложения.
Удачной оптимизации! И помните, что прежде чем оптимизировать, нужно провести измерения, чтобы получить максимальную отдачу!
Читайте также:
- Движок JavaScript, JIT-компилятор, стек, куча, память, примитивы, ссылки и сборка мусора
- Паттерн “Шаблонный метод” и его реализация в JavaScript
- Как преобразовать шестнадцатеричное число в десятичное в JavaScript
Читайте нас в Telegram, VK и Дзен
Перевод статьи Yaswanth: Think JavaScript Is Slow? Try These Hacks for 3x Faster Scripts Today





