Наше приложение будет собираться с помощью Vite. Инициализируем проект Vite+React+TypeScript:
$ npm create vite@latest react-svelte-mfe-manual
Need to install the following packages:
[email protected]
Ok to proceed? (y) y
> npx
> create-vite react-svelte-mfe-manual
│
◇ Select a framework:
│ React
│
◇ Select a variant:
│ TypeScript
│
◇ Use rolldown-vite (Experimental)?:
│ No
│
◇ Install with npm and start now?
│ Yes
│
Если в последней опции выбрано Yes, будет запущен Vite dev server, и в браузере по адресу http://localhost:5173 можно будет открыть наше приложение.
Зайдем в директорию созданного проекта:
cd react-svelte-mfe-manual
Запустим сервер Vite вручную:
npm run dev
Установим react-router:
npm i react-router
Теперь создадим компонент услуги (музыкальный трек), компоненты страниц списка услуг и одной услуги, их соответствующие CSS файлы:
// src/components/TrackCard.tsx
import { Link } from "react-router";
import "./TrackCard.css";
export interface TrackProps {
id: number;
title: string;
album: string;
author: string;
page?: "tracks" | "track";
}
export function TrackCard({ id, title, album, author, page }: TrackProps) {
return (
<div className="track-card">
<div className="track-card__cover"></div>
<div className="track-card__body">
<h2>{title}</h2>
<p>{album}</p>
<p>{author}</p>
{page === "tracks" && (
<Link to={`/tracks/${id}`}>
<button>Подробнее</button>
</Link>
)}
{page === "tracks" && (
<button onClick={() => alert("добавлено")}>В любимое</button>
)}
</div>
</div>
);
}
/* src/components/TrackCard.css */
.track-card {
width: 15rem;
background-color: darkgray;
border-radius: 8px;
overflow: hidden;
}
.track-card .track-card__cover {
width: 100%;
aspect-ratio: 1;
background-color: gray;
}
.track-card .track-card__body {
width: 100%;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.track-card .track-card__body * {
width: 100%;
margin: 0;
}
// src/pages/TracksPage.tsx
import { useState } from "react";
import { TrackCard, type TrackProps } from "../components/TrackCard";
import "./TracksPage.css";
import { Api } from "../api";
export function TracksPage() {
const [tracks, setTracks] = useState<TrackProps[]>();
const loadTracks = async () => {
setTracks(await Api.getTracks());
};
if (tracks === undefined) {
loadTracks();
}
return (
<>
<h1>Треки</h1>
<div className="tracks">
{tracks ? (
tracks.map((track) => (
<TrackCard {...track} page="tracks" key={track.id} />
))
) : (
<p>Загрузка...</p>
)}
</div>
</>
);
}
/* src/pages/TracksPage.css */
.tracks {
width: 100%;
margin-bottom: 2rem;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 2rem;
}
// src/pages/TrackPage.tsx
import { useState } from "react";
import { TrackCard, type TrackProps } from "../components/TrackCard";
import "./TrackPage.css";
import { useParams } from "react-router";
import { Api } from "../api";
export function TrackPage() {
const [track, setTrack] = useState<TrackProps>();
const { id } = useParams();
const loadTrack = async () => {
const idNum = Number(id);
setTrack(await Api.getTrackByID(idNum));
};
if (track === undefined) {
loadTrack();
}
return (
<div className="track">
{track ? <TrackCard {...track} page="track" /> : <p>Загрузка...</p>}
</div>
);
}
/* src/pages/TrackPage.css */
.track {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
Настроим роутер:
// src/App.tsx
import { BrowserRouter, Route } from "react-router";
import { TracksPage } from "./pages/TracksPage";
import { TrackPage } from "./pages/TrackPage";
import { Routes } from "react-router";
import "./App.css";
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/tracks" element={<TracksPage />} />
<Route path="/tracks/:id" element={<TrackPage />} />
</Routes>
</BrowserRouter>
);
}
export default App;
Изменим глобальные стили:
/* src/App.css */
#root {
width: 100%;
padding: 0 4rem;
}
#root * {
box-sizing: border-box;
}
Добавим модуль для работы с API:
// src/api.ts
import type { TrackProps } from "./components/TrackCard";
const API_BASE = "http://localhost:9000";
export const Api = {
async getTracks(): Promise<TrackProps[]> {
const response = await fetch(`${API_BASE}/tracks`);
return response.json();
},
async getTrackByID(id: number): Promise<TrackProps> {
const response = await fetch(`${API_BASE}/tracks/${id}`);
return response.json();
},
};
Создадим простой бэкенд на Express. Установим зависимости:
npm i express cors
Создадим скрипт бэкенда:
// backend.js
import express from "express";
import cors from "cors";
const app = express();
const PORT = 9000;
const TRACKS = [
{
id: 1,
title: "Blinding Lights",
album: "After Hours",
author: "The Weeknd",
},
{
id: 2,
title: "Shape of You",
album: "÷ (Divide)",
author: "Ed Sheeran",
},
{
id: 3,
title: "Hotel California",
album: "Hotel California",
author: "Eagles",
},
{
id: 4,
title: "Rolling in the Deep",
album: "21",
author: "Adele",
},
{
id: 5,
title: "Uptown Funk",
album: "Uptown Special",
author: "Mark Ronson ft. Bruno Mars",
},
{
id: 6,
title: "Levitating",
album: "Future Nostalgia",
author: "Dua Lipa",
},
{
id: 7,
title: "Hey Jude",
album: "Hey Jude",
author: "The Beatles",
},
];
app.use(cors());
app.get("/tracks", (req, res) => {
res.json(TRACKS);
});
app.get("/tracks/:id", (req, res) => {
const track = TRACKS.find((track) => track.id === Number(req.params.id));
if (track === undefined) {
res.sendStatus(404);
return;
}
res.json(track);
});
app.listen(PORT, () => {
console.log(`API listening on port ${PORT}`);
});
Запустим его:
node backend.js
Перейдем на http://localhost:5173/tracks и убедимся, что приложение работает корректно. Треки должны отображаться, и должен работать переход по кнопке "Подробнее" на страницу трека.
Установим Svelte, его плагины для Vite и eslint:
npm i svelte
npm i @sveltejs/vite-plugin-svelte eslint-plugin-svelte -D
Добавим установленные плагины в конфигурации Vite и eslint:
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { svelte } from "@sveltejs/vite-plugin-svelte";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), svelte()],
});
// eslint.config.js
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import svelte from "eslint-plugin-svelte";
import tseslint from "typescript-eslint";
import { defineConfig, globalIgnores } from "eslint/config";
export default defineConfig([
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx,svelte}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
svelte.configs.recommended,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
]);
Создадим примитивный Svelte компонент:
<!-- src/components/LikedIcon.svelte -->
<p>Привет от Svelte!</p>
Добавим его на страницу списка треков:
// src/pages/TracksPage.tsx
import { useLayoutEffect, useRef, useState } from "react";
import { TrackCard, type TrackProps } from "../components/TrackCard";
import "./TracksPage.css";
import { Api } from "../api";
import LikedIcon from "../components/LikedIcon.svelte";
import { mount, unmount } from "svelte";
export function TracksPage() {
const [tracks, setTracks] = useState<TrackProps[]>();
const likedIconRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const likedIcon = mount(LikedIcon, { target: likedIconRef.current! });
return () => {
unmount(likedIcon);
};
}, []);
const loadTracks = async () => {
setTracks(await Api.getTracks());
};
if (tracks === undefined) {
loadTracks();
}
return (
<>
<h1>Треки</h1>
<div className="tracks">
{tracks ? (
tracks.map((track) => (
<TrackCard {...track} page="tracks" key={track.id} />
))
) : (
<p>Загрузка...</p>
)}
</div>
<div ref={likedIconRef}></div>
</>
);
}
Мы импортировали компонент LikedIcon.
Затем создали пустой div, в который будет монтирован наш компонент. При желании можно использовать родительский компонент с детьми. Тогда Svelte компонент будет монтироваться в начало родителя, не мешая остальным детям. Однако текущий подход гораздо более читаемый.
Атрибут ref={likedIconRef} сохраняет ссылку на наш div в переменную likedIconRef. Эта переменная получена из хука useRef. Данный хук подобен useState с двумя основными отличиями. Во-первых, этот хук возвращает только одно значение - саму переменную, свойство .current которой мы можем свободно изменять. Во-вторых, изменение этой переменной не вызывает перерисовку компонента. useRef полезен, когда необходимо хранить состояние, не влияющее напрямую на внешний вид компонента. Если здесь использовать useState, возникнут проблемы с чтением состояния сразу после его изменения (setState) и появятся лишние перерисовки.
Само монтирование происходит в хуке useLayoutEffect. Его отличие от useEffect в том, что useEffect вызывается асинхронно после отрисовки браузером страницы, а useLayoutEffect - синхронно до отрисовки. Данный хук лучше использовать, когда необходимо получить данные из обновленных состояний/DOM и изменить DOM, не вызывая повторной отрисовки страницы.
Функция, возвращаемая из колбека useLayoutEffect работает аналогично таковой в useEffect: это функция очистки, вызывающаяся при каждом следующем вызове колбека useLayoutEffect и при финальном демонтировании компонента. В ней мы демонтируем Svelte компонент. Вы можете убрать эту функцию и увидеть, что Svelte компонентов будет два после отрисовки данных из API.
Начнем с добавления непосредственно иконки. Наша корзина - список любимых треков. Нужна иконка сердечка. Для удобного встраивания иконок на фронтенде существует библиотека иконок Lucide. Она имеет версии для всех популярных фреймворков, включая React и Svelte.
Установим Lucide для Svelte:
npm i @lucide/svelte
Теперь перейдем на сайт https://lucide.dev и в строку поиска введем "heart". Нажмем на иконку сердечка и внизу страницы на стрелочку кнопки "Copy ..." > "Copy Svelte". В буфере обмена будет готовый Svelte компонент с выбранной иконкой: <Heart />.
Теперь вокруг этой иконки наверстаем наш компонент:
<!-- src/components/LikedIcon.svelte -->
<script lang="ts">
import { Heart } from "@lucide/svelte";
import "./LikedIcon.css";
const count = 2;
</script>
<button
class="liked-icon"
onclick={() => alert(`У Вас ${count} любимых трека.`)}
>
<Heart />
{#if count > 0}
<span>{count}</span>
{/if}
</button>
/* src/components/LikedIcon.css */
.liked-icon {
position: fixed;
bottom: 2rem;
left: 2rem;
z-index: 10;
display: flex;
flex-direction: row;
gap: 0.5rem;
}
Страница списка треков должна выглядеть так:
Чтобы переходить между страницами в React, нужна функция navigate, получаемая из хука useNavigate. Но хуки нельзя размещать вне React компонентов. Если попытаться использовать хук в Svelte компоненте, в браузере мы получим соответствующую ошибку.
Чтобы обойти это ограничение, воспользуемся передачей props из React в Svelte компонент. Получим navigate в TracksPage и передадим в LikedIcon:
// src/pages/TracksPage.tsx
import { useLayoutEffect, useRef, useState } from "react";
import { TrackCard, type TrackProps } from "../components/TrackCard";
import "./TracksPage.css";
import { Api } from "../api";
import LikedIcon from "../components/LikedIcon.svelte";
import { mount, unmount } from "svelte";
import { useNavigate } from "react-router";
export function TracksPage() {
const [tracks, setTracks] = useState<TrackProps[]>();
const likedIconRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
useLayoutEffect(() => {
const likedIcon = mount(LikedIcon, {
target: likedIconRef.current!,
props: { navigate },
});
return () => {
unmount(likedIcon);
};
}, [navigate]);
// ...
Добавим переход на страницу и сделаем, чтобы при отсутствии любимых треков иконка была некликабельной и полупрозрачной:
<!-- src/components/LikedIcon.svelte -->
<script lang="ts">
import { Heart } from "@lucide/svelte";
import type { NavigateFunction } from "react-router";
import "./LikedIcon.css";
const { navigate }: { navigate: NavigateFunction } = $props();
const request_id = 1;
const count = 2;
</script>
<button
class="liked-icon"
class:liked-icon_disabled={count === 0}
onclick={() => navigate(`/liked/${request_id}`)}
>
<Heart />
{#if count > 0}
<span>{count}</span>
{/if}
</button>
/* src/components/LikedIcon.css */
/* ... */
.liked-icon_disabled {
pointer-events: none;
opacity: 0.5;
}
Чтобы не верстать страницу любимых треков, добавим немного кода напрямую в роутер:
// src/App.tsx
// ...
<Route path="/tracks" element={<TracksPage />} />
<Route path="/tracks/:id" element={<TrackPage />} />
<Route
path="/liked/:id"
element={<p>Здесь будут ваши любимые треки</p>}
/>
// ...
Теперь при нажатии на иконку любимых треков нас должно переводить на страницу с надписью "Здесь будут ваши любимые треки".
Добавим обработчик для /liked в скрипт бэкенда:
// backend.js
// ...
function getRandomInt(max) {
return Math.floor(Math.random() * max);
}
app.get("/liked", (req, res) => {
const count = getRandomInt(TRACKS.length);
res.json({
request_id: count > 0 ? 1 + getRandomInt(4) : -1,
count,
});
});
// ...
Далее добавим соответствующую функцию на фронтенд:
// src/api.ts
// ...
async getLiked(): Promise<{ request_id: number; count: number }> {
const response = await fetch(`${API_BASE}/liked`);
return response.json();
},
// ...
Подключим эту функцию к компоненту иконки:
<!-- src/components/LikedIcon.svelte -->
<script lang="ts">
// ...
import { Api } from "../api";
import { onMount } from "svelte";
const { navigate }: { navigate: NavigateFunction } = $props();
let request_id = $state(-1);
let count = $state(0);
onMount(async () => {
({ request_id, count } = await Api.getLiked());
});
</script>
<!-- ... -->
Теперь при каждой загрузке страницы списка треков мы будем получать с бэкенда случайную информацию о любимых треках.