Введение: почему профилирование — это must-have для каждого разработчика
Вы когда-нибудь писали код, который вроде бы работает, но на продакшене начинает «тормозить»? Или, может быть, вы запускали сервер Node.js, и через пару минут он «съедал» всю память? Чаще всего проблема не в языке, а в узких местах — участках кода, которые выполняются неоптимально.
Профилирование (profiling) — это процесс сбора метрик о работе программы: времени выполнения функций, потреблении памяти, частоте вызовов. Без него любая оптимизация — это гадание на кофейной гуще. В этой статье мы разберем, как профилировать JavaScript код в среде Node.js, находить «бутылочные горлышки» и устранять их.
Вы узнаете о встроенных инструментах V8, Chrome DevTools и практических приемах, которые помогут сделать ваш код быстрее.
1. Встроенное профилирование в Node.js: флаг --prof
Самый простой способ начать профилирование — использовать флаг --prof при запуске Node.js приложения. Он включает сбор статистики V8, которая затем преобразуется в читаемый отчет.
Как это работает
Запустите ваше приложение с флагом:
node --prof app.js
После завершения работы приложения в папке появится файл isolate-0x...-v8.log. Это сырые данные. Чтобы получить отчет, используйте утилиту node --prof-process:
node --prof-process isolate-0x...-v8.log > profile.txt
Пример: находим медленную функцию
Допустим, у нас есть такой код:
function slowSort(arr) {
return arr.sort((a, b) => a - b);
}
function heavyComputation() {
let sum = 0;
for (let i = 0; i < 1e7; i++) {
sum += Math.sqrt(i);
}
return sum;
}
// Запуск
const data = Array.from({ length: 10000 }, () => Math.random());
slowSort(data);
heavyComputation();
После профилирования в отчете вы увидите что-то вроде:
[JavaScript]:
ticks total nonlib name
120 60.0% 70.0% LazyCompile: *heavyComputation app.js:5
40 20.0% 25.0% LazyCompile: *slowSort app.js:1
Цифра ticks показывает количество «тиков» (семплов), которые пришлись на каждую функцию. Чем больше тиков, тем дольше выполнялась функция. В нашем случае heavyComputation — главный кандидат на оптимизацию.
2. Chrome DevTools: визуальный профайлер для Node.js
Если текстовый отчет кажется неудобным, используйте Chrome DevTools. Это графический интерфейс, который показывает flamegraph (пламенный граф) — визуализацию стека вызовов во времени.
Подключение к Node.js процессу
Запустите приложение с флагом --inspect:
node --inspect app.js
Откройте Chrome, в адресной строке введите chrome://inspect. Нажмите «Open dedicated DevTools for Node». Вы попадете в интерфейс, где можно снимать профили.
Практический разбор
Рассмотрим пример с утечкой памяти:
const cache = [];
function addToCache(item) {
cache.push(item);
}
function processData() {
for (let i = 0; i < 100000; i++) {
addToCache({ id: i, data: new Array(1000).fill('x') });
}
}
processData();
Запишите профиль через вкладку «Memory» (сделайте снимок кучи). Вы увидите, что массив cache занимает сотни мегабайт. Flamegraph во вкладке «Performance» покажет, что функция addToCache вызывается 100 000 раз — это и есть узкое место.
Решение: либо ограничить размер кэша, либо использовать структуру с автоматическим удалением старых элементов (например, Map с лимитом).
3. Профилирование с помощью модуля perf_hooks
Для точечного измерения времени выполнения конкретных участков кода используйте встроенный модуль perf_hooks. Это аналог console.time, но с большей точностью и возможностью интеграции с другими инструментами.
Пример: измеряем производительность
const { performance, PerformanceObserver } = require('perf_hooks');
function multiplyByTwo(arr) {
return arr.map(x => x * 2);
}
function multiplyByTwoFor(arr) {
const result = [];
for (let i = 0; i < arr.length; i++) {
result.push(arr[i] * 2);
}
return result;
}
const data = Array.from({ length: 1e6 }, () => Math.random());
// Измеряем map
performance.mark('map-start');
multiplyByTwo(data);
performance.mark('map-end');
performance.measure('Map time', 'map-start', 'map-end');
// Измеряем for
performance.mark('for-start');
multiplyByTwoFor(data);
performance.mark('for-end');
performance.measure('For time', 'for-start', 'for-end');
// Выводим результаты
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
console.log(`${entry.name}: ${entry.duration}ms`);
});
});
observer.observe({ entryTypes: ['measure'] });
Вывод может быть таким:
Map time: 12.34ms
For time: 8.56ms
Классический цикл for оказался быстрее map на 30%. Теперь вы можете принять обоснованное решение: если скорость критична — используйте цикл, если читаемость важнее — оставьте map.
4. Продвинутые техники: профилирование асинхронного кода
Асинхронный код (Promise, async/await) часто скрывает узкие места. Например, если вы запускаете 1000 запросов параллельно без лимита, это может «убить» Event Loop.
Проблема: слишком много параллельных операций
async function fetchAll(urls) {
const promises = urls.map(url => fetch(url));
return Promise.all(promises);
}
Если urls содержит 10 000 элементов, Node.js попытается открыть 10 000 TCP-соединений одновременно. Это вызовет нехватку памяти и ошибки.