Рано или поздно почти любой бэкенд-проект приходит к задаче: нужна простая внутренняя страница. Посмотреть список чего-то, нажать пару кнопок, может быть что-то удалить.На «внутренней» странице пользователей не будет, а значит – «и так сойдёт». И вот тут начинается выбор: какие технологии взять или какой стек выбрать?
Привет! На связи Евгений Захаров — backend разработчик в компании ecom.tech. Моя команда занимается вопросами координации и планирования работы внештатных сотрудников. В этой статье мой опыт, накопившейся за многие года работы в разных компаниях, дальше мы разберём базовые сценарии, риски, сложности. Поехали!
Типичные сценарии:
-
Взял JSP -> через месяц нужна динамика, начинаешь лепить JavaScript поверх серверного HTML, получается каша;
-
Взял полноценный Vue/React с отдельным проектом -> ради трёх страниц с таблицами теперь нужно поддерживать два деплоя, настраивать CORS, синхронизировать версии API;
-
Взял Vaadin или похожий фреймворк -> огромная зависимость ради пары страниц, UI описывается в Java-коде, и теперь ты одновременно бэкенд и фронтенд разработчик;
-
Взял Thymeleaf -> всё хорошо, пока не понадобился live-update или сложная интерактивность без перезагрузки страницы. Сами шаблоны тоже с некоторого момента превращаются в совершенно нечитаемые и неподдерживаемые простыни тегов.
Есть один подход, который мало где описывается, но позволяет закрыть собой нишу от «Просто вывод текста» до «Полноценного SPA со сложной логикой». Сам его применял не раз. Каждый раз возникает ситуация, когда нет полноценного фронта для работы, либо нет смысла привлекать его для простой задачи. И приходится самому делать что-нибудь простое, чтобы вывести данные с сервера или сделать небольшую страницу управления или админку. Почти для любой внутренней админки имеет смысл сразу идти по этому пути. И самое ценное, данный выбор не тупиковый – он позволяет достаточно безболезненно перейти к SPA, если это будет необходимо.
Кому не хочется читать дальше
Возьмем Thymeleaf и сразу добавляем Vue, но не как отдельное web приложение со своей сборкой, а в виде WebJar зависимостей и максимально быстрого перехода в данные Vue.
Суть в том, что «внутренняя страница» обманчива: начинается как «просто список», а заканчивается требованиями уровня полноценного SPA. И чем раньше был сделан неправильный выбор, тем дороже переписывать.
Оценить сложность
Прежде чем браться за инструмент, стоит честно ответить на вопрос: что именно нужно?
-
Просто показать список объектов?
-
Форма редактирования?
-
Дерево навигации с вложенными разделами?
-
Интерактивные обновления без перезагрузки страницы?
От ответа зависит всё. Чем сложнее интерактивность, тем больше серверный рендеринг будет тормозить разработку. И тем больший смысл имеет сразу делать отдельное SPA-приложение.
А может JSP?
Технически JSP никуда не делось. В Spring Boot оно подключается, работает, страницы рендерит. Но давайте честно: сейчас 2026, и писать вот это:
<c:forEach items="${pages}" var="page">
<li>${page.name}</li>
</c:forEach>
…можно. Но зачем? У JSP накопился целый список проблем – часть из них врождённая, часть выкристаллизовалась с годами:
-
Нечитаемость на всех уровнях. JSP-исходники сложно читать, сгенерированный Java-код ещё хуже. Особенно проблемы множатся при отладке.
-
Данные теряют типы. Всё либо превращается в строку, либо прячется в атрибуты request/session с явным кастингом:
(MyObject) request.getAttribute("thing"). Никакой типобезопасности –ни в момент передачи данных, ни в шаблоне. -
Смешение Java и HTML. Технически в JSP можно писать Java-код прямо в шаблоне. Практически – это антипаттерн, от которого давно отказались, но соблазн и возможность никуда не делись.
-
Экосистема заморожена. Последнее существенное обновление спецификации JSP – 2013 год. Сообщество, инструменты, примеры –всё это живёт в прошлом десятилетии.
Инструмент из другой эпохи – пусть и живой.
А может Vaadin?
Vaadin – это Java-фреймворк, в котором весь UI описывается на сервере в виде Java/Kotlin-кода. Никакого HTML, никакого JavaScript. Ты пишешь компоненты, а Vaadin генерирует всё остальное и синхронизирует состояние через WebSocket. Звучит заманчиво для Java-разработчика: не нужно учить фронтенд, всё в одном языке, типобезопасность из коробки. Но на практике это оборачивается рядом проблем.
UI в Java-коде – это не так хорошо, как кажется. Когда вёрстка описывается через add(new HorizontalLayout(...)), она быстро становится нечитаемой. Разобраться, как выглядит страница, не запустив приложение, практически невозможно. Дизайнер или верстальщик к такому коду не подойдут.
Всё состояние живёт на сервере. Каждый клик, каждое изменение поля – это round-trip на сервер. При большом количестве пользователей память сервера заканчивается раньше, чем ожидаешь. Для внутренней страницы с пятью одновременными пользователями это не проблема,но ограничение надо держать в голове.
Платный. Базовые компоненты открытые, но многие нужные – grid с сортировкой, charts, rich text editor – доступны только в коммерческой лицензии. Для внутренней страницы платить за Vaadin Pro несколько тысяч в год – спорно.
Vendor lock-in. Весь UI завязан на Vaadin. Если понадобится уйти или добавить что-то нестандартное –это боль. Любая нестандартная задача решается через Vaadin-специфичные механизмы или через интеграцию с JS-компонентами, что частично обесценивает всю идею “UI на Java”.
Тяжёлая зависимость. Vaadin тянет за собой несколько мегабайт JS-бандла и серьёзную серверную инфраструктуру. Для простой внутренней страницы это как нанять бригаду строителей, чтобы повесить полку.
Vaadin оправдан в очень специфичных сценариях, когда команда бэкендовая, а фронтенд-разработчиков нет совсем, и нужны сложные data-grid компоненты прямо из коробки. В большинстве других случаев проще и дешевле потратить день на освоение базового Vue.
Thymeleaf – современный server-side рендеринг
Thymeleaf –это шаблонизатор, который работает с обычным HTML. Никаких специальных тегов, которые ломают валидацию. Файл .html можно открыть прямо в браузере и он будет выглядеть нормально – Thymeleaf-атрибуты просто игнорируются.
Подключение минимальное:
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
Контроллер передаёт данные в модель:
@GetMapping("/thymeleaf")
fun thymeleafApp(model: Model): String {
model.addAttribute("pages", adminStorage.pages)
return "thymeleaf"
}
Фрагменты
Одна из сильных сторон Thymeleaf – переиспользование кусков разметки через фрагменты. Общая шапка со стилями выносится один раз:
<!-- fragments/head.html -->
<head th:fragment="head">
<meta charset="UTF-8"/>
<title>Admin page</title>
<link rel="stylesheet" href="..."/>
</head>
И подключается в любой странице:
<head data-th-replace="~{fragments/head::head}"></head>
Итерация и рекурсия
Простой список – одна строчка:
<li th:each="page : ${pages}" th:text="${page.name}"></li>
Но реальные данные часто иерархические. Например, раздел «Логи» содержит подразделы: “Prod log”, “Dev log”, “All logs”. Здесь Thymeleaf тоже справляется через рекурсивные фрагменты:
<ul th:fragment="pages-app">
<li th:each="page : ${pages}">
<div th:replace="~{fragments/thymeleaf-app::render-page(${page})}"></div>
</li>
</ul>
<div th:fragment="render-page(page)">
<span th:text="${page.id} + ' - ' + ${page.name}"></span>
<ul th:if="${not #lists.isEmpty(page.items)}">
<li th:each="item : ${page.items}">
<div th:replace="~{fragments/thymeleaf-app::render-page(${item})}"></div>
</li>
</ul>
</div>
Фрагмент render-page вызывает сам себя для каждого дочернего элемента. Никакого JavaScript, всё генерируется на сервере.
Что ещё умеет Thymeleaf
Условный рендеринг. th:if и th:unless – простейшие условия:
<span th:if="${page.items.empty}" th:text="${page.name}"></span>
<ul th:unless="${page.items.empty}">...</ul>
Для множества вариантов есть th:switch / th:case:
<div th:switch="${page.type}">
<span th:case="'LOG'">Логи</span>
<span th:case="'INFO'">Информация</span>
<span th:case="*">Прочее</span>
</div>
Статус итерации. При th:each доступна переменная состояния со счётчиком, флагами первого/последнего элемента и чётности:
<li th:each="page, stat : ${pages}"
th:classappend="${stat.odd} ? 'odd' : 'even'">
<span th:text="${stat.count} + '. ' + ${page.name}"></span>
</li>
stat.index – индекс с нуля, stat.count – с единицы, stat.first / stat.last – булевые флаги.
Локальные переменные. th:with позволяет вычислить значение один раз и переиспользовать его в блоке:
<div th:with="hasItems=${not #lists.isEmpty(page.items)}">
<span th:if="${hasItems}" class="badge">Есть подразделы</span>
<ul th:if="${hasItems}">...</ul>
</div>
Утилитные объекты. Thymeleaf предоставляет набор хелперов для работы со строками, числами, датами и коллекциями:
<!-- строки -->
<span th:text="${#strings.toUpperCase(page.name)}"></span>
<span th:if="${#strings.contains(page.name, 'Log')}">...</span>
<!-- даты -->
<span th:text="${#temporals.format(page.createdAt, 'dd.MM.yyyy')}"></span>
<!-- коллекции -->
<span th:text="${#lists.size(page.items)}"></span>
Динамические атрибуты. Можно управлять классами, стилями и любыми атрибутами через выражения:
<li th:classappend="${page.active} ? 'active'">...</li>
<a th:href="@{/pages/{id}(id=${page.id})}" th:text="${page.name}"></a>
URL-выражение @{...} само подставит контекстный путь приложения и экранирует параметры.
Тонкости для упрощения разработки
По умолчанию Thymeleaf кеширует шаблоны, и так если запустить локально, то изменения не будут обновляться на лету. Это не очень удобно при разработке, поэтому проще сразу выключить кеширование:
spring:
thymeleaf:
cache: false
Или можно сразу добавить кастомизированный вариант, прописать пути для поиска шаблонов и заранее добавить DirectoryMode, по которому отключать кеширование.
@ConfigurationProperties(prefix = "develop")
data class DevelopProperties(
val mode: DirectoryMode?
)
enum class DirectoryMode {
LOCAL
}
@Configuration
@EnableConfigurationProperties(DevelopProperties::class)
class ThymeleafConfiguration(
private val applicationContext: ApplicationContext,
private val developProperties: DevelopProperties
) {
@Bean
fun thymeleafTemplateResolver(): ITemplateResolver {
val resolver = SpringResourceTemplateResolver()
resolver.setApplicationContext(applicationContext)
if (developProperties.mode == DirectoryMode.LOCAL) {
resolver.prefix = "file:./app/src/main/resources/templates/"
resolver.isCacheable = false
} else {
resolver.prefix = "classpath:templates/"
resolver.isCacheable = true
}
resolver.suffix = ".html"
resolver.templateMode = TemplateMode.HTML
resolver.checkExistence = false
return resolver
}
@Bean
fun thymeleafTemplateEngine(): SpringTemplateEngine {
val templateEngine = SpringTemplateEngine()
templateEngine.setTemplateResolver(thymeleafTemplateResolver())
return templateEngine
}
@Bean
fun thymeleafViewResolver(): ThymeleafViewResolver {
val viewResolver = ThymeleafViewResolver()
viewResolver.templateEngine = thymeleafTemplateEngine()
return viewResolver
}
}
Как правило нужны статичные данные, какие-либо библиотеки из npm экосистемы и тут хорошо подходят WebJar. Они представляют собой упакованные js зависимости в jar, которые подключаются в том же gradle, не уходя в другую экосистему. Выглядит это как просто подключение зависимости:
implementation("org.webjars.npm:vue:3.4.21")
После чего добавив настройку для получения ресурсов, их можно сделать доступными по /webjars/**:
@Configuration
@EnableWebMvc
class WebConfig: WebMvcConfigurer {
override fun addResourceHandlers(registry: ResourceHandlerRegistry) {
registry
.addResourceHandler("/webjars/**")
.addResourceLocations("/webjars/")
}
}
Например, vue из примера выше, после подключения WebJar будет доступен по /webjars/vue/3.4.21/dist/vue.global.js. Можно убрать версию из пути подключив org.webjars:webjars-locator-lite, с ней путь становится /webjars/vue/dist/vue.global.js. Для работы этой настройки придется добавить немного конфигурации. Тут же можно в зависимости от DirectoryMode, описанного выше, заодно и выключать кеширование для статики:
@Configuration
class WebConfiguration(
private val developProperties: DevelopProperties
) : WebMvcConfigurer {
override fun addResourceHandlers(registry: ResourceHandlerRegistry) {
if (developProperties.mode == DirectoryMode.LOCAL) {
// выключаем кеширования для статики
registry.addResourceHandler("/static/**")
.addResourceLocations("file:./app/src/main/resources/static/")
.setCacheControl(CacheControl.noCache())
} else {
registry.addResourceHandler("/static/**")
.addResourceLocations("classpath:/static/")
.setCacheControl(CacheControl.maxAge(Duration.ofDays(1)).cachePublic())
}
// настройка для webjars-locator-lite
registry.addResourceHandler("/webjars/**")
.addResourceLocations("classpath:/META-INF/resources/webjars/")
.setCacheControl(CacheControl.maxAge(Duration.ofDays(1)).cachePublic())
.resourceChain(true)
.addResolver(LiteWebJarsResourceResolver())
}
}
Когда Thymeleaf начинает болеть
Thymeleaf отлично работает пока страница статична или почти статична. Но как только появляются требования:
-
обновить часть страницы без перезагрузки,
-
реагировать на действия пользователя,
-
управлять сложным состоянием UI,
…сразу начинаются костыли. Можно таскать данные через fetch и вставлять HTML-строки в DOM. Можно использовать HTMX. Но в какой-то момент ты понимаешь, что пишешь реактивный UI руками,и делаешь это хуже, чем уже написали за тебя.
Есть и более тихая проблема – отсутствие статической типизации между контроллером и шаблоном. В отличие от JSP здесь нет ручного кастинга: SpEL ходит по графу объектов напрямую и ${page.name} просто работает. Но Model.addAttribute() принимает Any, и компилятор не знает, что именно лежит под ключом "pages" в шаблоне. Опечатка в ${page.naem} не даст ошибки компиляции –только пустое значение в рантайме. Это осознанный компромисс server-side шаблонизаторов, и с ним можно жить. Но это стоит держать в голове.
Добавляем Vue.js – без сборки
Классический путь с Vue –это Node.js, npm, webpack или Vite, отдельный проект, прокси, деплой двух артефактов. Для внутренней админки это часто избыточно.
Почему Vue, если уже есть Thymeleaf?
Справедливый вопрос. Но посмотрим на то, что было выше: Thymeleaf не даёт статической типизации между контроллером и шаблоном. Чтобы сделать что-то нетривиальное (рендеринг, форматирование, работу с коллекциями) приходится учить специфический синтаксис: #lists.isEmpty(), #strings.contains(), #temporals.format() и т.д. Vue предлагает тоже самое, но проще и на обычном JavaScript (условно). Можно часть логики выносить в JS функции, добавлять Typescript, где нужно.
Разберём на примерах простые вещи:
Итерация со счётчиком и чётностью строк
Thymeleaf:
<li th:each="page, stat : ${pages}"
th:classappend="${stat.odd} ? 'odd' : 'even'"
th:text="${stat.count} + '. ' + ${page.name}">
</li>
Vue:
<li v-for="(page, i) in pages" :class="i % 2 === 0 ? 'even' : 'odd'">
{{ i + 1 }}. {{ page.name }}
</li>
Условный рендеринг
Thymeleaf:
<span th:if="${not #lists.isEmpty(page.items)}" class="badge">
Подразделов: <span th:text="${#lists.size(page.items)}"></span>
</span>
<span th:unless="${not #lists.isEmpty(page.items)}" class="muted">нет подразделов</span>
Vue:
<span v-if="page.items.length" class="badge">Подразделов: {{ page.items.length }}</span>
<span v-else class="muted">нет подразделов</span>
Форматирование строк
Thymeleaf:
<span th:text="${#strings.toUpperCase(page.name)}"></span>
<span th:if="${#strings.startsWith(page.name, 'Prod')}">production</span>
Vue:
<span>{{ page.name.toUpperCase() }}</span>
<span v-if="page.name.startsWith('Prod')">production</span>
Динамические классы и стили
Thymeleaf:
<!-- добавить класс через условие -->
<li th:classappend="${page.active} ? 'active' : 'disabled'">
<span th:text="${page.name}"></span>
</li>
<!-- несколько классов через th:class (полная замена) -->
<li th:class="${page.active} ? 'menu-item active' : 'menu-item disabled'">
<span th:text="${page.name}"></span>
</li>
<!-- инлайн-стиль -->
<span th:style="'color: ' + (${page.active} ? 'green' : 'gray') + ';'"
th:text="${page.name}"></span>
Vue:
<!-- объект: ключ — класс, значение — условие -->
<li :class="{ active: page.active, disabled: !page.active }">
{{ page.name }}
</li>
<!-- массив классов (можно миксовать строки и условия) -->
<li :class="['menu-item', page.active ? 'active' : 'disabled']">
{{ page.name }}
</li>
<!-- объект стилей (camelCase свойства) -->
<span :style="{ color: page.active ? 'green' : 'gray' }">
{{ page.name }}
</span>
Thymeleaf управляет классами через th:classappend (добавляет к существующим) и th:class (полностью заменяет), а стилями — через th:style со строковой конкатенацией.
Vue предлагает более выразительный синтаксис: объект { className: condition } или массив ['base-class', conditionalClass], а стили задаются как обычный JS-объект.
Реактивный поиск по списку – то, что в Thymeleaf вообще не сделать без JavaScript:
<input v-model="search" placeholder="Поиск..." />
<li v-for="page in pages.filter(p => p.name.includes(search))" :key="page.id">
{{ page.name }}
</li>
Во всех этих случаях Vue использует обычный JavaScript, который понимают IDE, линтеры и TypeScript. Никакого специфичного DSL. Код выглядит как минимум не хуже, а во многих местах проще. А особенно Vue раскрывается, когда требуется добавить динамичность:
import { createApp, ref } from 'vue'
createApp({
setup() {
return {
count: ref(0)
}
}
}).mount('#app')
<div id="app">
<button @click="count++">
Count is: {{ count }}
</button>
</div>
Просто подключить Vue
Самый минимальный вариант – один script-тег через WebJar:
<script th:src="@{/webjars/vue/dist/vue.global.prod.js}"></script>
После этого Vue доступен глобально, и уже можно монтировать приложение прямо в шаблоне:
const { createApp, ref } = Vue;
createApp({
setup() {
return { pages: ref(pages) };
},
template: `
<ul>
<li v-for="page in pages" :key="page.id">{{ page.name }}</li>
</ul>
`
}).mount('#root');
Данные (pages) уже есть на странице – их передал Thymeleaf через inline-скрипт. Vue просто берёт их и рендерит.
Передача данных из контроллера
Для Vue объекты удобнее сразу сериализовать в JSON на сервере:
@GetMapping("/vue")
fun vueApp(model: Model): String {
model.addAttribute("pages", objectMapper.writeValueAsString(pages))
return "vue"
}
И распарсить в шаблоне через Thymeleaf inline-скрипт – после этого pages доступен как обычный JS-массив:
<script th:inline="javascript">
const pages = JSON.parse(/*[[${pages}]]*/ "[]");
</script>
И дальше Vue начинает выигрывать ещё сильнее: компоненты, scoped-стили, реактивное состояние, вычисляемые свойства – всё это делается естественно, а не через костыли.
Переходим к .vue компонентам
Чтобы писать полноценные .vue файлы без сборки, добавим vue3-sfc-loader – он загружает компоненты прямо из браузера. И добавим babel если хотим typescript:
implementation("org.webjars.npm:vue3-sfc-loader:0.8.4") // для подгрузки vue компонентов
implementation("org.webjars.npm:babel__standalone:7.28.6") // для typescript
Инициализация в шаблоне:
const { loadModule } = window['vue3-sfc-loader'];
const options = {
moduleCache: { vue: Vue },
// И строки если нужен typescript
// additionalBabelParserPlugins: ['typescript'],
// additionalBabelPlugins: { typescript: Babel.availablePlugins['transform-typescript'] },
async getFile(url) {
const res = await fetch(url);
return { getContentData: (asBinary) => asBinary ? res.arrayBuffer() : res.text() };
},
addStyle(textContent) {
const style = Object.assign(document.createElement('style'), { textContent });
document.head.insertBefore(style, document.head.querySelector('style') || null);
}
};
const app = Vue.createApp({
components: { PagesApp: Vue.defineAsyncComponent(() => loadModule('/static/vue/pages-app.vue', options)) },
template: '<PagesApp />'
});
app.mount('#root');
Теперь компоненты живут в отдельных файлах с TypeScript, scoped-стилями и чистыми шаблонами:
<script lang="ts">
interface AdminPage {
id: string;
name: string;
items: AdminPage[];
}
export default {
props: {
page: { type: Object as () => AdminPage, required: true }
}
};
</script>
<template>
<span>{{ page.id }} - {{ page.name }}</span>
</template>
<style scoped>
span { font-weight: 500; }
</style>
А другие могут их вызывать напрямую:
<script>
// импорт другого компонента line.vue из примера выше
import Line from "./line.vue";
const {ref} = Vue;
export default {
components: {Line},
setup() {
return {pages: ref(pages)};
}
};
</script>
<template>
<ul>
<li v-for="page in pages" :key="page.id">
<Line :page="page" />
<ul v-for="subPage in page.items">
<Line :page="subPage" />
</ul>
</li>
</ul>
</template>
<style scoped>
ul {
list-style: none;
}
</style>
Каждый компонент – это самодостаточный файл: логика, шаблон и стили вместе, изолированно от остального.
Плавный переход к SPA
Самое приятное в этом подходе – он не тупиковый. Если в какой-то момент окажется, что масштаб вырос и нужен полноценный SPA, то переход будет почти безболезненным:
-
.vueкомпоненты уже написаны в стандартном формате,они почти без изменений переедут в Vite-проект. -
API уже есть – Spring Boot отдаёт данные, фронт их потребляет. Во многих случаях можно существующие апи с json продублировать и завернуть их в контроллеры.
-
Разработчики уже знают Vue, TypeScript и структуру компонентов
Разница будет только в том, как эти файлы доставляются в браузер: сейчас через vue3-sfc-loader на лету, потом через собранный Vite бандл. Бизнес-логика компонентов остаётся той же.
Неужели все так идеально?
Нет, конечно. Есть несколько честных оговорок.
WebJar есть не для всего. Репозиторий webjars.org содержит тысячи пакетов, но не все npm-библиотеки туда попали. Если нужна какая-то специфичная или свежая версия,есть шанс не найти нужный WebJar или наткнуться на устаревший. В таком случае остаётся либо скачать JS вручную и положить в static/, либо собрать WebJar самостоятельно. Оба варианта работают, но добавляют ручной работы. Иногда часть зависимостей поломанная и тянет транзитивные зависимости с версиями которых нет в maven central.
Подход немного “сбоку”. Загрузка .vue файлов через vue3-sfc-loader в браузере – это не тот путь, который подразумевает Vue. Официальный вариант – Vite с горячей перезагрузкой, оптимизацией бандла. Здесь этого нет: каждый .vue файл запрашивается отдельным HTTP-запросом и компилируется в браузере в рантайме. Для внутренней страницы с десятком компонентов это незаметно, но с ростом масштаба начнёт ощущаться.
Настоящие фронтенд-разработчики поморщатся. Отсутствие package.json, компиляция в браузере, Babel standalone, для человека, привыкшего к нормальному фронтенд-тулингу – выглядит как временное решение. И они будут правы – это и есть временное решение, осознанно выбранное ради простоты. Хотя, если показать им, что под капотом Vue с компонентами и TypeScript, скорее всего, оценят.
TypeScript работает, но с ограничениями. Типы проверяются только внутри компонента, IDE не всегда корректно их подхватывает без полноценной настройки проекта. Строгой компиляции нет, ошибки типов проявятся только в рантайме.
Всё это – осознанные компромиссы. Подход решает конкретную задачу: быстро сделать нормальную внутреннюю страницу без лишней инфраструктуры. Когда задача вырастет можно вырасти вместе с ней.
Итог
Описанный подход занимает нишу, которая обычно остаётся без нормального ответа: сложнее, чем “просто Thymeleaf”, но гораздо проще, чем полноценный SPA.
Пример проекта и настроек можно посмотреть на github. В нем 2 endpoint доступны по /vue и /thymeleaf. Каждый выводит список pages вместе с дочерними элементами.
Thymeleaf хорош для начала, но быстро начинает ограничивать специфичный DSL, отсутствие реактивности, нечитаемые шаблоны при малейшей сложности. Полноценный SPA с отдельной сборкой – это другая крайность: два проекта, два деплоя, CORS, синхронизация версий API, и всё это ради трёх внутренних страниц. Thymeleaf + Vue через WebJars – это:
-
Никакой отдельной сборки. Всё живёт в одном Spring Boot проекте, зависимости подключаются через Gradle, статика раздаётся как обычно
-
Постепенное усложнение. Начать можно с простого
createAppв inline-скрипте, добавить.vueкомпоненты когда понадобится, подключить TypeScript когда захочется – каждый шаг независим -
Привычный стек. Vue-компоненты пишутся в стандартном формате – IDE, линтеры, TypeScript работают как обычно, никакого специфичного инструментария.
-
Нет тупика. Если масштаб вырастет до настоящего SPA, компоненты переедут в Vite-проект без изменений. API уже есть, команда уже знает Vue, переход будет косметическим.
В большинстве случаев для внутренней админки можно сразу идти по этому пути, не думая о том, правильный ли выбор. Он достаточно лёгкий чтобы не перегрузить простые задачи, и достаточно гибкий чтобы не упереться в потолок, когда требования вырастут.
Автор: nerumb


