Олег Марков
Создание компонента component - практическое руководство
Введение
Создание компонента (component) — одна из базовых задач во фронтенд‑разработке. Почти любой современный интерфейс строится из независимых, переиспользуемых блоков, которые можно свободно комбинировать: кнопки, модальные окна, формы, карточки, таблицы и так далее.
Когда вы только начинаете, может казаться, что компонент — это просто кусок кода с версткой. Но как только приложение начинает расти, становится важным то, как именно вы проектируете компоненты: какие входные параметры они принимают, как управляют состоянием, как общаются с другими частями системы.
В этой статье я покажу вам на конкретных примерах, как:
- спроектировать компонент до того, как вы напишете код
- реализовать его на примере React (как самого популярного подхода к компонентам)
- разделить логику и представление
- настроить управление состоянием
- обеспечить переиспользуемость и предсказуемое поведение
- протестировать и документировать компонент
Все примеры будут на JavaScript с использованием React, но сами принципы одинаково полезны и для Vue, Svelte, Angular или Web Components. Смотрите, я буду пояснять не только «как сделать», но и «почему именно так».
Что такое компонент и из чего он состоит
Концепция компонента
Компонент — это самодостаточная часть интерфейса, у которой есть:
- Явное назначение (что он делает)
- Граница ответственности (что он не делает)
- Публичный интерфейс (как его использовать)
- Внутреннее устройство (как он это делает)
Давайте сформулируем это более структурно.
Основные элементы компонента
У любого компонента есть несколько ключевых частей:
- Входные данные (props, параметры)
- Внутреннее состояние (state, реактивные переменные)
- Шаблон / разметка (view, JSX, template)
- Логика (обработчики событий, вычисления)
- Выходные события (колбэки, кастомные события)
- Статическое поведение (стили, размеры, визуальное оформление)
Как видите, у компонента есть и «что снаружи видно», и «что внутри происходит». Очень важно не смешивать эти уровни без необходимости.
Планирование компонента перед реализацией
Прежде чем мы начнем писать код, имеет смысл коротко «спроектировать» компонент. Пусть это будет простой, но показательный пример — кнопка Button.
Шаг 1. Определяем назначение
Допустим, нам нужна кнопка:
- которая вызывает действие по клику
- может быть в нескольких визуальных вариантах (primary, secondary, danger)
- может быть заблокирована (disabled)
- может показывать индикатор загрузки (loading)
Слишком простые решения часто заканчиваются тем, что через пару месяцев компонент перестает устраивать, и вы начинаете «наращивать костыли». Лучше сразу выделить ключевые сценарии.
Шаг 2. Формируем публичный интерфейс
Здесь я размещаю список возможных входных параметров:
variant— тип кнопки (primary, secondary, danger)disabled— флаг отключенияloading— флаг загрузкиonClick— обработчик кликаchildrenилиlabel— текст содержимого
То есть внешний интерфейс примерно такой:
Button(props):
- variant: "primary" | "secondary" | "danger"
- disabled: boolean
- loading: boolean
- onClick: function
- children: ReactNode
Сейчас нам важно именно это — как компонент будет выглядеть для потребителя.
Шаг 3. Решаем, нужно ли внутреннее состояние
Для Button внутреннее состояние не нужно: все данные приходят снаружи. Это управляемый компонент, поведение которого полностью задается через props.
Хорошее правило:
Если можно обойтись без внутреннего состояния — обходитесь. Это делает компонент проще и предсказуемее.
Создание простого функционального компонента (React пример)
Теперь вы увидите, как это выглядит в коде. Начнем с минимальной версии кнопки.
Базовый каркас компонента
// Импортируем React, чтобы использовать JSX и функциональные компоненты
import React from "react";
// Определяем компонент Button как функцию
function Button(props) {
// Деструктурируем нужные пропсы для удобства
const { variant = "primary", disabled = false, onClick, children } = props;
// Формируем CSS класс на основе варианта кнопки
const className = `btn btn-${variant}`;
return (
// Передаем вычисленный класс и флаг disabled в HTML кнопку
<button
type="button"
className={className}
disabled={disabled}
onClick={onClick} // Вызываем обработчик, который передал родитель
>
{children /* Отображаем содержимое, переданное в компонент */}
</button>
);
}
// Экспортируем компонент, чтобы можно было использовать его в других файлах
export default Button;
Обратите внимание: здесь нет внутреннего состояния. Вся «умность» снаружи. Это очень хороший старт для большинства UI-компонентов.
Использование компонента Button
Давайте посмотрим, что происходит в следующем примере:
// Импортируем компонент Button
import Button from "./Button";
function App() {
// Обработчик клика по кнопке
const handleSave = () => {
// Здесь может быть логика сохранения данных
console.log("Сохранение...");
};
return (
<div>
{/* Кнопка с основным стилем */}
<Button variant="primary" onClick={handleSave}>
Сохранить
</Button>
{/* Отключенная кнопка без обработчика */}
<Button variant="secondary" disabled>
Недоступно
</Button>
</div>
);
}
export default App;
Здесь мы уже видим базовый принцип: компонент — это «чёрный ящик» с понятным набором входных параметров. Вы не думаете о том, как он устроен внутри, только о том, какие props передать.
Добавление гибкости и расширяемости компонента
Очень быстро появляется необходимость расширить поведение компонента. Давайте разберемся на примере с состоянием загрузки.
Поддержка состояния loading
Смотрите, я покажу вам, как это работает, если мы добавим состояние загрузки, контролируемое снаружи:
import React from "react";
function Button(props) {
const {
variant = "primary",
disabled = false,
loading = false, // Новый пропс - состояние загрузки
onClick,
children,
...rest // Остальные пропсы пробрасываем "как есть"
} = props;
const className = `btn btn-${variant}`;
// Оборачиваем исходный обработчик клика
const handleClick = (event) => {
// Если кнопка в состоянии disabled или loading - пропускаем клик
if (disabled || loading) {
return;
}
// Если обработчик передан - вызываем его
if (typeof onClick === "function") {
onClick(event);
}
};
return (
<button
type="button"
className={className}
disabled={disabled || loading} // Блокируем кнопку при загрузке
onClick={handleClick}
{...rest} // Дополнительные HTML атрибуты типа aria-label и так далее
>
{loading ? (
// Показываем индикатор загрузки, если loading === true
<span className="spinner">
{/* Здесь может быть иконка или SVG */}
Загрузка...
</span>
) : (
// В обычном состоянии показываем переданное содержимое
children
)}
</button>
);
}
export default Button;
Мы добавили:
- новый проп
loading - защиту от кликов в состоянии
loading - условный рендеринг содержимого
Так компонент стал чуть умнее, но остался управляемым — состояние по‑прежнему задается родителем.
Пример использования с внешним состоянием
import React, { useState } from "react";
import Button from "./Button";
function SaveForm() {
// Внешнее состояние загрузки, управляется родителем
const [isSaving, setIsSaving] = useState(false);
const handleSave = async () => {
// Устанавливаем состояние загрузки в true
setIsSaving(true);
try {
// Эмулируем асинхронный запрос
await new Promise((resolve) => setTimeout(resolve, 2000));
console.log("Данные сохранены");
} finally {
// В любом случае возвращаем состояние загрузки в false
setIsSaving(false);
}
};
return (
<div>
{/* Передаем состояние загрузки в кнопку */}
<Button variant="primary" loading={isSaving} onClick={handleSave}>
Сохранить
</Button>
</div>
);
}
export default SaveForm;
Такой подход важен: компонент остается «глупым» в хорошем смысле — он не знает, откуда берется loading. Ему просто говорят: «Сейчас мы загружаем данные, веди себя вот так».
Управляемые и неуправляемые компоненты
Теперь давайте перейдем к следующему шагу и рассмотрим более сложный тип компонента — например, текстовое поле ввода Input. Здесь часто возникает вопрос: где хранить введенное пользователем значение?
Управляемый компонент
Управляемый компонент получает значение и колбэк для изменения этого значения. Он ничего не хранит внутри.
function Input({ value, onChange, ...rest }) {
// Обработчик изменения значения поля ввода
const handleChange = (event) => {
// Если обработчик передан - вызываем его с новым значением
if (typeof onChange === "function") {
onChange(event.target.value);
}
};
return (
<input
{...rest} // Пробрасываем остальные HTML атрибуты
value={value} // Значение приходит снаружи
onChange={handleChange}
/>
);
}
export default Input;
Использование:
import React, { useState } from "react";
import Input from "./Input";
function LoginForm() {
// Состояние для логина
const [login, setLogin] = useState("");
return (
<div>
{/* Управляемое поле ввода */}
<Input
type="text"
placeholder="Логин"
value={login} // Привязываем значение к состоянию
onChange={setLogin} // Передаем сеттер как обработчик изменений
/>
</div>
);
}
export default LoginForm;
Обратите внимание: компонент Input не знает, где и как хранится значение. Он только «транслирует» изменения наружу.
Неуправляемый компонент
Иногда удобнее, чтобы компонент сам хранил свое состояние. Давайте посмотрим пример:
import React, { useState } from "react";
function InputUncontrolled({ defaultValue = "", onChange, ...rest }) {
// Локальное состояние значения поля
const [value, setValue] = useState(defaultValue);
const handleChange = (event) => {
const newValue = event.target.value;
// Обновляем локальное состояние
setValue(newValue);
// Если передан внешний обработчик, уведомляем о смене значения
if (typeof onChange === "function") {
onChange(newValue);
}
};
return (
<input
{...rest}
value={value} // Используем внутреннее состояние
onChange={handleChange}
/>
);
}
export default InputUncontrolled;
Здесь компонент сам контролирует свое значение, но при этом может уведомлять родителя. Такой подход полезен, когда:
- родителю не нужно постоянно знать текущее значение
- вы хотите упростить использование компонента «из коробки»
Смешанный подход
Во многих реальных библиотеках используется паттерн: если передан проп value, компонент работает как управляемый; если нет — как неуправляемый.
import React, { useState } from "react";
function SmartInput({ value, defaultValue = "", onChange, ...rest }) {
// Локальное состояние используется, только если value не передан
const [innerValue, setInnerValue] = useState(defaultValue);
// Вычисляем, какое значение сейчас отображать
const isControlled = value !== undefined;
const currentValue = isControlled ? value : innerValue;
const handleChange = (event) => {
const newValue = event.target.value;
// Если компонент неуправляемый - обновляем локальное состояние
if (!isControlled) {
setInnerValue(newValue);
}
// В любом случае вызываем внешний обработчик, если он есть
if (typeof onChange === "function") {
onChange(newValue);
}
};
return (
<input
{...rest}
value={currentValue}
onChange={handleChange}
/>
);
}
export default SmartInput;
Такой паттерн делает компонент гибким, но требует аккуратной реализации, чтобы не было неожиданных переходов между режимами.
Разделение логики и представления: контейнер и презентационный компонент
Когда компонент начинает «обрастать» логикой (запросы к API, валидация, сложные вычисления), полезно разделять:
- презентационный компонент — отвечает за верстку и внешний вид
- контейнерный компонент — отвечает за бизнес‑логику и данные
Давайте разберемся на примере компонента UserCard, который показывает информацию о пользователе.
Презентационный компонент UserCardView
// Компонент отвечает только за отображение данных пользователя
function UserCardView({ user, loading, error, onReload }) {
// Если идет загрузка - показываем скелетон или текст
if (loading) {
return <div className="user-card user-card--loading">Загрузка пользователя...</div>;
}
// Если произошла ошибка - показываем сообщение и кнопку перезагрузки
if (error) {
return (
<div className="user-card user-card--error">
<p>Не удалось загрузить данные пользователя</p>
<button type="button" onClick={onReload}>
Повторить
</button>
</div>
);
}
// Если данных нет - можно показать заглушку
if (!user) {
return <div className="user-card user-card--empty">Нет данных пользователя</div>;
}
// Основной вариант отображения
return (
<div className="user-card">
<h2 className="user-card__name">{user.name}</h2>
<p className="user-card__email">{user.email}</p>
<p className="user-card__role">{user.role}</p>
</div>
);
}
export default UserCardView;
Этот компонент не знает:
- откуда берутся данные
user - как устроен запрос к серверу
- как обрабатывается ошибка на уровне приложения
Его задача одна — красиво и корректно отобразить то, что ему передали.
Контейнерный компонент UserCard
Теперь давайте посмотрим на контейнер:
import React, { useEffect, useState } from "react";
import UserCardView from "./UserCardView";
// Функция-обертка для запроса пользователя
async function fetchUser(userId) {
// Здесь вы можете использовать fetch, axios или любую другую библиотеку
const response = await fetch(`/api/users/${userId}`);
// Проверяем статус ответа
if (!response.ok) {
// Если ответ не 2xx - выбрасываем ошибку
throw new Error("Ошибка загрузки пользователя");
}
// Парсим JSON и возвращаем объект пользователя
return response.json();
}
function UserCard({ userId }) {
// Локальное состояние для данных пользователя
const [user, setUser] = useState(null);
// Флаг загрузки
const [loading, setLoading] = useState(false);
// Объект ошибки
const [error, setError] = useState(null);
const loadUser = async () => {
setLoading(true); // Включаем индикатор загрузки
setError(null); // Сбрасываем предыдущую ошибку
try {
const data = await fetchUser(userId); // Загружаем данные
setUser(data); // Сохраняем пользователя
} catch (err) {
setError(err); // Сохраняем ошибку
} finally {
setLoading(false); // В любом случае снимаем флаг загрузки
}
};
// Загружаем пользователя при первом рендере и при изменении userId
useEffect(() => {
loadUser();
}, [userId]);
return (
<UserCardView
user={user}
loading={loading}
error={error}
onReload={loadUser} // Передаем колбэк для перезагрузки
/>
);
}
export default UserCard;
Так мы отделили:
- бизнес‑логику (контейнер)
- интерфейс (view)
Этот паттерн особенно полезен, когда вы создаете компоненты, которые могут переиспользоваться в разных контекстах (например, карточка пользователя в разных разделах приложения).
Переиспользуемость и API компонента
Переиспользуемый компонент должен иметь продуманный API. Давайте на конкретных принципах разберем, что это значит.
Явные и предсказуемые пропсы
Старайтесь:
- давать понятные, самодокументирующиеся имена пропсам
- не передавать в пропс «много всего сразу»
- избегать «магических» значений (строк, чисел без описания)
Например, вместо:
// Неудачный пример
<Card mode="1" />
Лучше:
// Более понятный пример
<Card variant="outlined" />
Хорошей практикой будет использовать перечисления (enum) или PropTypes (в React), чтобы явно задать возможные значения.
Проброс дополнительных атрибутов
Очень полезный прием — передавать «остальные» пропсы дальше к корневому элементу. Вы уже видели его ранее:
function Button({ variant = "primary", ...rest }) {
const className = `btn btn-${variant}`;
return (
<button
type="button"
className={className}
{...rest} // Позволяет передать onMouseEnter, data-атрибуты и так далее
/>
);
}
Так компонент остается гибким: вы можете добавлять поведение «точечно», не изменяя сам компонент.
Слоты и переопределение частей компонента
Иногда вам нужно не просто контролировать текст, а полностью менять, что будет внутри. В React это делается через children и дополнительные «слоты» (рендер‑пропсы).
Пример: компонент Modal, который принимает заголовок, тело и футер.
function Modal({ isOpen, title, children, footer }) {
if (!isOpen) {
// Если модальное окно закрыто - ничего не рендерим
return null;
}
return (
<div className="modal">
<div className="modal__overlay" />
<div className="modal__content">
<div className="modal__header">
<h2 className="modal__title">{title}</h2>
</div>
<div className="modal__body">
{children /* Основное содержимое модального окна */}
</div>
{footer && (
<div className="modal__footer">
{footer /* Кастомный футер, если передан */}
</div>
)}
</div>
</div>
);
}
Использование:
<Modal
isOpen={isOpen}
title="Удаление записи"
footer={
<div>
<Button variant="secondary" onClick={handleCancel}>
Отмена
</Button>
<Button variant="danger" onClick={handleConfirm}>
Удалить
</Button>
</div>
}
>
<p>Вы уверены, что хотите удалить эту запись</p>
</Modal>
Такой подход делает компонент модального окна универсальным и расширяемым.
Работа со стилями в компонентах
Стили — важная часть компонента. Давайте коротко разберем основные подходы.
Использование CSS классов
Самый простой вариант — обычные CSS классы:
function Badge({ color = "gray", children }) {
const className = `badge badge-${color}`;
return <span className={className}>{children}</span>;
}
CSS:
/* Базовый стиль бейджа */
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
}
/* Вариант цвета - серый */
.badge-gray {
background-color: #f0f0f0;
color: #333;
}
/* Вариант цвета - зеленый */
.badge-green {
background-color: #d1fae5;
color: #065f46;
}
Плюсы:
- понятно новичкам
- легко переопределять стили
- не привязаны к конкретной технологии
Минусы:
- глобальность (конфликты имен)
- сложнее поддерживать большие системы
CSS Modules / CSS-in-JS / Tailwind
В реальных проектах используются более продвинутые техники, но принцип для компонента один:
- стили должны быть привязаны к компоненту или к дизайн‑системе
- поведение компонента должно быть предсказуемым, независимо от места использования
Главное — не смешивать ответствености компонента с «магическими» стилями, которые приходят извне и полностью меняют поведение.
Обработка событий и использование колбэков
Компонент редко существует сам по себе — он должен «общаться» с внешним миром. Для этого используются колбэки.
Базовый паттерн колбэка
function Toggle({ checked, onChange }) {
const handleClick = () => {
// Инвертируем текущее значение и сообщаем наружу
if (typeof onChange === "function") {
onChange(!checked);
}
};
return (
<button type="button" onClick={handleClick}>
{/* Показываем состояние в UI */}
{checked ? "Включено" : "Выключено"}
</button>
);
}
Использование:
import React, { useState } from "react";
import Toggle from "./Toggle";
function Settings() {
const [notificationsEnabled, setNotificationsEnabled] = useState(false);
return (
<div>
<Toggle
checked={notificationsEnabled}
onChange={setNotificationsEnabled}
/>
</div>
);
}
Компонент Toggle не знает, что он включает или выключает — он просто сообщает о смене значения. Это безопасный и гибкий паттерн.
События с дополнительными данными
Иногда нужно передавать не просто новое значение, а дополнительный контекст. Покажу вам, как это реализовано на практике.
function ListItem({ id, label, onSelect }) {
const handleClick = () => {
if (typeof onSelect === "function") {
// Передаем ID и сам объект события (по желанию)
onSelect({ id });
}
};
return (
<li onClick={handleClick}>
{label}
</li>
);
}
Использование:
function List({ items, onSelectItem }) {
return (
<ul>
{items.map((item) => (
<ListItem
key={item.id}
id={item.id}
label={item.label}
onSelect={onSelectItem}
/>
))}
</ul>
);
}
// В родителе
<List
items={items}
onSelectItem={({ id }) => {
console.log("Выбран элемент", id);
}}
/>
Очень важно не «привязывать» компонент к конкретной бизнес‑логике. Он не должен сам, например, ходить в API при клике (если это не строго его предназначение).
Тестирование компонента
Компонент считается полноценным, когда его поведение покрыто тестами. Давайте коротко разберем, что стоит проверять.
Что обычно тестируют
- корректный рендер (что показывается по умолчанию)
- рендер в разных состояниях (variant, disabled, loading)
- реакция на события (клик, ввод)
- корректную работу колбэков
- отсутствие побочных эффектов
Пример теста (React Testing Library)
Давайте посмотрим, как это выглядит на примере кнопки.
// Импортируем необходимые функции для тестирования
import { render, screen, fireEvent } from "@testing-library/react";
import Button from "./Button";
test("отображает текст кнопки", () => {
// Рендерим компонент Button с текстом "Сохранить"
render(<Button>Сохранить</Button>);
// Проверяем, что текст появился в документе
const buttonElement = screen.getByText("Сохранить");
expect(buttonElement).toBeInTheDocument();
});
test("вызывает обработчик клика", () => {
// Создаем мок-функцию для отслеживания вызова
const handleClick = jest.fn();
// Рендерим компонент с обработчиком
render(<Button onClick={handleClick}>Нажать</Button>);
// Ищем кнопку по тексту
const buttonElement = screen.getByText("Нажать");
// Эмулируем клик по кнопке
fireEvent.click(buttonElement);
// Проверяем, что обработчик был вызван ровно один раз
expect(handleClick).toHaveBeenCalledTimes(1);
});
test("не вызывает обработчик клика, если disabled", () => {
const handleClick = jest.fn();
// Рендерим отключенную кнопку
render(
<Button disabled onClick={handleClick}>
Нажать
</Button>
);
const buttonElement = screen.getByText("Нажать");
// Эмулируем клик
fireEvent.click(buttonElement);
// Убеждаемся, что обработчик не был вызван
expect(handleClick).not.toHaveBeenCalled();
});
Комментарии в коде помогают лучше понять, что именно мы проверяем.
Документация и примеры использования компонента
Даже хорошо спроектированный компонент бесполезен, если его сложно понять и использовать. Здесь помогает документация.
Что лучше всего описывать
- Краткое назначение компонента
- Обязательные и необязательные пропсы
- Типы пропсов и значение по умолчанию
- Примеры использования в типичных сценариях
- Рекомендации и ограничения
Пример короткого описания для Button:
Button
Компонент кнопки, который используется для инициирования действий пользователем.
Props
variant— визуальный стиль кнопки (primary,secondary,danger), по умолчаниюprimarydisabled— отключает кнопку, по умолчаниюfalseloading— включает состояние загрузки, блокирует клики, по умолчаниюfalseonClick— обработчик клика (функция)children— содержимое кнопки (обычно текст)
Пример
<Button variant="primary" onClick={handleSubmit}>
Отправить
</Button>
Хорошей практикой является использование Storybook или аналогичных инструментов для интерактивной документации компонентов.
Заключение
Создание компонента — это не только вопрос синтаксиса фреймворка. Важнее сама архитектура и дизайн API:
- сначала вы определяете назначение и границы компонента
- продумываете входные данные, события и состояние
- решаете, будет ли компонент управляемым или неуправляемым
- разделяете логику и отображение, когда компонент становится сложнее
- обеспечиваете возможность переиспользования через продуманный набор пропсов и слотов
- тестируете и документируете поведение
Если придерживаться этих принципов, ваши компоненты будут:
- предсказуемыми
- легко переиспользуемыми
- удобными для других разработчиков
- менее хрупкими по мере роста кода
Дальше вы можете развивать эту базу: подключать типизацию (TypeScript), строить дизайн‑систему, выделять компоненты в отдельные библиотеки. Но фундамент всегда один и тот же — хороший дизайн компонента как самостоятельной единицы интерфейса.
Частозадаваемые технические вопросы по теме и ответы
1. Как передать ссылку (ref) во внутренний DOM элемент компонента
Используйте forwardRef в React:
import React, { forwardRef } from "react";
const Input = forwardRef(function Input(props, ref) {
return (
<input
{...props}
ref={ref} // Пробрасываем ref во внутренний input
/>
);
});
export default Input;
Теперь родитель может получить доступ к реальному DOM:
const inputRef = useRef(null);
<Input ref={inputRef} />;
2. Как оптимизировать рендер компонента при частых обновлениях
Используйте React.memo и мемоизацию обработчиков:
const Button = React.memo(function Button(props) {
// Компонент будет перерисовываться только если изменились его пропсы
return <button {...props} />;
});
В родителе оборачивайте колбэки в useCallback, чтобы не создавать новую функцию при каждом рендере.
3. Как правильно типизировать компонент с пропсом children в TypeScript
Опишите интерфейс пропсов и используйте React.FC или явный тип:
import { ReactNode } from "react";
interface ButtonProps {
children: ReactNode;
onClick?: () => void;
}
function Button({ children, onClick }: ButtonProps) {
return <button onClick={onClick}>{children}</button>;
}
Так вы получите подсказки типов при использовании компонента.
4. Как сделать компонент доступным для скринридеров
Добавьте aria‑атрибуты и корректные роли:
function IconButton({ label, ...rest }) {
return (
<button
type="button"
aria-label={label} // Текст для скринридера
{...rest}
>
{/* Здесь может быть иконка без текста */}
<Icon />
</button>
);
}
Для сложных компонентов, например модалок, используйте role="dialog", aria-modal="true", связывайте заголовок через aria-labelledby.
5. Как переопределить стили компонента без его изменения
Добавьте проп className и применяйте его к корневому элементу:
function Card({ className = "", children }) {
return (
<div className={`card ${className}`}>
{children}
</div>
);
}
Теперь родитель может передать свои классы:
<Card className="card--highlighted">Текст</Card>
При необходимости можно также добавить проп style для инлайн‑стилей.
Постройте личный план изучения Vue до уровня Middle — бесплатно!
Vue — часть карты развития Frontend
100+ шагов развития
30 бесплатных лекций
300 бонусных рублей на счет
Бесплатные лекции
Все гайды по Vue
Лучшие курсы по теме

Vue 3 и Pinia
Антон Ларичев
TypeScript с нуля
Антон Ларичев