Appearance
视图解析与渲染
Spring MVC 提供了灵活的视图解析和渲染机制,支持多种视图技术,如 JSP、Thymeleaf、FreeMarker 等。本文详细介绍 Spring MVC 中的视图解析和渲染过程。
视图解析基础
在 Spring MVC 中,控制器处理完请求后,通常会返回一个逻辑视图名(如 "home"、"users/list" 等),然后由视图解析器将其解析为具体的视图对象。
视图解析流程
- 控制器方法处理请求并返回 ModelAndView 或视图名
- DispatcherServlet 接收控制器返回值
- 如果返回的是视图名,DispatcherServlet 将其解析为具体的 View 对象
- View 对象使用 Model 数据渲染响应
视图名解析方式
直接返回字符串(最常见):
java@GetMapping("/home") public String home(Model model) { model.addAttribute("message", "Welcome home!"); return "home"; // 返回逻辑视图名 }
返回 ModelAndView:
java@GetMapping("/users") public ModelAndView listUsers() { List<User> users = userService.findAll(); ModelAndView mav = new ModelAndView("user/list"); mav.addObject("users", users); return mav; }
通过 Model 隐式返回视图名:
java@GetMapping("/products") public void listProducts(Model model) { // 不返回视图名,默认使用请求路径作为视图名 // 视图名将是 "products" model.addAttribute("products", productService.findAll()); }
使用 RedirectView 或重定向前缀:
java@PostMapping("/users") public String createUser(@ModelAttribute User user) { userService.save(user); return "redirect:/users"; // 重定向到 /users 路径 } // 或者使用 RedirectView @PostMapping("/products") public RedirectView createProduct(@ModelAttribute Product product) { productService.save(product); return new RedirectView("/products"); }
使用 forward 前缀:
java@GetMapping("/legacy-path") public String forwardToNewPath() { return "forward:/new-path"; // 内部转发到 /new-path }
ViewResolver 视图解析器
Spring MVC 提供了多种视图解析器,每种适用于不同的视图技术。
常用视图解析器
InternalResourceViewResolver:
- 解析为 JSP 或其他内部资源
- 为视图名添加前缀和后缀
java@Bean public ViewResolver internalResourceViewResolver() { InternalResourceViewResolver resolver = new InternalResourceViewResolver(); resolver.setPrefix("/WEB-INF/views/"); resolver.setSuffix(".jsp"); return resolver; }
ThymeleafViewResolver:
- 解析为 Thymeleaf 模板
java@Bean public ViewResolver thymeleafViewResolver(SpringTemplateEngine templateEngine) { ThymeleafViewResolver resolver = new ThymeleafViewResolver(); resolver.setTemplateEngine(templateEngine); resolver.setCharacterEncoding("UTF-8"); return resolver; } @Bean public SpringTemplateEngine templateEngine(TemplateResolver templateResolver) { SpringTemplateEngine engine = new SpringTemplateEngine(); engine.setTemplateResolver(templateResolver); return engine; } @Bean public SpringResourceTemplateResolver templateResolver() { SpringResourceTemplateResolver resolver = new SpringResourceTemplateResolver(); resolver.setPrefix("classpath:/templates/"); resolver.setSuffix(".html"); resolver.setTemplateMode("HTML"); resolver.setCharacterEncoding("UTF-8"); return resolver; }
FreeMarkerViewResolver:
- 解析为 FreeMarker 模板
java@Bean public ViewResolver freeMarkerViewResolver() { FreeMarkerViewResolver resolver = new FreeMarkerViewResolver(); resolver.setCache(true); resolver.setPrefix(""); resolver.setSuffix(".ftl"); resolver.setContentType("text/html; charset=UTF-8"); return resolver; } @Bean public FreeMarkerConfigurer freeMarkerConfigurer() { FreeMarkerConfigurer configurer = new FreeMarkerConfigurer(); configurer.setTemplateLoaderPath("classpath:/templates/"); configurer.setDefaultEncoding("UTF-8"); return configurer; }
ContentNegotiatingViewResolver:
- 根据请求的内容类型(Accept 头)或 URL 后缀选择合适的视图解析器
- 可以组合多个视图解析器
java@Bean public ViewResolver contentNegotiatingViewResolver( ContentNegotiationManager manager, List<ViewResolver> resolvers) { ContentNegotiatingViewResolver resolver = new ContentNegotiatingViewResolver(); resolver.setContentNegotiationManager(manager); resolver.setViewResolvers(resolvers); // 添加默认视图 List<View> defaultViews = new ArrayList<>(); defaultViews.add(new MappingJackson2JsonView()); resolver.setDefaultViews(defaultViews); return resolver; }
ResourceBundleViewResolver:
- 从资源包(属性文件)中解析视图
java@Bean public ResourceBundleViewResolver resourceBundleViewResolver() { ResourceBundleViewResolver resolver = new ResourceBundleViewResolver(); resolver.setBasename("views"); return resolver; }
BeanNameViewResolver:
- 将视图名解析为同名的 Spring bean
java@Bean public BeanNameViewResolver beanNameViewResolver() { return new BeanNameViewResolver(); } @Bean public View pdfView() { return new PdfView(); // 自定义视图实现 }
XmlViewResolver:
- 从 XML 文件中解析视图
java@Bean public XmlViewResolver xmlViewResolver() { XmlViewResolver resolver = new XmlViewResolver(); resolver.setLocation(new ClassPathResource("views.xml")); return resolver; }
视图解析器链
Spring MVC 支持配置多个视图解析器,形成解析链:
java
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Bean
public ViewResolver contentNegotiatingViewResolver(ContentNegotiationManager manager) {
ContentNegotiatingViewResolver resolver = new ContentNegotiatingViewResolver();
resolver.setContentNegotiationManager(manager);
// 设置默认视图为 JSON
List<View> defaultViews = new ArrayList<>();
defaultViews.add(new MappingJackson2JsonView());
resolver.setDefaultViews(defaultViews);
return resolver;
}
@Bean
public ViewResolver beanNameViewResolver() {
BeanNameViewResolver resolver = new BeanNameViewResolver();
resolver.setOrder(1); // 优先级最高
return resolver;
}
@Bean
public ViewResolver thymeleafViewResolver(SpringTemplateEngine templateEngine) {
ThymeleafViewResolver resolver = new ThymeleafViewResolver();
resolver.setTemplateEngine(templateEngine);
resolver.setCharacterEncoding("UTF-8");
resolver.setOrder(2); // 第二优先级
return resolver;
}
@Bean
public ViewResolver internalResourceViewResolver() {
InternalResourceViewResolver resolver = new InternalResourceViewResolver();
resolver.setPrefix("/WEB-INF/views/");
resolver.setSuffix(".jsp");
resolver.setOrder(3); // 最低优先级
return resolver;
}
}
解析器按优先级顺序尝试解析视图名,直到一个解析器成功为止。
常用视图技术
JSP 视图
JSP 是传统的 Java Web 视图技术:
xml
<!-- Maven 依赖 -->
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>jstl</artifactId>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
<scope>provided</scope>
</dependency>
java
// 配置
@Bean
public InternalResourceViewResolver jspViewResolver() {
InternalResourceViewResolver resolver = new InternalResourceViewResolver();
resolver.setPrefix("/WEB-INF/views/");
resolver.setSuffix(".jsp");
return resolver;
}
JSP 模板示例:
jsp
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form"%>
<!DOCTYPE html>
<html>
<head>
<title>User List</title>
</head>
<body>
<h1>User List</h1>
<table>
<tr>
<th>ID</th>
<th>Name</th>
<th>Email</th>
<th>Actions</th>
</tr>
<c:forEach items="${users}" var="user">
<tr>
<td>${user.id}</td>
<td>${user.name}</td>
<td>${user.email}</td>
<td>
<a href="<c:url value='/users/${user.id}' />">View</a>
<a href="<c:url value='/users/${user.id}/edit' />">Edit</a>
</td>
</tr>
</c:forEach>
</table>
<a href="<c:url value='/users/new' />">Add User</a>
</body>
</html>
Thymeleaf 视图
Thymeleaf 是现代的、HTML5 友好的模板引擎:
xml
<!-- Maven 依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
java
// 在 Spring Boot 中,Thymeleaf 自动配置
// 手动配置示例
@Bean
public SpringResourceTemplateResolver templateResolver() {
SpringResourceTemplateResolver resolver = new SpringResourceTemplateResolver();
resolver.setPrefix("classpath:/templates/");
resolver.setSuffix(".html");
resolver.setTemplateMode("HTML");
return resolver;
}
@Bean
public SpringTemplateEngine templateEngine() {
SpringTemplateEngine engine = new SpringTemplateEngine();
engine.setTemplateResolver(templateResolver());
// 添加方言
engine.addDialect(new LayoutDialect());
engine.addDialect(new SpringSecurityDialect());
return engine;
}
@Bean
public ThymeleafViewResolver thymeleafViewResolver() {
ThymeleafViewResolver resolver = new ThymeleafViewResolver();
resolver.setTemplateEngine(templateEngine());
resolver.setCharacterEncoding("UTF-8");
return resolver;
}
Thymeleaf 模板示例:
html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>User List</title>
</head>
<body>
<h1>User List</h1>
<table>
<tr>
<th>ID</th>
<th>Name</th>
<th>Email</th>
<th>Actions</th>
</tr>
<tr th:each="user : ${users}">
<td th:text="${user.id}">1</td>
<td th:text="${user.name}">John Doe</td>
<td th:text="${user.email}">john@example.com</td>
<td>
<a th:href="@{/users/{id}(id=${user.id})}">View</a>
<a th:href="@{/users/{id}/edit(id=${user.id})}">Edit</a>
</td>
</tr>
</table>
<a th:href="@{/users/new}">Add User</a>
</body>
</html>
FreeMarker 视图
FreeMarker 是一个功能强大的模板引擎:
xml
<!-- Maven 依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>
java
// 配置
@Bean
public FreeMarkerConfigurer freeMarkerConfigurer() {
FreeMarkerConfigurer configurer = new FreeMarkerConfigurer();
configurer.setTemplateLoaderPath("classpath:/templates/");
configurer.setDefaultEncoding("UTF-8");
// 设置 FreeMarker 属性
Properties props = new Properties();
props.setProperty("auto_import", "/spring.ftl as spring");
configurer.setFreemarkerSettings(props);
return configurer;
}
@Bean
public FreeMarkerViewResolver freeMarkerViewResolver() {
FreeMarkerViewResolver resolver = new FreeMarkerViewResolver();
resolver.setCache(true);
resolver.setPrefix("");
resolver.setSuffix(".ftl");
resolver.setContentType("text/html; charset=UTF-8");
return resolver;
}
FreeMarker 模板示例:
html
<!DOCTYPE html>
<html>
<head>
<title>User List</title>
</head>
<body>
<h1>User List</h1>
<table>
<tr>
<th>ID</th>
<th>Name</th>
<th>Email</th>
<th>Actions</th>
</tr>
<#list users as user>
<tr>
<td>${user.id}</td>
<td>${user.name}</td>
<td>${user.email}</td>
<td>
<a href="/users/${user.id}">View</a>
<a href="/users/${user.id}/edit">Edit</a>
</td>
</tr>
</#list>
</table>
<a href="/users/new">Add User</a>
</body>
</html>
JSON 和 XML 视图
对于 RESTful API,常需要以 JSON 或 XML 格式返回数据。
使用 @ResponseBody 或 @RestController
java
// 使用 @ResponseBody
@Controller
public class UserApiController {
@GetMapping("/api/users")
@ResponseBody
public List<User> getUsers() {
return userService.findAll(); // 自动转换为 JSON
}
}
// 使用 @RestController (包含 @ResponseBody)
@RestController
@RequestMapping("/api/products")
public class ProductApiController {
@GetMapping
public List<Product> getProducts() {
return productService.findAll(); // 自动转换为 JSON
}
}
使用 HttpMessageConverter
Spring MVC 使用 HttpMessageConverter 实现对象与 HTTP 消息的转换:
java
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
// 添加 Jackson JSON 转换器
MappingJackson2HttpMessageConverter jsonConverter = new MappingJackson2HttpMessageConverter();
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
jsonConverter.setObjectMapper(objectMapper);
converters.add(jsonConverter);
// 添加 XML 转换器
Jaxb2RootElementHttpMessageConverter xmlConverter = new Jaxb2RootElementHttpMessageConverter();
converters.add(xmlConverter);
}
}
使用 ResponseEntity
java
@GetMapping("/api/users/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
return userService.findById(id)
.map(user -> ResponseEntity.ok().body(user)) // 200 OK
.orElse(ResponseEntity.notFound().build()); // 404 Not Found
}
@PostMapping("/api/users")
public ResponseEntity<User> createUser(@RequestBody User user) {
User savedUser = userService.save(user);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(savedUser.getId())
.toUri();
return ResponseEntity.created(location).body(savedUser); // 201 Created
}
自定义视图
可以通过实现 View 接口创建自定义视图:
java
public class PdfView extends AbstractView {
public PdfView() {
setContentType("application/pdf");
}
@Override
protected void renderMergedOutputModel(
Map<String, Object> model,
HttpServletRequest request,
HttpServletResponse response) throws Exception {
// 设置响应头
response.setHeader("Content-Disposition", "attachment; filename=\"users.pdf\"");
// 获取模型数据
List<User> users = (List<User>) model.get("users");
// 使用 iText 或其他 PDF 库生成 PDF
Document document = new Document(PageSize.A4);
PdfWriter.getInstance(document, response.getOutputStream());
document.open();
// 添加内容
document.add(new Paragraph("User List"));
PdfPTable table = new PdfPTable(3);
table.addCell("ID");
table.addCell("Name");
table.addCell("Email");
for (User user : users) {
table.addCell(String.valueOf(user.getId()));
table.addCell(user.getName());
table.addCell(user.getEmail());
}
document.add(table);
document.close();
}
}
在控制器中使用自定义视图:
java
@GetMapping("/users/pdf")
public String getUsersPdf(Model model) {
model.addAttribute("users", userService.findAll());
return "pdfView"; // 与 @Bean 名称匹配
}
// 或者直接返回视图
@GetMapping("/users/pdf-direct")
public View getUsersPdfDirect(Model model) {
model.addAttribute("users", userService.findAll());
return new PdfView();
}
需要配置 BeanNameViewResolver 以支持按名称查找视图:
java
@Bean
public BeanNameViewResolver beanNameViewResolver() {
return new BeanNameViewResolver();
}
@Bean
public PdfView pdfView() {
return new PdfView();
}
视图中的国际化(i18n)
配置国际化资源
java
@Bean
public MessageSource messageSource() {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.setBasename("messages");
messageSource.setDefaultEncoding("UTF-8");
return messageSource;
}
@Bean
public LocaleResolver localeResolver() {
SessionLocaleResolver resolver = new SessionLocaleResolver();
resolver.setDefaultLocale(Locale.US);
return resolver;
}
@Bean
public LocaleChangeInterceptor localeChangeInterceptor() {
LocaleChangeInterceptor interceptor = new LocaleChangeInterceptor();
interceptor.setParamName("lang");
return interceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(localeChangeInterceptor());
}
在 Thymeleaf 中使用国际化
html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title th:text="#{app.title}">My App</title>
</head>
<body>
<h1 th:text="#{user.list.title}">User List</h1>
<!-- 语言选择器 -->
<div>
<a th:href="@{''(lang=en)}">English</a> |
<a th:href="@{''(lang=zh_CN)}">中文</a>
</div>
<table>
<tr>
<th th:text="#{user.id}">ID</th>
<th th:text="#{user.name}">Name</th>
<th th:text="#{user.email}">Email</th>
<th th:text="#{common.actions}">Actions</th>
</tr>
<tr th:each="user : ${users}">
<td th:text="${user.id}">1</td>
<td th:text="${user.name}">John Doe</td>
<td th:text="${user.email}">john@example.com</td>
<td>
<a th:href="@{/users/{id}(id=${user.id})}" th:text="#{common.view}">View</a>
<a th:href="@{/users/{id}/edit(id=${user.id})}" th:text="#{common.edit}">Edit</a>
</td>
</tr>
</table>
<a th:href="@{/users/new}" th:text="#{user.add}">Add User</a>
</body>
</html>
在 JSP 中使用国际化
jsp
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%>
<!DOCTYPE html>
<html>
<head>
<title><spring:message code="app.title" /></title>
</head>
<body>
<h1><spring:message code="user.list.title" /></h1>
<!-- 语言选择器 -->
<div>
<a href="?lang=en">English</a> |
<a href="?lang=zh_CN">中文</a>
</div>
<table>
<tr>
<th><spring:message code="user.id" /></th>
<th><spring:message code="user.name" /></th>
<th><spring:message code="user.email" /></th>
<th><spring:message code="common.actions" /></th>
</tr>
<c:forEach items="${users}" var="user">
<tr>
<td>${user.id}</td>
<td>${user.name}</td>
<td>${user.email}</td>
<td>
<a href="<c:url value='/users/${user.id}' />">
<spring:message code="common.view" />
</a>
<a href="<c:url value='/users/${user.id}/edit' />">
<spring:message code="common.edit" />
</a>
</td>
</tr>
</c:forEach>
</table>
<a href="<c:url value='/users/new' />">
<spring:message code="user.add" />
</a>
</body>
</html>
视图中的静态资源
在 Spring Boot 中配置静态资源
默认情况下,Spring Boot 支持从以下位置加载静态资源:
- /static
- /public
- /resources
- /META-INF/resources
可以自定义静态资源位置:
java
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/assets/**")
.addResourceLocations("classpath:/static/assets/")
.setCachePeriod(3600)
.resourceChain(true)
.addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"));
}
}
在 Thymeleaf 中使用静态资源
html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<link rel="stylesheet" th:href="@{/css/main.css}" />
<script type="text/javascript" th:src="@{/js/app.js}"></script>
</head>
<body>
<img th:src="@{/images/logo.png}" alt="Logo" />
</body>
</html>
在 JSP 中使用静态资源
jsp
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="<c:url value='/css/main.css' />" />
<script type="text/javascript" src="<c:url value='/js/app.js' />"></script>
</head>
<body>
<img src="<c:url value='/images/logo.png' />" alt="Logo" />
</body>
</html>
视图共享布局(布局复用)
Thymeleaf 布局方言
xml
<!-- Maven 依赖 -->
<dependency>
<groupId>nz.net.ultraq.thymeleaf</groupId>
<artifactId>thymeleaf-layout-dialect</artifactId>
</dependency>
java
// 配置
@Bean
public SpringTemplateEngine templateEngine() {
SpringTemplateEngine engine = new SpringTemplateEngine();
engine.setTemplateResolver(templateResolver());
engine.addDialect(new LayoutDialect()); // 添加布局方言
return engine;
}
布局模板(layout.html):
html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<title layout:title-pattern="$CONTENT_TITLE - $LAYOUT_TITLE">My App</title>
<link rel="stylesheet" th:href="@{/css/main.css}" />
<script th:src="@{/js/app.js}"></script>
<th:block layout:fragment="styles"></th:block>
</head>
<body>
<header>
<h1>My Application</h1>
<nav>
<ul>
<li><a th:href="@{/home}">Home</a></li>
<li><a th:href="@{/users}">Users</a></li>
<li><a th:href="@{/products}">Products</a></li>
</ul>
</nav>
</header>
<main layout:fragment="content">
<!-- 页面内容将被插入此处 -->
</main>
<footer>
<p>© 2023 My Company</p>
</footer>
<th:block layout:fragment="scripts"></th:block>
</body>
</html>
内容页面(users.html):
html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout}">
<head>
<title>User List</title>
<th:block layout:fragment="styles">
<link rel="stylesheet" th:href="@{/css/users.css}" />
</th:block>
</head>
<body>
<main layout:fragment="content">
<h2>User List</h2>
<table>
<!-- 用户列表 -->
</table>
</main>
<th:block layout:fragment="scripts">
<script th:src="@{/js/users.js}"></script>
</th:block>
</body>
</html>
JSP Tiles 布局
xml
<!-- Maven 依赖 -->
<dependency>
<groupId>org.apache.tiles</groupId>
<artifactId>tiles-jsp</artifactId>
<version>3.0.8</version>
</dependency>
java
// 配置
@Bean
public TilesConfigurer tilesConfigurer() {
TilesConfigurer configurer = new TilesConfigurer();
configurer.setDefinitions("/WEB-INF/tiles.xml");
configurer.setCheckRefresh(true);
return configurer;
}
@Bean
public TilesViewResolver tilesViewResolver() {
TilesViewResolver resolver = new TilesViewResolver();
resolver.setOrder(1);
return resolver;
}
Tiles 配置(tiles.xml):
xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE tiles-definitions PUBLIC
"-//Apache Software Foundation//DTD Tiles Configuration 3.0//EN"
"http://tiles.apache.org/dtds/tiles-config_3_0.dtd">
<tiles-definitions>
<definition name="base" template="/WEB-INF/views/layout/layout.jsp">
<put-attribute name="title" value="My App" />
<put-attribute name="header" value="/WEB-INF/views/layout/header.jsp" />
<put-attribute name="menu" value="/WEB-INF/views/layout/menu.jsp" />
<put-attribute name="body" value="" />
<put-attribute name="footer" value="/WEB-INF/views/layout/footer.jsp" />
</definition>
<definition name="users" extends="base">
<put-attribute name="title" value="User List" />
<put-attribute name="body" value="/WEB-INF/views/user/list.jsp" />
</definition>
<definition name="user/detail" extends="base">
<put-attribute name="title" value="User Detail" />
<put-attribute name="body" value="/WEB-INF/views/user/detail.jsp" />
</definition>
</tiles-definitions>
布局模板(layout.jsp):
jsp
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%@ taglib prefix="tiles" uri="http://tiles.apache.org/tags-tiles"%>
<!DOCTYPE html>
<html>
<head>
<title><tiles:getAsString name="title" /></title>
<link rel="stylesheet" href="<c:url value='/css/main.css' />" />
<script src="<c:url value='/js/app.js' />"></script>
</head>
<body>
<header>
<tiles:insertAttribute name="header" />
</header>
<nav>
<tiles:insertAttribute name="menu" />
</nav>
<main>
<tiles:insertAttribute name="body" />
</main>
<footer>
<tiles:insertAttribute name="footer" />
</footer>
</body>
</html>
实战:完整视图渲染示例
控制器
java
@Controller
@RequestMapping("/products")
public class ProductController {
private final ProductService productService;
private final CategoryService categoryService;
@Autowired
public ProductController(ProductService productService, CategoryService categoryService) {
this.productService = productService;
this.categoryService = categoryService;
}
@ModelAttribute("categories")
public List<Category> populateCategories() {
return categoryService.findAll();
}
@GetMapping
public String listProducts(
@RequestParam(required = false) String category,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
Model model) {
Page<Product> products;
if (category != null && !category.isEmpty()) {
products = productService.findByCategory(category, PageRequest.of(page, size));
model.addAttribute("category", category);
} else {
products = productService.findAll(PageRequest.of(page, size));
}
model.addAttribute("products", products.getContent());
model.addAttribute("currentPage", page);
model.addAttribute("totalPages", products.getTotalPages());
model.addAttribute("totalItems", products.getTotalElements());
return "product/list";
}
@GetMapping("/{id}")
public String getProduct(@PathVariable Long id, Model model) {
Product product = productService.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product not found"));
model.addAttribute("product", product);
model.addAttribute("relatedProducts",
productService.findRelatedProducts(product, 4));
return "product/detail";
}
@GetMapping("/new")
public String createProductForm(Model model) {
model.addAttribute("product", new ProductDto());
return "product/form";
}
@PostMapping
public String createProduct(
@Valid @ModelAttribute("product") ProductDto productDto,
BindingResult result,
RedirectAttributes redirectAttributes) {
if (result.hasErrors()) {
return "product/form";
}
Product savedProduct = productService.save(convertToEntity(productDto));
redirectAttributes.addFlashAttribute("message", "Product created successfully");
redirectAttributes.addAttribute("id", savedProduct.getId());
return "redirect:/products/{id}";
}
// 其他方法...
}
Thymeleaf 模板
产品列表页面(products/list.html):
html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout}">
<head>
<title>Product List</title>
<link rel="stylesheet" th:href="@{/css/products.css}" />
</head>
<body>
<main layout:fragment="content">
<h2>Products</h2>
<!-- 分类过滤器 -->
<div class="filter">
<form th:action="@{/products}" method="get">
<label for="category">Category:</label>
<select id="category" name="category">
<option value="">All Categories</option>
<option th:each="cat : ${categories}"
th:value="${cat.name}"
th:text="${cat.name}"
th:selected="${cat.name == category}">Category</option>
</select>
<button type="submit">Filter</button>
</form>
</div>
<!-- 产品列表 -->
<div class="product-grid">
<div th:each="product : ${products}" class="product-card">
<a th:href="@{/products/{id}(id=${product.id})}">
<img th:src="${product.imageUrl}" alt="Product image" />
<h3 th:text="${product.name}">Product Name</h3>
</a>
<p th:text="${product.shortDescription}">Description</p>
<div class="price">
<span th:if="${product.discountPrice}" class="original-price"
th:text="${'$' + #numbers.formatDecimal(product.price, 1, 2)}">$100.00</span>
<span th:if="${product.discountPrice}" class="discount-price"
th:text="${'$' + #numbers.formatDecimal(product.discountPrice, 1, 2)}">$80.00</span>
<span th:unless="${product.discountPrice}"
th:text="${'$' + #numbers.formatDecimal(product.price, 1, 2)}">$100.00</span>
</div>
<button class="add-to-cart" th:data-id="${product.id}">Add to Cart</button>
</div>
</div>
<!-- 分页控件 -->
<div class="pagination" th:if="${totalPages > 1}">
<span th:if="${currentPage > 0}">
<a th:href="@{/products(page=0, size=${size}, category=${category})}">First</a>
<a th:href="@{/products(page=${currentPage - 1}, size=${size}, category=${category})}">Previous</a>
</span>
<span th:each="i : ${#numbers.sequence(0, totalPages - 1)}">
<a th:if="${i != currentPage}"
th:href="@{/products(page=${i}, size=${size}, category=${category})}"
th:text="${i + 1}">1</a>
<span th:if="${i == currentPage}" th:text="${i + 1}" class="current">1</span>
</span>
<span th:if="${currentPage < totalPages - 1}">
<a th:href="@{/products(page=${currentPage + 1}, size=${size}, category=${category})}">Next</a>
<a th:href="@{/products(page=${totalPages - 1}, size=${size}, category=${category})}">Last</a>
</span>
</div>
</main>
<th:block layout:fragment="scripts">
<script th:src="@{/js/products.js}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const addToCartButtons = document.querySelectorAll('.add-to-cart');
addToCartButtons.forEach(button => {
button.addEventListener('click', function() {
const productId = this.getAttribute('data-id');
addProductToCart(productId, 1);
});
});
});
</script>
</th:block>
</body>
</html>
产品详情页面(products/detail.html):
html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout}">
<head>
<title th:text="${product.name}">Product Detail</title>
<link rel="stylesheet" th:href="@{/css/product-detail.css}" />
</head>
<body>
<main layout:fragment="content">
<div class="product-detail">
<div class="product-images">
<img th:src="${product.imageUrl}" alt="Product image" class="main-image" />
<div class="thumbnails" th:if="${not #lists.isEmpty(product.additionalImages)}">
<img th:each="img : ${product.additionalImages}"
th:src="${img}"
alt="Product thumbnail"
class="thumbnail" />
</div>
</div>
<div class="product-info">
<h1 th:text="${product.name}">Product Name</h1>
<div class="product-price">
<span th:if="${product.discountPrice}" class="original-price"
th:text="${'$' + #numbers.formatDecimal(product.price, 1, 2)}">$100.00</span>
<span th:if="${product.discountPrice}" class="discount-price"
th:text="${'$' + #numbers.formatDecimal(product.discountPrice, 1, 2)}">$80.00</span>
<span th:unless="${product.discountPrice}" class="price"
th:text="${'$' + #numbers.formatDecimal(product.price, 1, 2)}">$100.00</span>
</div>
<div class="product-availability" th:text="${product.available ? 'In Stock' : 'Out of Stock'}">
In Stock
</div>
<div class="product-description" th:utext="${product.description}">
Product description...
</div>
<div class="product-actions">
<div class="quantity">
<button class="decrease-qty">-</button>
<input type="number" id="quantity" value="1" min="1" max="99" />
<button class="increase-qty">+</button>
</div>
<button id="add-to-cart" class="add-to-cart-btn"
th:data-id="${product.id}"
th:disabled="${!product.available}">Add to Cart</button>
</div>
<div class="product-meta">
<div class="category">
<span>Category:</span>
<a th:href="@{/products(category=${product.category.name})}"
th:text="${product.category.name}">Category</a>
</div>
<div class="tags" th:if="${not #lists.isEmpty(product.tags)}">
<span>Tags:</span>
<span th:each="tag, iterStat : ${product.tags}">
<a th:href="@{/products(tag=${tag})}" th:text="${tag}">Tag</a><th:block th:if="${!iterStat.last}">, </th:block>
</span>
</div>
</div>
</div>
</div>
<!-- 相关产品 -->
<div class="related-products" th:if="${not #lists.isEmpty(relatedProducts)}">
<h3>Related Products</h3>
<div class="product-grid">
<div th:each="related : ${relatedProducts}" class="product-card">
<a th:href="@{/products/{id}(id=${related.id})}">
<img th:src="${related.imageUrl}" alt="Related product image" />
<h4 th:text="${related.name}">Related Product</h4>
</a>
<span class="price" th:text="${'$' + #numbers.formatDecimal(related.price, 1, 2)}">$100.00</span>
</div>
</div>
</div>
</main>
<th:block layout:fragment="scripts">
<script th:src="@{/js/product-detail.js}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// 数量增减
const decreaseBtn = document.querySelector('.decrease-qty');
const increaseBtn = document.querySelector('.increase-qty');
const quantityInput = document.querySelector('#quantity');
decreaseBtn.addEventListener('click', function() {
let value = parseInt(quantityInput.value);
if (value > 1) {
quantityInput.value = value - 1;
}
});
increaseBtn.addEventListener('click', function() {
let value = parseInt(quantityInput.value);
if (value < 99) {
quantityInput.value = value + 1;
}
});
// 添加到购物车
const addToCartBtn = document.querySelector('#add-to-cart');
addToCartBtn.addEventListener('click', function() {
const productId = this.getAttribute('data-id');
const quantity = parseInt(quantityInput.value);
addProductToCart(productId, quantity);
});
// 缩略图切换
const thumbnails = document.querySelectorAll('.thumbnail');
const mainImage = document.querySelector('.main-image');
thumbnails.forEach(thumb => {
thumb.addEventListener('click', function() {
mainImage.src = this.src;
});
});
});
</script>
</th:block>
</body>
</html>