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 с понятным сообщением тут обязателен.
Когда генерация уезжает на сервер
Клиентский генератор закрывает большинство задач, но не все. Код нужно вложить в письмо — почтовые клиенты не выполняют скрипты. Код нужно подставить в 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().
В браузерах без этого API задачу закрывают библиотекой-декодером, которая делает ту же работу на JavaScript и весит уже заметно больше генератора. Практичный компромисс: использовать встроенный детектор там, где он есть, а декодер подгружать динамическим импортом только при отказе проверки.
Частые ошибки
Почти все проблемы с QR-кодами сводятся к короткому списку, и почти все обнаруживаются уже после печати.
- Съеденная зона тишины. Вокруг кода должно оставаться пустое поле в четыре модуля. Прижатый к краю карточки или к тексту код читается заметно хуже, а иногда не читается вовсе.
- Инверсия. Светлые модули на тёмном фоне выглядят стильно и ломают часть сканеров: они ищут тёмный узор на светлом. Если дизайн требует тёмной карточки — код всё равно кладут на белую подложку.
- Низкий контраст. Серое на светло-сером и любые градиентные заливки — та же история. Чёрное на белом читается камерой телефона с двух метров, светло-синее на бежевом — с пятнадцати сантиметров при хорошем свете.
- Логотип поверх кода без запаса. Картинка в центре съедает модули, и работает это только потому, что коррекция ошибок восстанавливает потерянное. Логотип требует уровня H и площади не больше примерно десятой части кода — и обязательной проверки на реальных телефонах.
- Слишком длинная строка. Чем больше данных, тем гуще сетка. Для печати это означает или крупный код, или нечитаемый. Длинные адреса с параметрами отслеживания стоит сокращать до кодирования.
- Проверка только на своём телефоне. Камера флагмана вытягивает почти любой код. Реальные пользователи приходят со старыми устройствами, из полутёмного помещения и под углом — тестировать стоит там же.
Сам генератор, как видно, — действительно пятнадцать минут работы. Всё остальное время уходит на то, чтобы полученная картинка сработала не только на экране разработчика: выбрать уровень коррекции под носитель, оставить поля, отдать пользователю нормальный PNG и не подключить по дороге библиотеку, которую бросили десять лет назад.
Комментарии (0)