34. Полноценное приложение на Solid.js
В этом уроке мы строим Kanban-доску — реальное приложение, которое объединяет всё, что вы изучили. Это финальный урок курса, и здесь вы увидите, как все концепции работают вместе в одном production-ready решении.
Архитектура приложения
Заголовок раздела «Архитектура приложения»Хорошая архитектура Solid-приложения разделяет:
src/├── routes/ # Маршруты (SolidStart)│ ├── index.tsx # Главная страница / Канбан│ └── board/[id].tsx├── components/ # UI компоненты│ ├── Card.tsx│ ├── Column.tsx│ ├── Modal.tsx│ └── Toolbar.tsx├── stores/ # Глобальное состояние│ └── board.ts # createStore для канбана├── primitives/ # Кастомные примитивы│ ├── createDnd.ts # Drag-and-drop примитив│ └── createModal.ts└── types.ts # TypeScript типыТипизация данных
Заголовок раздела «Типизация данных»export interface Card { id: string; title: string; description: string; priority: 'low' | 'medium' | 'high'; tags: string[]; createdAt: number;}
export interface Column { id: string; title: string; color: string; cardIds: string[];}
export interface BoardStore { columns: Column[]; cards: Record<string, Card>;}Стор канбан-доски
Заголовок раздела «Стор канбан-доски»import { createStore, produce } from 'solid-js/store';import type { BoardStore, Card, Column } from '../types';
// Персистентный стор с localStoragefunction createBoardStore() { const saved = localStorage.getItem('kanban-board'); const initial: BoardStore = saved ? JSON.parse(saved) : { columns: [ { id: 'todo', title: 'К выполнению', color: '#2c67d5', cardIds: [] }, { id: 'progress', title: 'В работе', color: '#fb923c', cardIds: [] }, { id: 'done', title: 'Готово', color: '#34d399', cardIds: [] }, ], cards: {}, };
const [board, setBoard] = createStore<BoardStore>(initial);
// Авто-сохранение при изменениях createEffect(() => { localStorage.setItem('kanban-board', JSON.stringify(board)); });
const addCard = (columnId: string, data: Omit<Card, 'id' | 'createdAt'>) => { const id = crypto.randomUUID(); setBoard(produce(b => { b.cards[id] = { ...data, id, createdAt: Date.now() }; b.columns.find(c => c.id === columnId)?.cardIds.push(id); })); };
const moveCard = (cardId: string, fromColumnId: string, toColumnId: string) => { setBoard(produce(b => { const from = b.columns.find(c => c.id === fromColumnId); const to = b.columns.find(c => c.id === toColumnId); if (!from || !to) return; from.cardIds = from.cardIds.filter(id => id !== cardId); if (!to.cardIds.includes(cardId)) to.cardIds.push(cardId); })); };
const deleteCard = (cardId: string) => { setBoard(produce(b => { b.columns.forEach(col => { col.cardIds = col.cardIds.filter(id => id !== cardId); }); delete b.cards[cardId]; })); };
return { board, addCard, moveCard, deleteCard };}
export const boardStore = createBoardStore();Компонент карточки
Заголовок раздела «Компонент карточки»function KanbanCard(props: { card: Card; columnId: string; onEdit: () => void; onDelete: () => void; onMove: (toColumnId: string) => void;}) { const priorityColors = { low: '#34d399', medium: '#fb923c', high: '#f87171', };
return ( <div draggable onDragStart={e => e.dataTransfer!.setData('cardId', props.card.id)} class="card" > {/* Индикатор приоритета */} <div style={{ background: priorityColors[props.card.priority] }} class="priority-bar" />
<h4>{props.card.title}</h4>
<Show when={props.card.description}> <p>{props.card.description}</p> </Show>
<div class="tags"> <For each={props.card.tags}> {tag => <span class="tag">{tag}</span>} </For> </div>
<div class="card-actions"> <button onClick={props.onEdit}>✏️</button> <button onClick={props.onDelete}>🗑️</button> </div> </div> );}Компонент колонки
Заголовок раздела «Компонент колонки»function KanbanColumn(props: { column: Column; cards: Card[] }) { const [isDragOver, setIsDragOver] = createSignal(false);
const handleDrop = (e: DragEvent) => { e.preventDefault(); const cardId = e.dataTransfer?.getData('cardId'); if (cardId) { boardStore.moveCard(cardId, /* fromColumnId */, props.column.id); } setIsDragOver(false); };
return ( <div class="column" classList={{ 'drag-over': isDragOver() }} onDragOver={e => { e.preventDefault(); setIsDragOver(true); }} onDragLeave={() => setIsDragOver(false)} onDrop={handleDrop} > <div class="column-header" style={{ 'border-color': props.column.color }}> <h3>{props.column.title}</h3> <span class="badge">{props.cards.length}</span> </div>
<div class="cards-list"> <For each={props.cards}> {card => ( <KanbanCard card={card} columnId={props.column.id} onEdit={() => openEditModal(card)} onDelete={() => boardStore.deleteCard(card.id)} onMove={toColumnId => boardStore.moveCard(card.id, props.column.id, toColumnId)} /> )} </For> </div>
<button class="add-card-btn" onClick={() => openAddModal(props.column.id)} > + Добавить карточку </button> </div> );}Контекст и провайдер доски
Заголовок раздела «Контекст и провайдер доски»// Контекст позволяет любому компоненту в дереве получить доступ к storeconst BoardContext = createContext<ReturnType<typeof createBoardStore>>();
export const useBoardStore = () => { const ctx = useContext(BoardContext); if (!ctx) throw new Error('useBoardStore must be used within BoardProvider'); return ctx;};
function BoardProvider(props: { children: JSXElement }) { const store = createBoardStore(); return ( <BoardContext.Provider value={store}> {props.children} </BoardContext.Provider> );}
// Использование в Appfunction App() { return ( <BoardProvider> <KanbanBoard /> </BoardProvider> );}Оптимизация производительности
Заголовок раздела «Оптимизация производительности»// Мемоизация отфильтрованных карточекconst filteredCards = createMemo(() => { const search = searchQuery().toLowerCase(); if (!search) return boardStore.board.cards;
return Object.fromEntries( Object.entries(boardStore.board.cards).filter(([_, card]) => card.title.toLowerCase().includes(search) || card.tags.some(tag => tag.toLowerCase().includes(search)) ) );});
// createSelector для точечных обновлений колонокconst isColumnDragTarget = createSelector( () => dragTargetColumnId(), (columnId, targetId) => columnId === targetId);