Разработка QR-сканера билетов на React с Clean Architecture для WordPress
Подробный рассказ о создании React-приложения для сканирования и валидации билетов для проекта noname-theatre.ru с интеграцией WordPress, построенного по принципам чистой архитектуры
По понятным причинам весь код проекта продемонстрирован не будет. Но основные моменты отметим.
Введение: Что мы создавали
В мире мероприятий и концертов валидация билетов — критически важный процесс. Представьте: сотни людей у входа, каждый с QR-кодом на телефоне, и вам нужно быстро проверить подлинность билета, увидеть информацию о владельце и месте, а затем активировать билет, чтобы исключить повторное использование.
Мы создали именно такую систему — веб-приложение для сканирования билетов, которое работает на мобильных устройствах администраторов мероприятия. Приложение должно было интегрироваться с существующей WordPress CMS, где уже велась продажа билетов и учёт клиентов.


Ключевые требования к функциональности:
- Мгновенное сканирование — открыл камеру, навёл на QR-код, получил результат
- Real-time валидация — каждый билет проверяется на сервере в реальном времени
- Защита от дублирования — невозможно активировать билет дважды
- Подтверждение активации — двухэтапный процесс с показом полной информации о билете
- Детальная информация — ФИО владельца, место (ряд/место), время сеанса, название представления
- Безопасность — все запросы с nonce-токенами и проверкой прав доступа
Но помимо функциональных требований, мы ставили перед собой архитектурную задачу: создать масштабируемую, тестируемую и поддерживаемую кодовую базу. Именно поэтому выбрали Clean Architecture как основу проекта.
Технологический стек и архитектурные решения
Выбор технологий определялся несколькими факторами: это веб-приложение должно работать на мобильных устройствах (через браузер), интегрироваться с WordPress, иметь доступ к камере, и при этом быть достаточно быстрым для работы в условиях мероприятия.
Фронтенд:
- React.js — мы выбрали React за его зрелость, огромную экосистему и отличную производительность. Hooks API идеально подходит для инкапсуляции бизнес-логики.
- Vite — современный сборщик, который обеспечивает мгновенный hot reload при разработке и оптимальную сборку для production. Встроенная поддержка HTTPS критична для работы с камерой.
- @yudiel/react-qr-scanner — надёжная библиотека для работы с QR-кодами, которая использует современный WebRTC API и работает на всех основных браузерах.
Бэкенд:
- WordPress PHP Plugin — кастомный плагин с AJAX handlers для проверки и активации билетов. WordPress уже используется на проекте, поэтому интеграция была естественным выбором.
- MySQL — база данных WordPress, где хранится таблица билетов с кодами, статусами и связями с заказами.
- WordPress Nonce — встроенный механизм защиты от CSRF-атак, обязательный для всех AJAX запросов.
Формат сборки: Приложение собирается в формат UMD (Universal Module Definition) с внешними зависимостями React и ReactDOM. Это значит, что WordPress загружает свои версии React, а наше приложение использует их — никакого дублирования кода, минимальный размер бандла.
Архитектурное решение: Почему Clean Architecture
Когда мы начинали проектировать архитектуру приложения, перед нами встал выбор: сделать быстро «в лоб» или потратить время на продуманную структуру. Мы выбрал второе, и вот почему.
Типичное React-приложение часто превращается в спагетти-код: компоненты напрямую вызывают API, бизнес-логика размазана по разным файлам, тестирование требует мокирования половины приложения. Через полгода такой код превращается в кошмар для поддержки.
Clean Architecture решает эти проблемы через чёткое разделение ответственности. Представьте приложение как слоёный пирог, где каждый слой имеет свою задачу и не знает о внутренностях других слоёв:
┌─────────────────────────────┐
│ Presentation Layer │ ← React компоненты, хуки, UI
│ (Что видит пользователь) │
├─────────────────────────────┤
│ Domain Layer (Ядро) │ ← Бизнес-логика, сущности
│ (Что делает приложение) │
├─────────────────────────────┤
│ Data Layer │ ← API, репозитории, данные
│ (Откуда берутся данные) │
└─────────────────────────────┘Ключевой принцип — Dependency Rule: зависимости направлены строго внутрь, к центру. Domain Layer (ядро бизнес-логики) абсолютно независим — он не знает ни о React, ни о WordPress API, ни о том, откуда приходят данные. Это чистая бизнес-логика, которую можно протестировать, не поднимая ни одного React-компонента.
Presentation Layer знает о Domain (использует его сущности и use cases), но не знает о Data Layer.
Data Layer реализует интерфейсы, определённые в Domain. Такая структура даёт невероятную гибкость: можем заменить React на Vue, WordPress API на REST API другого сервиса, и ядро приложения останется нетронутым.
Domain Layer: Ядро приложения
Начнём путешествие по архитектуре с самого важного — бизнес-логики. Domain Layer — это сердце приложения, которое содержит всё, что делает приложение приложением, а не просто набором компонентов.
Сущности (Entities): Моделируем реальный мир
Сущности — это объекты предметной области. В нашем случае центральная сущность — это Билет. Билет имеет код, статус (активный/использованный/недействительный), сообщение от сервера и дополнительную информацию (владелец, место, время сеанса).
Класс Ticket, который инкапсулирует всю логику работы с билетом. Важный момент: сущность не знает, откуда пришли данные. Она просто представляет билет со всеми его свойствами и методами.
export class Ticket {
constructor(code, status, message = null, info = null) {
this.code = code
this.status = status
this.message = message
this.info = info
}
static fromApiResponse(response, ticketCode) {
if (!response.status) {
return new Ticket(
ticketCode,
'invalid',
response.data?.message || 'Билет не найден'
)
}
return new Ticket(
ticketCode,
'active',
response.data?.message || 'Билет действителен',
response.data?.info || null
)
}
isActive() {
return this.status === 'active'
}
isUsed() {
return this.status === 'used'
}
canBeActivated() {
return this.isActive()
}
getMessage() {
return this.message
}
}Обратите внимание на фабричный метод fromApiResponse. Это паттерн, который решает важную задачу: Domain Layer не должен знать о формате данных от API. API может возвращать JSON с любыми полями, но фабрика преобразует его в нашу доменную модель. Если завтра формат API изменится, мы просто обновим фабрику, не трогая остальной код.
Методы isActive(), isUsed(), canBeActivated() — это не просто геттеры. Они инкапсулируют бизнес-правила. Например, билет можно активировать только если он в статусе ‘active’. Это правило живёт в сущности, а не размазано по компонентам.
Use Cases: Оркестрация бизнес-процессов
Use Cases (Варианты использования) — это сценарии работы приложения. Каждый use case отвечает за одну конкретную бизнес-операцию и ничего более. Это центральная концепция Clean Architecture.
CheckTicketUseCase отвечает за проверку билета. Когда пользователь сканирует QR-код, именно этот use case координирует весь процесс:
export class CheckTicketUseCase {
constructor(ticketRepository) {
this.ticketRepository = ticketRepository
}
async execute(ticketCode) {
if (!ticketCode || ticketCode.trim().length === 0) {
return ScanResult.failure('Код билета не может быть пустым')
}
try {
const ticket = await this.ticketRepository.checkTicket(
ticketCode.trim()
)
return ScanResult.success(ticket)
} catch (error) {
return ScanResult.failure(
error.message || 'Ошибка при проверке билета'
)
}
}
}Этот use case делает несколько вещей: валидирует входные данные (пустой код недопустим), вызывает репозиторий для проверки на сервере, оборачивает результат в объект ScanResult. Важно: он не знает, откуда репозиторий берёт данные — это может быть WordPress API, может быть mock-сервер, может быть локальная база данных. Use case работает с абстракцией.
ScanTicketUseCase — более сложный use case, который обрабатывает процесс сканирования. Он решает две важные проблемы реального мира:
- Cooldown между сканированиями — когда администратор сканирует билеты, камера может случайно захватить один и тот же QR-код несколько раз подряд за долю секунды. Cooldown в 2 секунды предотвращает это.
- Защита от дублирования — если один и тот же код просканировался дважды, второе сканирование игнорируется. Это предотвращает случайную двойную активацию.
Вот как это работает:
export class ScanTicketUseCase {
constructor(checkTicketUseCase) {
this.checkTicketUseCase = checkTicketUseCase
this.lastScannedCode = null
this.cooldownUntil = 0
}
async execute(qrCodeData) {
const ticketCode = this.extractTicketCode(qrCodeData)
// Cooldown 2 секунды
const now = Date.now()
if (now < this.cooldownUntil) {
return null
}
// Защита от повторного сканирования
if (ticketCode === this.lastScannedCode) {
return null
}
this.lastScannedCode = ticketCode
this.cooldownUntil = now + 2000
return await this.checkTicketUseCase.execute(ticketCode)
}
extractTicketCode(qrCodeData) {
if (Array.isArray(qrCodeData) && qrCodeData.length > 0) {
return qrCodeData[0].rawValue || null
}
if (typeof qrCodeData === 'object' && qrCodeData.rawValue) {
return qrCodeData.rawValue
}
if (typeof qrCodeData === 'string') {
return qrCodeData
}
return null
}
}Метод extractTicketCode обрабатывает разные форматы данных от QR-сканера: массив объектов, один объект, или просто строку. Это ещё один пример того, как Domain Layer защищается от внешних изменений — если библиотека QR-сканера изменит формат, мы просто обновим этот метод.
Главное, что нужно понять про use cases: они не знают о React, WordPress API, или UI. Это чистая бизнес-логика на JavaScript, которую можно запустить в Node.js, в браузере, где угодно. Это делает их невероятно тестируемыми.
Data Layer: Работа с внешним миром
Если Domain Layer — это мозг приложения, то Data Layer — это руки, которые взаимодействуют с внешним миром: API, базами данных, локальным хранилищем. Этот слой реализует интерфейсы (контракты), определённые в Domain Layer.
Repository Pattern: Абстракция над источником данных
Репозиторий — это ключевой паттерн, который делает всю архитектуру гибкой. Он скрывает детали получения данных от остального приложения. Domain Layer работает с абстрактным TicketRepository, а конкретная реализация TicketRepositoryImpl решает, откуда на самом деле брать данные.
Почему это важно? Представьте, что завтра вы решили заменить WordPress на Django REST API. Вы просто создаёте новую реализацию репозитория, которая работает с Django, и всё. Ни Domain Layer, ни Presentation Layer даже не заметят изменений.
export class TicketRepositoryImpl extends TicketRepository {
constructor(apiDataSource) {
super()
this.apiDataSource = apiDataSource
}
async checkTicket(ticketCode) {
try {
const response = await this.apiDataSource.checkTicket(ticketCode)
return Ticket.fromApiResponse(response, ticketCode)
} catch (error) {
console.error('TicketRepository checkTicket error:', error)
throw error
}
}
async activateTicket(ticketCode) {
try {
const response = await this.apiDataSource.activateTicket(ticketCode)
// Поддержка двух форматов API
const message = response.message || response.data?.message || 'Операция завершена'
return {
success: response.status,
message: message
}
} catch (error) {
console.error('TicketRepository activateTicket error:', error)
throw error
}
}
}Обратите внимание на строку с поддержкой двух форматов API:
const message = response.message || response.data?.message || 'Операция завершена'Это пример гибкости кода. Сервер может возвращать данные в разных форматах (упрощённом или полном), и код корректно обрабатывает оба варианта. Приоритет отдаётся response.message (текущий формат сервера), затем response.data?.message (унифицированный формат), и в конце — fallback константа.
Data Sources: Два режима работы
Репозиторий делегирует реальную работу с данными объектам Data Source. Мы создали две реализации:
WordPressApiDataSource — для production, работает с реальным WordPress API:
export class WordPressApiDataSource extends ApiDataSource {
async checkTicket(ticketCode) {
return await this.makeRequest(ENDPOINTS.CHECK_TICKET, {
ticket_code: ticketCode
})
}
async activateTicket(ticketCode) {
return await this.makeRequest(ENDPOINTS.ACTIVATE_TICKET, {
ticket_code: ticketCode
})
}
async makeRequest(action, data = {}) {
const formData = new FormData()
formData.append('action', action)
formData.append('nonce', getNonce())
if (isDevelopment()) {
formData.append('isTest', 'true')
}
Object.keys(data).forEach((key) => {
formData.append(key, data[key])
})
try {
const response = await fetch(getAjaxUrl(), {
method: 'POST',
body: formData,
credentials: 'same-origin'
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return await response.json()
} catch (error) {
return {
status: false,
data: {
message: error.message || 'Ошибка соединения'
}
}
}
}
}Этот класс инкапсулирует всю работу с WordPress AJAX API. Метод makeRequest формирует FormData с обязательными полями: action (название AJAX handler в WordPress), nonce (токен безопасности), и параметры запроса.
Важная деталь: в режиме разработки к запросу добавляется параметр isTest=true. Это позволяет серверу понять, что запрос пришёл из dev-окружения и может использовать тестовые данные или особую логику.
MockApiDataSource — для разработки без сервера:
Разработка веб-приложения с реальным API — это медленно. Каждое изменение требует перезагрузки, каждый запрос идёт по сети. Для быстрой разработки мы реализовали mock-источник данных, который имитирует API прямо в браузере:
export class MockApiDataSource extends ApiDataSource {
constructor() {
super()
this.mockTickets = {
'c790db8813894d7c3cd96ad697df977b': {
status: 'active',
info: {
seat_row: '2',
seat_col: '6',
user_fio: 'Иванов Иван Иванович',
user_email: 'test@example.com',
schedule: {
date: '25',
month: 'Декабря',
year: '2025',
time: '19:00'
},
performance_title: 'Тестовое представление'
}
}
}
}
async checkTicket(ticketCode) {
await this.delay(800) // Имитация сетевой задержки
const ticket = this.mockTickets[ticketCode]
if (!ticket) {
return {
status: false,
data: {
message: 'Контрольная сумма билета отличается!'
}
}
}
return {
status: true,
data: {
message: 'Проверка билета пройдена!',
info: ticket.info
}
}
}
delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
}Mock источник содержит предопределённые билеты с разными статусами для тестирования всех сценариев. Метод delay(800) имитирует сетевую задержку — это делает разработку более реалистичной, ведь в production запросы не мгновенные.
Ключевая особенность: MockApiDataSource имеет точно такой же интерфейс как WordPressApiDataSource. Они оба наследуются от абстрактного ApiDataSource и реализуют методы checkTicket и activateTicket. Это значит, что код, использующий data source, не знает и не должен знать, с каким именно источником он работает.
Dependency Injection Container: Управление зависимостями
Теперь у нас есть куча классов: entities, use cases, repositories, data sources. Как их все связать между собой? Как создавать объекты правильно, без создания жёстких зависимостей?
Решение — Dependency Injection Container. Это объект, который знает, как создавать и связывать все зависимости в приложении. Мы реализовали его по паттерну Singleton:
class DIContainer {
constructor() {
this._instances = {}
}
getApiDataSource() {
if (!this._instances.apiDataSource) {
const isDevMode = isDevelopment()
const useMock = isDevMode && !USE_SERVER
this._instances.apiDataSource = useMock
? new MockApiDataSource()
: new WordPressApiDataSource()
}
return this._instances.apiDataSource
}
getTicketRepository() {
if (!this._instances.ticketRepository) {
this._instances.ticketRepository = new TicketRepositoryImpl(
this.getApiDataSource()
)
}
return this._instances.ticketRepository
}
getCheckTicketUseCase() {
if (!this._instances.checkTicketUseCase) {
this._instances.checkTicketUseCase = new CheckTicketUseCase(
this.getTicketRepository()
)
}
return this._instances.checkTicketUseCase
}
getScanTicketUseCase() {
if (!this._instances.scanTicketUseCase) {
this._instances.scanTicketUseCase = new ScanTicketUseCase(
this.getCheckTicketUseCase()
)
}
return this._instances.scanTicketUseCase
}
}
export const container = new DIContainer()Контейнер работает как фабрика с кешированием (Singleton). При первом вызове getCheckTicketUseCase() он создаёт весь граф зависимостей: use case → repository → data source. При повторных вызовах возвращает уже созданный объект.
Магия происходит в методе getApiDataSource(). Он проверяет, в каком режиме мы работаем (development или production), смотрит на константу USE_SERVER, и на основе этого решает, какой data source создать:
- Production — всегда
WordPressApiDataSource - Development + USE_SERVER = false —
MockApiDataSource(работа без сервера) - Development + USE_SERVER = true —
WordPressApiDataSource(тестирование с реальным сервером)
Это невероятно удобно при разработке: меняешь одну строчку — и переключаешься между режимами.
Presentation Layer: React и пользовательский интерфейс
Мы добрались до верхнего слоя — того, что видит пользователь. Presentation Layer построен на React и следует важному правилу: он знает о Domain (использует entities и use cases), но не знает о Data Layer. Для Presentation Layer не важно, откуда берутся данные — это дело Data Layer.
Custom Hooks: Мост между React и бизнес-логикой
React Hooks — идеальный инструмент для интеграции use cases в компоненты. Мы создали custom hooks, которые оборачивают use cases и предоставляют удобный API для компонентов:
export const useScanTicket = () => {
const [result, setResult] = useState(null)
const [loading, setLoading] = useState(false)
const scanTicketUseCase = container.getScanTicketUseCase()
const scan = useCallback(async (qrCodeData) => {
setLoading(true)
try {
const scanResult = await scanTicketUseCase.execute(qrCodeData)
setResult(scanResult)
return scanResult
} catch (error) {
const errorResult = ScanResult.failure(error.message)
setResult(errorResult)
return errorResult
} finally {
setLoading(false)
}
}, [scanTicketUseCase])
const reset = useCallback(() => {
setResult(null)
scanTicketUseCase.resetCooldown()
}, [scanTicketUseCase])
return { scan, result, loading, reset }
}Хук useScanTicket решает несколько задач:
- Управление состоянием — хранит результат сканирования и статус загрузки
- Асинхронные операции — оборачивает вызов use case в try-catch
- Интеграция с DI — получает use case из контейнера
- React-оптимизация — использует
useCallbackдля стабильности ссылок
Компонент просто вызывает scan(qrCodeData) и получает результат. Вся сложность скрыта внутри хука.
ScannerContainer: Оркестрация процесса сканирования
Главный компонент приложения — ScannerContainer. Это container component (паттерн из React), который объединяет всю логику работы сканера. Давайте разберём, что происходит при сканировании билета:
export const ScannerContainer = () => {
const [showConfirmation, setShowConfirmation] = useState(false)
const [currentTicket, setCurrentTicket] = useState(null)
const { scan, loading: scanLoading, reset: resetScan } = useScanTicket()
const { activate, loading: activateLoading, reset: resetActivate } = useActivateTicket()
const handleScan = useCallback(async (result) => {
if (!result || loading || showConfirmation) return
const scanResult = await scan(result)
if (!scanResult) {
showMessage(MESSAGES.TICKET_INVALID, 'error')
return
}
if (scanResult.isSuccess()) {
const ticket = scanResult.getTicket()
if (ticket.isActive()) {
setCurrentTicket(ticket)
setShowConfirmation(true)
} else if (ticket.isUsed()) {
const message = ticket.getMessage() || MESSAGES.TICKET_USED
showMessage(message, 'warning')
}
} else {
showMessage(scanResult.getError(), 'error')
}
}, [scan, loading, showConfirmation])
const handleActivate = useCallback(async () => {
if (!currentTicket) return
const activationResult = await activate(currentTicket.code)
if (!activationResult) {
showMessage('Ошибка активации билета', 'error')
return
}
if (activationResult.isSuccess()) {
const message = activationResult.getMessage() || MESSAGES.TICKET_ACTIVATED
showMessage(message, 'success')
} else {
const message = activationResult.getMessage() || 'Ошибка активации'
showMessage(message, 'error')
}
setCurrentTicket(null)
resetScan()
resetActivate()
setShowConfirmation(false)
}, [currentTicket, activate, resetScan, resetActivate])
return (
<>
<ScannerView
onScan={handleScan}
loading={loading}
// ... Другие пропсы
/>
{showConfirmation && currentTicket && (
<ConfirmationModal
ticket={currentTicket}
onConfirm={handleActivate}
onCancel={handleCancel}
/>
)}
</>
)
}Этот компонент реализует сложный workflow:
- Сканирование → вызывается
handleScan - Проверка cooldown — если идёт загрузка или открыто модальное окно, сканирование блокируется
- Валидация через use case —
scan(result)вызывает весь граф: ScanTicketUseCase → CheckTicketUseCase → Repository → DataSource - Обработка результата:
- Если билет активный → показать модальное окно подтверждения
- Если использованный → показать предупреждение
- Если ошибка → показать сообщение об ошибке
- Подтверждение активации → пользователь нажимает «Подтвердить» в модальном окне
- Активация →
handleActivateвызываетActivateTicketUseCase - Финальное сообщение → показывается результат активации
Важная деталь: компонент работает с доменными объектами (Ticket, ScanResult, ActivationResult), а не с сырыми данными API. Он вызывает методы isSuccess(), isActive(), getMessage() — это абстракции Domain Layer. Если завтра формат API изменится, компонент останется нетронутым.
Интеграция с WordPress: Два мира встречаются
Интеграция React-приложения с WordPress — это интересная задача. WordPress — это PHP-приложение с традиционной серверной архитектурой, а наше приложение — современный SPA. Нужно построить мост между ними.
Передача конфигурации из PHP в JavaScript
WordPress использует механизм wp_localize_script для передачи данных из PHP в JavaScript:
window.qrScannerData = {
ajaxurl: '/wp-admin/admin-ajax.php',
nonce: 'generated_nonce_value'
}Этот объект создаётся при инициализации WordPress плагина и доступен в JavaScript через window.qrScannerData. Наше приложение использует его для получения AJAX URL и nonce-токена.
AJAX Handlers: Обработка запросов на сервере
Для каждой операции (проверка и активация билета) создаётся WordPress AJAX handler:
add_action('wp_ajax_checkTicket', 'handle_check_ticket');
add_action('wp_ajax_activateTicket', 'handle_activate_ticket');
function handle_check_ticket() {
check_ajax_referer('qr_scanner_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(['message' => 'Нет прав доступа']);
}
$ticket_code = sanitize_text_field($_POST['ticket_code']);
// Проверка билета в БД
// ...
wp_send_json_success([
'status' => true,
'data' => [
'message' => 'Билет действителен',
'info' => $ticket_info
]
]);
}Функция check_ajax_referer проверяет nonce-токен — это защита от CSRF-атак. WordPress генерирует уникальный токен для каждой сессии, и все AJAX запросы должны его содержать.
current_user_can('manage_options') проверяет права пользователя — только администраторы могут сканировать билеты.
sanitize_text_field очищает входные данные от потенциально опасного кода.
Handler обращается к базе данных, проверяет билет, и возвращает ответ через wp_send_json_success или wp_send_json_error. Формат ответа может быть как упрощённым ({status, message}), так и полным ({status, data: {message, info}}). Наш код на фронтенде корректно обрабатывает оба варианта.


Vite конфигурация: Сборка для WordPress
Сборка React-приложения для WordPress — это не тривиальная задача. Нужно создать файл, который можно подключить через <script> тег, при этом использовать React от WordPress, а не дублировать его в бандле.
Решение — UMD формат с внешними зависимостями:
// vite.config.js
export default defineConfig({
build: {
lib: {
entry: './src/main.jsx',
name: 'QRScanner',
formats: ['umd'],
fileName: () => 'main.umd.cjs'
},
rollupOptions: {
external: ['react', 'react-dom'],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM'
}
}
}
},
server: {
host: '0.0.0.0',
port: 5173,
https: true // Обязательно для камеры!
}
})Разберём конфигурацию:
- lib.entry — точка входа приложения
- lib.name — глобальное имя в UMD (доступно как
window.QRScanner) - lib.formats: [‘umd’] — формат сборки, совместимый с любыми загрузчиками
- external: [‘react’, ‘react-dom’] — критически важно! Это говорит Vite не включать React в бандл
- output.globals — маппинг внешних зависимостей на глобальные переменные WordPress
Результат: файл main.umd.cjs который ожидает, что React и ReactDOM уже загружены глобально.
В WordPress плагине приложение подключается так:
wp_enqueue_script(
'qr-scanner',
plugin_dir_url(__FILE__) . 'build/main.umd.cjs',
['react', 'react-dom'],
'1.0.0',
true
);WordPress сначала загружает свои версии React и ReactDOM, затем наш скрипт. Параметр ['react', 'react-dom'] в wp_enqueue_script говорит, что наш скрипт зависит от них.
Dev сервер настроен на HTTPS (обязательно для камеры!) и доступен по сети:
server: {
host: '0.0.0.0', // Доступ со всех устройств в сети
port: 5173,
https: true // Самоподписанный сертификат через basicSsl plugin
}Режим разработки: Быстрая итерация без сервера
Один из ключевых моментов, влияющих на скорость разработки — это возможность работать без сервера. Представьте: вы пишете код, сохраняете, и видите результат мгновенно. Не нужно ждать запросов к серверу, не нужно поднимать WordPress локально.
Константа USE_SERVER управляет этим:
// ./constants.js
export const USE_SERVER = false // true для реального сервера
// ./di/container.js
getApiDataSource() {
const isDevMode = isDevelopment()
const useMock = isDevMode && !USE_SERVER
return useMock ? new MockApiDataSource() : new WordPressApiDataSource()
}При USE_SERVER = false (по умолчанию в разработке) DI контейнер создаёт MockApiDataSource вместо WordPressApiDataSource. Все запросы обрабатываются локально, в браузере, с имитацией сетевых задержек.
При USE_SERVER = true можно тестировать с реальным сервером, при этом к запросам автоматически добавляется параметр isTest=true:
if (isDevelopment() && USE_SERVER) {
formData.append('isTest', 'true')
}Работа с камерой: WebRTC и безопасность
Работа с камерой в веб-приложениях требует особого внимания к безопасности. Браузеры разрешают доступ к камере только через HTTPS (или localhost), это защита от злоумышленников.
Vite решает эту проблему через плагин @vitejs/plugin-basic-ssl, который создаёт самоподписанный SSL-сертификат автоматически:
import basicSsl from '@vitejs/plugin-basic-ssl'
export default defineConfig({
plugins: [react(), basicSsl()],
server: {
https: true
}
})При первом запуске dev-сервера браузер покажет предупреждение о недоверенном сертификате — это нормально для разработки. Нажимаете «Продолжить», и всё работает.
Управление разрешениями камеры
Доступ к камере — это чувствительная операция. Пользователь должен явно разрешить приложению использовать камеру. Custom hook useCameraPermission управляет этим процессом:
export const useCameraPermission = () => {
const [permissionState, setPermissionState] = useState('checking')
useEffect(() => {
checkPermission()
}, [])
const checkPermission = async () => {
if (navigator.permissions && navigator.permissions.query) {
const result = await navigator.permissions.query({ name: 'camera' })
setPermissionState(result.state)
} else {
// Fallback к getUserMedia
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: true
})
stream.getTracks().forEach(track => track.stop())
setPermissionState('granted')
} catch {
setPermissionState('prompt')
}
}
}
return { permissionState, requestPermission, retryPermission }
}Хук работает в несколько этапов:
- Проверка поддержки Permissions API — современный браузерный API для проверки разрешений
- Query разрешения на камеру — без фактического запроса доступа
- Fallback на getUserMedia — если Permissions API не поддерживается, пробуем напрямую получить доступ
- Управление состоянием —
granted,denied,prompt,checking
UI приложения адаптируется к состоянию разрешений: если камера запрещена, показывается экран с инструкцией, как разрешить доступ в настройках браузера. Если нужно запросить разрешение — кнопка «Разрешить доступ к камере».
Функциональный процесс: Как всё работает вместе
Давайте проследим полный путь от сканирования QR-кода до активации билета, чтобы увидеть, как все слои архитектуры работают вместе:
Шаг 1: Сканирование
Пользователь (администратор мероприятия) наводит камеру телефона на QR-код билета. Библиотека @yudiel/react-qr-scanner детектирует код и вызывает callback onScan в компоненте ScannerView.
Шаг 2: Обработка в Container
ScannerContainer.handleScan получает сырые данные от сканера. Он проверяет, что не идёт загрузка и не открыто модальное окно (защита от случайного повторного сканирования).
Шаг 3: Use Case Pipeline
Вызывается useScanTicket.scan(qrCodeData), который запускает цепочку:
- ScanTicketUseCase.execute — проверяет cooldown и защиту от дублирования
- CheckTicketUseCase.execute — валидирует код и вызывает репозиторий
- TicketRepository.checkTicket — делегирует запрос data source
- WordPressApiDataSource.checkTicket — отправляет AJAX запрос к WordPress
Шаг 4: Сервер
WordPress AJAX handler handle_check_ticket получает запрос, проверяет nonce и права доступа, обращается к базе данных MySQL, ищет билет по коду, и возвращает JSON ответ.
Шаг 5: Обратный путь
Ответ проходит обратно по цепочке:
- DataSource возвращает сырой response
- Repository извлекает нужные поля и обрабатывает разные форматы
- Ticket.fromApiResponse создаёт доменный объект
Ticket - CheckTicketUseCase оборачивает его в
ScanResult.success(ticket) - Hook обновляет состояние React
Шаг 6: Отображение результата
ScannerContainer.handleScan получает ScanResult, проверяет scanResult.isSuccess(), извлекает билет scanResult.getTicket(), проверяет ticket.isActive(), и если всё ОК — показывает модальное окно ConfirmationModal с полной информацией о билете.
Шаг 7: Подтверждение
Администратор видит данные: имя владельца билета, ряд 2 место 6, время сеанса 19:00 25 декабря, название представления. Он проверяет, что человек пришёл на правильное мероприятие, и нажимает «Подтвердить».
Шаг 8: Активация
Запускается аналогичная цепочка для активации: ActivateTicketUseCase → Repository → DataSource → WordPress API. Сервер обновляет статус билета в базе данных на ‘used’, возвращает подтверждение.
Шаг 9: Финал
Приложение показывает сообщение «Билет успешно активирован!» (или сообщение от сервера, если оно есть), закрывает модальное окно, сбрасывает состояние. Готово к следующему сканированию.
Весь процесс занимает 2-3 секунды. За это время данные проходят через все три слоя архитектуры в обе стороны, и каждый слой выполняет свою роль, не зная о деталях других слоёв.
Поток данных
Полный flow сканирования и активации
┌─────────────────────────────────────────────────────────┐
│ 1. User scans QR code │
└────────────────────┬────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ 2. ScannerView │
│ onScan(result) → вызывает prop из Container │
└────────────────────┬────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ 3. ScannerContainer │
│ handleScan(result) │
│ - Блокирует если loading или modal открыт │
│ - Вызывает scan() │
└────────────────────┬────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ 4. useScanTicket hook │
│ scan(qrCodeData) │
│ - setLoading(true) │
│ - Вызывает use case │
└────────────────────┬────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ 5. ScanTicketUseCase │
│ execute(qrCodeData) │
│ - extractTicketCode() │
│ - Проверка cooldown │
│ - Защита от повторов │
│ - Делегация в CheckTicketUseCase │
└────────────────────┬────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ 6. CheckTicketUseCase │
│ execute(ticketCode) │
│ - Валидация кода │
│ - Вызывает repository │
└────────────────────┬────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ 7. TicketRepositoryImpl │
│ checkTicket(ticketCode) │
│ - Вызывает apiDataSource │
│ - Преобразует response в Ticket │
└────────────────────┬────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ 8. WordPressApiDataSource / MockApiDataSource │
│ checkTicket(ticketCode) │
│ - Делает HTTP запрос (или имитацию) │
│ - Возвращает { status, data } │
└────────────────────┬────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ 9. Response → Repository → UseCase → Hook │
│ ← Ticket entity │
│ ← ScanResult │
│ setLoading(false) │
└────────────────────┬────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ 10. ScannerContainer │
│ if scanResult.isSuccess() && ticket.isActive() │
│ → setCurrentTicket(ticket) │
│ → setShowConfirmation(true) │
│ else │
│ → showMessage(error/warning) │
└─────────────────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ 11. ConfirmationModal отображается │
│ User нажимает "Подтвердить" │
│ → onConfirm() │
└────────────────────┬────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ 12. ScannerContainer.handleActivate() │
│ activate(currentTicket.code) │
└────────────────────┬────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ 13. useActivateTicket hook │
│ ActivateTicketUseCase.execute() │
│ → TicketRepository.activateTicket() │
│ → ApiDataSource.activateTicket() │
└────────────────────┬────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ 14. Response { status, data: { message } } │
│ ← ActivationResult │
└────────────────────┬────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ 15. ScannerContainer │
│ if success → showMessage('Успешно активирован') │
│ Закрытие модалки, сброс состояния │
└─────────────────────────────────────────────────────────┘Результаты: Что получилось в итоге
Метрики проекта
Финальное приложение получилось компактным и эффективным:
- Размер production сборки: 177KB JavaScript + 10KB CSS
- Время сборки: ~2 секунды (Vite невероятно быстр)
- Время загрузки: <1 секунды на 4G
- Время от сканирования до результата: 1-2 секунды (зависит от сети)
- Cooldown между сканированиями: 2 секунды (защита от дублей)
Архитектурные метрики
- Слои архитектуры: 3 полностью независимых слоя
- Entities: 4 (Ticket, ScanResult, ActivationResult, TicketInfo)
- Use Cases: 3 (ScanTicket, CheckTicket, ActivateTicket)
- Repositories: 1 интерфейс, 1 реализация
- Data Sources: 2 (WordPress API и Mock)
- Custom Hooks: 3 (useScanTicket, useActivateTicket, useCameraPermission)
- React Components: 10+ переиспользуемых компонентов
Структура проекта
src/
├── domain/ # 0 внешних зависимостей
│ ├── entities/ # Бизнес-объекты
│ ├── usecases/ # Бизнес-операции
│ └── repositories/ # Интерфейсы доступа к данным
│
├── data/ # Зависит только от Domain
│ ├── datasources/ # WordPress API, Mock
│ └── repositories/ # Реализации интерфейсов
│
├── presentation/ # Зависит только от Domain
│ ├── components/ # Чистые UI компоненты
│ ├── containers/ # Компоненты с логикой
│ └── hooks/ # Интеграция с use cases
│
├── di/ # Dependency Injection
│ └── container.js # Singleton контейнер
│
└── utils/ # Вспомогательные функции
├── constants.js # Константы, включая USE_SERVER
└── security.js # WordPress интеграцияФункциональные возможности
Что умеет финальное приложение:
✅ Быстрое сканирование — открыл камеру, навёл, получил результат
✅ Детальная информация — видно всё: владелец, место, время, представление
✅ Защита от ошибок — cooldown, защита от дублей, валидация на каждом уровне
✅ Гибкая обработка ответов — поддержка разных форматов API
✅ Graceful degradation — корректная работа при проблемах с сетью
✅ Режим разработки — мгновенная итерация с mock-данными
✅ Production-ready — полная интеграция с WordPress, безопасность, оптимизация
Выводы: Чему учит этот проект
Clean Architecture работает на практике
Главный вывод: Clean Architecture — это не абстрактная теория из учебников. Это практичный подход, который решает реальные проблемы реальных проектов.
Масштабируемость. Когда заказчик попросит добавить печать билетов, экспорт в Excel, интеграцию с CRM — всё это будут новые use cases. Domain Layer останется стабильным, добавятся новые варианты использования. Не нужно переписывать половину приложения.
Тестируемость. Domain Layer можно тестировать в изоляции. Создаёшь mock-репозиторий, передаёшь в use case, проверяешь логику. Никаких React-компонентов, никаких AJAX запросов. Быстро, надёжно, понятно.
Независимость от фреймворков. Решили переписать на Vue? Меняете только Presentation Layer. Решили заменить WordPress на Django? Меняете только Data Layer. Domain остаётся нетронутым — это и есть настоящая модульность.
Гибкость. Поддержка разных форматов API, переключение между mock и реальным сервером, возможность работать без интернета — всё это естественно вытекает из архитектуры. Когда слои независимы, добавлять альтернативные реализации легко.
Практические уроки
Фабричные методы спасают. Метод Ticket.fromApiResponse() изолирует Domain от изменений API. API поменялся? Обновили фабрику. Остальной код даже не заметил.
DI Container — must have. Без контейнера пришлось бы вручную создавать и связывать объекты в каждом компоненте. С контейнером — один раз настроил, везде используешь. Плюс лёгкое переключение между реализациями.
Mock данные ускоряют разработку. Работа с реальным API при разработке UI — это медленно. Mock данные дают мгновенную обратную связь. Изменил компонент — сразу видишь результат. Константа USE_SERVER делает переключение тривиальным.
Типизация была бы кстати. TypeScript добавил бы безопасности и документированности. Но даже без него, правильная архитектура делает код понятным и предсказуемым.
Защита на каждом уровне. Валидация в use cases, проверка прав на сервере, nonce-токены, санитизация данных. Безопасность — это не что-то отдельное, а часть архитектуры.
Что дальше
Этот проект показывает, что правильная архитектура доступна не только Enterprise-разработчикам. Вы можете применить эти принципы в своих проектах, даже небольших. Да, на старте нужно больше времени на проектирование. Но когда через полгода вам нужно добавить новую фичу или исправить баг, вы будете благодарны себе за то, что выбрали чистую архитектуру.
Код — это не только про то, чтобы работало. Код — это про то, чтобы завтра можно было понять, что ты написал вчера. Чтобы коллега мог разобраться без часовой лекции. Чтобы через год проект можно было развивать, а не переписывать с нуля.
Clean Architecture помогает писать именно такой код.
Надеемся, эта статья была полезной.
Полезные ссылки:
- Clean Architecture (Robert Martin)
- React Clean Architecture
- Vite Documentation
- @yudiel/react-qr-scanner
Теги: #React #CleanArchitecture #WordPress #QRScanner #JavaScript #Vite
