QR-код нужен чаще, чем кажется: ссылка на оплату в письме, купон в личном кабинете, адрес страницы на слайде презентации, одноразовый код входа. Задача выглядит на пятнадцать минут — подключить библиотеку и нарисовать картинку — и ровно поэтому её обычно делают небрежно. Код рисуется, на экране разработчика сканируется, а на печатной наклейке или на тёмном фоне сайта телефон его уже не видит.

Разберём задачу целиком: что внутри QR-кода и какие его особенности, как собрать живой генератор в браузере, чем отдать пользователю готовую картинку, когда генерацию имеет смысл вынести на сервер и почему половина туториалов по теме ведёт на библиотеку, заброшенную десять лет назад.

Из чего собран QR-код

QR-код (от Quick Response) описан стандартом ISO/IEC 18004 и состоит из квадратиков — их называют модулями. Три больших квадрата по углам — поисковые узоры, по ним сканер понимает, где код и как он повёрнут. Всё остальное — служебная разметка и собственно данные.

Две характеристики решают почти всё, и обе существуют в виде опций любой библиотеки.

Версия — это размер матрицы. Версия 1 — 21×21 модуль, каждая следующая добавляет по четыре модуля к стороне, версия 40 — 177×177. Версию почти никогда не задают руками: библиотека берёт минимальную, в которую влезли данные. Но знать про неё полезно — чем длиннее строка, тем гуще сетка и тем крупнее придётся печатать картинку, чтобы камера различила отдельные модули.

Уровень коррекции ошибок — сколько избыточных данных подмешано в код, чтобы он читался, даже если часть модулей испорчена. Уровней четыре:

Уровень Восстанавливает потерю Когда брать
L (Low) до 7 % экран, чистый белый фон, код никто не трогает руками
M (Medium) до 15 % разумный дефолт для веба и обычной печати
Q (Quartile) до 25 % наклейка, упаковка, код поверх фонового изображения
H (High) до 30 % логотип поверх кода, уличная печать, грязь и износ

Плата за живучесть — размер. Одна и та же ссылка на 47 символов даёт версию 3 (29×29) при уровне L и версию 6 (41×41) при уровне H: модулей почти вдвое больше, и на том же физическом размере каждый из них мельче. Это и есть компромисс, который выбирается под носитель, а не «на всякий случай повыше».

И третья цифра, про которую забывают: ёмкость. Максимум у версии 40 при уровне L — 7089 цифр или 2953 байта произвольного текста. При уровне H байтовый максимум падает до 1273. Кириллица в UTF-8 занимает по два байта на букву, так что «уместить абзац» в код не выйдет: длинный текст в QR всегда заменяют ссылкой на страницу с этим текстом.

Генератор в браузере: минимальный рабочий вариант

Если код нужен здесь и сейчас, на клиенте — вся задача укладывается в поле ввода и один вызов. Возьмём uqr: пакет без зависимостей, отдаёт готовую SVG-строку и одинаково работает и в браузере, и в Node.

<label for="source">Что зашить в код</label>
<input type="text" id="source" value="https://fruntend.com" />
<div id="output"></div>
import { renderSVG } from 'https://cdn.jsdelivr.net/npm/uqr@0.1.3/+esm';

const source = document.querySelector('#source');
const output = document.querySelector('#output');

const draw = () => {
  output.innerHTML = renderSVG(source.value.trim(), {
    ecc: 'M',      // уровень коррекции
    border: 4,     // поля вокруг кода, в модулях
    pixelSize: 8,  // сторона одного модуля в SVG-единицах
  });
};

draw();
source.addEventListener('input', draw);

Это весь генератор. Обработчик input перерисовывает картинку на каждое нажатие клавиши — кодирование короткой строки занимает доли миллисекунды, троттлить тут нечего.

Два момента, которые стоит заметить в этом коде сразу.

Первый: border: 4 выставлен явно. По умолчанию библиотека рисует поле шириной в один модуль, а стандарт требует четыре — это та самая зона тишины, по которой сканер отделяет код от окружения. С полем в один модуль код прекрасно читается на белой странице и перестаёт читаться, как только вокруг появляется что-то контрастное. У разных библиотек дефолт разный: в qrcode опция называется margin и по умолчанию равна четырём, в uqr это border со значением один. Проверять его стоит всегда, а не надеяться, что там стандартное значение.

Второй: на выходе renderSVG — обычная строка с разметкой, без width и height, только с viewBox. Размер задаётся снаружи стилями, качество при этом не теряется на любом масштабе.

Отдельно про ввод: если в код зашивается ссылка, её стоит проверить до кодирования — опечатка в адресе превращается в красивый код, который ведёт в никуда, и узнают об этом уже после печати тиража. Способы разобраны в статье про валидацию URL в JavaScript.

Какую библиотеку брать и сколько она весит

Здесь начинается самое интересное. Большинство туториалов по QR-генератору, которые выдаёт поиск, подключают с CDN файл qrcode.min.js пакета qrcodejs и строят весь пример вокруг конструктора new QRCode(element). Код рабочий, но последняя публикация этого пакета в npm датирована 18 июня 2015 года. Десять лет без единого обновления пакета — это не «стабильный и законченный», это заброшенный: ни исправлений, ни реакции на изменения в браузерах, ни ES-модулей.

Живых вариантов два, и выбирают между ними по тому, где код рисуется.

Пакет Выход Собранный размер Особенности
qrcode canvas, data URL, SVG-строка, текст для терминала ~24 КБ, ~9,7 КБ в gzip де-факто стандарт, есть CLI, отдельные сборки для браузера и Node
uqr SVG-строка, матрица булевых значений, ANSI и Unicode ~12 КБ, ~4,7 КБ в gzip без зависимостей, только ES-модули, один код на все окружения
qrcodejs canvas и таблица ~19,5 КБ, ~7 КБ в gzip последний релиз в 2015 году, брать не стоит

Про вес нужна оговорка, потому что именно из-за неё тему любят раздувать. Папка пакета qrcode в node_modules занимает около 135 КБ — и эту цифру часто приводят как аргумент «библиотека слишком тяжёлая для фронтенда». Но в node_modules лежат исходники, типы, документация, CLI и серверный рендерер, а до браузера доезжает только то, что реально импортировано и пережило минификацию: примерно 24 КБ, а по сети — меньше десяти.

Десять килобайт в gzip — это вес одной средней иконки. Выносить генерацию куда-то ради такой экономии смысла нет; причины вынести её на сервер есть, но они другие — о них дальше.

Если библиотека подключается прямо с публичного CDN, а не собирается в бандл, стоит хотя бы прописать integrity и понимать, какие права получает чужой скрипт на странице.

Скачать, скопировать, распечатать

Нарисованный на экране код — это половина задачи. Дальше пользователь захочет его сохранить, вставить в документ или отправить в мессенджер, и удобнее всего дать обе кнопки сразу: скачивание файлом и копирование в буфер.

SVG хорош для печати, но принимают его не везде: мессенджеры, офисные редакторы и половина систем вёрстки ждут растр. Поэтому векторную картинку перед отдачей переводят в PNG через canvas.

const svgToPng = (svg, size = 512) => new Promise((resolve, reject) => {
  const blob = new Blob([svg], { type: 'image/svg+xml' });
  const url = URL.createObjectURL(blob);
  const image = new Image();

  image.onload = () => {
    const canvas = document.createElement('canvas');
    canvas.width = size;
    canvas.height = size;

    const context = canvas.getContext('2d');
    context.imageSmoothingEnabled = false;  // модули должны остаться резкими
    context.drawImage(image, 0, 0, size, size);

    URL.revokeObjectURL(url);
    canvas.toBlob(resolve, 'image/png');
  };

  image.onerror = reject;
  image.src = url;
});

Выключенное сглаживание здесь не косметика: при масштабировании браузер по умолчанию размывает границы, а размытая граница модуля — это ровно та деталь, на которой спотыкаются дешёвые сканеры. И размер стоит брать кратным размеру матрицы, чтобы каждый модуль занял целое число пикселей.

Дальше готовый Blob либо скармливается download-ссылке, либо кладётся в буфер обмена:

const download = async () => {
  const png = await svgToPng(currentSvg);
  const link = document.createElement('a');

  link.href = URL.createObjectURL(png);
  link.download = 'qr.png';
  link.click();

  URL.revokeObjectURL(link.href);
};

const copy = async () => {
  const png = await svgToPng(currentSvg);
  await navigator.clipboard.write([
    new ClipboardItem({ 'image/png': png }),
  ]);
};

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

У копирования есть три ограничения, о которых лучше узнать заранее, чем из отзывов: работает только в защищённом контексте (HTTPS или localhost), вызов должен идти из обработчика реального клика, а из форматов картинок гарантированно принимается PNG — SVG в буфер положить не получится. Промис отклоняется молча, поэтому catch с понятным сообщением тут обязателен.

Поддержка браузерами
chrome
Chrome
76
firefox
Firefox
127
edge
Edge
79
safari
Safari
13.1
opera
Opera
63

Когда генерация уезжает на сервер

Клиентский генератор закрывает большинство задач, но не все. Код нужно вложить в письмо — почтовые клиенты не выполняют скрипты. Код нужно подставить в PDF или в изображение для соцсетей, которые собираются на бэкенде. Код содержит подписанный токен, который нельзя отдавать в браузер. Или картинка нужна по постоянному адресу, чтобы её можно было просто вставить в <img src> из любого места.

Ради этого не разворачивают отдельный сервис — хватает serverless-функции: короткий обработчик, который живёт у провайдера, поднимается по запросу и тарифицируется по вызовам. У DigitalOcean это Functions, разворачиваются они через doctl.

doctl serverless init --language js qr-generator
cd qr-generator/packages/sample/qr
npm install qrcode

Команда создаёт каркас проекта: папку packages с примером функции и манифест project.yml. В манифесте описано, что и как деплоить.

packages:
  - name: sample
    actions:
      - name: qr
        runtime: 'nodejs:22'
        web: true
        limits:
          timeout: 5000
          memory: 256

В сгенерированном файле в поле runtime стоит nodejs:default. Лучше сразу заменить его на конкретную версию из тех, что поддерживает провайдер — сейчас это Node 14, 18, 22 и 24. Иначе однажды дефолт переедет на следующий мажор, а функция об этом не спросит.

Сама функция — один экспорт, который получает параметры запроса и возвращает объект с телом и заголовками:

const QRCode = require('qrcode');

exports.main = async (args) => {
  const payload = (args.data || '').slice(0, 512);

  if (!payload) {
    return { statusCode: 400, body: 'Нужен параметр data' };
  }

  const dataUrl = await QRCode.toDataURL(payload, {
    errorCorrectionLevel: args.ecc || 'M',
    margin: 4,
    width: 512,
  });

  return {
    statusCode: 200,
    headers: {
      'content-type': 'image/png',
      'cache-control': 'public, max-age=86400, immutable',
    },
    body: dataUrl.split(',')[1],  // платформе нужен голый base64, без префикса
  };
};

Деплой и получение адреса:

doctl serverless connect
doctl serverless deploy qr-generator
doctl serverless functions get sample/qr --url

После этого генерация превращается в обычный адрес картинки: <img src="https://.../sample/qr?data=https://example.com">. Его можно положить в письмо, в шаблон PDF, в превью для соцсетей.

Три вещи, которые в такой функции появляются не сразу, но появляются обязательно.

  • Ограничение длины. Без обрезки входной строки любой желающий заставит функцию кодировать мегабайт текста. В примере выше вход режется до 512 символов — больше в ссылку и не нужно.
  • Кэширование. Одна и та же строка всегда даёт одну и ту же картинку, поэтому длинный cache-control — не оптимизация, а норма. Иначе каждое открытие письма будит функцию заново и капает в счёт.
  • Защита от чужого использования. Открытый эндпоинт, который рисует картинку по любой строке, со временем найдут и начнут использовать как бесплатный сервис — вместе с вашим счётом. Лечится ограничением по источнику запроса или подписанным параметром.

Вызывать такую функцию из своего кода можно как любой другой HTTP-эндпоинт — варианты разобраны в статье про методы запросов через Fetch API.

И отдельное предупреждение для тех, кто гуглил эту задачу раньше. В старых ответах и комментариях популярен совет «не городите ничего, возьмите готовый адрес Google Chart API» с параметром cht=qr. Совет мёртв: сервис отключён в марте 2019 года и на запросы отвечает ошибкой. Любые инструкции, которые его до сих пор предлагают, устарели целиком.

Обратная задача: прочитать код в браузере

Если генератор делается для какого-то рабочего процесса, почти наверняка понадобится и вторая половина — распознавание. Тут в браузерах есть встроенный инструмент, BarcodeDetector: он умеет искать коды в картинке, в кадре видео и в canvas, без единой сторонней библиотеки. Описание API целиком лежит на MDN.

if ('BarcodeDetector' in window) {
  const detector = new BarcodeDetector({ formats: ['qr_code'] });
  const codes = await detector.detect(imageOrVideoFrame);

  codes.forEach((code) => {
    console.log(code.rawValue);        // что зашито
    console.log(code.cornerPoints);    // координаты углов в кадре
  });
}

Поддержка тут неровная, и проверка через in window обязательна. Более того, даже там, где конструктор существует, набор распознаваемых форматов зависит от системы — список доступных всегда возвращает BarcodeDetector.getSupportedFormats().

Поддержка браузерами
chrome
Chrome
83
firefox
Firefox
 
edge
Edge
83
safari
Safari
 
opera
Opera
69

В браузерах без этого API задачу закрывают библиотекой-декодером, которая делает ту же работу на JavaScript и весит уже заметно больше генератора. Практичный компромисс: использовать встроенный детектор там, где он есть, а декодер подгружать динамическим импортом только при отказе проверки.

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

Почти все проблемы с QR-кодами сводятся к короткому списку, и почти все обнаруживаются уже после печати.

  • Съеденная зона тишины. Вокруг кода должно оставаться пустое поле в четыре модуля. Прижатый к краю карточки или к тексту код читается заметно хуже, а иногда не читается вовсе.
  • Инверсия. Светлые модули на тёмном фоне выглядят стильно и ломают часть сканеров: они ищут тёмный узор на светлом. Если дизайн требует тёмной карточки — код всё равно кладут на белую подложку.
  • Низкий контраст. Серое на светло-сером и любые градиентные заливки — та же история. Чёрное на белом читается камерой телефона с двух метров, светло-синее на бежевом — с пятнадцати сантиметров при хорошем свете.
  • Логотип поверх кода без запаса. Картинка в центре съедает модули, и работает это только потому, что коррекция ошибок восстанавливает потерянное. Логотип требует уровня H и площади не больше примерно десятой части кода — и обязательной проверки на реальных телефонах.
  • Слишком длинная строка. Чем больше данных, тем гуще сетка. Для печати это означает или крупный код, или нечитаемый. Длинные адреса с параметрами отслеживания стоит сокращать до кодирования.
  • Проверка только на своём телефоне. Камера флагмана вытягивает почти любой код. Реальные пользователи приходят со старыми устройствами, из полутёмного помещения и под углом — тестировать стоит там же.

Сам генератор, как видно, — действительно пятнадцать минут работы. Всё остальное время уходит на то, чтобы полученная картинка сработала не только на экране разработчика: выбрать уровень коррекции под носитель, оставить поля, отдать пользователю нормальный PNG и не подключить по дороге библиотеку, которую бросили десять лет назад.