Skip to content

视图解析与渲染

Spring MVC 提供了灵活的视图解析和渲染机制,支持多种视图技术,如 JSP、Thymeleaf、FreeMarker 等。本文详细介绍 Spring MVC 中的视图解析和渲染过程。

视图解析基础

在 Spring MVC 中,控制器处理完请求后,通常会返回一个逻辑视图名(如 "home"、"users/list" 等),然后由视图解析器将其解析为具体的视图对象。

视图解析流程

  1. 控制器方法处理请求并返回 ModelAndView 或视图名
  2. DispatcherServlet 接收控制器返回值
  3. 如果返回的是视图名,DispatcherServlet 将其解析为具体的 View 对象
  4. View 对象使用 Model 数据渲染响应

视图名解析方式

  1. 直接返回字符串(最常见):

    java
    @GetMapping("/home")
    public String home(Model model) {
        model.addAttribute("message", "Welcome home!");
        return "home"; // 返回逻辑视图名
    }
  2. 返回 ModelAndView

    java
    @GetMapping("/users")
    public ModelAndView listUsers() {
        List<User> users = userService.findAll();
        ModelAndView mav = new ModelAndView("user/list");
        mav.addObject("users", users);
        return mav;
    }
  3. 通过 Model 隐式返回视图名

    java
    @GetMapping("/products")
    public void listProducts(Model model) {
        // 不返回视图名,默认使用请求路径作为视图名
        // 视图名将是 "products"
        model.addAttribute("products", productService.findAll());
    }
  4. 使用 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");
    }
  5. 使用 forward 前缀

    java
    @GetMapping("/legacy-path")
    public String forwardToNewPath() {
        return "forward:/new-path"; // 内部转发到 /new-path
    }

ViewResolver 视图解析器

Spring MVC 提供了多种视图解析器,每种适用于不同的视图技术。

常用视图解析器

  1. InternalResourceViewResolver

    • 解析为 JSP 或其他内部资源
    • 为视图名添加前缀和后缀
    java
    @Bean
    public ViewResolver internalResourceViewResolver() {
        InternalResourceViewResolver resolver = new InternalResourceViewResolver();
        resolver.setPrefix("/WEB-INF/views/");
        resolver.setSuffix(".jsp");
        return resolver;
    }
  2. 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;
    }
  3. 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;
    }
  4. 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;
    }
  5. ResourceBundleViewResolver

    • 从资源包(属性文件)中解析视图
    java
    @Bean
    public ResourceBundleViewResolver resourceBundleViewResolver() {
        ResourceBundleViewResolver resolver = new ResourceBundleViewResolver();
        resolver.setBasename("views");
        return resolver;
    }
  6. BeanNameViewResolver

    • 将视图名解析为同名的 Spring bean
    java
    @Bean
    public BeanNameViewResolver beanNameViewResolver() {
        return new BeanNameViewResolver();
    }
    
    @Bean
    public View pdfView() {
        return new PdfView(); // 自定义视图实现
    }
  7. 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>&copy; 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>