HTMX и Yoyo компоненты
Flute CMS интегрирует HTMX и Yoyo для создания динамичных веб-приложений без необходимости написания JavaScript кода. HTMX позволяет обновлять части страницы без перезагрузки, а Yoyo предоставляет PHP-классы для управления состоянием компонентов.
Нюансы ядра:
- Маршрут Yoyo регистрируется автоматически (
/liveили/admin/live) с middlewareweb, csrf HtmxMiddleware(алиасhtmx) вернёт 404, если запрос не HX — используйте его только на HTMX-эндпоинтахModuleServiceProvider::loadComponents()автоматически регистрирует классы изComponents/в Yoyo (kebab-case без суффиксаComponent)- В админке Yoyo используется через
router()->screen()(см. админ-интеграцию)
Архитектура HTMX
Основные возможности HTMX
<!-- Базовое использование HTMX -->
<button hx-get="/api/data"
hx-target="#result"
hx-swap="innerHTML">
Загрузить данные
</button>
<div id="result"></div>Атрибуты HTMX
<!-- GET запрос -->
<button hx-get="/articles">Загрузить статьи</button>
<!-- POST запрос -->
<form hx-post="/articles" hx-target="#articles-list">
<input name="title" type="text">
<button type="submit">Создать статью</button>
</form>
<!-- PUT запрос -->
<button hx-put="/articles/1"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'>
Обновить статью
</button>
<!-- DELETE запрос -->
<button hx-delete="/articles/1"
hx-confirm="Вы уверены?">
Удалить статью
</button>Цели и обмен контентом
<!-- Замена всего элемента -->
<div hx-get="/content" hx-target="#container" hx-swap="innerHTML">
<!-- Контент -->
</div>
<!-- Добавление в конец -->
<div hx-get="/items" hx-target="#list" hx-swap="beforeend">
<ul id="list">
<li>Существующий элемент</li>
</ul>
</div>
<!-- Добавление в начало -->
<div hx-get="/messages" hx-target="#chat" hx-swap="afterbegin">
<div id="chat">
<div class="message">Старая сообщение</div>
</div>
</div>
<!-- Замена только текста -->
<span hx-get="/time" hx-target="#time" hx-swap="text">
<span id="time">Загрузка...</span>
</span>Для HTMX-ручек добавьте middleware htmx, чтобы защищать их от прямых обращений.
Yoyo компоненты
Создание Yoyo компонента
<?php
namespace Flute\Modules\Blog\Components;
use Flute\Core\Support\FluteComponent;
class ArticleLikeComponent extends FluteComponent
{
public int $articleId;
public int $likesCount = 0;
public bool $isLiked = false;
public function mount(int $articleId)
{
$this->articleId = $articleId;
$this->loadLikeData();
}
public function like()
{
$userId = user()->id;
if ($this->isLiked) {
// Удаление лайка
rep(\Flute\Modules\Blog\Database\Entities\ArticleLike::class)
->select()
->where('article_id', $this->articleId)
->where('user_id', $userId)
->delete();
$this->likesCount--;
$this->isLiked = false;
} else {
// Добавление лайка
$like = new \Flute\Modules\Blog\Database\Entities\ArticleLike();
$like->article_id = $this->articleId;
$like->user_id = $userId;
$like->created_at = now();
transaction($like)->run();
$this->likesCount++;
$this->isLiked = true;
}
// Отправка события
$this->emitEvent('article-liked', [
'article_id' => $this->articleId,
'liked' => $this->isLiked,
'likes_count' => $this->likesCount
]);
}
protected function loadLikeData()
{
$userId = user()->id;
if ($userId) {
$like = rep(\Flute\Modules\Blog\Database\Entities\ArticleLike::class)
->select()
->where('article_id', $this->articleId)
->where('user_id', $userId)
->fetchOne();
$this->isLiked = $like !== null;
}
$this->likesCount = rep(\Flute\Modules\Blog\Database\Entities\ArticleLike::class)
->select()
->where('article_id', $this->articleId)
->count();
}
public function render()
{
return $this->view('blog::components.article-like', [
'article_id' => $this->articleId,
'likes_count' => $this->likesCount,
'is_liked' => $this->isLiked
]);
}
}FluteComponent добавляет интеграцию с валидатором ($this->validate(...), $this->getValidatorErrors()), троттлингом через TooManyRequestsException и удобными методами confirm()/emit().
Шаблон Yoyo компонента
{{-- resources/views/components/article-like.blade.php --}}
@php
$buttonClass = $is_liked ? 'btn-danger' : 'btn-outline-danger';
$iconClass = $is_liked ? 'heart-filled' : 'heart';
@endphp
<div class="article-like-component">
@auth
<form method="POST" class="d-inline">
@csrf
<button type="submit"
class="btn {{ $buttonClass }} btn-sm like-button"
yoyo:post="like()"
yoyo:vals="{}"
data-article-id="{{ $article_id }}">
<i class="{{ $iconClass }}"></i>
<span class="likes-count">{{ $likes_count }}</span>
</button>
</form>
@else
<span class="text-muted">
<i class="far fa-heart"></i>
<span class="likes-count">{{ $likes_count }}</span>
</span>
@endauth
</div>
@push('scripts')
<script>
// Обработка событий компонента
document.addEventListener('article-liked', function(event) {
const data = event.detail;
console.log('Article liked:', data);
});
</script>
@endpushПродвинутые возможности HTMX
Заголовки и параметры запроса
<!-- Отправка заголовков -->
<button hx-get="/api/user/profile"
hx-headers='{"Authorization": "Bearer {{ auth()->token() }}"}'>
Загрузить профиль
</button>
<!-- Отправка дополнительных данных -->
<button hx-post="/api/articles/search"
hx-vals='{"category": "tech", "sort": "newest"}'>
Поиск статей
</button>
<!-- Динамические заголовки -->
<div hx-get="/api/notifications"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'>
</div>Обработка ответов
<!-- Обработка успешного ответа -->
<button hx-post="/api/contact"
hx-target="#contact-form"
hx-swap="innerHTML"
hx-on:htmx:after-request="handleContactResponse(event)">
Отправить
</button>
<!-- Обработка ошибок -->
<form hx-post="/api/login"
hx-target="#login-result"
hx-on:htmx:response-error="handleLoginError(event)">
<!-- Поля формы -->
</form>
<script>
function handleContactResponse(event) {
if (event.detail.success) {
showNotification('Сообщение отправлено!', 'success');
}
}
function handleLoginError(event) {
const error = event.detail.error;
showNotification(error, 'error');
}
</script>Синхронизация и очереди
<!-- Синхронизация нескольких запросов -->
<button hx-get="/api/data1" hx-sync="this:replace">Загрузить 1</button>
<button hx-get="/api/data2" hx-sync="this:replace">Загрузить 2</button>
<!-- Очереди запросов -->
<div hx-get="/api/slow-operation"
hx-queue="first"
hx-target="#result">
Медленная операция
</div>
<div hx-get="/api/fast-operation"
hx-queue="last"
hx-target="#result">
Быстрая операция
</div>Компоненты с формами
Форма создания статьи
<?php
namespace Flute\Modules\Blog\Components;
use Flute\Core\Support\FluteComponent;
class ArticleFormComponent extends FluteComponent
{
public ?int $articleId = null;
public string $title = '';
public string $content = '';
public ?int $categoryId = null;
public array $tags = [];
public bool $published = false;
public function mount(?int $articleId = null)
{
$this->articleId = $articleId;
if ($articleId) {
$this->loadArticle();
}
$this->loadCategories();
}
public function save()
{
$this->validateForm();
try {
if ($this->articleId) {
$this->updateArticle();
$message = __('blog.article_updated');
} else {
$this->createArticle();
$message = __('blog.article_created');
}
$this->flashMessage($message, 'success');
// Перенаправление или обновление компонента
$this->emitEvent('article-saved', [
'article_id' => $this->articleId
]);
} catch (\Exception $e) {
$this->flashMessage(__('blog.save_failed'), 'error');
}
}
public function addTag($tagName)
{
if (!in_array($tagName, $this->tags)) {
$this->tags[] = $tagName;
}
}
public function removeTag($tagIndex)
{
if (isset($this->tags[$tagIndex])) {
unset($this->tags[$tagIndex]);
$this->tags = array_values($this->tags); // Переиндексация
}
}
protected function validateForm()
{
$rules = [
'title' => 'required|string|min-str-len:3|max-str-len:255',
'content' => 'required|string|min-str-len:10',
'category_id' => 'nullable|integer|exists:blog_categories,id',
'tags' => 'array|max:10',
'tags.*' => 'string|max-str-len:50'
];
$isValid = $this->validate($rules);
if (!$isValid) {
throw new \Exception('Validation failed');
}
}
protected function createArticle()
{
$article = new \Flute\Modules\Blog\Database\Entities\Article();
$article->title = $this->title;
$article->content = $this->content;
$article->category_id = $this->categoryId;
$article->published = $this->published;
$article->author_id = user()->id;
$article->created_at = now();
$article->updated_at = now();
transaction($article)->run();
// Сохранение тегов
$this->saveTags($article->id);
$this->articleId = $article->id;
}
protected function updateArticle()
{
$article = rep(\Flute\Modules\Blog\Database\Entities\Article::class)
->findByPK($this->articleId);
if (!$article) {
throw new \Exception('Article not found');
}
$article->title = $this->title;
$article->content = $this->content;
$article->category_id = $this->categoryId;
$article->published = $this->published;
$article->updated_at = now();
transaction($article)->run();
// Обновление тегов
$this->saveTags($this->articleId);
}
protected function saveTags($articleId)
{
// Удаление старых тегов
rep(\Flute\Modules\Blog\Database\Entities\ArticleTag::class)
->select()
->where('article_id', $articleId)
->delete();
// Добавление новых тегов
foreach ($this->tags as $tagName) {
$tag = rep(\Flute\Modules\Blog\Database\Entities\Tag::class)
->select()
->where('name', $tagName)
->fetchOne();
if (!$tag) {
$tag = new \Flute\Modules\Blog\Database\Entities\Tag();
$tag->name = $tagName;
$tag->slug = Str::slug($tagName);
transaction($tag)->run();
}
$articleTag = new \Flute\Modules\Blog\Database\Entities\ArticleTag();
$articleTag->article_id = $articleId;
$articleTag->tag_id = $tag->id;
transaction($articleTag)->run();
}
}
protected function loadArticle()
{
$article = rep(\Flute\Modules\Blog\Database\Entities\Article::class)
->findByPK($this->articleId);
if ($article) {
$this->title = $article->title;
$this->content = $article->content;
$this->categoryId = $article->category_id;
$this->published = $article->published;
// Загрузка тегов
$this->tags = $article->tags->pluck('name')->toArray();
}
}
protected function loadCategories()
{
// Категории загружаются в шаблоне через сервис
}
public function render()
{
$categories = app(\Flute\Modules\Blog\Services\CategoryService::class)->getAllCategories();
return $this->view('blog::components.article-form', [
'article_id' => $this->articleId,
'title' => $this->title,
'content' => $this->content,
'category_id' => $this->categoryId,
'tags' => $this->tags,
'published' => $this->published,
'categories' => $categories,
'is_edit' => $this->articleId !== null
]);
}
}Шаблон формы статьи
{{-- resources/views/components/article-form.blade.php --}}
<x-card class="article-form-component">
<form class="article-form">
@csrf
{{-- Заголовок --}}
<x-forms.field class="mb-3">
<x-forms.label for="title" required>{{ __('blog.title') }}</x-forms.label>
<x-input name="title" id="title" :value="old('title', $title)" yoyo yoyo:trigger="blur" required />
@error('title')
<div class="text-danger">{{ $message }}</div>
@enderror
</x-forms.field>
{{-- Категория --}}
<x-forms.field class="mb-3">
<x-forms.label for="category_id">{{ __('blog.category') }}</x-forms.label>
<select id="category_id" name="category_id" class="form-select" yoyo yoyo:trigger="change">
<option value="">{{ __('blog.select_category') }}</option>
@foreach($categories as $category)
<option value="{{ $category->id }}" {{ $category_id == $category->id ? 'selected' : '' }}>
{{ $category->name }}
</option>
@endforeach
</select>
</x-forms.field>
{{-- Теги --}}
<x-forms.field class="mb-3">
<x-forms.label>{{ __('blog.tags') }}</x-forms.label>
<div class="tags-input">
<div class="tags-list">
@foreach($tags as $index => $tag)
<span class="badge bg-primary me-1">
{{ $tag }}
<button type="button" class="btn-close btn-close-white ms-1"
yoyo:click="removeTag({{ $index }})" aria-label="Remove tag"></button>
</span>
@endforeach
</div>
<x-input id="tag-input" type="text" class="mt-2"
:placeholder="__('blog.add_tag')" onkeypress="handleTagInput(event)" />
</div>
</x-forms.field>
{{-- Содержимое --}}
<x-forms.field class="mb-3">
<x-forms.label for="content" required>{{ __('blog.content') }}</x-forms.label>
<textarea id="content" name="content" class="form-control" rows="10"
yoyo yoyo:trigger="blur" required>{{ old('content', $content) }}</textarea>
@error('content')
<div class="text-danger">{{ $message }}</div>
@enderror
</x-forms.field>
{{-- Опубликовать --}}
<x-forms.field class="mb-3">
<div class="form-check">
<input type="checkbox" id="published" name="published" class="form-check-input"
{{ $published ? 'checked' : '' }} yoyo yoyo:trigger="change">
<label for="published" class="form-check-label">{{ __('blog.publish_immediately') }}</label>
</div>
</x-forms.field>
{{-- Действия --}}
<div class="form-actions">
<button type="submit" class="btn btn-primary" yoyo:click="save()">
<x-icon path="save" />
{{ $is_edit ? __('blog.update_article') : __('blog.create_article') }}
</button>
<button type="button" class="btn btn-secondary ms-2" onclick="window.history.back()">
{{ __('blog.cancel') }}
</button>
</div>
</form>
</x-card>
@push('scripts')
<script>
function handleTagInput(event) {
if (event.key === 'Enter') {
event.preventDefault();
const tagName = event.target.value.trim();
if (tagName) {
// Отправка события для добавления тега
htmx.ajax('POST', window.location.href, {
values: { action: 'add_tag', tag_name: tagName },
target: '#tag-input',
swap: 'none'
});
event.target.value = '';
}
}
}
// Инициализация редактора (если используется)
document.addEventListener('DOMContentLoaded', function() {
if (typeof ClassicEditor !== 'undefined') {
ClassicEditor
.create(document.querySelector('#content'))
.catch(error => {
console.error(error);
});
}
});
</script>
@endpushКомпоненты с загрузкой файлов
Компонент загрузки изображений
<?php
namespace Flute\Modules\Blog\Components;
use Flute\Core\Support\FluteComponent;
class ImageUploadComponent extends FluteComponent
{
public array $images = [];
public string $uploadPath = 'uploads/blog/images/';
public array $allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
public int $maxFileSize = 5 * 1024 * 1024; // 5MB
public int $maxFiles = 10;
public function upload($files)
{
$uploadedFiles = [];
foreach ($files as $file) {
try {
$uploadedFile = $this->processFile($file);
$uploadedFiles[] = $uploadedFile;
} catch (\Exception $e) {
$this->flashMessage($e->getMessage(), 'error');
}
}
if (!empty($uploadedFiles)) {
$this->images = array_merge($this->images, $uploadedFiles);
$this->emitEvent('images-uploaded', [
'uploaded' => $uploadedFiles,
'total' => count($this->images)
]);
}
}
public function removeImage($index)
{
if (isset($this->images[$index])) {
$image = $this->images[$index];
// Удаление файла
if (file_exists(public_path($image['path']))) {
unlink(public_path($image['path']));
}
// Удаление из массива
unset($this->images[$index]);
$this->images = array_values($this->images);
$this->emitEvent('image-removed', [
'removed' => $image,
'total' => count($this->images)
]);
}
}
protected function processFile($file)
{
// Валидация типа файла
if (!in_array($file['type'], $this->allowedTypes)) {
throw new \Exception(__('blog.invalid_file_type'));
}
// Валидация размера файла
if ($file['size'] > $this->maxFileSize) {
throw new \Exception(__('blog.file_too_large'));
}
// Проверка количества файлов
if (count($this->images) >= $this->maxFiles) {
throw new \Exception(__('blog.too_many_files'));
}
// Генерация имени файла
$extension = pathinfo($file['name'], PATHINFO_EXTENSION);
$filename = uniqid('blog_') . '.' . $extension;
$fullPath = $this->uploadPath . $filename;
// Создание директории
$directory = dirname(public_path($fullPath));
if (!is_dir($directory)) {
mkdir($directory, 0755, true);
}
// Перемещение файла
if (!move_uploaded_file($file['tmp_name'], public_path($fullPath))) {
throw new \Exception(__('blog.upload_failed'));
}
// Получение размеров изображения
$imageInfo = getimagesize(public_path($fullPath));
$dimensions = $imageInfo ? $imageInfo[0] . 'x' . $imageInfo[1] : null;
return [
'name' => $file['name'],
'filename' => $filename,
'path' => $fullPath,
'size' => $file['size'],
'type' => $file['type'],
'dimensions' => $dimensions,
'uploaded_at' => now()->toISOString()
];
}
public function render()
{
return $this->view('blog::components.image-upload', [
'images' => $this->images,
'max_files' => $this->maxFiles,
'allowed_types' => implode(', ', array_map(function($type) {
return str_replace('image/', '.', $type);
}, $this->allowedTypes)),
'max_file_size' => $this->formatFileSize($this->maxFileSize)
]);
}
protected function formatFileSize($bytes)
{
$units = ['B', 'KB', 'MB', 'GB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= pow(1024, $pow);
return round($bytes, 2) . ' ' . $units[$pow];
}
}Шаблон загрузки изображений
{{-- resources/views/components/image-upload.blade.php --}}
<div class="image-upload-component">
{{-- Загруженные изображения --}}
@if(count($images) > 0)
<div class="uploaded-images mb-3">
<h6>{{ __('blog.uploaded_images') }} ({{ count($images) }}/{{ $max_files }})</h6>
<div class="images-grid">
@foreach($images as $index => $image)
<div class="image-item">
<div class="image-preview">
<img src="{{ asset($image['path']) }}"
alt="{{ $image['name'] }}"
class="img-thumbnail">
<button type="button"
class="btn btn-danger btn-sm remove-btn"
yoyo:click="removeImage({{ $index }})"
title="{{ __('blog.remove_image') }}">
<x-icon path="times" />
</button>
</div>
<div class="image-info small text-muted">
<div>{{ Str::limit($image['name'], 20) }}</div>
@if($image['dimensions'])
<div>{{ $image['dimensions'] }}</div>
@endif
<div>{{ number_format($image['size'] / 1024, 1) }} KB</div>
</div>
</div>
@endforeach
</div>
</div>
@endif
{{-- Зона загрузки --}}
<div class="upload-zone"
hx-post="{{ route('blog.images.upload') }}"
hx-target="#upload-result"
hx-swap="innerHTML">
<input type="file"
name="images[]"
id="image-input"
multiple
accept="{{ $allowed_types }}"
style="display: none;">
<div class="upload-area" onclick="document.getElementById('image-input').click()">
<div class="upload-icon">
<x-icon path="cloud-upload-alt" class="fa-3x text-muted" />
</div>
<div class="upload-text">
<h6>{{ __('blog.drop_images_here') }}</h6>
<p class="text-muted small">
{{ __('blog.or_click_to_select') }}<br>
{{ __('blog.max_files') }}: {{ $max_files }}<br>
{{ __('blog.allowed_types') }}: {{ $allowed_types }}<br>
{{ __('blog.max_size') }}: {{ $max_file_size }}
</p>
</div>
</div>
<div id="upload-result"></div>
</div>
</div>
@push('styles')
<style>
.upload-zone {
border: 2px dashed #dee2e6;
border-radius: 0.375rem;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
}
.upload-zone:hover {
border-color: #0d6efd;
background-color: #f8f9fa;
}
.upload-area {
pointer-events: none;
}
.images-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
}
.image-item {
position: relative;
}
.image-preview {
position: relative;
aspect-ratio: 1;
overflow: hidden;
border-radius: 0.375rem;
}
.image-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.remove-btn {
position: absolute;
top: 0.25rem;
right: 0.25rem;
padding: 0.125rem 0.25rem;
border-radius: 50%;
width: 1.5rem;
height: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
}
</style>
@endpush
@push('scripts')
<script>
// Drag and drop functionality
document.addEventListener('DOMContentLoaded', function() {
const uploadZone = document.querySelector('.upload-zone');
const fileInput = document.getElementById('image-input');
// Drag and drop events
uploadZone.addEventListener('dragover', function(e) {
e.preventDefault();
this.classList.add('dragover');
});
uploadZone.addEventListener('dragleave', function(e) {
e.preventDefault();
this.classList.remove('dragover');
});
uploadZone.addEventListener('drop', function(e) {
e.preventDefault();
this.classList.remove('dragover');
const files = Array.from(e.dataTransfer.files);
handleFiles(files);
});
// File input change
fileInput.addEventListener('change', function(e) {
const files = Array.from(e.target.files);
handleFiles(files);
});
function handleFiles(files) {
// Filter image files
const imageFiles = files.filter(file => file.type.startsWith('image/'));
if (imageFiles.length === 0) {
showNotification('{{ __('blog.no_image_files_selected') }}', 'warning');
return;
}
// Upload files using HTMX
const formData = new FormData();
imageFiles.forEach(file => {
formData.append('images[]', file);
});
htmx.ajax('POST', '{{ route('blog.images.upload') }}', {
body: formData,
target: '#upload-result',
swap: 'innerHTML'
});
}
});
// Обработка событий компонента
document.addEventListener('images-uploaded', function(event) {
const data = event.detail;
showNotification(`{{ __('blog.images_uploaded') }}: ${data.uploaded.length}`, 'success');
});
document.addEventListener('image-removed', function(event) {
const data = event.detail;
showNotification('{{ __('blog.image_removed') }}', 'info');
});
</script>
@endpushИнтеграция с модальными окнами
Модальный компонент
<?php
namespace Flute\Modules\Blog\Components;
use Flute\Core\Support\FluteComponent;
class ArticleQuickViewComponent extends FluteComponent
{
public ?int $articleId = null;
protected ?\Flute\Modules\Blog\Database\Entities\Article $article = null;
public function mount(int $articleId)
{
$this->articleId = $articleId;
$this->loadArticle();
}
public function like()
{
if (!user()->isLoggedIn()) {
$this->flashMessage(__('blog.login_required'), 'warning');
return;
}
// Логика лайка статьи
$this->toggleLike();
}
public function addToBookmarks()
{
if (!user()->isLoggedIn()) {
$this->flashMessage(__('blog.login_required'), 'warning');
return;
}
// Логика добавления в закладки
$this->toggleBookmark();
}
public function share($platform)
{
$url = route('blog.show', ['slug' => $this->article->slug]);
$title = $this->article->title;
$shareUrl = match($platform) {
'twitter' => "https://twitter.com/intent/tweet?url={$url}&text={$title}",
'facebook' => "https://www.facebook.com/sharer/sharer.php?u={$url}",
'telegram' => "https://t.me/share/url?url={$url}&text={$title}",
default => $url
};
$this->emitEvent('article-shared', [
'platform' => $platform,
'url' => $shareUrl
]);
}
protected function loadArticle()
{
$this->article = rep(\Flute\Modules\Blog\Database\Entities\Article::class)
->select()
->where('id', $this->articleId)
->load('author')
->load('category')
->load('tags')
->fetchOne();
}
protected function toggleLike()
{
// Логика лайка/дизлайка
}
protected function toggleBookmark()
{
// Логика закладок
}
public function render()
{
if (!$this->article) {
return $this->view('blog::components.article-not-found');
}
return $this->view('blog::components.article-quick-view', [
'article' => $this->article
]);
}
}Лучшие практики
Производительность
- Используйте кеширование для часто запрашиваемых данных
- Минифицируйте HTML, CSS и JavaScript
- Оптимизируйте изображения перед загрузкой
- Используйте ленивую загрузку для больших списков
Безопасность
- Валидируйте все входные данные на сервере
- Используйте CSRF токены для форм
- Проверяйте права доступа перед выполнением действий
- Очищайте пользовательский ввод от вредоносного кода
Доступность
- Добавьте ARIA-атрибуты для скрин-ридеров
- Обеспечьте навигацию с клавиатуры
- Добавьте альтернативные тексты для изображений
- Используйте семантические теги HTML
Поддерживаемость
- Разделяйте логику на небольшие компоненты
- Используйте события для связи между компонентами
- Документируйте API компонентов
- Пишите тесты для критической функциональности
HTMX и Yoyo компоненты позволяют создавать современные, интерактивные веб-приложения с минимальным количеством JavaScript кода, обеспечивая высокую производительность и отличный пользовательский опыт.