三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

SpringBoot整合Thymeleaf与ECharts:服务端渲染下的数据可视化实践

SpringBoot整合Thymeleaf与ECharts:服务端渲染下的数据可视化实践

1. 项目概述:从数据到图表的最后一公里

做后端开发的朋友,尤其是用SpringBoot的,肯定都遇到过这样的场景:费了老大劲从数据库里把数据查出来,在Service层里各种计算、聚合,Controller里也封装得漂漂亮亮,结果一到前端页面,就变成了干巴巴的表格,或者更糟——一堆让人眼花缭乱的JSON字符串。业务方或者产品经理看着直摇头:“这数据我看不懂啊,能不能直观一点?” 这时候,一个能把数据“画”出来的图表库,就成了刚需。

ECharts,这个百度开源的前端可视化库,凭借其丰富的图表类型、流畅的交互和详尽的文档,几乎成了国内开发者做数据可视化的首选。但问题来了,在传统的服务端渲染架构里,比如我们常用的SpringBoot + Thymeleaf组合,如何把后端Java对象里的数据,丝滑地送到前端的ECharts实例里,让它渲染出我们想要的折线图、柱状图或者饼图?这个过程,就是数据展示的“最后一公里”,看似简单,却藏着不少门道。

很多人一听到“前后端数据交互”,第一反应就是搞个前后端分离,用Vue或React,通过REST API来异步获取数据。这当然是一种主流且优秀的架构。但在很多内部管理系统、对首屏加载速度有要求、或者项目体量没那么大的场景下,服务端渲染(SSR)依然有其独特的优势:SEO友好、首屏直出速度快、无需额外部署Node服务。SpringBoot整合Thymeleaf正是这种模式的经典代表。在这个模式下,我们不再通过Ajax请求JSON,而是直接在服务器端将数据“塞”进HTML页面,由Thymeleaf模板引擎渲染成最终的HTML,连同数据和图表初始化逻辑一并发送给浏览器。

所以,“Thymeleaf+ECharts,显示后端传来的数据”这个主题,核心就是解决在服务端渲染的SpringBoot应用中,如何高效、优雅地完成从后端Java对象到前端ECharts图表的数据绑定与渲染。这不仅仅是调通一个Demo,更涉及到数据格式的转换、Thymeleaf模板语法的灵活运用、以及面对复杂数据结构时的架构设计思考。接下来,我就以一个实际迭代过的数据看板项目为例,拆解这里面的核心环节和那些容易踩坑的细节。

2. 核心思路与架构选型:为什么是Thymeleaf内联脚本?

在决定用Thymeleaf传递数据给ECharts之前,我们其实有几个备选方案。理解为什么最终选择特定方案,比直接看代码更重要。

2.1 备选方案对比与抉择

最常见的思路无非以下几种:

  1. Ajax异步加载:页面加载完成后,前端JavaScript发起Ajax请求到某个Controller接口,获取JSON数据,然后初始化ECharts。这是前后端分离的常规操作。
  2. 将数据输出到HTML的><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> <!-- 可选,用于简化JSON操作,如手动序列化 --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

    3. 从零构建:一个完整的销售数据看板示例

    我们通过一个模拟的“月度销售数据看板”来贯穿整个流程。假设我们需要展示两个图表:1)月度销售额趋势折线图;2)产品类别销售额占比饼图。

    3.1 后端数据模型与控制器设计

    首先,在后端定义清晰的数据结构。这是所有工作的基石。

    1. 定义图表数据模型 (ChartData.java)我们不是简单地把数据库Entity扔到前端,而是构建专为前端图表服务的DTO(Data Transfer Object)。这符合关注点分离的原则。

    import lombok.Data; import java.util.List; @Data public class SalesTrendDTO { // 折线图X轴数据:月份列表,如 ["1月", "2月", ...] private List<String> months; // 折线图Y轴数据:销售额列表,如 [120, 200, ...] private List<BigDecimal> amounts; // 可以扩展其他系列,比如“成本”线 // private List<BigDecimal> costs; } @Data public class CategoryShareDTO { // 饼图数据项列表 private List<PieItem> data; @Data public static class PieItem { // 产品类别名称,如 “电子产品” private String name; // 该类别的销售额 private BigDecimal value; // 可以为每个项自定义颜色等(可选) // private String itemStyle; } }

    使用BigDecimal而不是Double来处理金额是避免精度丢失的好习惯。

    2. 构建服务层 (ChartService.java)这里模拟从数据库或其它服务获取数据并组装成DTO的过程。

    @Service public class ChartService { public SalesTrendDTO getMonthlySalesTrend() { // 模拟数据,实际应从数据库查询 SalesTrendDTO dto = new SalesTrendDTO(); dto.setMonths(Arrays.asList("1月", "2月", "3月", "4月", "5月", "6月")); dto.setAmounts(Arrays.asList( new BigDecimal("120.5"), new BigDecimal("200.0"), new BigDecimal("180.3"), new BigDecimal("300.7"), new BigDecimal("280.9"), new BigDecimal("350.2") )); return dto; } public CategoryShareDTO getCategoryShare() { CategoryShareDTO dto = new CategoryShareDTO(); List<CategoryShareDTO.PieItem> items = new ArrayList<>(); items.add(new CategoryShareDTO.PieItem("电子产品", new BigDecimal("150.2"))); items.add(new CategoryShareDTO.PieItem("服装", new BigDecimal("89.5"))); items.add(new CategoryShareDTO.PieItem("食品", new BigDecimal("65.8"))); items.add(new CategoryShareDTO.PieItem("图书", new BigDecimal("45.3"))); dto.setData(items); return dto; } }

    3. 编写控制器 (DashboardController.java)控制器负责调用服务,并将数据模型传递给Thymeleaf视图。

    @Controller @RequestMapping("/dashboard") public class DashboardController { @Autowired private ChartService chartService; @GetMapping public String index(Model model) { // 将图表数据对象添加到Model中,Thymeleaf可以通过变量名访问 model.addAttribute("salesTrend", chartService.getMonthlySalesTrend()); model.addAttribute("categoryShare", chartService.getCategoryShare()); // 返回视图名称,对应 src/main/resources/templates/dashboard.html return "dashboard"; } }

    关键点在于model.addAttribute,这里把名为salesTrendcategoryShare的对象放入了请求上下文中。

    3.2 前端页面与Thymeleaf模板集成

    接下来是核心的前端模板页面dashboard.html

    1. 基础页面结构与ECharts引入

    <!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>销售数据看板</title> <!-- 引入 ECharts CDN --> <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script> <style> .chart-container { width: 600px; height: 400px; margin: 20px auto; border: 1px solid #eee; border-radius: 8px; padding: 10px; } h2 { text-align: center; } </style> </head> <body> <h1>销售数据看板</h1> <div> <h2>月度销售额趋势</h2> <div id="trendChart" class="chart-container"></div> </div> <div> <h2>产品类别销售额占比</h2> <div id="shareChart" class="chart-container"></div> </div> <!-- 图表初始化脚本 --> <script th:inline="javascript"> // 接下来的脚本内容将在这里编写 </script> </body> </html>

    注意<html>标签中的xmlns:th声明,这是使用Thymeleaf属性的基础。我们为两个图表准备了具有唯一ID的容器div

    2. 使用Thymeleaf传递数据到JavaScript(关键步骤)这是最精髓的部分。我们在<script th:inline="javascript">标签内操作。

    <script th:inline="javascript"> /*<![CDATA[*/ // 1. 使用Thymeleaf表达式将后端数据赋值给JS变量 // Thymeleaf会自动处理Java对象到JSON的转换 var salesTrendData = /*[[${salesTrend}]]*/ null; var categoryShareData = /*[[${categoryShare}]]*/ null; // 2. 调试:在控制台打印数据,确认数据已正确注入 console.log("趋势数据:", salesTrendData); console.log("占比数据:", categoryShareData); // 3. 初始化图表 document.addEventListener('DOMContentLoaded', function() { // 初始化趋势折线图 var trendChart = echarts.init(document.getElementById('trendChart')); var trendOption = { title: { text: '月度销售额趋势', left: 'center' }, tooltip: { trigger: 'axis' }, legend: { data: ['销售额'], bottom: 0 }, xAxis: { type: 'category', // 直接使用从后端注入的JS变量 data: salesTrendData.months }, yAxis: { type: 'value' }, series: [{ name: '销售额', type: 'line', // 直接使用从后端注入的JS变量 data: salesTrendData.amounts, smooth: true }] }; trendChart.setOption(trendOption); // 初始化占比饼图 var shareChart = echarts.init(document.getElementById('shareChart')); var shareOption = { title: { text: '产品类别销售额占比', left: 'center' }, tooltip: { trigger: 'item', formatter: '{a} <br/>{b}: {c} ({d}%)' }, legend: { orient: 'vertical', left: 'left', // 图例数据可以从 series.data 的 name 属性生成,也可以单独指定 data: categoryShareData.data.map(item => item.name) }, series: [{ name: '销售额占比', type: 'pie', radius: '50%', // 直接使用从后端注入的JS变量 data: categoryShareData.data, emphasis: { itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0, 0, 0, 0.5)' } } }] }; shareChart.setOption(shareOption); // 4. 响应窗口大小变化 window.addEventListener('resize', function() { trendChart.resize(); shareChart.resize(); }); }); /*]]>*/ </script>

    代码深度解析:

    • th:inline="javascript":这个属性告知Thymeleaf引擎,此<script>块内的内容需要被解析,其中的Thymeleaf表达式([[...]])会被求值。
    • /*<![CDATA[*/ ... /*]]>*/:这是XML CDATA区块,用于包裹可能包含特殊字符(如<,&)的JavaScript代码,防止被解析为XML。虽然现代浏览器在HTML中不一定需要,但这是一个好习惯,能保证兼容性。
    • /*[[${salesTrend}]]*/ null:这是Thymeleaf的内联表达式。
      • [[...]]表示在JavaScript上下文中的求值。
      • ${salesTrend}引用我们在Controller中放入Model的属性。
      • Thymeleaf会智能地将Java对象salesTrend(一个SalesTrendDTO实例)序列化成JSON字符串,并直接嵌入到JavaScript源代码中。最终在浏览器里看到的会是:var salesTrendData = {"months":["1月","2月",...], "amounts":[120.5,200.0,...]};
      • null是“原型注释”,当直接在浏览器打开此HTML文件(不经过Thymeleaf渲染)时,变量会被赋值为null,避免了脚本错误,便于前端单独调试。
    • 数据使用:在ECharts配置项的data中,我们直接使用了salesTrendData.monthscategoryShareData.data这些JS对象属性,非常直观。

    至此,一个完整的、数据从后端Java对象通过Thymeleaf传递到前端ECharts图表的基础流程就完成了。启动SpringBoot应用,访问/dashboard,就能看到渲染好的图表。

    4. 进阶技巧与深度优化

    基础跑通后,我们会遇到更实际的问题:数据需要格式化、数据结构更复杂、需要动态更新等。下面分享几个进阶处理技巧。

    4.1 复杂数据结构的处理与格式化

    场景一:数字格式化与千分位后端传来的BigDecimal金额,在前端显示时可能需要千分位分隔(如1,200.50)。我们可以在后端格式化,也可以在前端用ECharts的formatter处理。更推荐在后端DTO中直接提供格式化后的字符串,避免前端计算负担。

    // 在Service层或DTO内部方法中格式化 public class SalesTrendDTO { private List<String> months; private List<BigDecimal> amounts; private List<String> formattedAmounts; // 新增:格式化后的字符串列表 // 提供一个方法,在设置amounts时同步生成formattedAmounts public void setAmounts(List<BigDecimal> amounts) { this.amounts = amounts; this.formattedAmounts = amounts.stream() .map(amount -> NumberFormat.getNumberInstance(Locale.US).format(amount)) .collect(Collectors.toList()); } // ... getters }

    在ECharts的tooltipaxisLabelformatter中,就可以使用formattedAmounts了。如果使用Thymeleaf的#numbers工具对象,也可以在模板内格式化,但这样会混入视图逻辑,不够优雅。

    场景二:多系列数据与动态颜色假设折线图要同时展示“销售额”和“成本”两个系列。我们需要调整DTO和图表配置。

    @Data public class SalesTrendDTO { private List<String> months; private List<BigDecimal> salesAmounts; // 销售额系列 private List<BigDecimal> costAmounts; // 成本系列 // 可以包含系列名称、颜色等元数据 private List<SeriesMeta> seriesMetas; }

    前端配置需要对应调整series数组:

    series: [ { name: '销售额', type: 'line', data: salesTrendData.salesAmounts }, { name: '成本', type: 'line', data: salesTrendData.costAmounts, itemStyle: { color: '#ff9800' } // 自定义颜色 } ]

    4.2 使用Thymeleaf工具对象进行模板内处理

    Thymeleaf提供了强大的工具对象(如#dates,#numbers,#lists),可以在模板内进行简单处理。例如,如果后端传来的是Date对象,可以在模板内格式化:

    // 假设后端传来的是 List<Date> monthDates var monthNames = /*[[${monthDates.![#dates.format(., 'MM月')]}]]*/ [];

    ${monthDates.![#dates.format(., 'MM月')]}使用了Thymeleaf的“投影”语法,对列表中的每个元素应用#dates.format方法。但请注意,复杂的逻辑处理应尽量放在后端,保持模板简洁。

    4.3 图表组件的复用与模块化

    当页面有多个类似图表时,重复的初始化代码会显得臃肿。我们可以将图表初始化逻辑封装成函数。

    function initLineChart(containerId, chartData, title, seriesName) { var chart = echarts.init(document.getElementById(containerId)); var option = { title: { text: title, left: 'center' }, xAxis: { type: 'category', data: chartData.months }, yAxis: { type: 'value' }, series: [{ name: seriesName, type: 'line', data: chartData.amounts }] }; chart.setOption(option); return chart; // 返回图表实例,便于后续操作(如resize) } // 使用 var trendChart = initLineChart('trendChart', salesTrendData, '月度销售额趋势', '销售额');

    更进一步,可以将不同图表的配置(如饼图、柱状图)也封装成工厂函数或配置对象,大大提高代码的可维护性。

    5. 常见问题排查与性能优化实录

    在实际开发中,你肯定会遇到下面这些问题。我把踩过的坑和解决方案记录下来,希望能帮你节省时间。

    5.1 数据未正确绑定:页面空白或控制台报错

    这是最常见的问题。请按以下步骤排查:

    1. 检查Controller是否将数据放入Model:确保model.addAttribute的键名与模板中${}内的变量名完全一致(区分大小写)。
    2. 检查Thymeleaf表达式语法:确保使用了th:inline="javascript",并且表达式写在/*[[${...}]]*/内。
    3. 查看网页源代码:在浏览器中右键点击页面,选择“查看网页源代码”。搜索你定义的JS变量名(如salesTrendData)。你应该能看到类似var salesTrendData = {"months":[...]};已渲染的JSON字符串。如果看到的是var salesTrendData = null;或原始的/*[[${salesTrend}]]*/文本,说明Thymeleaf没有执行渲染。
      • 可能原因A:访问的URL不对,没有经过Spring MVC的Controller处理。确保你访问的是http://localhost:8080/dashboard,而不是直接打开静态HTML文件。
      • 可能原因B:模板文件位置错误。Thymeleaf默认在classpath:/templates/目录下查找模板,且视图名(Controller返回的字符串)需要与模板文件名(不含后缀)匹配。
    4. 检查浏览器控制台(Console):打开开发者工具,查看是否有JavaScript错误。常见的错误是“Uncaught ReferenceError: salesTrendData is undefined”,这通常意味着变量声明失败,回到第3步检查源代码。
    5. 检查ECharts容器:确保echarts.init(document.getElementById('...'))中的ID与页面上div的ID匹配,并且该div在脚本执行前已经加载(这就是为什么我们把脚本放在body底部或使用DOMContentLoaded事件)。

    5.2 数据格式错误:图表显示异常

    图表能出来,但数据不对,比如X轴标签乱码、Y轴数值为0。

    1. 数据类型不符:ECharts的series.data对于折线图、柱状图通常接收数值数组(number[])。如果你从后端传来的是字符串数组["120.5", "200.0"],图表可能无法正确解析。确保在后端使用BigDecimalDouble等数值类型,Thymeleaf会将其序列化为JSON数字。
    2. JSON序列化问题:复杂的Java对象(如包含LocalDateTime、自定义枚举)可能无法被Thymeleaf默认的序列化机制正确处理。这时,可以在DTO中将其转换为字符串或基本类型,或者使用Jackson的@JsonFormat等注解来定制序列化行为。
    3. 空值或null处理:如果数据列表中有null,ECharts可能会中断绘制。在后端数据组装阶段,尽量用0或空字符串等默认值替换null

    5.3 性能优化与最佳实践

    当图表数据量变大或页面图表过多时,需要考虑性能。

    1. 数据量控制:这是最重要的优化点。尽量避免一次性将成千上万条数据点推送到前端。对于时间序列数据,考虑在后端进行聚合(按小时、天聚合)、采样分页加载。ECharts渲染大量数据时也会卡顿。
    2. 使用数据集(dataset):对于多系列共享同一维度数据的情况,使用ECharts的dataset特性可以更高效地管理数据,并且方便进行数据过滤、映射等操作。
      // 传统方式 xAxis: { data: months }, series: [{ data: sales }, { data: costs }] // 使用dataset option = { dataset: { source: [ ['month', 'sales', 'cost'], // 维度定义 ['1月', 120, 95], ['2月', 200, 110], // ... ] }, xAxis: { type: 'category' }, // 不再需要显式指定data yAxis: {}, series: [ { type: 'line', encode: { x: 'month', y: 'sales' } }, { type: 'line', encode: { x: 'month', y: 'cost' } } ] };
      我们可以将salesTrendData构造成适合dataset.source的二维数组格式,通过Thymeleaf传递。
    3. 懒加载与按需渲染:如果页面图表很多,可以考虑初始只渲染可视区域内的图表,当用户滚动时再动态初始化其他图表。
    4. 图表实例管理:在单页面应用(SPA)或标签页切换的场景中,记得在销毁DOM元素前调用echartsInstance.dispose()来释放图表实例,防止内存泄漏。

    5.4 安全性考量

    1. XSS防护:Thymeleaf的th:text[[...]]在输出到HTML和JavaScript上下文时,默认会进行转义,这为我们提供了基础的安全防护。绝对不要使用不安全的字符串拼接方式将数据注入JS,例如:var data = '[[${rawString}]]';
    2. 数据权限:在服务端渲染模型中,数据是在服务器端组装的。务必在Service层或Controller层做好数据权限校验,确保用户只能看到其有权访问的数据。不要因为前端做了隐藏,就认为数据安全了——用户依然可以通过查看网页源代码看到所有通过Thymeleaf注入的数据。

    6. 扩展思考:何时选择服务端渲染 vs. 前后端分离

    通过这个项目,我们实践了在服务端渲染架构下集成ECharts的方案。那么,它和纯粹的前后端分离(前端框架 + REST API)相比,优劣如何?该如何选择?

    选择 SpringBoot + Thymeleaf + ECharts(服务端渲染)当:

    • 项目相对简单:主要是CRUD和管理界面,交互复杂度不高。
    • 追求极致的首屏加载速度:页面内容(包括数据)一次性返回,无需等待多个API调用。
    • SEO很重要:搜索引擎爬虫能直接抓取到渲染好的包含数据的HTML内容。
    • 团队技术栈偏后端:不想引入复杂的前端工程化(Webpack, Node.js环境等),希望用Java统一技术栈。

    选择前后端分离(如Vue/React + SpringBoot API)当:

    • 前端交互极其复杂:需要丰富的单页面应用(SPA)体验,大量组件化、状态管理。
    • 多端复用API:同一套后端API需要同时服务于Web、移动端App、小程序等。
    • 前后端开发完全解耦:前后端团队可以并行开发,通过API契约进行协作。
    • 前端需要强大的状态管理和构建工具:项目庞大,需要代码分割、热更新、静态资源优化等。

    混合模式:在实际项目中,也存在混合模式。例如,主要页面使用服务端渲染保证首屏和SEO,而其中的某个复杂数据看板模块,通过内嵌的Vue组件来开发,该组件通过Ajax动态加载数据。这种模式对架构设计提出了更高要求。

    Thymeleaf配合ECharts的方案,在它适用的场景下,是一种简洁、高效、稳定的选择。它让后端开发者能够以熟悉的模式快速构建出数据可视化界面,而不必深入前端框架的细节。理解其原理,掌握数据绑定的技巧,并注意性能和安全性问题,就能让数据在后端与前端的图表间流畅起舞。

← 返回列表