Ошибки, рекурсия, события и таймеры
В традиционных JS-курсах эти четыре темы разносят по разным неделям, и редко кто пытается связать их одной идеей. Но идея есть, и она довольно простая. Все четыре механизма про то, что делать, когда поток исполнения перестаёт быть линейным.
Ошибка прерывает выполнение и поднимает контроль вверх по стеку до
ближайшего try / catch, ломая обычный порядок “сверху вниз”.
Рекурсия закручивает поток в саму себя и возвращается через тот же
стек обратно. Событие может прилететь в произвольный момент и
заставить пересобрать состояние. Таймер откладывает кусок работы на
потом, и этот “потом” наступает не сразу, а через очередь задач event
loop. Все четыре механизма управляют тем, что произойдёт следующим, и
все четыре зависят от того, как ты обращаешься со стеком, очередью и
подписками.
Этот урок собирает инструменты, которые JavaScript даёт под каждый
случай. Иерархия Error, кастомные классы и cause. Паттерны
рекурсии, включая trampoline и хвостовую оптимизацию, которой в V8
нет. EventTarget и EventEmitter. setTimeout, setInterval,
setImmediate, queueMicrotask. И связующее между ними звено,
AbortController. Полноценная асинхронность с промисами и async/await
ждёт нас в разделе 05, но подложку под неё (event loop, очередь
задач, отписки) мы подложим здесь.
1. Error и её устройство
Error, обычный класс. У него есть три поля:
const err = new Error('something went wrong');
err.name; // 'Error'
err.message; // 'something went wrong'
err.stack; // 'Error: something went wrong\n at ...'
stack собирается движком в момент создания (не throw), это снимок
вызовов на тот момент. Поэтому если ты создал ошибку в одной функции,
а кинул в другой, stack показывает место создания.
1.1. Иерархия встроенных
Error
├── EvalError (legacy, не бросается современным движком)
├── RangeError ('out of range', стек переполнен)
├── ReferenceError (обращение к необъявленной переменной, TDZ)
├── SyntaxError (ошибки парсинга, кидаются при загрузке)
├── TypeError (вызов не-функции, .x на undefined)
├── URIError (decodeURI на невалидной строке)
└── AggregateError (Promise.any: все промисы упали)
В Node ещё SystemError (ENOENT, EADDRINUSE), в браузере, доменные
вроде DOMException.
1.2. Кастомные классы
Базовый шаблон:
class AppError extends Error {
constructor(message, { code, httpStatus, cause } = {}) {
super(message, { cause }); // ES2022: cause через options
this.name = this.constructor.name;
this.code = code ?? 'UNKNOWN';
this.httpStatus = httpStatus ?? 500;
}
}
class NotFoundError extends AppError {
constructor(resource) {
super(`${resource} not found`, { code: 'NOT_FOUND', httpStatus: 404 });
}
}
throw new NotFoundError('user');
В Node при extends Error важно проставить name, иначе stacktrace
будет показывать просто “Error”. В V8 ещё доступен
Error.captureStackTrace(this, ConstructorOrFn), чтобы убрать сам
конструктор из стека.
1.3. Error.cause (ES2022)
Передавай оригинальную ошибку через cause, чтобы не потерять контекст:
try {
await db.query(sql);
} catch (originalErr) {
throw new AppError('failed to load user', {
code: 'DB_ERROR',
cause: originalErr,
});
}
В логах увидишь обе ошибки:
AppError: failed to load user
at loadUser (user.js:42:11)
Caused by: PostgresError: connection timeout
at ... (pg-pool.js:188:32)
Это идиоматичный способ заменить bookkeeping вида err.original = ...
или err.inner = ... из старых библиотек.
2. Try / catch и async
2.1. Sync
try {
riskyThing();
} catch (err) {
if (err instanceof NotFoundError) handle404(err);
else throw err; // pass дальше всё, что не знаем
}
Не злоупотребляй try/catch на уровне каждой функции. Лови там, где есть осмысленная обработка. Иначе, пробрасывай вверх до boundary (HTTP-обработчик, top-level CLI, error reporter).
2.2. Async / await
async function loadUser(id) {
try {
return await db.query('SELECT ...', [id]);
} catch (err) {
throw new AppError('failed to load user', { code: 'DB_ERROR', cause: err });
}
}
Важное, await обязательно. Без него db.query(...) возвращает
Promise, который не дожидается, и ошибка вылетит за пределами
try/catch, превратится в unhandledRejection. Это известный баг,
поэтому ESLint правило no-return-await (точнее, require-await)
часто полезно.
2.3. Promise цепочки
Если используешь .then без await:
db.query(sql)
.then(handle)
.catch((err) => {
throw new AppError('failed', { cause: err });
});
.catch ловит и предыдущий .then, и сам себя. Если внутри .catch
что-то падает, нужен ещё один .catch за ним.
2.4. Глобальные обработчики
В Node:
process.on('unhandledRejection', (reason, promise) => {
log.error('unhandled rejection', { reason });
// process.exit(1) если хочется fail-fast
});
process.on('uncaughtException', (err) => {
log.error('uncaught', err);
process.exit(1); // безопаснее завершить, чем продолжать в нестабильном состоянии
});
В браузере:
window.addEventListener('error', (e) => {
reportToSentry(e.error);
});
window.addEventListener('unhandledrejection', (e) => {
reportToSentry(e.reason);
});
Эти обработчики, safety net. Не используй их как штатный механизм; лови ошибки локально и пробрасывай явно.
3. Chaining
Chaining,
паттерн, при котором каждый метод возвращает this (мутирующая
цепочка) или новый объект (immutable chain), чтобы вызовы складывались
в выражение.
Мутирующий chain (Builder):
class QueryBuilder {
#table = '';
#where = [];
from(table) { this.#table = table; return this; }
where(cond) { this.#where.push(cond); return this; }
build() { return `SELECT * FROM ${this.#table} WHERE ${this.#where.join(' AND ')}`; }
}
new QueryBuilder()
.from('users')
.where('age > 18')
.where('status = "active"')
.build();
Иммутабельный chain (каждый шаг, новый объект):
class ImmutableQuery {
constructor(table = '', where = []) {
this.table = table;
this.where = where;
}
from(table) { return new ImmutableQuery(table, this.where); }
where(cond) { return new ImmutableQuery(this.table, [...this.where, cond]); }
}
Иммутабельный вариант сильно дороже по аллокациям, зато безопасен в многопоточном/concurrent контексте и легче тестируется.
В HTTP-клиенте Effect (модуль effect/http) запрос строится
как иммутабельная цепочка, потому что один builder может разделяться
между запросами.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.