Олег Марков
FSD для Svelte с использованием библиотеки svelte-fsd
Введение
Feature Sliced Design (FSD) за последние годы стал одним из самых популярных подходов к архитектуре фронтенда. Он помогает упорядочить код, уменьшить связность между модулями, упростить поддержку и развитие проекта. Если вы уже работали с FSD в React, то, скорее всего, видели множество реализаций на базе React и Redux. Но со Svelte все немного иначе, и здесь на помощь приходит библиотека svelte-fsd.
Задача svelte-fsd — не «привязать» вас к жесткому фреймворку, а дать удобные соглашения, вспомогательные функции и структуры, которые упрощают применение FSD в Svelte-приложениях. Смотрите, я покажу вам, как на практике использовать этот подход: от структуры каталогов до конкретных примеров кода.
В статье вы разберете:
- какие уровни и сущности выделяются в FSD для Svelte;
- как svelte-fsd помогает организовывать импорт и изоляцию слоев;
- как структурировать страницы, виджеты, фичи и сущности в Svelte;
- как использовать сторы, компоненты и контракты между слоями;
- как избежать типичных ошибок при внедрении FSD в существующий проект.
Базовая идея FSD в контексте Svelte
Что такое FSD в общем виде
FSD (Feature Sliced Design) — это архитектурный подход, в котором приложение делится не на слои по технологиям (components, services, utils), а на слои и срезы по домену:
- слои (layers) — крупные зоны ответственности;
- срезы (slices) — конкретные доменные области внутри слоев;
- сегменты (segments) — типы модулей внутри среза (ui, model, lib и т.д.).
Основная мысль: вы проектируете приложение вокруг бизнес-функций (фич, сущностей, страниц), а не вокруг технических деталей. Это облегчает модульность и контроль зависимости между частями системы.
Как это ложится на Svelte
Svelte сам по себе сильно отличатся от React: нет JSX, компоненты компилируются, сторы встроены. Поэтому реализация FSD для Svelte учитывает особенности:
- нет привычных хуков вроде useEffect, вместо этого — реактивные декларации и сторы;
- маршрутизация обычно реализуется внешними библиотеками или самостоятельно;
- стили часто пишут прямо в компоненте, но можно использовать SCSS, Tailwind и т.д.
Библиотека svelte-fsd не навязывает конкретный роутер или стор-менеджер, а помогает:
- выстроить структуру папок;
- унифицировать точки входа в слои;
- стандартизировать импорт между слоями;
- использовать вспомогательные функции и типы (если вы с TypeScript).
Структура проекта по FSD с svelte-fsd
Давайте разберемся на примере базовой структуры приложения на Svelte с применением FSD и svelte-fsd.
Базовая структура слоев
Обычно структура на уровне src может выглядеть так:
- app — корневые вещи приложения;
- pages — страницы;
- widgets — крупные блоки интерфейса;
- features — функциональные фичи;
- entities — доменные сущности;
- shared — переиспользуемые утилиты, дизайн-система, API и т.п.
Один из типичных вариантов для Svelte:
- src
- app
- index.svelte
- providers
- routing
- pages
- widgets
- features
- entities
- shared
- app
Svelte-fsd предлагает придерживаться именно такой иерархии, чтобы было проще ориентироваться и контролировать зависимости.
Срезы и сегменты
Внутри каждого слоя вы создаете срезы (slices). Например:
entities
- user
- product
features
- auth
- add-to-cart
Каждый срез, в свою очередь, делится на сегменты. Чаще всего используют:
- ui — компоненты;
- model — сторы, бизнес-логика;
- lib — вспомогательные функции;
- config — конфиги (если нужно);
- api — запросы к backend (иногда выносят в shared/api).
Для примера возьмем сущность user:
- src
- entities
- user
- ui
- model
- lib
- user
- entities
Теперь вы увидите, как это выглядит в коде с точки зрения svelte-fsd.
Использование svelte-fsd на практике
Установка и базовая настройка
Предположим, у вас уже есть проект на Svelte (например, созданный через SvelteKit или Vite + Svelte).
Установка библиотеки (псевдокоманда, название может отличаться, ориентируйтесь на документацию репозитория svelte-fsd):
# Устанавливаем библиотеку svelte-fsd
npm install svelte-fsd
Чаще всего далее вам нужно:
- Настроить alias-ы в проекте (чтобы импортировать слои по понятным путям).
- Определить базовые правила слоёв и использовать вспомогательные функции библиотеки.
Пример настройки alias в Vite + Svelte (файл vite.config.js):
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import path from 'path';
export default defineConfig({
plugins: [svelte()],
resolve: {
alias: {
// Уровень приложения
'@app': path.resolve(__dirname, 'src/app'),
// Страницы
'@pages': path.resolve(__dirname, 'src/pages'),
// Виджеты
'@widgets': path.resolve(__dirname, 'src/widgets'),
// Фичи
'@features': path.resolve(__dirname, 'src/features'),
// Сущности
'@entities': path.resolve(__dirname, 'src/entities'),
// Общие модули
'@shared': path.resolve(__dirname, 'src/shared'),
},
},
});
// Здесь мы задаем алиасы для каждого слоя // Это упростит импорт и сделает архитектуру более прозрачной
Теперь в коде вы сможете писать:
// Импорт компонента сущности user по FSD-алису
import UserCard from '@entities/user/ui/UserCard.svelte';
Индексные файлы как контракты слоев
Важная практика FSD — не открывать весь срез наружу, а явно определять публичный API среза через index-файлы. Svelte-fsd поддерживает этот подход.
Пример для сущности user:
Структура:
- src
- entities
- user
- ui
- UserCard.svelte
- model
- store.ts
- index.ts
- ui
- user
- entities
Файл src/entities/user/index.ts:
// Публичный API сущности user
// Экспортируем UI-компонент карточки пользователя
export { default as UserCard } from './ui/UserCard.svelte';
// Экспортируем стор с данными пользователя
export { userStore } from './model/store';
// Можно экспортировать типы, если используется TypeScript
export type { User } from './model/types';
// Здесь мы объявляем, что из сущности user "видят" остальные слои // Внутренние детали (другие компоненты, вспомогательные функции) остаются скрыты
Теперь, чтобы использовать сущность user в другом месте:
// Импортируем только то, что явно разрешено публичным API
import { UserCard, userStore } from '@entities/user';
Svelte-fsd рекомендует всегда работать только через такие индексные файлы. Это помогает избежать хаоса в импортируемых модулях и облегчает рефакторинг.
Пример: сущность User с Svelte-сторами
Давайте посмотрим, что происходит в следующем примере. Создадим простую сущность user, которая хранит текущего пользователя и рендерит карточку.
Модель: сторы и бизнес-логика
Файл src/entities/user/model/store.ts:
import { writable, derived } from 'svelte/store';
// Тип пользователя
// В реальном проекте структура может быть сложнее
export type User = {
id: string;
name: string;
email: string;
};
// Создаем writable-стор для текущего пользователя
// null означает, что пользователь не авторизован
const currentUser = writable<User | null>(null);
// Экспортируем функции для управления стором
function setUser(user: User) {
// Устанавливаем текущего пользователя
currentUser.set(user);
}
function clearUser() {
// Сбрасываем текущего пользователя
currentUser.set(null);
}
// Создаем derived-стор для удобства
// isAuth будет true, если пользователь авторизован
const isAuth = derived(currentUser, ($currentUser) => !!$currentUser);
// Экспортируем объект userStore как публичный API модели
export const userStore = {
subscribe: currentUser.subscribe, // Позволяет подписываться на изменения
setUser,
clearUser,
isAuth,
};
// Здесь мы группируем работу с пользователем в одном месте // Компоненты будут использовать только userStore, не зная о внутренних деталях
UI: компонент карточки пользователя
Файл src/entities/user/ui/UserCard.svelte:
<script lang="ts">
import type { User } from '../model/store';
// Принимаем пользователя через проп
// Это позволяет использовать компонент как "глупый" компонент без привязки к сторам
export let user: User | null = null;
</script>
{#if user}
<!-- Отображаем данные пользователя, если он есть -->
<div class="user-card">
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
{:else}
<!-- Если пользователя нет, выводим заглушку -->
<div class="user-card user-card--empty">
<p>Пользователь не авторизован</p>
</div>
{/if}
<style>
/* Простейшие стили для иллюстрации */
.user-card {
border: 1px solid #ccc; /* Простая рамка вокруг карточки */
padding: 8px 12px; /* Внутренние отступы */
border-radius: 4px; /* Скругляем углы */
}
.user-card--empty {
opacity: 0.6; /* Делаем заглушку визуально менее заметной */
}
</style>
// В этом компоненте нет прямой работы со стором // Это упрощает тестирование и повторное использование
Далее вы можете создать отдельный «обвязочный» компонент (например, в features или widgets), который подключит userStore и передаст данные в UserCard.
Пример: фича авторизации (feature auth)
Теперь давайте перейдем к следующему шагу и посмотрим, как организовать фичу авторизации на уровне features.
Структура фичи auth
Предположим, нам нужна фича, которая:
- показывает форму логина;
- вызывает API;
- при успехе обновляет userStore.
Структура:
- src
- features
- auth
- ui
- LoginForm.svelte
- model
- login.ts
- api
- authApi.ts
- index.ts
- ui
- auth
- features
API-слой фичи
Файл src/features/auth/api/authApi.ts:
import type { User } from '@entities/user/model/store';
// Тип данных для логина
type LoginPayload = {
email: string;
password: string;
};
// Пример функции авторизации
// В реальном приложении здесь будет fetch к backend
export async function loginRequest(
payload: LoginPayload
): Promise<User> {
// Здесь мы имитируем запрос на сервер
// Обычно вы будете вызывать fetch и обрабатывать ответ
await new Promise((resolve) => setTimeout(resolve, 500)); // Задержка 500 мс
// Возвращаем "фейкового" пользователя
return {
id: '1',
name: 'Demo User',
email: payload.email,
};
}
// authApi.ts инкапсулирует всю работу с сервером для фичи auth // Это упрощает замену реализации (например, при смене backend)
Модель фичи auth
Файл src/features/auth/model/login.ts:
import { writable } from 'svelte/store';
import { loginRequest } from '../api/authApi';
import { userStore } from '@entities/user';
// Стор для состояния загрузки
const isLoading = writable(false);
// Стор для хранения ошибки
const error = writable<string | null>(null);
// Функция для выполнения логина
export async function login(email: string, password: string) {
// При начале логина очищаем ошибку и включаем состояние загрузки
error.set(null);
isLoading.set(true);
try {
// Делаем запрос к API
const user = await loginRequest({ email, password });
// Если запрос успешен, обновляем userStore
userStore.setUser(user);
} catch (e) {
// Обрабатываем ошибку, в реальном коде уточняйте типы и сообщения
error.set('Ошибка авторизации'); // Сообщение можно сделать более подробным
} finally {
// В любом случае после завершения запроса выключаем состояние загрузки
isLoading.set(false);
}
}
// Экспортируем модель в виде объекта
export const loginModel = {
isLoading,
error,
login,
};
// Здесь фича auth напрямую работает с сущностью user // Это нормально, потому что features могут зависеть от entities
UI фичи auth
Файл src/features/auth/ui/LoginForm.svelte:
<script lang="ts">
import { loginModel } from '../model/login';
import { get } from 'svelte/store';
let email = ''; // Локальное состояние для email
let password = ''; // Локальное состояние для пароля
// Подписываемся на сторы через $-синтаксис
$: isLoading = $loginModel.isLoading;
$: error = $loginModel.error;
async function handleSubmit() {
// Вызываем login и передаем данные формы
await loginModel.login(email, password);
// Здесь можно добавить редирект после успешного логина
// Например, через роутер или события
}
</script>
<form on:submit|preventDefault={handleSubmit}>
<!-- Поле для ввода email -->
<input
type="email"
bind:value={email}
placeholder="Email"
required
/>
<!-- Поле для ввода пароля -->
<input
type="password"
bind:value={password}
placeholder="Пароль"
required
/>
<!-- Кнопка отправки формы -->
<button type="submit" disabled={isLoading}>
{#if isLoading}
Входим...
{:else}
Войти
{/if}
</button>
{#if error}
<!-- Отображаем ошибку, если она есть -->
<p class="error">{error}</p>
{/if}
</form>
<style>
/* Небольшие стили для наглядности */
form {
display: flex; /* Располагаем элементы по вертикали */
flex-direction: column;
gap: 8px; /* Расстояние между элементами формы */
max-width: 240px; /* Ограничиваем ширину формы */
}
.error {
color: red; /* Красный цвет для сообщения об ошибке */
font-size: 0.9rem;
}
</style>
// UI-компонент работает только с loginModel // Внешний код просто использует LoginForm как часть интерфейса
Публичный API фичи auth
Файл src/features/auth/index.ts:
// Публичный API фичи авторизации
// Экспортируем UI-форму логина
export { default as LoginForm } from './ui/LoginForm.svelte';
// Экспортируем модель для продвинутых кейсов
export { loginModel } from './model/login';
// В большинстве случаев снаружи будет использоваться только LoginForm // Но при необходимости можно работать с loginModel напрямую
Виджеты и страницы: сборка интерфейса
Теперь давайте посмотрим, как собрать все вместе на уровне widgets и pages.
Виджет: Header с пользователем и формой логина
Представим, что у нас есть Header, который:
- показывает информацию о пользователе;
- если пользователь не авторизован — показывает кнопку «Войти» и форму.
Структура:
- src
- widgets
- header
- ui
- Header.svelte
- index.ts
- ui
- header
- widgets
Файл src/widgets/header/ui/Header.svelte:
<script lang="ts">
import { userStore } from '@entities/user';
import { LoginForm } from '@features/auth';
// Подписываемся на userStore
$: isAuth = $userStore.isAuth;
$: currentUser = $userStore; // Здесь мы получаем объект стора userStore
</script>
<header class="header">
<h1>Мое Svelte приложение</h1>
{#if isAuth}
<!-- Если пользователь авторизован, показываем карточку -->
<div class="header__user">
<!-- Здесь вы можете использовать компонент UserCard -->
<span>Вы вошли как {currentUser?.name}</span>
</div>
{:else}
<!-- Если пользователь не авторизован, показываем форму логина -->
<div class="header__login">
<LoginForm />
</div>
{/if}
</header>
<style>
.header {
display: flex; /* Располагаем элементы в одну строку */
justify-content: space-between;
align-items: center;
padding: 12px 16px; /* Внутренние отступы */
border-bottom: 1px solid #ddd;
}
.header__user,
.header__login {
display: flex;
align-items: center;
gap: 8px;
}
</style>
// Header использует сущность user и фичу auth // Такой виджет можно переиспользовать на разных страницах
Файл src/widgets/header/index.ts:
// Публичный API виджета header
export { default as Header } from './ui/Header.svelte';
Страница: Главная
На уровне страниц мы только собираем уже готовые блоки (виджеты, фичи, сущности).
Структура:
- src
- pages
- home
- ui
- HomePage.svelte
- index.ts
- ui
- home
- pages
Файл src/pages/home/ui/HomePage.svelte:
<script lang="ts">
import { Header } from '@widgets/header';
import { UserCard, userStore } from '@entities/user';
// Подписываемся на userStore для отображения карточки пользователя
$: currentUser = $userStore;
</script>
<Header />
<main class="page">
<h2>Главная страница</h2>
<!-- Отображаем карточку пользователя -->
<section>
<h3>Ваш профиль</h3>
<UserCard user={currentUser} />
</section>
</main>
<style>
.page {
padding: 16px; /* Отступы для главного содержимого страницы */
}
</style>
// Страница не содержит сложной логики // Она только комбинирует уже подготовленные виджеты, фичи и сущности
Файл src/pages/home/index.ts:
// Публичный API страницы home
export { default as HomePage } from './ui/HomePage.svelte';
Уровень app: корневой компонент и роутинг
Svelte-fsd рекомендует держать все, что связано с корнем приложения, в слое app.
Структура:
- src
- app
- index.svelte
- providers
- routing
- app
В простейшем случае, если у вас одна страница, app/index.svelte может выглядеть так:
<script lang="ts">
import { HomePage } from '@pages/home';
</script>
<!-- Здесь мы просто рендерим главную страницу -->
<HomePage />
// В реальном проекте здесь могут быть провайдеры темизации, i18n и роутер // Все это остается в слое app и не выносится в shared
Если вы используете роутер, его конфигурация также будет в app/routing, а каждая страница останется в своем срезе pages.
Правила зависимостей между слоями
Одна из ключевых идей FSD — ограничение направлений зависимости между слоями. Обычно принимают такие правила:
- app может зависеть от всех слоев;
- pages зависят от widgets, features, entities, shared;
- widgets зависят от features, entities, shared;
- features зависят от entities, shared;
- entities зависят от shared;
- shared не зависит от других слоев.
Давайте зафиксируем это в виде «лестницы»:
- app
- pages
- widgets
- features
- entities
- shared
- entities
- features
- widgets
- pages
Svelte-fsd часто предоставляет утилиты для валидации импортов (через ESLint или собственные скрипты). Идея в том, чтобы никакой модуль из нижнего слоя не импортировал верхний. Например:
- features не может импортировать pages;
- entities не может импортировать features и widgets;
- shared вообще не должен знать о существовании других слоев.
Это правило защищает архитектуру от «разрастания» связей и нарушений изоляции.
Использование общих модулей (shared)
Уровень shared — это место для всего, что не относится к конкретной доменной области. Чаще всего сюда помещают:
- дизайн-систему (UI-кит, кнопки, инпуты);
- общие утилиты и хелперы;
- типы, которые используются во многих местах;
- API-клиент, настройки запросов;
- конфиги.
Пример: общий UI-кит кнопок.
Структура:
- src
- shared
- ui
- Button.svelte
- Input.svelte
- lib
- formatDate.ts
- ui
- shared
Файл src/shared/ui/Button.svelte:
<script lang="ts">
// Тип для варианта кнопки
export let variant: 'primary' | 'secondary' = 'primary';
export let disabled = false; // Флаг отключения кнопки
</script>
<button
class={`btn btn--${variant}`}
disabled={disabled}
>
<slot />
</button>
<style>
.btn {
padding: 8px 12px; /* Внутренние отступы */
border-radius: 4px; /* Скругление углов */
border: none; /* Убираем стандартную рамку */
cursor: pointer; /* Курсор в виде руки */
}
.btn--primary {
background-color: #007bff; /* Синий фон */
color: white; /* Белый текст */
}
.btn--secondary {
background-color: #f0f0f0; /* Светло-серый фон */
color: #333; /* Темный текст */
}
.btn:disabled {
opacity: 0.6; /* Делаем отключенную кнопку полупрозрачной */
cursor: not-allowed; /* Запрещаем клик визуально */
}
</style>
// Такой компонент можно использовать в любом слое // Главное — чтобы зависимости были только "вниз" по слоям от места использования
Интеграция FSD в существующий Svelte-проект
Часто разработчики хотят внедрить FSD не в новый проект, а в существующий. Покажу вам, как это реализовано на практике пошагово.
Шаг 1. Вводим слои и alias-ы
Сначала вы:
- Создаете в src папки app, pages, widgets, features, entities, shared.
- Настраиваете alias-ы (как мы делали выше в Vite).
Важно: на этом этапе вы можете не переносить сразу весь код. Начните с новых модулей.
Шаг 2. Новый функционал — только по FSD
Дальше вы принимаете правило:
- весь новый код создается сразу в структуре FSD;
- старый код переносится постепенно по мере доработок.
Например, если вы добавляете новую фичу, сделайте ее в src/features/<название> вместо src/components.
Шаг 3. Перенос сущностей и общего кода
Постепенно:
- выделяете сущности (user, product, order) и переносите их в entities;
- выносите общие компоненты и утилиты в shared;
- делите крупные компоненты страниц на виджеты и фичи.
В результате вы придете к гибридной структуре, в которой «старый мир» постепенно уменьшается.
Шаг 4. Валидация импортов
Когда структура уже более-менее устойчива, можно подключить правила линтинга, чтобы:
- запретить прямые импорты между неправильными слоями;
- заставить использовать index-файлы как публичный API.
Большинство таких проверок настраивается через ESLint и плагины, которые идут вместе с svelte-fsd (или описаны в его документации).
Типичные ошибки при использовании FSD в Svelte
Слишком ранняя детализация
Иногда разработчики создают слишком много слоев и папок сразу. Например:
- разделяют почти каждый компонент на отдельную фичу;
- делают много «пустых» срезов.
Рекомендация: начинайте с базовых слоев и нескольких ключевых сущностей и фич. Когда код вырастет, структуру можно уточнить и расширить.
Отсутствие index-файлов
Без index-файлов разработчики начинают импортировать все подряд:
- из конкретных файлов;
- с обходом слоя.
Это делает рефакторинг крайне болезненным. Лучше потратить время и:
- создать index.ts в каждом срезе;
- экспортировать через него только то, что действительно нужно снаружи.
Смешивание бизнес-логики и UI
В Svelte легко писать «все сразу» в одном компоненте:
- запросы к API;
- бизнес-логика;
- UI.
В рамках FSD и svelte-fsd лучше:
- держать бизнес-логику в model (stores, функции);
- выносить API в отдельные файлы (api);
- оставлять UI-компоненты максимально «глупыми».
Это упрощает тестирование, переиспользование и рефакторинг.
Нарушение зависимостей слоев
Еще одна частая проблема: entities начинает зависеть от features или widgets. Это сильно ломает идею FSD.
Решение:
- если вам нужно переиспользовать UI компонент из widgets в entities, скорее всего, он должен быть в shared/ui;
- если фича зависит от другой фичи, подумайте, не является ли это одной более крупной фичей или частью уровня widgets/pages.
Заключение
Feature Sliced Design в связке с Svelte и библиотекой svelte-fsd помогает структурировать код вокруг бизнес-функциональности, а не технологий. Вы разделяете приложение на слои, срезы и сегменты, определяете публичные API через index-файлы, контролируете зависимости и тем самым упрощаете развитие проекта.
В контексте Svelte подход FSD особенно хорошо сочетается с встроенными сторами и простотой компонентов. Svelte-fsd добавляет к этому четкие соглашения, вспомогательные инструменты и рекомендации по тому, как правильно организовать слои и точки входа. Вы получили примеры:
- сущностей (entities) на основе Svelte-сторов;
- фич (features) с разделением api, model и ui;
- виджетов (widgets), которые собирают фичи и сущности;
- страниц (pages), которые только компонируют интерфейс;
- общего кода (shared), который остается независимым.
Подход рекомендуется внедрять постепенно: начинать с новых модулей, затем переносить ключевые сущности и фичи, а уже после — ужесточать правила импортов и зависимостей. При аккуратном применении FSD и svelte-fsd проект на Svelte становится более предсказуемым, модульным и удобным для работы команды.
Частозадаваемые технические вопросы по теме статьи и ответы на них
Как организовать тесты в структуре FSD для Svelte
Обычно тесты размещают рядом с кодом среза. Например:
- entities/user/model/store.test.ts
- features/auth/model/login.test.ts
Важно сохранять ту же иерархию слоев и срезов. Для UI-компонентов создавайте файлы вроде ui/UserCard.test.ts. Это позволит быстро находить тесты и поддерживать их в актуальном состоянии.
Как использовать TypeScript с svelte-fsd без дублирования типов между слоями
Общие типы, например для ID сущностей или стандартных ответов API, выносите в shared/types. Доменные типы конкретных сущностей держите в entities/
Как адаптировать FSD для SvelteKit с серверным рендерингом
SvelteKit добавляет слои маршрутизации и серверных обработчиков (load, +page.server.ts и т.д.). При этом структура FSD по слоям (app, pages, widgets, features, entities, shared) остается той же. Внутри маршрутов SvelteKit вы подключаете страницы из слоя pages. Логику загрузки данных (load-функции) можно считать частью слоя app/pages и использовать внутри них model/api из features и entities.
Как разделять код по чанкам с учетом слоев FSD
Код-сплиттинг в Svelte и SvelteKit обычно настраивается на уровне маршрутов или динамических импортов. В FSD это хорошо сочетается: страница (pages/home) становится естественной границей чанка. Внутри страницы можно использовать динамический импорт для тяжелых виджетов или фич, например import('src/widgets/chart'), не нарушая структуры слоев.
Как поступать с кросс-слайсовыми событиями и шиной событий
Если вам нужна общая шина событий, она должна располагаться в shared (например shared/lib/eventBus.ts). Фичи и сущности могут подписываться на события и публиковать их через эту шину. Главное — не допускать, чтобы eventBus знал о конкретных фичах или сущностях, только о событиях в виде строк или типизированных структур.