Профилирование JavaScript кода: находим узкие места
Когда ваш Node.js проект начинает тормозить, первая мысль — «надо всё переписать на Rust». Но не спешите! Чаще всего проблема кроется в нескольких строчках кода, которые работают неэффективно. В этой статье мы разберём, как находить эти «бутылочные горлышки» с помощью простых инструментов профилирования. Вы узнаете, как замерить производительность, построить flamegraph и понять, куда уходит CPU.
Зачем профилировать код?
Представьте, что ваш сервер обрабатывает 100 запросов в секунду, а нужно 500. Вы можете оптимизировать всё подряд, но это займёт недели. Или вы можете найти одну функцию, которая выполняется 80% времени, и исправить её за час. Профилирование — это именно то, что показывает, где тратится время.
Рассмотрим основные сценарии, когда профилирование необходимо:
- Высокая задержка ответов API
- Утечки памяти (memory leaks)
- Высокая нагрузка на CPU без видимых причин
- Медленная обработка больших массивов данных
Инструмент 1: Встроенный профайлер Node.js (--prof)
Самый простой способ — запустить Node.js с флагом --prof. Он собирает статистику выполнения и сохраняет её в файл isolate-*.log.
Создадим тестовый скрипт, который содержит узкое место:
// slow.js
function heavyComputation() {
let sum = 0;
for (let i = 0; i < 1e7; i++) {
sum += Math.sqrt(i) * Math.sin(i);
}
return sum;
}
function fastFunction() {
return 42;
}
console.time('total');
heavyComputation();
fastFunction();
console.timeEnd('total');
Запустим профилирование:
node --prof slow.js
После выполнения появится файл isolate-0x...-v8.log. Чтобы прочитать его, используем утилиту node --prof-process:
node --prof-process isolate-0x...-v8.log > profile.txt
Откроем profile.txt и увидим таблицу с процентами времени:
[Summary]:
ticks total nonlib name
785 78.5% 85.3% JavaScript
215 21.5% 14.7% C++
...
[Bottom up (heavy) profile]:
ticks parent name
500 50.0% LazyCompile: *heavyComputation slow.js:2:17
100 10.0% Builtin: MathSqrt
50 5.0% Builtin: MathSin
Видим, что 50% времени уходит на функцию heavyComputation. Теперь мы знаем, что оптимизировать в первую очередь.
Инструмент 2: Chrome DevTools (инспектор)
Для более наглядного анализа подключим Chrome DevTools. Запустим Node.js с флагом --inspect:
node --inspect slow.js
Откроем Chrome и перейдём по адресу chrome://inspect. Нажмём «Open dedicated DevTools for Node». Вкладка «Profiler» → «Start» → запустите скрипт → «Stop».
Вы увидите flamegraph — график, где ширина блока показывает время выполнения. Чем шире блок, тем «горячее» функция. Наш heavyComputation будет огромным прямоугольником.
Совет: Используйте фильтр «Focus selected» на функции, чтобы увидеть её внутренние вызовы. Например, вы заметите, что Math.sqrt вызывается 10 миллионов раз — возможно, стоит вычислить корни заранее.
Инструмент 3: perf_hooks — точные замеры в коде
Иногда нужно замерить конкретный участок кода без запуска внешних инструментов. Для этого используем встроенный модуль perf_hooks.
Пример замера времени выполнения функции:
const { performance } = require('perf_hooks');
function processData(items) {
let result = [];
for (let item of items) {
// Имитация сложной обработки
for (let j = 0; j < 1000; j++) {
result.push(item * j);
}
}
return result;
}
const data = Array.from({ length: 1000 }, (_, i) => i);
const start = performance.now();
processData(data);
const end = performance.now();
console.log(`Время выполнения: ${(end - start).toFixed(2)} мс`);
Но это замеряет весь блок. Чтобы найти узкое место внутри функции, используем PerformanceObserver и маркеры:
const { performance, PerformanceObserver } = require('perf_hooks');
// Настраиваем наблюдатель
const obs = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(`${entry.name}: ${entry.duration.toFixed(2)}ms`);
}
});
obs.observe({ entryTypes: ['measure'] });
function analyzeData(items) {
performance.mark('start-loop');
for (let item of items) {
// Узкое место: вложенный цикл
for (let j = 0; j < 500; j++) {
Math.sqrt(item * j);
}
}
performance.mark('end-loop');
performance.measure('Внутренний цикл', 'start-loop', 'end-loop');
performance.mark('start-sort');
items.sort((a, b) => a - b);
performance.mark('end-sort');
performance.measure('Сортировка', 'start-sort', 'end-sort');
}
analyzeData(Array.from({ length: 10000 }, () => Math.random()));
Вывод покажет, что Внутренний цикл занял 120ms, а Сортировка — всего 5ms. Значит, оптимизируем цикл.
Практический пример: оптимизация после профилирования
Допустим, у нас есть функция, которая фильтрует массив объектов:
// До оптимизации
function filterUsers(users) {
let result = [];
for (let i = 0; i < users.length; i++) {
if (users[i].age > 18 && users[i].active === true) {
result.push(users[i]);
}
}
return result;
}
Профилирование показало, что 70% времени уходит на эту фильтрацию. Анализ выявил, что users.length вычисляется на каждой итерации (хотя V8 это кеши