你还在把一切都塞进前端打包里?SSR 回潮真只是“复古情怀”吗?

举报
bug菌 发表于 2026/01/13 22:05:28 2026/01/13
【摘要】 🏆本文收录于《滚雪球学SpringBoot 3》:https://blog.csdn.net/weixin_43970743/category_12795608.html,专门攻坚指数提升,本年度国内最系统+最专业+最详细(永久更新)。  本专栏致力打造最硬核 SpringBoot3 从零基础到进阶系列学习内容,🚀均为全网独家首发,打造精品专栏,专栏持续更新中…欢迎大家订阅持续学习。...

🏆本文收录于《滚雪球学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 片段”两套路由

我们准备两个接口:

  1. GET /contacts:返回整页(首次进入)
  2. 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>

这里发生了什么?(别跳过,这就是“魔法”来源)

  1. 动态搜索
  • hx-get="/contacts/rows":输入框触发 GET 请求
  • hx-trigger="input delay:300ms, keyup[key=='Enter']":输入后延迟 300ms 再发(debounce),或按回车立刻发
  • hx-target="#tbody":把响应塞进 tbody
  • hx-swap="innerHTML":用返回片段替换 tbody 内部内容
    这类“Active Search”的写法在 HTMX 文档中就是典型模式。
  1. 无限滚动
  • 关键在“哨兵行”(最后一行)
  • 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:insertth: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:滚动容器场景别选错

官方说明很直接:

  • 页面滚动:revealed OK
  • 自定义滚动容器(overflow scroll):用 intersect once 更靠谱

6.3 防抖别写太激进:delay 太小,后端会被你“打成筛子”

delay:300ms 是个舒服的折中。你写 delay:50ms,用户手速快一点,你的服务端日志会像机关枪。
HTMX 的触发修饰符(例如 delaychangedonce)在文档里都有说明。

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 -

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。