ru
Feedback
Настоящий JavaScript

Настоящий JavaScript

Открыть в Telegram

Тот самый канал по JavaScript. Личный блог автора - @just_genych По вопросам рекламы или разработки: @g_abashkin

Больше
6 194
Подписчики
-424 часа
-317 дней
-5130 день
Привлечение подписчиков
июль '26
июль '26
+2
в 0 каналах
июнь '26
+110
в 0 каналах
Get PRO
май '26
+384
в 0 каналах
Get PRO
апрель '26
+83
в 0 каналах
Get PRO
март '26
+71
в 0 каналах
Get PRO
февраль '26
+23
в 0 каналах
Get PRO
январь '26
+18
в 0 каналах
Get PRO
декабрь '25
+13
в 0 каналах
Get PRO
ноябрь '25
+24
в 0 каналах
Get PRO
октябрь '25
+22
в 0 каналах
Get PRO
сентябрь '25
+21
в 0 каналах
Get PRO
август '25
+20
в 0 каналах
Get PRO
июль '25
+38
в 0 каналах
Get PRO
июнь '25
+52
в 0 каналах
Get PRO
май '25
+121
в 0 каналах
Get PRO
апрель '25
+88
в 0 каналах
Get PRO
март '25
+363
в 20 каналах
Get PRO
февраль '25
+30
в 0 каналах
Get PRO
январь '25
+38
в 0 каналах
Get PRO
декабрь '24
+41
в 0 каналах
Get PRO
ноябрь '24
+64
в 2 каналах
Get PRO
октябрь '24
+70
в 1 каналах
Get PRO
сентябрь '24
+84
в 2 каналах
Get PRO
август '24
+70
в 0 каналах
Get PRO
июль '24
+115
в 0 каналах
Get PRO
июнь '24
+89
в 4 каналах
Get PRO
май '24
+101
в 0 каналах
Get PRO
апрель '24
+121
в 0 каналах
Get PRO
март '24
+132
в 0 каналах
Get PRO
февраль '24
+101
в 0 каналах
Get PRO
январь '24
+86
в 0 каналах
Get PRO
декабрь '23
+90
в 0 каналах
Get PRO
ноябрь '23
+16
в 0 каналах
Get PRO
октябрь '23
+15
в 0 каналах
Get PRO
сентябрь '23
+16
в 0 каналах
Get PRO
август '23
+24
в 0 каналах
Get PRO
июль '23
+35
в 0 каналах
Get PRO
июнь '23
+20
в 0 каналах
Get PRO
май '23
+15
в 0 каналах
Get PRO
апрель '23
+264
в 0 каналах
Get PRO
март '23
+17
в 0 каналах
Get PRO
февраль '23
+207
в 0 каналах
Get PRO
январь '23
+40
в 0 каналах
Get PRO
декабрь '22
+478
в 0 каналах
Get PRO
ноябрь '22
+1 627
в 0 каналах
Get PRO
октябрь '22
+45
в 0 каналах
Get PRO
сентябрь '22
+451
в 0 каналах
Get PRO
август '22
+459
в 0 каналах
Get PRO
июль '22
+1 991
в 0 каналах
Get PRO
июнь '22
+1 567
в 0 каналах
Get PRO
май '22
+1 769
в 0 каналах
Get PRO
апрель '22
+97
в 0 каналах
Get PRO
март '22
+2 701
в 0 каналах
Дата
Привлечение подписчиков
Упоминания
Каналы
03 июля0
02 июля+1
01 июля+1
Посты канала
WeakRef и FinalizationRegistry: слабые ссылки как production-ready инструмент управления памятью В асинхронных пайплайнах — от Node.js-сервисов до SSR и heavy client-side кэширования — каждый временный объект (кэши, буферы, промисы, слушатели) держится сильной ссылкой, блокируя сборщик мусора. Типичная ошибка: держать в Map результаты дорогих API-вызовов, не понимая, что Map хранит сильные ссылки, и память растёт бесконтрольно. WeakRef: слабая ссылка для кэширования без утечек WeakRef позволяет GC очищать объект, если на него нет сильных ссылок. Идеально для кэша, где данные можно пересоздать — экономит память под нагрузкой:
const cache = new Map<string, WeakRef<any>>();
const registry = new FinalizationRegistry((key: string) => {
  cache.delete(key);
});

async function fetchData(key: string): Promise<any> {
  let ref = cache.get(key);
  let cached = ref?.deref();
  if (cached) return cached;

  const data = await expensiveAPICall(key);
  const weakRef = new WeakRef(data);
  cache.set(key, weakRef);
  registry.register(data, key);
  return data;
}
GC решает, когда удалить объект. Если памяти хватает — данные живут. Если нет — кэш "сдувается" без ручной очистки и утечек. FinalizationRegistry: детекция утечек и чистка ресурсов Колбэк при удалении объекта — можно отслеживать, что объект не был освобождён вовремя:
class LeakDetector {
  private registry: FinalizationRegistry<string>;
  private active = new Map<string, number>();

  constructor(onLeak: (name: string) => void) {
    this.registry = new FinalizationRegistry((name: string) => {
      const count = (this.active.get(name) ?? 0) - 1;
      if (count <= 0) this.active.delete(name);
      else this.active.set(name, count);
      onLeak(name);
    });
  }

  track(obj: object, name: string) {
    this.active.set(name, (this.active.get(name) ?? 0) + 1);
    this.registry.register(obj, name);
  }
}
Практический совет: используй в отладке для обнаружения утечек долгоживущих объектов — подписок, web-сокетов, буферов. Типобезопасная обёртка с generic TypeScript дружит с WeakRef через generic, что даёт надёжные типы:
class WeakCache<T extends object> {
  private refs = new Map<string, WeakRef<T>>();
  private registry = new FinalizationRegistry<string>((key) => {
    this.refs.delete(key);
  });

  set(key: string, value: T): void {
    const ref = new WeakRef(value);
    this.refs.set(key, ref);
    this.registry.register(value, key);
  }

  get(key: string): T | undefined {
    return this.refs.get(key)?.deref();
  }
}
Предупреждение: WeakRef не работают с примитивами (string, number) — только объекты. Для примитивов используй WeakMap или обёртки. Также не используй WeakRef для критичных по времени кэшей — GC непредсказуем. Вывод: Слабые ссылки — не экзотика, а инженерный инструмент для надёжного управления памятью, где главное правило — "используй, если данные можно пересоздать, и никогда не полагайся на детерминизм GC".

2
⁣Buffer в браузере: портирование Node.js-кода на Web Streams API и неочевидные грабли с TextEncoder/TextDecoder в production Перетащить обработку бинарных данных из Node.js в браузер — берешь Buffer, оборачиваешь в Uint8Array и production падает. Проблема в том, что Buffer — глобал из Node.js, его нет в браузере, а полифилы тащат лишние килобайты. Современный путь — Web Streams API и TextEncoder/TextDecoder, но есть грабли, которые ломают production. Грабли 1. slice не уважает UTF-8 В Node.js Buffer.from('Привет!').slice(0, 5) дает 5 байт и строку 'Прив'. В браузере new TextEncoder().encode('Привет!').slice(0, 5) режет посреди многобайтового символа — получаешь битый байт. В production это вылезает при обрезании логов с кириллицей или эмодзи. Решение: не используй slice на Uint8Array вслепую, применяй subarray и TextDecoder с stream: true для восстановления границ. Грабли 2. Потоковое декодирование без stream: true Web Streams отдают данные чанками. Если декодировать каждый чанк как отдельную строку, последний может быть урезан — многобайтовый символ разобьется на два чанка. Типичная ошибка: // Ломается на границе чанков for await (const chunk of reader) { const str = new TextDecoder().decode(chunk); } Нужно так: const decoder = new TextDecoder('utf-8', { stream: true }); for await (const chunk of reader) { const str = decoder.decode(chunk, { stream: true }); } const final = decoder.decode(); // завершающий вызов Пропустишь stream: true — получишь битые данные на границах чанков, например, при парсинге CSV с кириллицей в production. Грабли 3. Утечка памяти из-за создания инстансов Создание new TextEncoder() внутри цикла или хендлера запросов вызывает GC-шторм. Вынеси в константы модуля один раз. Тоже самое с TextDecoder — кэшируй для всех чанков потока. Практические советы при портировании - Buffer.concat заменяй на ручное объединение через new Uint8Array(totalLength) и set. - fs.createReadStream заменяй на fetch + response.body (ReadableStream). - Для энкодинга/декодинга используй только TextEncoder/TextDecoder с stream: true на чанках. - Никогда не доверяй прямому slice по Uint8Array, если внутри UTF-8 — это ломает строки с акцентами, кириллицей и эмодзи в production. Вывод: Грабли не в том, что Buffer нет в браузере, а в том, что TextEncoder.encode ведет себя иначе — учитывай многобайтовые символы, кэшируй инстансы и всегда декодируй с stream: true.
125
3
⁣Symbol.toStringTag — тот самый символ, который ломает всё подряд Казалось бы, просто добавил [Symbol.toStringTag] в класс, получил [object MyCoolClass] — и радуешься. Но production показывает, что это "удобство" превращает дебаг в квест: особенно когда фреймворк или библиотека ожидают строгий "Object", "Array" или "Promise", а получают что-то своё. Сериализация и JSON Прямого влияния на JSON.stringify() нет. Но если пишешь свой toJSON(), где проверяешь this[Symbol.toStringTag] — вот тут баг. Класс-наследник с другим тегом превращается в родительский тип. Я такое ловил, когда сериализовал DTO: потомок считался предком, и бэкенд падал с валидацией. * Типичная ошибка: проверять тип через тег, а не через this.constructor.name. * Практический совет: в кастомной сериализации используй отдельное поле _typeTag. Прототипы под прицелом Многие фреймворки определяют тип через Object.prototype.toString.call(obj). Зачем? Потому что typeof не отличает Array от обычного объекта. А если ты переопределил тег в прототипе (например, сделал Array.prototype[Symbol.toStringTag] = "MyArray") — библиотека, ожидающая "Array", просто сломает иммутабельность или реактивность. Видел такое в старых полифиллах. * Предупреждение: не трогай Symbol.toStringTag в прототипах Array, Promise, Map — движок использует их для внутренних проверок. * Trade-off: изменяя тег на прототипе встроенных типов, ты создаёшь скрытые зависимости от строковых сравнений в чужих модулях. shouldOverrideVisibility и production-фреймворки В недрах React Native или Vue 2 composition API есть функция, которая проверяет видимость компонента через Symbol.toStringTag. Кейс: обернул компонент в HOC, HOC перетирает тег. shouldOverrideVisibility сравнивает тег с ожидаемым — и игнорирует компонент как "чужой". Элементы рендерятся, но никогда не становятся видимыми. Это не баг документации — это примитивная проверка на уровне типа. Пример бага: class Base { get [Symbol.toStringTag]() { return "Base" } } class Child extends Base {} // Child будет сериализоваться как Base * Чтобы защититься в HOC: прокидывай тег через Object.defineProperty с оригинальным значением. Вывод: Маленький символ Symbol.toStringTag — это скрытая точка отказа при сериализации, защите прототипов и в production-фреймворках, поэтому относись к нему как к мине — лучше не трогать, если не контролируешь все уровни стека.
188
4
⁣Feature flags на TypeScript: типобезопасная композиция с branded types и runtime validation для production-конфигураций Feature flags в production — удобно, но конфиги часто превращаются в мешанину строк. Простое { isNewFeature: true } не защищает от опечаток вроде isNewFeautre, и линтер молчит до прогона тестов или деплоя. Используем branded types с Zod для строгой проверки на этапе компиляции и выполнения. Branded types для compile-time безопасности Тип Brand<T, B> добавляет уникальную метку. Передать простой boolean вместо NewFeature не выйдет — TS потребует явного приведения. Это исключает путаницу между флагами: type Brand<T, B> = T & { __brand: B }; type NewFeature = Brand<boolean, 'NewFeature'>; type LegacyFallback = Brand<boolean, 'LegacyFallback'>; function useFeature(newFeature: NewFeature, legacyFallback: LegacyFallback): boolean { return newFeature || legacyFallback; } Runtime validation с Zod Даже с branded types в JSON может прилететь невалидное значение. Добавляем схему с z.boolean().brand() — тогда при парсинге ошибка будет явной, а не тихим undefined: const ConfigSchema = z.object({ newFeature: z.boolean().brand<'NewFeature'>(), legacyFallback: z.boolean().brand<'LegacyFallback'>(), }); type Config = z.infer<typeof ConfigSchema>; function loadConfig(raw: unknown): Config { return ConfigSchema.parse(raw); } Дискриминируемые union для состояний флагов Когда два флага конфликтуют (например, newFeature и legacyFallback), обычные типы не мешают включить оба одновременно. Discriminated union сужает область до корректных комбинаций — это предотвращает два источника истины: type FlagState = | { newFeature: true; legacyFallback: false } | { newFeature: false; legacyFallback: boolean }; function processState(state: FlagState) { if (state.newFeature) { return state.legacyFallback; // TS сужает до false } return state.legacyFallback; } * Предупреждение: branded types добавляют шум в код и усложняют вложенные конфиги без линтера. Для плоских A/B тестов и канареечных релизов это окупается — ошибка ловится на этапе компиляции, а не в production. * Совет: комбинируй discriminated union с zod-схемой на весь конфиг — тогда даже опечатка в JSON упадёт с понятным сообщением, а не молча сломает логику. Вывод: Типобезопасная композиция флагов через branded types и runtime-валидация даёт двухуровневую защиту — compile-time строгость и runtime-проверку входных данных, что критично для надёжности A/B экспериментов и канареечных деплоев.
180
5
⁣Скрытые баги в AbortSignal.timeout() и типобезопасная композиция AbortController в асинхронных цепочках При работе с асинхронными операциями в Node.js или браузере часто используют AbortSignal.timeout() для ограничения времени выполнения. Это удобно, но таит подводные камни, особенно в цепочках promise или при композиции нескольких timeout-сигналов. Разработчики нередко забывают про race condition между завершением запроса и отменой, а также про невозможность отличить таймаут от ручного прерывания без явной настройки причины. Проблема: гонка состояний и необработанные ошибки Когда AbortSignal.timeout(5000) передаётся в fetch, а запрос завершается раньше таймаута, сигнал просто игнорируется — это нормально. Но если запрос падает с ошибкой до срабатывания таймаута, сам таймаут всё равно вызывает abort. Возникает ситуация: ошибка уже обработана в catch, а AbortError от таймаута остаётся необработанным. В консоли появляется unhandled rejection. Пример: если fetch упадёт с 500-м ответом на 3-й секунде, а таймаут срабатывает на 5-й, то второй reject — от таймаута — не будет пойман. Это классический race condition, который рвет цепочку error handling. Отсутствие различимости таймаутов AbortSignal.timeout() бросает DOMException с именем AbortError. Свой AbortController при вызове .abort() тоже создаёт такой же exception. В итоге в catch нельзя отличить, что пошло не так: таймаут или ручная отмена. Единственный способ — задать signal.reason при abort, но по умолчанию он undefined. Это делает диагностику сложной, особенно в production. try { await fetch(url, { signal }); } catch (err) { if (err.name === 'AbortError') { // Что это? Таймаут? Ручной abort? Непонятно. } } Типобезопасная композиция: решение для цепочек Для асинхронных операций, где нужно несколько таймаутов (например, сначала fetch, потом обработка данных), AbortSignal.any() — не выход: он не сохраняет причину и не убирает race condition. Вместо этого используйте композицию контроллеров с явным контролем таймаутов. Пример простой утилиты для оборачивания promise с таймаутом: function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> { const controller = new AbortController(); const id = setTimeout(() => controller.abort(new Error('Timeout')), ms); return Promise.race([ promise, new Promise<never>((_, reject) => { controller.signal.addEventListener('abort', () => reject(controller.signal.reason)); }) ]).finally(() => clearTimeout(id)); } Здесь таймаут генерирует кастомную ошибку с понятным сообщением, которую можно отловить. Для цепочек — складывайте контроллеры в массив и управляйте ими вручную: const acc = new AbortController(); const fetchPromise = fetch(url, { signal: acc.signal }); const timeoutId = setTimeout(() => acc.abort(new Error('Fetch timeout')), 5000); fetchPromise.finally(() => clearTimeout(timeoutId)); Типичная ошибка при композиции Попытка объединить AbortSignal.timeout() с собственным контроллером через AbortSignal.any() приводит к потере контроля над причиной и времени отмены. Если таймаут от AbortSignal.timeout() сработает первым, ваш контроллер никогда не будет вызван, что приводит к утечке ресурсов (например, незавершённый HTTP-запрос). Вывод: Не используйте AbortSignal.timeout() в production без явной обработки причины и контроля race condition — для асинхронных цепочек надёжнее собрать композицию вручную с кастомными ошибками и явным управлением таймаутами.
193
6
Получи грант до 3,48 млн на обучение дизайну Поступай на дизайн в Центральный университет с грантом. Для учеников 10–11-х кла
Получи грант до 3,48 млн на обучение дизайну Поступай на дизайн в Центральный университет с грантом. Для учеников 10–11-х классов и СПО. Освой графический, UI/UX и продуктовый дизайн. Создавай визуальные концепты будущего. На программе студенты получают фундаментальную базу, развивают прикладные навыки, приобретают опыт работы над реальными проектами, собирают портфолио и строят связи внутри дизайн-сообщества Подать заявку #реклама 16+ cu.ru О рекламодателе
201
7
⁣Conditional types в рантайме: как runtime type guards оживляют discriminated unions Discriminated unions удобны в TypeScript, но в production их часто приходится проверять вручную через цепочки if и switch. Это ломает типы и множит ошибки на границе с внешними данными — API-ответами, JSON, AST-нодами. Решение: combine mapped types с runtime для автоматической генерации type guards. Mapped type как контракт для runtime Вместо того чтобы писать отдельный guard для каждого варианта union, определяем объект-гард через mapped type: type ShapeGuards = { [K in Shape['kind']]: (obj: unknown) => obj is Extract<Shape, { kind: K }> }; Теперь для каждой ветки (circle, rect, group) нужно только реализовать валидацию полей. Тип обяжет вернуть правильный предикат. Единая точка входа и generic фабрика Добавляем общую функцию: function isShape<T extends Shape['kind']>(kind: T, obj: unknown): obj is Extract<Shape, { kind: T }> { return shapeGuards[kind](obj); } Типизация работает полностью: после isShape('circle', data) TypeScript знает, что data — это { kind: 'circle'; radius: number }. Для рекурсивных веток вроде group с вложенными Shape[] это тоже безопасно. Можно уйти дальше — сделать generic-фабрику, которая возвращает строго типизированный guard для конкретного kind без кастингов внутри: type GuardOfKind<T> = T extends Shape['kind'] ? (obj: unknown) => obj is Extract<Shape, { kind: T }> : never; function createKindGuard<T extends Shape['kind']>(kind: T): GuardOfKind<T> { return shapeGuards[kind] as any; } Типичная ошибка и trade-off Ошибка: проверять только kind, а не поля варианта. TypeScript поведет, но в runtime придет некорректный объект — и сломается что-то ниже. Всегда проверяй специфичные поля: radius, shapes и т.д. Минус подхода: для глубоко вложенных дифференцированных union придется писать много ручной валидации. Лечится либо кодогенерацией, либо внешними библиотеками (zod, io-ts). Но для легаси с 10+ ветками такой подход выигрывает у хаоса из switch и if-else. Вывод: Соединение conditional типов с runtime guards превращает абстрактную типизацию в production-ready механизм проверки на границе доверия — особенно в API-клиентах, SDK и парсерах AST.
185
8
⁣Типизация FSM без библиотек: почему discriminated unions подводят в production и как это чинить Discriminated unions в TypeScript отлично работают для статичных конечных автоматов, но в реальном production — с динамической сменой стейтов, асинхронными переходами и конфигами из плагинов — быстро проявляют лимиты. Частая ошибка: думать, что union решает все проблемы FSM, а потом ловить race conditions и невалидные переходы. Закрытый union и динамические стейты Union дискриминирован только на этапе компиляции. Если стейты подгружаются в рантайме — например, из конфига или через плагинную архитектуру — тип не расширить. Решение: карта стейтов через mapped types. type StateMap = { idle: Record<string, never> loading: { progress: number } error: { message: string } } type State = { [K in keyof StateMap]: { status: K } & StateMap[K] }[keyof StateMap] Новый стейт в StateMap — и тип обновляется автоматически. Но типовая безопасность переходов остаётся хрупкой. Нетипизированные переходы и гонки DU типизирует только состояние, а не граф переходов. После loading можно прыгнуть в error, хотя бизнес-логика требует success. В production это даёт гонки при параллельных запросах. Попытка типизировать через guard-функции: function transition<S extends State, T extends State>( prev: S, next: T, guard: (prev: S) => boolean ): T { if (!guard(prev)) throw new Error('Invalid transition') return next } Но это не спасает от асинхронных race conditions — требуется флаг блокировки. Типы не гарантируют порядок, только композицию. Контекст и дублирование полей При 10+ стейтах общие поля (например, id заказа) дублируются в каждом типе. Вынос в дженерик — простое, но эффективное решение: type BaseState<T extends string, Ctx> = { status: T } & Ctx type FSM = | BaseState<'idle', {}> | BaseState<'loading', { progress: number }> | BaseState<'error', { message: string }> Так контекст не размазывается, а union остаётся читаемым. Вывод: Discriminated unions — стартовая точка, но для production FSM без xstate обязательно нужны карта стейтов, типизированные переходы с guard'ами и вынесенный контекст, иначе на втором километре кода типы начнут врать при динамической смене состояний.
213
9
Как я собираю мини‑аналитику по рынку профессий Давно работая с HR‑аналитикой, стало интересно не просто смотреть на рынок, н
Как я собираю мини‑аналитику по рынку профессий Давно работая с HR‑аналитикой, стало интересно не просто смотреть на рынок, но и самому выделить что‑то основное, что можно собрать и представить: зарплатная аналитика, аналитика подбора персонала и тому подобное. Частные случаи отсутствия роста оплаты труда могут восприниматься людьми так, будто такое везде, но это может быть ошибкой. Год назад была достаточно сильная гонка зарплат, которая сейчас привела к акценту на производительности труда в стране. Многие ее не заметили. Безработица низкая, что означает дефицит кадров. Но сейчас не дефицит кадров вообще, а дефицит квалифицированных кадров и дефицит рабочих. Без данных такие фразы превращаются в ощущения, а ощущения — плохая основа для выводов. Поэтому начат сбор небольшого аналитического проекта по рынку профессий. Идея простая: брать открытые данные, приводить их в порядок и собирать короткие профили по отдельным профессиям. Читать далее
231
10
⁣Ambient type augmentation в declare module при динамической загрузке: скрытые конфликты с фактической типизацией и стратегии разрешения В монорепозиториях declare module в .d.ts файлах часто считают удобным способом типизировать динамически загружаемые модули. Однако на production это приводит к трудноуловимым багам, когда глобальные декларации из разных пакетов склеиваются, а не заменяются, создавая пересечения типов, которые не совпадают с реальным runtime-поведением. Проблема склейки в пересечение Когда два пакета в монорепозитории объявляют declare module 'lib' с разными версиями опций, TypeScript не выбирает одну — он объединяет их в пересечение. Результат: { mode: never }, если типы конфликтуют. Локально тесты проходят, а в production код падает из-за несовместимости. Типичная ошибка — считать, что declare module изолирован. Динамический импорт и runtime-разрыв При динамической загрузке через import('lib').then(...) TypeScript использует глобальные ambient-типы, а не фактическую сигнатуру модуля в рантайме. Если модуль загружается по условию (например, A/B-тест или фича-флаг), статически верный тип может не совпадать с реально загруженной версией. Результат — ошибки в рантайме, которые не ловятся статическим анализатором. Стратегии для монорепозиториев * Изоляция через tsconfig paths — явно указывай пути к реальным файлам модуля и исключай лишние .d.ts. Минус: накладные расходы на настройку каждого пакета. * Wrapper-пакет с type guard — создай прослойку, которая делает динамический импорт и экспортирует явный тип. Контролируешь, что отдаёшь наружу, но добавляешь лишний слой. * Маркер версии в declare module — добавь export const __version: '2.0.0' в декларацию. В коде видно, какая версия предполагалась, и можно проверить совпадение. Практический совет Периодически прогоняй tsc --noEmit --traceResolution в каждом пакете отдельно. Это покажет, откуда TypeScript берёт типы для declare module, и выявит случайные пересечения из node_modules или соседних пакетов. В монорепозиториях это обязательно, иначе баги с типами уйдут на production. Вывод: Ambient type augmentation в declare module — опасный компромисс между удобством и надёжностью типов в монорепозиториях, требующий явной изоляции через tsconfig paths или wrapper-пакеты для предотвращения runtime-ошибок.
246
11
⁣React Server Components в Next.js App Router: грабли, о которые споткнулись многие Даже на middle+ проектах RSC в Next.js подкидывают сюрпризы, которые ломают гидрацию, порождают race conditions в параллельных роутах и сбивают с толку типизацию Server Actions. Разберём три production-кейса, где интуиция подводит. Гидрация RSC: клиент оборачивает сервер Когда клиентский компонент оборачивает серверный, Next.js сначала рендерит всё на сервере, затем гидратирует на клиенте. Ошибка: если серверный компонент использует cookies() или headers(), а клиентский в это же время делает revalidation, данные расходятся — сервер видит один стейт, клиент другой. Фикс: добавь Suspense на границе между клиентским и серверным кодом или вынеси состояние в отдельный провайдер. Это не универсально, но покрывает 90% кейсов. Race conditions в параллельных роутах Parallel Routes выглядят мощно, но на практике легко получить состояние гонки. Когда два слота — children и modal — одновременно делают fetch() с ревалидацией, один может тихо перезаписать кэш другого. Особенно больно, если у них loading.tsx с разным временем загрузки. Решение: используй generateMetadata для детерминированной загрузки и вешай ключи вроде key={searchParams.tab} на слоты, чтобы принудительно перемаунтить компонент. Костыль, но работает. Типизация Server Actions Server Actions не наследуют типы при передаче в клиентские компоненты — TypeScript думает, что всё ок, а на деле тип съезжает, потенциально вызывая runtime-ошибку. Решение: вручную объявляй дженерик-тип: type ActionState = { success: boolean; error?: string }; type ServerAction = (state: ActionState, formData: FormData) => Promise<ActionState>; export const updateUser: ServerAction = async (state, formData) => { 'use server'; return { success: true }; }; Типобезопасно, но требует явной аннотации — не доверяй автоматическому выводу. Вывод: RSC в Next.js — мощный инструмент, но границы между клиентом и сервером, синхронизация кэша в параллельных роутах и явная типизация Server Actions требуют строгого инженерного контроля, иначе production ловит рассогласование данных.
242
12
⁣Как объявить Proxy типобезопасным: рефлексивные типы-обёртки для API, логирования и моков без потери IntelliSense Взяли Proxy, обернули API-клиент для логирования запросов. Вроде всё ок, пока не попытались вызвать метод с аргументами. IntelliSense молчит, типы потерялись, а в рантайме — undefined. Знакомо? Проблема в том, что new Proxy(target, handler) возвращает тип target как есть, но handler.get возвращает any, потому что тип prop — string | symbol. Компилятор не знает, что именно ты достаёшь, и подсказки пропадают. Решение: пересоздание типов через mapped types Не просто T, а структура, где каждый метод сохраняет свою сигнатуру: type TypedProxy<T> = { [K in keyof T]: T[K] extends (...args: infer A) => infer R ? (...args: A) => R : T[K]; }; Теперь createProxy возвращает объект, где IntelliSense видит те же аргументы и возврат, что у оригинала. В handler.get мы сами вызываем obj[prop](...args) — да, тут any, но наружу типы уже прокинуты нормально. Где это реально выручает * Логирование вызовов API без изменения типов ответов. * Моки для тестов — подменяешь метод, а интерфейс остаётся. * Валидация на set с проверкой типов через conditional types. Ловушки и компромиссы Если нужен доступ к динамическим полям вроде proxy[userInput], mapped types не помогут — придётся добавить index signature с [key: string]: unknown. IntelliSense для такого не заработает, это компромисс. Ещё одна ловушка: T extends object пропустит примитив. Лучше уточнять через T extends Record<string, any>, иначе прокси упадёт на числах или строках. Вывод: Без пересоздания типов Proxy остаётся чёрным ящиком; с mapped types — нормальный инструмент без потери подсказок, но с осознанием границ динамических полей.
277
13
На Stepik запустили мощный курс по «Troubleshooting Docker и Kubernetes: поиск и устранение проблем» В программе только важны
На Stepik запустили мощный курс по «Troubleshooting Docker и Kubernetes: поиск и устранение проблем» В программе только важные аспекты: — troubleshooting Docker и образов — диагностика сетевых проблем — настройка readiness/liveness probes — отладка pod’ов, деплоев и ingress — анализ логов контейнеров и кластера — разбор ошибок CrashLoopBackOff, OOMKilled, ImagePullBackOff и других Собеседования на DevOps/SRE сейчас всё чаще строятся вокруг реальных инцидентов. Данный курс фокусируется именно на таких сценариях и помогает в подготовке к практическим вопросам 48 часов доступен со скидкой 25% ↗️ Пройти курс на Stepik
237
14
⁣Deep dirty structuredClone: лимиты, нюансы сериализации и хаки для клонирования неподдерживаемых типов в production сценариях В production часто нужно глубокое копирование объектов, особенно при работе с состоянием в SPA, API клиентами или кэшированием в Node.js сервисах. Многие разработчики полагаются на structuredClone как на серебряную пулю, но забывают о его строгих ограничениях, которые могут привести к потере данных или багам в runtime. Что structuredClone копирует, а что нет Он успешно обрабатывает объекты, массивы, Map, Set, Date, RegExp, Blob, File и ArrayBuffer, включая циклические ссылки. Но есть черный список: функции, классы с методами, Symbol (как ключи и значения), DOM-элементы, а также Error объекты, которые клонируются как простые объекты с полями message и stack, но без прототипа. В production это проблема, например, при сериализации конфигов с колбэками или при работе с кастомными классами в shared libraries. Хаки для продакшна Для функций нужна ручная обвязка:function cloneWithFunctions(obj: Record<string, unknown>) { const clone = structuredClone(obj); for (const key of Object.keys(obj)) { if (typeof obj[key] === 'function') { clone[key] = obj[key]; } } return clone; } Этот подход грубый: замыкания привязаны к оригинальному скоупу, а не к копии. Полная изоляция через new Function или eval опасна и подходит только для ограниченных сценариев (например, серверные конфиги с проверенными функциями). Для DOM-элементов используйте cloneNode(true). Для Error восстанавливайте прототип: function cloneError(err: Error): Error { return Object.assign(Object.create(Error.prototype), err, { message: err.message, name: err.name, stack: err.stack }); } Но это меняет поведение — оригинальный Error мог иметь кастомные свойства, которые не сериализуются. Типичная ошибка Нельзя использовать structuredClone для клонирования объектов с кастомными прототипами, например, экземпляров классов с методами, или для сохранения ссылочной идентичности. Все копии становятся независимыми плоскими объектами. Symbol-ключи полностью теряются. В бандлинге или дизайн-системах это ведет к неожиданным багам, когда копия не имеет ожидаемого поведения или ломает инварианты модуля. Вывод: structuredClone надежен только для изолированных данных без функций, Symbol и кастомных прототипов — для production-сценариев с артефактами runtime всегда делайте ручную обвязку и документируйте границы клонирования.
240
15
⁣Кеш для Intl — не опция, а необходимость при heavy load интернационализации Тема performance в интернационализации часто недооценивается. В production — будь то SPA с динамической сменой локали, Node.js API, отдающий отформатированные цены тысячам пользователей, или SSR с рендерингом сотен дат на страницу — каждый вызов new Intl.DateTimeFormat() или Intl.NumberFormat() без кеша нагружает процессор парсингом CLDR-данных и построением форматтера. Типичная ошибка: писать форматтинг прямо внутри рендера или цикла, думая, что это "микрооптимизация". Кешируйте форматтеры, не конструкторы Создавать форматтер на каждый вызов — путь к тормозам. Решение: храните готовые экземпляры в Map или WeakMap, ключом делайте строку JSON.stringify({ locale, ...options }). Но будьте внимательны: если опции меняются часто (например, динамический timeZone), кеш может не дать выгоды. Всегда включайте изменчивые параметры в ключ. Типизируйте опции явно, а не строковыми literals В TypeScript DateTimeFormatOptions — это широкий тип, легко передать 'number' вместо 'numeric'. Ошибка всплывет в runtime. Создайте строгий union: type DatePart = 'numeric' | '2-digit'; type StrictOptions = { year?: DatePart; month?: DatePart; day?: DatePart; }; Это ловит опечатки на этапе компиляции. Практический совет: для Intl.NumberFormat используйте NumberFormatOptions с кастомными константами валют — это предотвращает случайное переключение формата. Предварительно проверяйте кастомные локали Intl.DateTimeFormat.supportedLocalesOf() — не просто метод, а must-have перед вызовом конструктора. Без него передача неподдерживаемой локали генерирует RangeError. Для Intl.NumberFormat кешируйте по ключу locale:currency: когда пользователь переключает язык на лету, это предотвращает фризы в UI. В Node.js проверьте, не используете ли вы small-icu — без full ICU кастомные локали приведут к багам за день до релиза. Не считайте кеширование Intl микрооптимизацией. Это базовый паттерн для надежного DX в heavy-load системах. Вывод: Всегда кешируйте экземпляры Intl.XXXFormat по стабильному ключу и типизируйте опции — это дает стабильную производительность и предотвращает runtime-ошибки в production.
276
16
⁣Вложенные Promise-конструкторы и скрытые утечки памяти: почему new Promise(resolve => executor(resolve)) опаснее, чем кажется, и как типизировать ручное управление resolve/reject На code review я часто вижу этот паттерн от middle и senior разработчиков. В production — в Node.js сервисах, подписках на EventEmitter, SPA с кастомными колбэками — это фабрика висячих промисов, которые никогда не завершаются. Основная ошибка: передача resolve как колбэка без обёртки. Почему промис «не зарезолвится»? Когда вы пишете new Promise(resolve => executor(resolve)), внутренний executor сохраняет ссылку на resolve как колбэк. Если executor — подписка на EventEmitter или долгоживущий объект, resolve живёт в замыкании, пока жив объект. Промис ждёт вызова, которого не будет — утечка памяти гарантирована. GC не соберёт его из-за замкнутой ссылки. Пример: function subscribeToEvents(emitter) { return new Promise(resolve => { emitter.on('data', resolve); // resolve висит, пока emitter жив // emitter никогда не эмитит — промис висит вечно }); } Типизировать resolve/reject: не просто (value?) => void Простая сигнатура не показывает, что resolve должен вызываться ровно один раз. Правильнее — замыкание с внутренним состоянием или явный Deferred. Лучшее решение — Promise.withResolvers() (ES2024) или кастомный Deferred: interface Deferred<T> { promise: Promise<T>; resolve: (value: T | PromiseLike<T>) => void; reject: (reason?: any) => void; } function createDeferred<T>(): Deferred<T> { let resolve!: Deferred<T>['resolve']; let reject!: Deferred<T>['reject']; const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } Deferred даёт явное управление жизнью промиса — вы контролируете, когда и как его завершить. Это trade-off: больше кода, но меньше утечек. Как избежать висячих промисов Совет: если сохраняете resolve в объекте (подписка на класс), удаляйте ссылку после завершения через .finally(). Иначе накопите кучу функций, которые GC не вычистит. const deferred = createDeferred<Data>(); emitter.on('data', deferred.resolve); deferred.promise.finally(() => { emitter.off('data', deferred.resolve); }); Предупреждение: передавать resolve как колбэк в сторонние подписки без привязки к жизненному циклу — плохая идея. Используйте Deferred с гарантией однократного вызова и очисткой. Утечки через промисы трудно ловить, но код должен быть чистым по памяти. Вывод: Ручное управление resolve/reject в JavaScript требует явного контроля жизненного цикла — без Deferred или Promise.withResolvers() вы рискуете утечками памяти, которые не видны в тестах, но проявляются в production при долгоживущих подписках.
236
17
⁣Buffer и Uint8Array: грабли, которые стоят часов дебага Одна из самых коварных ловушек в Node.js — смешивание Buffer и Uint8Array при парсинге бинарных протоколов. На первый взгляд они взаимозаменяемы: Buffer наследует от Uint8Array. Но под капотом — разные контракты, и "тихая" конвертация данных может убить zero-copy или вызвать TypeError. Проблема: slice vs subarray Допустим, ты парсишь TCP-поток с zero-copy, работая с подмассивами: // Ошибка: buffer.slice() копирует данные в Node <20 function parsePacket(raw: Buffer): number { const header = raw.slice(0, 4); return header.readUInt32BE(); // работает, но копия } // Правильно: subarray() делает zero-copy view function parsePacket(raw: Buffer): number { const header = raw.subarray(0, 4); // Uint8Array // TypeError: header.readUInt32BE is not a function } Ключевые грабли * Buffer.prototype.slice() до Node 20 копирует данные, убивая zero-copy. * Buffer.prototype.subarray() возвращает Uint8Array, а не Buffer. Методы .readUInt* недоступны. * Обратная конвертация (передача Uint8Array в Buffer API) — тихая, но с сюрпризом: методы Buffer не работают. Правильный подход: DataView Для кросс-платформенного zero-copy парсинга используй DataView поверх ArrayBuffer: class PacketParser { private view: DataView; constructor(private buffer: ArrayBuffer | Buffer) { this.view = new DataView( buffer instanceof Buffer ? buffer.buffer : buffer ); } readUint32(offset: number): number { return this.view.getUint32(offset, false); // big-endian } readSlice(offset: number, length: number): ArrayBuffer { return this.buffer.slice(offset, offset + length); } } // Zero-copy: subarray, затем DataView const raw = Buffer.from([0, 0, 0, 42]); const firstPacket = raw.subarray(0, 4); // Uint8Array const parser = new PacketParser(firstPacket.buffer); console.log(parser.readUint32(0)); // 42 Практические советы * Не используй Buffer.read* при zero-copy — они не работают с Uint8Array. * Для кросс-платформенного парсинга (браузер + Node) — только DataView. * Buffer.concat возвращает Buffer и копирует данные — явная конвертация. * Если нужен гибридный парсер, оберни все в new Uint8Array(bufferOrView) и используй TextDecoder или DataView. Предупреждение Неявное смешивание типов — главный источник багов производительности и логики. Zero-copy теряется, как только ты вызываешь .slice() или передаешь Buffer в API браузера (например, WebSocket.send). Вывод: Для надежного zero-copy парсинга бинарных протоколов используй DataView поверх subarray — это единственный способ избежать тихих конвертаций и сохранить производительность в production.
249
18
⁣Branded Memento: типизация приватного состояния без компромиссов Классический паттерн Снимок (Memento) часто сводится к поверхностной инкапсуляции: TypeScript private ломается через as any или Object.keys, а runtime-доступ к состоянию остается открытым. В production — будь то state management в SPA, логирование в Node.js-сервисах или rollback в API-клиентах — это приводит к неявным мутациям и багам. Решение лежит в комбинации branded типов и WeakMap. Branded type как физический барьер Memento — не контейнер данных, а заглушка с уникальным символом: declare const MementoBrand: unique symbol; interface Memento { readonly [MementoBrand]: void; } Снаружи невозможно прочитать [MementoBrand] — он просто не существует в runtime. Любая попытка as any не даст доступа к реальному состоянию, так как его там нет. WeakMap: приватность без утечек Реальное состояние хранится в WeakMap<Memento, State>. Ключ — сам объект Memento, который генерируется внутри класса: const stateMap = new WeakMap<Memento, { balance: number }>(); class BankAccount { private balance = 0; createSnapshot(): Memento { const memento = {} as Memento; stateMap.set(memento, { balance: this.balance }); return memento; } restore(memento: Memento) { const state = stateMap.get(memento); if (state) this.balance = state.balance; } } Сборщик мусора автоматически удаляет запись, когда снимок больше не используется. Никаких утечек. Практический совет и типичная ошибка Сериализовать такой Memento напрямую в JSON нельзя — WeakMap не итерируется. Решение: если нужна де/сериализация, замени WeakMap на Map<string, State> и генерируй UUID для снимка. Теряешь автогарбаж, но получаешь поддержку persistence. *Типичная ошибка:* пытаться сделать Memento как простой объект с полями. Даже readonly в TS не защитит от Object.assign или деструктуризации в модульных тестах. Вывод: Branded types в паре с WeakMap превращают TypeScript из системы типов в инструмент настоящей инкапсуляции, где приватность гарантируется на уровне исполнения, а не только компиляции.
239
19
🤯 Девушка получила оффер в OpenAI и поделилась своим опытом поиска работы Внутри статьи она подробно расписывает этапы собес
🤯 Девушка получила оффер в OpenAI и поделилась своим опытом поиска работы Внутри статьи она подробно расписывает этапы собеседований, лайфхаки и делится учебными ресурсами, которые ей помогли. Плюс девушка великодушно оставила ссылки на свой Notion с полезными заметками по математике и LLM. ✖️ xCode Journal
313
20
⁣Node.js: WeakRef, FinalizationRegistry и GC-триггеры для long-running процессов Long-running процессы в Node.js — зона риска утечек памяти. ES2021 даёт два API для активного контроля над GC: WeakRef и FinalizationRegistry. Многие разработчики полагаются на автоматическое управление памятью, не учитывая, что GC не может освободить ресурсы, удерживаемые сильными ссылками, а сами API недетерминированы. WeakRef: слабые ссылки WeakRef не удерживает объект в памяти. GC может удалить его, если нет сильных ссылок. Это полезно для кэшей, где не требуется гарантированное хранение данных. const cache = new Map(); function getOrCreate(key) { const ref = cache.get(key); if (ref) { const obj = ref.deref(); if (obj) return obj; cache.delete(key); } const obj = new ExpensiveObject(); cache.set(key, new WeakRef(obj)); return obj; } deref() может вернуть undefined в любой момент — GC асинхронен. Если подряд дёргаешь deref(), не жди, что вернёт то же самое. Типичная ошибка — полагаться на deref() как на гарантированный доступ. FinalizationRegistry: колбэк после GC Вызывается после сборки объекта. Идеален для очистки нативных ресурсов, например, закрытия файловых дескрипторов. const registry = new FinalizationRegistry((handle) => { cleanupNativeHandle(handle); }); function createResource() { const handle = openNativeConnection(); registry.register({ handle }, handle); return { handle }; } Предостережения: колбэк не гарантирует порядок и может не выполниться при падении процесса. В продакшене я бы не вешал на него критичную логику вроде записи в БД. Только освобождение нативных хендлов или логгирование. Паттерн: MemoryGuard Триггер на основе FinalizationRegistry для мониторинга памяти. Помогает выявить растущие объекты, но не заменяет ручное управление. class MemoryGuard { constructor(thresholdMB) { this.threshold = thresholdMB; this.registry = new FinalizationRegistry(() => this.onGC()); } track(obj) { this.registry.register(obj, () => {}); } onGC() { const usage = process.memoryUsage().heapUsed / 1024 / 1024; if (usage > this.threshold) console.warn(Memory spike: ${usage.toFixed(2)} MB); } } По опыту, этот паттерн хорош скорее для локальной отладки, чем для прода. В production лучше комбинировать с метриками в Prometheus и лимитами heap (--max-old-space-size=4096). Production-паттерны * LRU-кэш с WeakRef — автоочистка при нехватке памяти, но без гарантий времени. * async_hooks для мониторинга асинхронных ресурсов: ловит утечки через незакрытые сокеты или таймеры. * Регулярные snapshot console.profile() для поиска растущих объектов. * Слушать process.on('warning') для уведомлений о превышении лимитов. Я часто вижу, как разработчики забывают про async_hooks: он ловит утечки через незакрытые сокеты или таймеры. Это дешевле, чем дебажить heap snapshot. Вывод: WeakRef и FinalizationRegistry — полезные инструменты для оптимизации памяти, но их недетерминированность требует ручного управления ссылками (WeakMap, cancellable promises) для production-надёжности.
301