你还在把一切都塞进前端打包里?SSR 回潮真只是“复古情怀”吗?
🏆本文收录于《滚雪球学SpringBoot 3》:
https://blog.csdn.net/weixin_43970743/category_12795608.html,专门攻坚指数提升,本年度国内最系统+最专业+最详细(永久更新)。
本专栏致力打造最硬核 SpringBoot3 从零基础到进阶系列学习内容,🚀均为全网独家首发,打造精品专栏,专栏持续更新中…欢迎大家订阅持续学习。 如果想快速定位学习,可以看这篇【SpringBoot3教程导航帖】https://blog.csdn.net/weixin_43970743/article/details/151115907,你想学习的都被收集在内,快速投入学习!!两不误。
若还想学习更多,可直接前往《滚雪球学SpringBoot(全版本合集)》:https://blog.csdn.net/weixin_43970743/category_11599389.html,涵盖SpringBoot所有版本教学文章。
演示环境说明:
- 开发工具:IDEA 2021.3
- JDK版本: JDK 17(推荐使用 JDK 17 或更高版本,因为 Spring Boot 3.x 系列要求 Java 17,Spring Boot 3.5.4 基于 Spring Framework 6.x 和 Jakarta EE 9,它们都要求至少 JDK 17。)
- Spring Boot版本:3.5.4(于25年7月24日发布)
- Maven版本:3.8.2 (或更高)
- Gradle:(如果使用 Gradle 构建工具的话):推荐使用 Gradle 7.5 或更高版本,确保与 JDK 17 兼容。
- 操作系统:Windows 11
关键词:Thymeleaf + HTMX + Spring Boot + Fragment(HTML 片段)
目标:新时代 SSR:页面还是服务端渲染,但交互像 SPA 一样顺滑;前端不写 JS 也能做“动态搜索 + 无限滚动”。
前言:我不是反前端,我只是反“过度前端”🙂
先声明一下:我不反对 SPA、更不反对 JS。JS 很强,强到能让人半夜两点还在调 undefined is not a function。
我真正想吐槽的是另一件事:有些项目明明就一个后台管理页、一个列表页、一个搜索框,业务也不复杂,结果上来就:
- 前端:框架 + 状态管理 + 路由 + 请求库 + UI 库
- 后端:只提供 JSON
- 上线:首屏要等一堆 JS 下载、解析、执行
然后用户打开页面——白屏。那一刻我心里只有一句话:
“我到底是写业务,还是在给浏览器搬砖?”😮💨
SSR 的回潮,其实并不是“复古”,而是大家终于承认一个事实:
- 很多系统的核心是 内容与数据呈现,不是“把页面当应用跑”
- 对 SEO、首屏性能、弱网体验、复杂表单等场景,SSR 依然更稳定
- 当你不需要复杂前端状态机时,把交互收回来反而省心
一、为什么 SSR(服务端渲染)又回来了?
1.1 首屏与体验:用户不关心你的框架,只关心“我点了为啥没反应”
你有没有这种瞬间:产品说“页面打开慢”,你说“不会啊我本地秒开”,产品说“我手机 4G,地铁里”。
SSR 的直觉优势就是:服务端直接吐 HTML,浏览器拿到就能渲染内容。哪怕交互慢一点,用户也至少能先看到东西。
1.2 工程复杂度:前后端分离不是原罪,但“分离过头”是真的累
我见过不少团队:
- 前端要维护一堆 DTO、接口类型、分页规范
- 后端要维护一堆 JSON 结构兼容
- 需求一改,双方一起改,最后还得对齐字段名
SSR 的好处是:同一套模型更容易闭环。尤其在 Spring Boot 这种生态里,模板渲染和控制器天然一体。
1.3 “SSR + 局部刷新”才是今天的重点:不是回到过去,是换个姿势往前走
传统 SSR 的痛点你肯定也记得:
- 一点按钮刷新整页
- 表单提交跳转
- 交互像“网页时代”
但今天不一样了。HTMX 这种工具把一个关键点补齐:
不用把应用全塞到前端,只要在需要交互的局部做“增量更新”。
HTMX 的核心机制是:让 HTML 元素自己声明“触发请求、把结果塞到哪里、用什么方式替换”。官方文档对这些属性有非常直接的说明,比如 hx-get(请求地址)、hx-target(替换目标)、hx-trigger(触发方式)、hx-swap(替换策略)等。
二、HTMX 简介:把 AJAX 写进 HTML 属性里,这事儿靠谱么?😏
我第一次写 HTMX 的时候,手感很奇妙:
- 没有
fetch() - 没有
axios - 没有
useEffect - 甚至没有一行 JS
就像回到写 HTML 的年代,但交互却是现代的。
2.1 HTMX 的“声明式交互”到底在声明啥?
以一个最常见的“输入搜索”为例,HTMX 官方文档给了典型写法:输入框在用户输入后延迟触发请求,把返回内容塞进结果容器。
我们等会儿会用它做一个动态搜索。
2.2 hx-trigger:别小看它,很多“现代 UX”都靠它
hx-trigger 不只是 click,它支持延迟、条件、一次性触发、甚至视口相交触发等。官方对 intersect(元素进入视口触发)等行为有说明。
这就是无限滚动的关键。
2.3 无限滚动:HTMX 官方示例里就有“revealed / intersect”的套路
HTMX 官方示例(Infinite Scroll)里,思路非常朴素:
- “最后一行”带着
hx-get - 当它滚进视口(
revealed)就请求下一页 - 用
hx-swap="afterend"把新内容追加在后面
官方示例就是这么干的。
三、Spring Boot 为什么要返回 HTML 片段(Fragment),而不是整页?
这块是新时代 SSR 的“灵魂”。
你想想:如果每次动态搜索都返回整页 HTML,那页面标题、头部、导航、脚注都重复返回,网络浪费不说,浏览器还得替换整页结构,体验也不自然。
所以我们要做的是:
- 首次访问:返回完整页面(带布局、带容器、带初始列表)
- 后续交互:只返回局部片段(列表的
<tr>或<li>),由 HTMX 负责塞进页面里
3.1 Thymeleaf 的片段机制:不是“拼字符串”,是正规的模板能力
Thymeleaf 本身就支持“页面片段”的插入/替换:
th:insert:把片段插进宿主标签内部th:replace:用片段直接替换宿主标签
官方文章对th:insert/th:replace的语义解释得很清楚。
另外,Thymeleaf 的 Fragment 表达式有固定形式(模板 :: 片段(参数)),在 API 文档里也明确写了这种结构。
3.2 Spring MVC / Spring Boot 的视图渲染:模板引擎就是一等公民
Spring Boot 在 MVC 场景里对 Thymeleaf 的集成是非常常见的路径,Boot 文档也提到了 Thymeleaf 模板默认前缀后缀(classpath:/templates/ 与 .html)。
Spring Framework 自己也有专门页面介绍 Thymeleaf 在 Spring MVC 中的定位与配置思路。
四、实战:不写一行 JavaScript,实现动态搜索 + 无限滚动(可跑的代码)
好了,开始干活。我们做一个“联系人列表”页面:
- 顶部一个搜索框
- 下方一个表格列表
- 输入关键词自动搜索(带 debounce)
- 滚到底自动加载下一页
- 全程不写 JS(连
console.log都不给它机会)
技术栈假设:Spring Boot 3.x + Thymeleaf 3.x + Spring MVC
数据源:为了让你复制就能跑,我用内存模拟数据(你换成 JPA/MyBatis 都一样)
4.1 依赖与配置(Maven 示例)
<!-- pom.xml -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
</dependencies>
Spring Boot 默认会把 Thymeleaf 模板从 classpath:/templates/ 下加载,后缀默认 .html。
所以我们把模板放到:src/main/resources/templates/
4.2 数据模型与“假数据库”
// Contact.java
public record Contact(Long id, String name, String email) {}
// ContactRepository.java
import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;
@Repository
public class ContactRepository {
private final List<Contact> data = new ArrayList<>();
public ContactRepository() {
// 模拟 500 条数据
for (long i = 1; i <= 500; i++) {
data.add(new Contact(i, "Agent " + i, "void" + i + "@null.org"));
}
}
public List<Contact> search(String q, int page, int size) {
String keyword = (q == null) ? "" : q.trim().toLowerCase(Locale.ROOT);
List<Contact> filtered = data.stream()
.filter(c -> keyword.isEmpty()
|| c.name().toLowerCase(Locale.ROOT).contains(keyword)
|| c.email().toLowerCase(Locale.ROOT).contains(keyword))
.collect(Collectors.toList());
int from = Math.max(0, page * size);
int to = Math.min(filtered.size(), from + size);
if (from >= filtered.size()) return List.of();
return filtered.subList(from, to);
}
public int count(String q) {
String keyword = (q == null) ? "" : q.trim().toLowerCase(Locale.ROOT);
return (int) data.stream()
.filter(c -> keyword.isEmpty()
|| c.name().toLowerCase(Locale.ROOT).contains(keyword)
|| c.email().toLowerCase(Locale.ROOT).contains(keyword))
.count();
}
}
4.3 Controller:关键点是“整页 vs 片段”两套路由
我们准备两个接口:
GET /contacts:返回整页(首次进入)GET /contacts/rows:返回表格行片段(给 HTMX 用)
// ContactController.java
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
@Controller
public class ContactController {
private final ContactRepository repo;
public ContactController(ContactRepository repo) {
this.repo = repo;
}
@GetMapping("/contacts")
public String page(
@RequestParam(name = "q", required = false) String q,
Model model
) {
int page = 0;
int size = 20;
List<Contact> rows = repo.search(q, page, size);
int total = repo.count(q);
model.addAttribute("q", q == null ? "" : q);
model.addAttribute("rows", rows);
model.addAttribute("page", page);
model.addAttribute("size", size);
model.addAttribute("total", total);
return "contacts";
}
@GetMapping("/contacts/rows")
public String rows(
@RequestParam(name = "q", required = false) String q,
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "20") int size,
Model model
) {
List<Contact> rows = repo.search(q, page, size);
int total = repo.count(q);
model.addAttribute("rows", rows);
model.addAttribute("q", q == null ? "" : q);
model.addAttribute("page", page);
model.addAttribute("size", size);
model.addAttribute("total", total);
// 返回 Thymeleaf 模板中的片段:contacts :: rows
// 片段表达式 “template :: fragment” 是 Thymeleaf 的标准形式。:contentReference[oaicite:9]{index=9}
return "contacts :: rows";
}
}
看到没?后续交互我们只返回
rows片段。
这就是“新时代 SSR”的手艺活:不是拼 JSON,而是吐 HTML 片段。
4.4 Thymeleaf 模板:整页 + 片段同文件管理(别拆太碎,真会疯)
创建 src/main/resources/templates/contacts.html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8"/>
<title>Contacts</title>
<!-- 引入 htmx(这不是你写的 JS,是库脚本;你自己不写一行业务 JS) -->
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<style>
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; padding: 24px; }
.hint { opacity: 0.7; margin: 8px 0 16px; }
table { border-collapse: collapse; width: 100%; }
th, td { border-bottom: 1px solid #eee; padding: 10px 8px; text-align: left; }
.spinner { display:none; margin-top: 12px; }
.htmx-request .spinner { display:block; }
</style>
</head>
<body>
<h1>联系人列表</h1>
<div class="hint">
你随便输点什么试试,比如 <code>Agent 2</code>,别害羞 😄
</div>
<!-- 1) 动态搜索:输入框自己发请求 -->
<input
type="text"
name="q"
placeholder="输入关键字搜索..."
th:value="${q}"
hx-get="/contacts/rows"
hx-trigger="input delay:300ms, keyup[key=='Enter']"
hx-target="#tbody"
hx-swap="innerHTML"
hx-include="[name='q']"
/>
<p class="hint">
当前匹配:<span th:text="${total}">0</span> 条
</p>
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Email</th>
</tr>
</thead>
<!-- 结果容器:后续只更新 tbody 的内容 -->
<tbody id="tbody" th:fragment="rows">
<!-- 片段 rows:服务端只渲染这一段给 HTMX -->
<tr th:each="c : ${rows}">
<td th:text="${c.id()}">1</td>
<td th:text="${c.name()}">Agent</td>
<td th:text="${c.email()}">a@b.com</td>
</tr>
<!-- “哨兵行”:用于无限滚动加载下一页 -->
<tr th:if="${rows.size() > 0 and (page + 1) * size < total}"
hx-get="/contacts/rows"
th:attr="hx-get=@{/contacts/rows(q=${q}, page=${page + 1}, size=${size})}"
hx-trigger="revealed"
hx-swap="afterend">
<td colspan="3" style="opacity:.6;">
正在加载下一页…(别急,我在跑 🏃)
<div class="spinner">Loading...</div>
</td>
</tr>
<!-- 无更多内容 -->
<tr th:if="${rows.size() == 0}">
<td colspan="3" style="opacity:.6;">没有更多数据了</td>
</tr>
</tbody>
</table>
</body>
</html>
这里发生了什么?(别跳过,这就是“魔法”来源)
- 动态搜索:
hx-get="/contacts/rows":输入框触发 GET 请求hx-trigger="input delay:300ms, keyup[key=='Enter']":输入后延迟 300ms 再发(debounce),或按回车立刻发hx-target="#tbody":把响应塞进tbodyhx-swap="innerHTML":用返回片段替换tbody内部内容
这类“Active Search”的写法在 HTMX 文档中就是典型模式。
- 无限滚动:
- 关键在“哨兵行”(最后一行)
hx-trigger="revealed":当它滚到视口内就触发hx-swap="afterend":把下一页的行追加在它后面
HTMX 官方 Infinite Scroll 示例就是这个套路。
小提醒:如果你的列表容器用了
overflow-y: scroll,官方建议用intersect once替代revealed。
我这里用页面滚动,所以revealed就够了。
五、为什么这种写法“看起来像 SPA”,但本质更像“可维护的 SSR”?
我个人很喜欢这套组合的原因其实很现实:它不是为了炫技,而是为了降低长期维护成本。
5.1 前后端边界变了:不是“前端负责交互”,而是“页面负责交互声明”
HTMX 把“交互发生在哪、更新谁、怎么更新”这件事写进了 HTML。
你要说这是不是“把逻辑塞回模板”?是的,但它塞回的是交互编排,不是业务计算。
业务依然在后端:分页、过滤、权限、审计、日志……这些本来就不该丢给浏览器去“猜”。
5.2 Fragment 是你的“组件化”
Thymeleaf 的片段机制本质上就是模板组件化。官方对片段插入/替换语义讲得很清楚:th:insert 与 th:replace 都是为了复用片段。
我们这里直接把 tbody 定义成 th:fragment="rows",让同一个模板既能整页渲染,也能片段渲染——这招很省事。
5.3 你会少掉很多“前端状态同步的烦恼”
最爽的点来了:
- 搜索关键词就是一个
q参数 - 当前页就是一个
page参数 - 服务端返回的 HTML 就是最终 UI 状态
不需要你在前端维护一堆“列表状态”“加载状态”“是否还有下一页”等等。
你的页面状态,从某种意义上说就是:URL + HTML。
这事儿简直像给脑子减负。
六、把坑先告诉你:别等线上炸了才想起我😇
6.1 片段返回别夹带“多余包装”
你如果在 /contacts/rows 返回时不小心渲染了整页,HTMX 会把整页 HTML 塞进 tbody……
那画面怎么说呢,就像把一栋楼塞进一个抽屉里。
所以一定要走 return "contacts :: rows"; 这种片段返回方式(片段表达式的形式在 Thymeleaf 文档/迁移说明中有明确要求与规范化写法)。
6.2 revealed vs intersect:滚动容器场景别选错
官方说明很直接:
- 页面滚动:
revealedOK - 自定义滚动容器(overflow scroll):用
intersect once更靠谱
6.3 防抖别写太激进:delay 太小,后端会被你“打成筛子”
delay:300ms 是个舒服的折中。你写 delay:50ms,用户手速快一点,你的服务端日志会像机关枪。
HTMX 的触发修饰符(例如 delay、changed、once)在文档里都有说明。
6.4 分页与排序:别让“无限滚动”变成“无限重复”
如果你的排序规则不稳定(比如按更新时间、且更新频繁),无限滚动会出现:
- 上一页的最后几条,下一页又出现
- 用户看着像“鬼打墙”
解决办法很工程化: - 用稳定排序(例如按 id)
- 或使用游标分页(cursor-based)
这块跟 HTMX/Thymeleaf 无关,是分页策略的基本功。
七、扩展一把:把“搜索 + 无限滚动”做得更像产品,而不是 Demo
到这里功能已经成立了,但如果你真要上线,会遇到一些“人类用户”才会提出的问题(比如:我输错了能撤销吗?我刷新还想保留搜索吗?)。咱们顺手补上:
7.1 让搜索可分享:把 q 写进 URL
HTMX 支持通过请求参数携带值,我们已经用 name="q" 做到了请求携带。
更进一步,你可以让首次访问 /contacts?q=xxx 就带搜索状态,这样复制链接给同事也能复现问题。
(我们 Controller 的 /contacts 已经支持 q 参数了)
7.2 增加加载指示器:别让用户以为你卡死了
HTMX 有 hx-indicator 机制(示例里我用 .spinner + .htmx-request 做了简单效果)。如果你愿意更规范一点,可以在表格外放一个统一 indicator。
7.3 后端加上“最大 size 限制”:别被人一口气拉爆
有人把 size=10000 一带,你的服务端就开始表演“内存消失术”。
做法很简单:在 Controller 里 clamp 一下:
int safeSize = Math.min(Math.max(size, 1), 100);
八、再聊回主题:Thymeleaf + HTMX 这套组合,适合谁?不适合谁?
我说句可能得罪人的话:不是所有项目都适合这套。
它的优势非常清晰,但边界也清晰。
8.1 特别适合
- 后台管理系统、内部工具、B 端业务系统
- 内容展示为主的站点(列表、详情、搜索、筛选)
- 团队后端更强,前端资源紧张
- 需要快速交付、长期维护成本要低
- 希望“页面有交互,但不想养一个 SPA 大工程”
8.2 不太适合
- 强交互重状态应用(复杂画布、拖拽编辑器、实时协同)
- 需要离线能力、前端缓存策略复杂
- 交互需要大量客户端计算与图形渲染
九、总结:SSR 回来了,但它不是“回到过去”,而是“把复杂度放回该放的地方”
我写到这里,自己最大的感受是:
Thymeleaf + HTMX 不是要取代现代前端,而是把“必须上 SPA”的门槛抬高了。
以前很多需求我们下意识就会说:“这得写一堆 JS。”
现在你可以先反问一句:
“真的需要把整套应用搬到浏览器里吗?还是只需要把那一小块变得更灵活?”🤔
- SSR 负责首屏与结构
- HTMX 负责局部交互与增量更新(
hx-get/hx-target/hx-trigger/hx-swap等机制在官方文档中有清晰定义) - Thymeleaf Fragment 负责组件化复用与片段渲染(
th:insert/th:replace的语义在官方文章中解释明确;片段表达式形式在文档与 API 中有定义) - Spring Boot/Spring MVC 负责把这一切“天然粘合”在一起(Boot 的 Thymeleaf 前缀后缀默认值等在官方 how-to 中写得很直白)
最后说句人话收尾:
我不是要你从明天开始“戒 JS”,那不现实;我只是想让你在每次准备开新项目时,能多一个选项——一个更轻、更稳、更好维护的选项。
毕竟,写代码这事儿,最终目的不是证明我们会多少技术,而是让系统在真实世界里活得久一点、舒服一点,对吧?😄
🧧福利赠与你🧧
无论你是计算机专业的学生,还是对编程有兴趣的小伙伴,都建议直接毫无顾忌的学习此专栏「滚雪球学SpringBoot」,bug菌郑重承诺,凡是学习此专栏的同学,均能获取到所需的知识和技能,全网最快速入门SpringBoot,就像滚雪球一样,越滚越大, 无边无际,指数级提升。
最后,如果这篇文章对你有所帮助,帮忙给作者来个一键三连,关注、点赞、收藏,您的支持就是我坚持写作最大的动力。
同时欢迎大家关注公众号:「猿圈奇妙屋」 ,以便学习更多同类型的技术文章,免费白嫖最新BAT互联网公司面试题、4000G PDF编程电子书、简历模板、技术文章Markdown文档等海量资料。
ps:本文涉及所有源代码,均已上传至Gitee:
https://gitee.com/bugjun01/SpringBoot-demo开源,供同学们一对一参考 Gitee传送门https://gitee.com/bugjun01/SpringBoot-demo,同时,原创开源不易,欢迎给个star🌟,想体验下被🌟的感jio,非常感谢❗
🫵 Who am I?
我是 bug菌:
- 热活跃于 CSDN:
https://blog.csdn.net/weixin_43970743| 掘金:https://juejin.cn/user/695333581765240| InfoQ:https://www.infoq.cn/profile/4F581734D60B28/publish| 51CTO:https://blog.51cto.com/u_15700751| 华为云:https://bbs.huaweicloud.com/community/usersnew/id_1582617489455371| 阿里云:https://developer.aliyun.com/profile/uolxikq5k3gke| 腾讯云:https://cloud.tencent.com/developer/user/10216480/articles等技术社区; - CSDN 博客之星 Top30、华为云多年度十佳博主&卓越贡献奖、掘金多年度人气作者 Top40;
- 掘金、InfoQ、51CTO 等平台签约及优质作者;
- 全网粉丝累计 30w+。
更多高质量技术内容及成长资料,可查看这个合集入口 👉 点击查看:https://bbs.csdn.net/topics/612438251 👈️
硬核技术公众号 「猿圈奇妙屋」https://bbs.csdn.net/topics/612438251 期待你的加入,一起进阶、一起打怪升级。
- End -
- 点赞
- 收藏
- 关注作者
评论(0)