Buffer: бинарные данные в Node

Buffer — это область памяти фиксированного размера для сырых байтов: так Node работает с файлами, сетью и любыми бинарными данными.

Buffer — встроенный в Node.js класс, представляющий неизменяемую по длине последовательность байтов (чисел 0–255). Это «строка из байтов», а не из символов: один элемент — это один байт, а не одна буква.

JavaScript в браузере почти не сталкивается с байтами: там есть строки, числа и объекты. Но Node.js живёт на сервере, где данные приходят и уходят в виде сырых байтов — содержимое файла, пакет из сокета, тело HTTP-запроса. Чтобы хранить и обрабатывать эти байты, и нужен Buffer. Под капотом это блок памяти вне обычной кучи V8, поэтому он быстр и не давит на сборщик мусора.

Этот урок — про то, зачем вообще отделять байты от строк, как одни и те же байты читаются в разных кодировках и где новички теряют данные, путая длину в символах с длиной в байтах.

Зачем это на практике

Buffer всплывает везде, где Node соприкасается с внешним миром:

  • Чтение файла без указания кодировки: fs.readFile('pic.png') вернёт Buffer — потому что картинка это байты, а не текст.
  • Сетевые данные: каждый кусок (chunk) из TCP-сокета или HTTP-потока приходит как Buffer.
  • Криптография и хеши: crypto отдаёт результат в виде байтов, которые потом кодируют в hex или base64.
  • Бинарные протоколы: разбор заголовков пакетов, где важен конкретный байт по конкретному смещению.

Примеры ниже используют Buffer и модуль fs — это серверный API Node, в браузере его нет. Поэтому кнопки «Запустить» под кодом нет: читайте его как образец и проверяйте в локальном Node.

Как создать Buffer

Современные способы создания — статические методы класса. Старый конструктор new Buffer(...) объявлен устаревшим и небезопасным (мог отдать «грязную» память), его использовать нельзя.

// из строки (по умолчанию кодировка utf-8)
const a = Buffer.from('Привет');

// из массива байтов напрямую
const b = Buffer.from([0x48, 0x69]); // байты 'H', 'i'

// пустой буфер на 8 байт, заполненный нулями
const c = Buffer.alloc(8);

console.log(a.length, b.length, c.length);

Вывод:

12 2 2

Обратите внимание на первое число: строка «Привет» — это 6 символов, но 12 байтов. Каждая кириллическая буква в UTF-8 занимает 2 байта. Вот первое важное различие: Buffer.length — это длина в байтах, а не в символах.

Зачем нужны два метода выделения памяти:

МетодЧто делает
Buffer.alloc(n)создаёт буфер на n байтов, заполненный нулями — безопасно
Buffer.allocUnsafe(n)быстрее, но память НЕ обнулена (может содержать старые данные)
Buffer.from(...)создаёт буфер из строки, массива байтов или другого буфера

По умолчанию берите Buffer.alloc и Buffer.from. allocUnsafe оправдан, только когда вы тут же полностью перезаписываете весь буфер и важна скорость.

Кодировки: одни байты — разный текст

Кодировка — это правило, как превратить байты в текст и обратно. Сами байты нейтральны; смысл им придаёт кодировка. Один и тот же Buffer можно «прочитать» по-разному методом toString(encoding).

const buf = Buffer.from('Hi');

console.log(buf.toString('utf-8'));   // текст
console.log(buf.toString('hex'));     // шестнадцатеричные байты
console.log(buf.toString('base64')); // base64

Вывод:

Hi
4869
SGk=

Здесь байты 0x48 0x69 — это «Hi» в UTF-8, «4869» в hex и «SGk=» в base64. Сами байты не менялись — менялся способ их показать. Основные кодировки в Node:

utf-8текст по умолчанию; кириллица и эмодзи занимают несколько байтов
ascii / latin1один байт на символ; не годятся для кириллицы
hexкаждый байт как две шестнадцатеричные цифры — удобно для отладки и хешей
base64упаковка байтов в безопасные для текста символы (для JSON, ссылок, заголовков)

Симметрично работает обратное направление: Buffer.from(str, 'base64') декодирует base64-строку обратно в байты. Это типичный приём, когда бинарные данные нужно передать там, где разрешён только текст.

Чтение и запись отдельных байтов

К буферу можно обращаться как к массиву по индексу — это даёт числовое значение байта (0–255). Для многобайтовых чисел есть методы readUInt16BE, writeUInt32LE и подобные — они нужны при разборе бинарных форматов, где число занимает 2 или 4 байта.

const buf = Buffer.from('ABC');

console.log(buf[0]);          // 65  — код 'A'
buf[0] = 90;                  // меняем первый байт на код 'Z'
console.log(buf.toString()); // 'ZBC'
console.log(buf[3]);          // undefined — выхода за границу нет

Вывод:

65
ZBC
undefined

Содержимое буфера менять можно (байты перезаписываются), но длину — нельзя: буфер выделен под фиксированный размер. Чтобы «увеличить» данные, создают новый буфер, обычно через Buffer.concat([a, b]), который склеивает несколько буферов в один.

Buffer против строки

Когда что использовать? Если данные — это текст в известной кодировке, удобнее строка. Если это сырые байты (картинка, архив, кусок сетевого потока) или важен каждый байт — нужен Buffer. Главная ловушка возникает на границе: текст из нескольких байтов на символ нельзя резать по произвольному байту.

const buf = Buffer.from('ёж'); // 'ё' и 'ж' по 2 байта = 4 байта

// режем по 2 байта — случайно попали в середину символа
console.log(buf.slice(0, 1).toString()); // битый символ (�)
console.log(buf.slice(0, 2).toString()); // 'ё' — попали ровно

Срез по байтам 0..1 отрезал половину буквы «ё», и UTF-8-декодер не смог её восстановить — получился знак замены. А срез 0..2 совпал с границей символа и дал целую букву. Это объясняет, почему в потоках текст нельзя декодировать кусками наугад (об этом — в уроке про потоки).

Как это работает под капотом

Buffer — это представление над ArrayBuffer: блок «сырой» памяти, выделенный вне кучи V8. Это сделано осознанно. Сетевые и файловые данные приходят мегабайтами; если бы каждый байт был объектом JavaScript, сборщик мусора захлебнулся бы. Память буфера живёт отдельно, поэтому выделение и копирование быстрые.

Для эффективности маленькие буферы (по умолчанию до 4 КБ) Node нарезает из общего внутреннего пула. Практическое следствие: buf.length — это размер видимого «окна», а под ним может лежать буфер побольше. Поэтому buf.slice() (в новых версиях — buf.subarray()) не копирует данные, а возвращает вид на ту же память: изменишь срез — изменится и оригинал. Когда нужна именно независимая копия, используют Buffer.from(buf) или Buffer.copyBytesFrom.

Частые ошибки

  • Путают длину в символах и в байтах. 'Привет'.length равно 6, но Buffer.from('Привет').length равно 12. Для размера в байтах есть Buffer.byteLength(str).
  • Режут многобайтовый текст по байтам. Срез посреди UTF-8-символа даёт «битые» знаки. Текст режьте по символам или используйте StringDecoder, который копит «хвосты».
  • Используют new Buffer(). Старый конструктор устарел и небезопасен. Только Buffer.from, Buffer.alloc, Buffer.allocUnsafe.
  • Забыли кодировку при чтении файла. fs.readFile(path) отдаёт Buffer; чтобы получить строку, передайте кодировку: fs.readFile(path, 'utf-8').
  • Считают, что slice копирует. slice/subarray делят память с оригиналом; для копии нужен явный Buffer.from(buf).

Итоги

  • Buffer — фиксированная по длине последовательность байтов (0–255) для бинарных данных: файлы, сеть, крипто.
  • Создавайте через Buffer.from (из строки/массива) и Buffer.alloc (нули); new Buffer запрещён.
  • Кодировка задаёт смысл байтов: toString('utf-8'|'hex'|'base64') показывает одни и те же байты по-разному.
  • Длина буфера — в байтах, а не в символах; кириллица в UTF-8 — по 2 байта на букву.
  • Содержимое менять можно, длину — нет; slice/subarray делят память, для копии нужен Buffer.from(buf).
Проверьте себя
1. Чему равно Buffer.from('Привет').length?
A12 — каждая кириллическая буква в UTF-8 занимает 2 байта, а length считает байты
B6 — по числу символов в строке
C1 — буфер всегда хранит данные одним блоком
DЗависит от запуска — длина буфера недетерминирована
2. Что произойдёт, если у Buffer из строки 'ёж' (4 байта) взять slice(0, 1) и вызвать toString()?
AПолучится знак замены (битый символ), потому что срез отрезал половину двухбайтовой буквы 'ё'
BВернётся буква 'ё' целиком
CБудет выброшено исключение о выходе за границу
DВернётся пустая строка
3. Чем Buffer.alloc(n) отличается от Buffer.allocUnsafe(n)?
Aalloc заполняет память нулями (безопасно), allocUnsafe быстрее, но память не обнулена и может содержать старые данные
Balloc создаёт изменяемый буфер, а allocUnsafe — только для чтения
CЭто полные синонимы, разницы нет
DallocUnsafe создаёт буфер в куче V8, а alloc — вне её