Профилирование JavaScript кода: находим узкие места на практике

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: почему профилирование — это 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-соединений одновременно. Это вызовет нехватку памяти и ошибки.

Решение:

Блоги

Рекомендаций по книгам