Настя Котова // Frontend & Node.js
1.29K subscribers
57 photos
3 files
143 links
Фронтендерица с лапками 🐾
Посты каждый понедельник 💃 Копаюсь во внутрянке технологий и рассказываю вам
Download Telegram
Продолжаю про работу с сырыми данными. В этот раз — про Buffer.

Buffer — это Node.js-специфичная вещь, в браузере его нет. Технически он наследуется от Uint8Array, то есть это тот же TypedArray, но с дополнительными методами для удобной работы со строками и файлами:


const b = Buffer.from('hello', 'utf8');
b.toString('hex'); // '68656c6c6f'


У Buffer есть три статических метода для аллокации, и они работают принципиально по-разному.

Buffer.alloc(size) — безопасный вариант. Создаёт отдельный ArrayBuffer нужного размера, память заполняется нулями. Пул не используется.


Buffer.alloc(8); // <Buffer 00 00 00 00 00 00 00 00>


Buffer.allocUnsafe(size) — быстрый вариант. Память не обнуляется, в буфере могут быть остатки старых данных. Но самое интересное — это то, как он выделяет память.

Buffer.allocUnsafeSlow(size) — как allocUnsafe, но всегда создаёт отдельный ArrayBuffer, без пула. Зачем — объясню ниже.

Когда вы вызываете Buffer.allocUnsafe(100), Node.js не идёт каждый раз просить систему выделить 100 байт. Вместо этого он заранее выделяет один большой кусок памяти на 8 КБ (пул) и нарезает от него маленькие буферы:

Если запрошенный размер меньше 4096 байт (половина от размера всего пула), Node.js просто двигает указатель внутри пула и создаёт новый view на уже существующий ArrayBuffer. Новой аллокации памяти нет, поэтому это очень быстро.

Если в текущем пуле не хватает места — создаётся новый. Если запрошенный буфер слишком большой (≥ 4096) — пул не используется, создаётся отдельный ArrayBuffer.

Порог в половину пула выбран не случайно: если буфер занимает больше половины, он съест почти весь пул, а оставшийся хвост станет бесполезным.

После каждой нарезки poolOffset выравнивается до границы в 8 байт. Это нужно для производительности: невыровненный доступ к памяти на некоторых архитектурах медленнее.

Все маленькие буферы, нарезанные из одного пула, разделяют один ArrayBuffer. Это значит: пока хотя бы один такой буфер жив, все 8 КБ пула не могут быть собраны GC.

Именно для этого существует Buffer.allocUnsafeSlow(size) — он всегда создаёт отдельный ArrayBuffer. Если вы создаёте много маленьких буферов и удерживаете их надолго (кешируете, складываете в массив), allocUnsafeSlow может быть экономичнее по памяти.
11👍52🔥1
Продолжаю серию. В этот раз — про SharedArrayBuffer и историю с отключением во всех браузерах.

SharedArrayBuffer — почти то же самое, что ArrayBuffer, но с одним принципиальным отличием: его можно передать в несколько Web Workers одновременно, и все они будут работать с одной и той же памятью.


const sab = new SharedArrayBuffer(256);
worker.postMessage(sab); // worker получает доступ к той же памяти


С обычным ArrayBuffer при передаче через postMessage происходит transfer — оригинал становится недоступным, а получатель получает владение. Это сделано намеренно: два потока не должны одновременно писать в одну память без синхронизации. SharedArrayBuffer снимает это ограничение — но тогда синхронизацию нужно делать самостоятельно. Для этого существует объект Atomics. Он предоставляет атомарные операции — гарантированно неделимые чтение-запись:


const sab = new SharedArrayBuffer(4);
const view = new Int32Array(sab);

// В воркере 1:
Atomics.add(view, 0, 1); // атомарный инкремент

// В воркере 2:
Atomics.load(view, 0); // атомарное чтение


Без Atomics параллельная запись в SharedArrayBuffer приводит к data race — результат непредсказуем. Atomics.add, Atomics.load и другие методы гарантируют, что операция выполнится целиком, без прерывания другим потоком. Есть и механизм ожидания: Atomics.wait(view, index, expectedValue) блокирует поток, пока значение по индексу равно ожидаемому. Atomics.notify(view, index) будит ждущие потоки.

У SharedArrayBuffer непростая история. В начале 2018 года его отключили во всех браузерах — Chrome, Firefox, Safari, Edge — одновременно. Причина: атака Spectre. Чтобы понять, при чём тут SharedArrayBuffer, нужно разобрать саму атаку — она на самом деле довольно изящная.

Современные процессоры не ждут, пока проверится условие if — они предсказывают результат и выполняют код «наперёд». Если предсказание верное — результат уже готов. Если нет — процессор откатывает результат. Но есть нюанс: данные, которые попали в кеш процессора во время спекуляции, остаются там даже после отката.

Допустим, в памяти процесса (рядом с нашим JS-кодом) лежит секретный байт — например, кусок данных от другой вкладки. Напрямую прочитать его нельзя, есть проверка границ. Но атакующий может обойти её через спекуляцию.

Шаг 1: атакующий много раз вызывает код с валидным индексом. Предсказатель ветвлений запоминает: «условие всегда true».
Шаг 2: атакующий подаёт индекс, который выходит за границу массива и указывает на секретный байт. Процессор по привычке предсказывает «true» и спекулятивно читает секрет — допустим, значение 42.
Шаг 3: спекулятивный код использует прочитанное значение как индекс в другом массиве — probeArray[42 * 256]. Эта ячейка попадает в кеш. Потом процессор понимает, что условие было false, и откатывает всё. Но кеш не откатывается.
Шаг 4: атакующий перебирает все 256 возможных значений, обращаясь к probeArray[0 * 256], probeArray[1 * 256], …, probeArray[255 * 256], и замеряет время каждого обращения. Одна ячейка отвечает за ~3 наносекунды (из кеша), все остальные — за ~100 наносекунд (из оперативной памяти). Быстрая ячейка выдаёт значение секрета: 42.

Секрет утёк не напрямую, а через побочный канал: значение было использован как индекс в массиве и он оставил физический след в кеше.

Вся атака стоит на способности различить «3 наносекунды» и «100 наносекунд». Для этого нужен очень точный таймер. performance.now() давал точность ~5 микросекунд — это всё ещё достаточно грубо. А SharedArrayBuffer позволяет собрать самодельный наносекундный таймер буквально в пять строк:


// Воркер-таймер:
const counter = new Uint32Array(sharedBuffer);
while (true) {
counter[0]++;
}

// Основной поток:
const start = counter[0];
// ... замеряем что-то ...
const elapsed = counter[0] - start;


Один воркер крутит счётчик в бесконечном цикле, другой поток читает его до и после интересующей операции. Разница — время выполнения с точностью вплоть до наносекунд. Этого достаточно, чтобы отличить попадание в кеш от промаха, а значит — достаточно для Spectre.
10🔥161
Сам по себе SharedArrayBuffer не опасен. Опасна возможность построить на нём таймер, который «видит» побочные эффекты спекулятивного выполнения. Именно поэтому браузеры отреагировали двумя мерами: снизили точность performance.now() (например, в Chrome — с 5 до 100 микросекунд) и полностью отключили SharedArrayBuffer.

SharedArrayBuffer вернули, но только для страниц, которые изолированы от остальных. Для этого сервер должен отдавать два HTTP-заголовка:


Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp


COOP: same-origin означает, что страница отказывается от возможности иметь ссылки на окна другого origin (попапы, window.opener). COEP: require-corp означает, что все ресурсы (картинки, скрипты, iframe), загружаемые страницей, должны явно разрешить использование через Cross-Origin-Resource-Policy.

Зачем это нужно? Суть защиты от Spectre — изоляция процессов. Chrome начал изолировать сайты в отдельные процессы ещё в 2018 году. Но без COOP/COEP страница всё ещё может загрузить cross-origin ресурс (картинку, API-ответ) в свой процесс. А если данные другого сайта попали в ваш процесс — Spectre может их прочитать. COOP/COEP гарантируют, что в процесс попадают только ресурсы, которые явно дали на это согласие. После этого SharedArrayBuffer можно безопасно включить — его таймер может читать только данные из того же (изолированного) процесса. Проверить, изолирована ли страница, можно с помощью crossOriginIsolated.
👍144😱3👌1
Последняя часть серии — про Blob.

Blob (Binary Large Object) — это иммутабельный объект с сырыми данными и MIME-типом. В отличие от ArrayBuffer, он не даёт прямого доступа к байтам — только асинхронно, через конвертацию:


const blob = new Blob(['<h1>Hi</h1>'], { type: 'text/html' });
const text = await blob.text();
const buf = await blob.arrayBuffer();


File, который возвращает <input type="file">, наследуется от Blob.

Где Blob хранит данные? Из документации Chromium по blob storage system: данные Blob создаются в процессе рендерера, где JS-код исполняется. Затем они передаются в процесс браузера. Подробнее про процессы можно почитать в моей статье. Если в памяти достаточно квоты — данные хранятся в RAM. Если квота заканчивается или Blob слишком большой, то браузер переносит данные на диск. Вот почему чтение из Blob асинхронное — браузер может возвращать данные с диска. Это также объясняет, почему он иммутабельный: если данные на диске, мутабельность потребовала бы файловой синхронизации, что сильно усложнило бы реализацию.

blob.slice(start, end) не копирует данные, а создаёт новый Blob, который является view на часть существующего. Под капотом хранится ссылка на оригинальный Blob плюс смещение и длина.


const huge = new Blob([bigData]); // 100 МБ
const chunk = huge.slice(0, 1024); // view на первые 1 КБ, без копирования


Есть обратная сторона: пока chunk жив, huge (и все его 100 МБ) не могут быть освобождены. Похожая ситуация с памятью может возникнуть и при создании URL на Blob.


const url = URL.createObjectURL(blob);
// url → "blob:https://example.com/550e8400-e29b-..."
img.src = url;


Браузер внутри себя создаёт маппинг URL → Blob и удерживает Blob в памяти. Этот маппинг живёт, пока вы не вызовете URL.revokeObjectURL(url) или пока не закроется документ.
👍162💅2👌1🕊1
Тема того, как именно V8 понимает, что код стал «достаточно горячим» для оптимизации, зацепила меня почти сразу, как я начала копаться во внутренностях движка.

На докладах на это обычно не хватает времени — слишком много нюансов и хочется рассказать много всего и сразу. Поэтому я вынесла эту тему в отдельную статью, где подробно разобрала, как и когда происходят те самые заветные переходы между компиляторами внутри V8.

Горячий код в V8: что это значит?
🔥22💅83👍2💯1
Почему JSON.parse() может быть быстрее объектного литерала

Казалось бы, const config = {a: 1, b: 2, c: 3} — это самый прямой способ создать объект. Но если объект достаточно большой (от ~10 KB), JSON.parse('{"a":1,"b":2,"c":3}') окажется быстрее. На бенчмарке от GoogleChromeLabs на файле в 7 МБ JSON.parse оказался в 1.7× быстрее объектного литерала в V8, в Safari разница доходила до 2×.

Причин несколько. Во-первых, грамматика JSON тривиальна по сравнению с JS — у движка для неё отдельный, более простой и быстрый парсер. Объектный литерал — это полноценный JS-код, который проходит весь путь: токенизация → AST → байткод. Так же, как описано в блоге V8, большие объектные литералы могут парситься дважды — сначала при preparsing, потом при lazy-parsing. Строка внутри JSON.parse этой проблемы лишена.

В реальном кейсе с SSR-приложением на Redux такая замена дала улучшение Lighthouse-скора с 87 до 95 и снижение TTI на 0.7 секунды. Оптимизация актуальна и в 2026 году — фундаментальные причины никуда не делись, а V8 продолжает активно инвестировать в производительность JSON (например, недавний двукратный прирост JSON.stringify в V8 v13.8).
🤯30🔥93👍2💅1
Что такое back/forward cache (bfcache)

Когда пользователь нажимает «назад» или «вперёд», браузер может не загружать страницу заново, а достать из памяти полный снимок: DOM, JS-кучу, состояние скролла — всё. Страница замораживается при уходе и размораживается при возврате. Переход ощущается мгновенно, потому что это не навигация в привычном смысле — это restore.

Браузер делает эту оптимизацию автоматически, но страница должна соответствовать определённым условиям. Она не попадёт в bfcache, если:

- есть listener на unload
- открыт WebSocket
- на документе стоит Cache-Control: no-store
- есть незавершённая `IndexedDB`транзакция
- и другие причины

Полный список можно посмотреть на MDN.

Диагностировать всё это можно прямо в DevTools: вкладка Application → Back/forward cache. Там можно протестировать, попадает ли страница в bfcache, и если нет — увидеть конкретный список блокеров.

Для продакшена есть программный способ — PerformanceNavigationTiming.notRestoredReasons. Через него можно собирать данные об использовании bfcache в RUM-метриках и понимать, что ломает кэш у реальных пользователей.

По итогу bfcache — один из самых «дешёвых» способов ускорить воспринимаемую производительность. Ничего не нужно дополнительно оптимизировать — достаточно не ломать нативное поведение браузера.
🔥384👍4👏1💅1
Продолжая тему с небольшими оптимизациями в браузере — сегодня поговорим про заголовок stale-while-revalidate.

Полностью он используется так:
Cache-Control: max-age=600, stale-while-revalidate=30

Что здесь происходит:
- 0–600 сек — ресурс свежий, отдаётся из кэша
- 600–630 сек — ресурс устарел, но браузер всё равно отдаёт его мгновенно, а в фоне идёт за новой версией
- после 630 сек — кэш полностью стух, пользователь ждёт

Ключевой момент: пользователь, попавший в stale-окно, видит старую версию. Фоновый запрос незаметно обновляет кэш, и уже в следующий раз посетитель получит свежие данные.

Где его используем, а где нет?
- Нехешированная статика (`/logo.png`, `/fonts/custom.woff2`) — используем, потому что URL не меняется при обновлении файла.
- API-ответы и HTML, которые меняются нечасто (каталог, лендинг, результаты поиска) — тоже используем, это даёт мгновенный ответ, а данные отстают максимум на пару минут.
- Хешированная статика (`main.a3f8c2.js`) — не имеет смысла использовать stale-while-revalidate, тут правильнее будет immutable, потому что файл по этому URL с хэшом не изменится никогда.
- Критичные данные (баланс, цена, статус заказа) — тут уже нужен no-cache.

И да, паттерн SWR знаком многим по React-библиотекам (swr, React Query), но лично я раньше не задумывалась о том, что это буквально тот же принцип, только взятый из HTTP.
22👀1💅1
Недавно на рабочем проекте я обновляла Next.js с 12-й на 16-ю версию. Далось непросто, так как были кастомный сервер на NestJS и связка через пакет nest-next, который последний раз обновлялся три года назад. Больно и неприкольно, но мы справились 💪

Этот опыт вдохновил меня наконец заняться тем, что я так долго откладывала — сходить в отпуск. Ну и написать цикл статей про Next.js)

Так что впереди нас ждёт трёхнедельный перерыв на канале, а после него — новый цикл, не переключайтесь!
150🔥16👍6🙏1💯1💅1
А вот и обещанный новый цикл про Next.js под капотом.

И в первой части разберём, из каких слоёв состоит Next.js как система, как они связаны друг с другом и почему архитектура стала именно такой.

Next.js изнутри. Часть 1. Архитектура Next.js.
132🔥8💯2😐1💅1🤪1💊1
Продолжаем погружаться в Next.js. Сегодня разбираем Pages Router: что создаёт next build, как сервер рендерит HTML при первом заходе и что происходит при клиентской навигации.

Next.js изнутри. Часть 2. Как работает Pages Router.
218👍4💅4
На очереди App Router, и материал получается такой плотный, что я решила разбить его на две части. В первой посмотрим на ключевые концепции и разберём момент первого рендеринга, без клиентской навигации и server actions.

Next.js изнутри. Часть 3. App Router: от запроса до гидратации.
1🕊95💅5👍2🔥2
В продолжении разговора про App Router обсудим prefetch, Server Actions, клиентскую навигацию и даже немного затронем тему безопасности.

Next.js изнутри. Часть 4. App Router: навигация, кеш и мутации.
17🤣3💅3👍21
Не останавливаем своё погружение в Next.js! Сегодня поговорим про серверный слой и то, как вообще запрос доходит до рендера.

Next.js изнутри. Часть 5. Серверный слой.
1👍12🔥3
А вот и моя самая любимая (или нет)) вещь в приложениях на Next.js — кастомный сервер! Посмотрим, что это такое, как он настраивается, и я даже поделюсь историей из своего рабочего проекта.

Next.js изнутри. Часть 6. Кастомный сервер.
1🙏64💅2
Потихоньку подходим к завершению цикла про Next.js (осталось немного!), и теперь на очереди сборка: Webpack, Turbopack, SWC и не только — что это такое, как оно всё работает и с чем его едят.

Next.js изнутри. Часть 7. Сборка.
117💅2👨‍💻1
Отдельно поговорим про dev-mode в Next.js и особенности его работы.

Next.js изнутри. Часть 8. Dev-mode.
1❤‍🔥9💅1
На прошлой неделе я читала студентам лекцию про настройку инфраструктуры и рассказывала в том числе про кэширование слоёв при сборке Docker-образа.

Как всегда, стало интересно, как это работает под капотом?

Сборкой занимается BuildKit — компонент Docker, который исполняет Dockerfile. Он преобразует инструкции в граф операций и для каждой вычисляет ключ кэша. Ключи образуют цепочку: ключ текущей операции считается из её описания и ключа предыдущей. Если это COPY, к ним добавляется контрольная сумма копируемых файлов. Например, изменение базового образа меняет ключ FROM, вслед за ним — ключ следующего RUN и так далее.

Если подходящий ключ уже есть в кэше, операция не выполняется, а BuildKit использует сохранённый результат. Если ключ изменился, операция выполняется заново. Следующие операции тоже получают другие ключи, поскольку зависят уже от нового результата.

Сам результат хранится как снимок файловой системы после операции. Локально снимки и служебные записи находятся во внутреннем хранилище конкретного builder-а: например, в Docker Desktop это хранилище внутри его Linux VM.

В CI всё зависит от runner-а. На постоянном runner-е локальный кэш может пережить сборку, а на одноразовом исчезнет вместе с машиной. Поэтому в CI кэш обычно явно экспортируют в registry или хранилище CI через --cache-to, а перед следующей сборкой импортируют через --cache-from.

Именно поэтому в Dockerfile для Node.js сначала копируют package.json и lock-файл, затем выполняют npm ci и только после этого копируют исходники. Изменение кода в таком случае не инвалидирует слой с зависимостями. То есть порядок инструкций в Dockerfile определяет не только результат сборки, но и то, какую её часть придётся повторить при следующем запуске.

Ну а последняя часть цикла про Next.js будет на следующей неделе)
👍25💅32
Заключительная часть цикла про Next.js наконец-то здесь! Обобщим всё, о чём говорили до этого, и посмотрим на оптимизации.

Next.js изнутри. Часть 9. Способы оптимизации.
113💅1