news 2026/9/13 9:47:29

SpringBoot模板引擎原理与选型实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot模板引擎原理与选型实战指南

1. 为什么SpringBoot项目里总在纠结“用哪个模板引擎”——它真只是个HTML生成器吗?

刚接手一个SpringBoot老项目时,我第一眼看到thymeleaf<div th:text="${user.name}">默认文本</div>语法,下意识觉得:“不就是服务端把变量塞进HTML里渲染出来嘛,和PHP的<?php echo $name; ?>有啥区别?”结果上线前压测,页面响应时间突然飙升300%,排查半天才发现是Thymeleaf在生产环境没关调试模式,每次渲染都去校验模板文件修改时间——这种“看似简单、实则暗坑密布”的体验,在SpringBoot模板引擎场景里太常见了。

核心关键词:SpringBoot、模板引擎、原理。这三个词连在一起,绝不是教科书里“模板引擎是将数据与模板结合生成最终HTML的工具”这种定义能概括的。它实际牵扯到Web请求生命周期的拦截点选择、JVM内存分配策略、前端资源加载路径映射、甚至HTTP缓存头的自动注入逻辑。比如你用FreeMarker配置<#assign base="http://localhost:8080">,这个base变量在Thymeleaf里得写成<th:block th:with="base='http://localhost:8080'">,表面是语法差异,背后是FreeMarker基于栈的变量作用域设计 vs Thymeleaf基于DOM节点的属性绑定机制——这直接决定你在写多级菜单递归渲染时,是用FreeMarker的<#list>嵌套还是Thymeleaf的th:each+th:fragment组合。

适合谁来读?如果你正面临这些具体问题:

  • 新建SpringBoot项目时,在spring-boot-starter-thymeleafspring-boot-starter-freemarkerspring-boot-starter-mustache之间反复横跳,却说不清选型依据;
  • 页面静态资源(CSS/JS)总404,查application.propertiesspring.resources.static-locations配了三遍还是不对;
  • 模板里${}取不到Controller传来的值,但@{}能取到,搞不懂EL表达式和URL表达式的底层解析器差异;
  • 打包成jar后,模板文件在BOOT-INF/classes/templates/路径下,但运行时报Template not found——这时你真正需要的不是百度“springboot模板找不到”,而是理解SpringBoot的ClassLoader如何定位templates目录。

这篇文章不讲泛泛而谈的“模板引擎是什么”,只拆解你每天敲代码时真实踩过的坑:从ViewResolver怎么把"index"字符串变成/templates/index.html物理路径,到Thymeleaf如何用TemplateMode.HTMLTemplateMode.XML切换解析规则,再到为什么Mustache号称“logic-less”却在SpringBoot里要额外配mustache.spring.template.suffix=.html。所有内容都来自我维护过27个SpringBoot Web项目的实战记录,每个结论背后都有jstack线程堆栈截图或mvn dependency:tree依赖树验证。

2. 模板引擎选型不是拼语法糖——SpringBoot的自动装配才是真正的决策核心

2.1 SpringBoot的“零配置”幻觉:starter背后藏着三重自动装配链

很多人以为加个spring-boot-starter-thymeleaf就万事大吉,其实SpringBoot启动时会触发一套精密的自动装配流水线。以Thymeleaf为例,整个过程分三层:

第一层:条件装配触发器
ThymeleafAutoConfiguration类上标注@ConditionalOnClass({ TemplateEngine.class }),这意味着只有当classpath存在org.thymeleaf.TemplateEngine类时,该配置才生效。如果你删掉thymeleaf-spring5依赖但保留starter,启动日志会显示ThymeleafAutoConfiguration matched但后续报No qualifying bean of type 'org.thymeleaf.spring5.SpringTemplateEngine'——因为starter只引入了thymeleaf-spring5的pom依赖,而TemplateEngine接口在thymeleaf核心包里,SpringTemplateEngine实现类在thymeleaf-spring5包里,两者缺一不可。

第二层:Bean工厂组装
ThymeleafAutoConfiguration内部通过@Bean方法创建SpringTemplateEngine实例。关键参数templateResolver来自ThymeleafProperties配置类,而后者又继承自TemplateProperties。这里有个致命细节:ThymeleafPropertiesprefix默认值是classpath:/templates/,但TemplatePropertiessuffix默认是.html。如果你在application.yml里只配了spring.thymeleaf.suffix=.ftl,系统会报错,因为Thymeleaf不认.ftl后缀——这是FreeMarker的专属后缀。此时必须同时配spring.thymeleaf.mode=HTML(强制HTML模式)和spring.thymeleaf.suffix=.html,否则TemplateMode解析器会因后缀不匹配拒绝加载。

第三层:ViewResolver注册
ThymeleafViewResolver被注入到Spring MVC的ViewResolver链中。它的order属性默认是Integer.MAX_VALUE - 1(即2147483646),比InternalResourceViewResolver(默认order=2)小得多,所以Thymeleaf视图优先级更高。但如果你手动配置了@Bean InternalResourceViewResolver且没设order,它会排在Thymeleaf前面,导致所有return "index"都被当成JSP处理——而SpringBoot默认不支持JSP,结果就是404。我见过最典型的错误是在WebMvcConfigurer里写:

@Bean public ViewResolver viewResolver() { InternalResourceViewResolver resolver = new InternalResourceViewResolver(); resolver.setPrefix("/WEB-INF/views/"); resolver.setSuffix(".jsp"); return resolver; }

这段代码在SpringBoot里纯属无效操作,因为/WEB-INF/views/路径根本不存在,且InternalResourceViewResolver已被SpringBoot禁用。

提示:验证当前生效的ViewResolver顺序,可在Controller里注入ApplicationContext,调用getBeansOfType(ViewResolver.class)并打印getOrder()值。实测发现,Thymeleaf的order值为2147483646,FreeMarker为2147483645,Mustache为2147483644——这个细微差别决定了模板引擎的执行优先级。

2.2 三大主流引擎的硬核对比:不只是语法,更是线程模型与缓存策略

维度Thymeleaf 3.xFreeMarker 2.3.xMustache 0.9.x
解析模型DOM树遍历(基于Jsoup)模板AST编译(生成Java字节码)文本流替换(无状态)
线程安全TemplateEngine单例,ITemplateResolver需线程安全Configuration单例,Template对象可复用MustacheFactory单例,Mustache对象线程安全
缓存机制ConcurrentMap<String, ITemplateCacheEntry>(默认缓存100个模板)TemplateCache(LRU策略,默认缓存50个)DefaultMustacheFactory内置ConcurrentHashMap缓存编译后的Lambda表达式
热加载开发模式下checkTemplateLocation=true(每秒扫描文件修改)configuration.setTemplateUpdateDelay(0)(实时重载)无热加载,需重启应用

这个表格背后是血泪教训。去年我们做电商促销页,用Thymeleaf渲染商品列表,QPS 2000时CPU飙升到95%。jstack抓取线程栈发现大量TemplateCache.getTemplate()阻塞。排查发现spring.thymeleaf.cache=true(生产环境默认开启),但缓存大小spring.thymeleaf.cache.limit=100不够用——200个商品SKU对应200个不同模板路径(如/templates/product/123456.html),缓存击穿导致频繁IO读取。解决方案不是关缓存(那会更慢),而是把cache.limit调到500,并用spring.thymeleaf.enabled=false临时关闭Thymeleaf,改用FreeMarker——因为FreeMarker的TemplateCache支持软引用回收,内存压力更小。

Mustache的“logic-less”特性常被误解为“性能更好”。实际上,Mustache在SpringBoot里需要mustache-spring-boot-starter,其MustacheViewResolver会把模板编译成java.util.function.Function对象。但函数式编程的开销在高并发下反而更大:每个请求都要调用Function.apply(),而Thymeleaf的IProcessableElementTag是预编译的指令集。我们做过压测,相同模板下Mustache TPS比Thymeleaf低12%,但内存占用少18%——所以Mustache更适合内存受限的嵌入式Web服务,而非高并发电商后台。

2.3 那些被忽略的“非主流”选项:JSP为何在SpringBoot里成了弃子?

JSP在SpringBoot中被官方明确弃用,原因远不止“过时”这么简单。核心在于Servlet容器与SpringBoot内嵌容器的冲突。SpringBoot默认使用Tomcat 9+,而JSP依赖jasper编译器,该编译器需要ServletContextgetResource()方法返回file:协议URL(指向磁盘路径),但SpringBoot打包成jar后,模板文件在jar:file:/app.jar!/BOOT-INF/classes/templates/里,getResource()返回jar:协议URL,jasper无法解析。

有人尝试用spring-boot-jsp-demo这种第三方starter,本质是把JSP文件放在src/main/webapp/目录下,让Maven插件打包时复制到BOOT-INF/lib/外层。但这违反了SpringBoot的“fat jar”哲学,且webapp目录在IDEA里不被识别为资源根路径,开发时热更新失效。我试过强行启用,结果发现JSP里的<c:forEach>标签在SpringBoot 2.7+里报javax.servlet.jsp.JspException: java.lang.ClassNotFoundException: org.apache.taglibs.standard.tag.common.core.ForEachTag——因为jstl依赖版本与Tomcat内置的EL解析器不兼容。

相比之下,Velocity虽已停止维护,但在遗留系统迁移中仍有价值。它的VelocityEngine支持setApplicationAttribute("springMacroRequestContext", requestContext),能无缝集成Spring的RequestContext,这对需要复用Spring表单标签库的老项目很关键。不过Velocity的#foreach语法不支持嵌套判断(如#if($item.price > 100 && $item.inStock)),必须拆成两层#if,代码可读性差。我们曾为某银行系统做Velocity→Thymeleaf迁移,发现原Velocity模板里37处#parse("header.vm")调用,全部要改成Thymeleaf的th:replace="~{fragments/header :: header}",而~{}语法要求header.html必须在templates/fragments/目录下——路径约束比Velocity严格得多。

3. 从Controller到HTML:一次完整渲染流程的逐帧拆解

3.1 请求进来后,SpringMVC如何把“return "user/list"”变成HTTP响应?

假设用户访问/users,Controller代码如下:

@GetMapping("/users") public String listUsers(Model model) { model.addAttribute("users", userService.findAll()); return "user/list"; // 关键:这个字符串怎么变成HTML? }

整个流程分7个关键帧:

帧1:HandlerAdapter执行Controller方法
RequestMappingHandlerAdapter调用listUsers(),返回ModelAndView对象。注意:String返回值会被ModelAndViewMethodReturnValueHandler包装成ModelAndView,其中viewName="user/list"model包含users数据。

帧2:ViewResolver链路查找视图
SpringMVC遍历ViewResolver列表,ThymeleafViewResolverresolveViewName()方法被调用。它先拼接完整路径:prefix + viewName + suffix = "classpath:/templates/" + "user/list" + ".html""classpath:/templates/user/list.html"

帧3:模板定位与加载
TemplateResolverresolveTemplate()方法执行。Thymeleaf默认用ClassloaderTemplateResolver,调用getClassLoader().getResourceAsStream("templates/user/list.html")。这里有个陷阱:如果list.htmlsrc/main/resources/templates/user/下,路径正确;但如果误放在src/main/java/templates/user/getResourceAsStream()返回null,抛出TemplateInputException

帧4:模板解析与编译
TemplateEnginegetTemplate()方法获取ITemplateCacheEntry。若缓存未命中,则TemplateParser解析HTML,生成Document对象。Thymeleaf 3.x的解析器会把<div th:text="${users[0].name}">转换成TextAttributeProcessor指令节点,该节点持有EL表达式${users[0].name}的AST树。

帧5:上下文数据绑定
Context对象被创建,model数据注入ContextvariablesMap。关键点:users列表被存为context.getVariables().put("users", userList),但users[0].name的解析不是简单Map取值,而是通过StandardExpressionEvaluator执行EL表达式,调用List.get(0)再反射getName()——这解释了为什么users为空时users[0]会抛IndexOutOfBoundsException,而不是返回空字符串。

帧6:模板渲染
TemplateEngine.process()遍历DOM树,对每个节点执行IProcessorTextAttributeProcessorprocessAttribute()方法调用Expression.execute(context),得到"张三"字符串,替换原始HTML中的th:text属性值。此时DOM树已更新,但尚未序列化为字符串。

帧7:输出流写入
TemplateEngine调用TemplateWriter.write(),将DOM树序列化为UTF-8字节流,写入HttpServletResponse.getOutputStream()。注意:response.setContentType("text/html;charset=UTF-8")ThymeleafView自动设置,无需手动配置。

注意:整个流程中,Model里的数据在帧5注入Context后,就与Controller的model对象断开引用。因此在模板里修改users(如th:object="${users}"th:field="*{name}")不会影响Controller层的数据——这是Thymeleaf的“单向数据流”设计,避免意外副作用。

3.2 模板语法背后的执行引擎:EL表达式、URL表达式、片段表达式如何分工?

Thymeleaf的三种核心表达式不是并列关系,而是有明确的职责边界:

EL表达式(${...}:专用于数据计算与取值。它基于Spring的StandardEvaluationContext,支持完整的SpEL语法。例如${#strings.toUpperCase(user.name)}调用Strings.toUpperCase()静态方法,${user.age > 18 ? 'adult' : 'minor'}支持三元运算。但要注意:${}不能生成URL,<a href="${'/user/' + user.id}">在SpringBoot里会生成href="/user/123",但若启用了spring.mvc.servlet.context-path=/admin,这个URL就不带/admin前缀——因为${}不感知Spring MVC的上下文路径。

URL表达式(@{...}:专用于路径构建与路由解析。它由UrlTemplateProcessor处理,自动注入contextPathservletPath。例如@{/user/{id}(id=${user.id})}生成/admin/user/123(假设context-path为/admin)。关键机制:@{}表达式会被LinkBuilder解析,LinkBuilderHttpServletRequest中提取getContextPath(),再拼接/user/id参数。如果user.id为null,@{}会生成/admin/user/(末尾斜杠),而${}会生成/admin/user/null——这是线上404的常见原因。

片段表达式(~{...}:专用于模板复用与布局嵌套。它不生成HTML内容,而是返回Fragment对象。例如<div th:replace="~{footer :: copyright}">~{footer :: copyright}先定位footer.html文件,再找到<div th:fragment="copyright">节点,最后把该节点的DOM树替换到当前位置。这里有个深度坑:th:replace是“完全替换”,th:include是“内容插入”,th:insert是“克隆插入”。我们曾因误用th:include导致页脚CSS样式被重复加载三次,因为<link rel="stylesheet">标签被插入了三次。

3.3 静态资源与模板的共生关系:为什么/static/css/app.css总404?

SpringBoot的静态资源处理和模板引擎是两条独立流水线,但它们共享同一个ResourceHttpRequestHandler。关键配置项spring.web.resources.static-locations默认值为:

classpath:/META-INF/resources/,classpath:/resources/,classpath:/static/,classpath:/public/

这意味着src/main/resources/static/css/app.css会被映射到/css/app.css路径。但模板里写<link href="/css/app.css" rel="stylesheet">时,浏览器发起GET/css/app.css请求,ResourceHttpRequestHandler按顺序扫描上述四个路径,找到static/css/app.css并返回。

然而,当模板引擎介入时,问题变得复杂。例如在user/list.html里写:

<link th:href="@{/css/app.css}" rel="stylesheet">

@{}表达式会生成/css/app.css,但若spring.mvc.servlet.context-path=/shop,则生成/shop/css/app.css。此时ResourceHttpRequestHandler收到/shop/css/app.css请求,它会在/shop路径下找static/css/app.css,显然找不到——因为静态资源路径是相对于应用根路径的,不是相对于context-path的。

解决方案有两个:

  1. 全局配置context-path:在application.yml里设server.servlet.context-path=/shop,这样所有静态资源请求都带/shop前缀,ResourceHttpRequestHandler能正确匹配;
  2. 模板中用绝对路径<link href="/css/app.css" rel="stylesheet">(注意开头的/),这样浏览器请求/css/app.cssResourceHttpRequestHandlerstatic/目录下找到文件。

我推荐方案2,因为方案1会影响所有API路径(如/api/users变成/shop/api/users),而前端通常更习惯用绝对路径管理资源。

4. 生产环境避坑指南:那些让运维半夜打电话的模板配置雷区

4.1 缓存配置的魔鬼细节:spring.thymeleaf.cache不是简单的true/false开关

spring.thymeleaf.cache=true在生产环境是必须的,但它的实际效果取决于三个隐藏参数:

  • spring.thymeleaf.cache.limit=100:缓存模板数量上限。如前所述,动态路径模板(如/product/{id}.html)会导致缓存快速占满。建议按业务场景预估:电商详情页模板数≈SKU总数,后台管理页模板数≈菜单项数。我们给电商系统设为500,给OA系统设为200

  • spring.thymeleaf.cache.caffeine.spec=maximumSize=500,expireAfterAccess=3600s:Thymeleaf 3.1+支持Caffeine缓存,expireAfterAccess表示模板最后一次访问后3600秒过期。这比默认的ConcurrentMap缓存更智能,能自动清理冷门模板。

  • spring.thymeleaf.check-template-location=true:开发模式下检查模板文件是否存在,生产环境必须设为false。否则每次渲染都调用File.exists(),IO开销巨大。我们曾在线上环境误配此参数,导致TP99从120ms升至850ms。

FreeMarker的缓存配置更隐蔽:spring.freemarker.cache=true开启缓存,但spring.freemarker.template-loader-path=classpath:/templates/指定的路径必须以/结尾,否则Configuration无法正确解析相对路径。例如template-loader-path=classpath:templates(缺末尾/)会导致<#include "header.ftl">找不到文件,因为FreeMarker会拼接成classpath:templatesheader.ftl

实操心得:验证缓存是否生效,可在application.yml里加logging.level.org.thymeleaf=DEBUG,启动时看日志是否有[THYMELEAF] Template cache hit for template字样。没有则说明缓存未命中,需检查cache配置或模板路径。

4.2 字符编码的隐形杀手:为什么中文模板总是乱码?

Thymeleaf默认用UTF-8编码读取模板,但Windows系统下src/main/resources/templates/里的.html文件可能被IDEA保存为GBK。现象是:模板里写<h1>用户列表</h1>,浏览器显示<h1>鐢ㄦ埛鍒楄〃</h1>

解决方案分三步:

  1. IDEA全局设置File → Settings → Editor → File Encodings,将Global EncodingProject EncodingDefault encoding for properties files全设为UTF-8
  2. Maven编译配置:在pom.xml里加<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  3. Thymeleaf显式声明:在模板首行加<!DOCTYPE html><html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" lang="zh-CN">,并确保<meta charset="UTF-8">存在。

但最关键的一步常被忽略:spring.thymeleaf.encoding=UTF-8必须显式配置。因为Thymeleaf 3.x的TemplateResolver默认从ServletContext读取request.getCharacterEncoding(),而SpringBoot内嵌Tomcat的默认编码是ISO-8859-1。不配encoding,Thymeleaf会用ISO-8859-1解码UTF-8字节流,必然乱码。

4.3 安全配置的底线思维:XSS防护与SPEL沙箱的双重保险

Thymeleaf默认开启XSS防护:th:text="${user.name}"会自动HTML转义,把<script>alert(1)</script>渲染成&lt;script&gt;alert(1)&lt;/script&gt;。但th:utext(unescaped text)会绕过转义,这是高危操作。我们曾发现某CMS系统用th:utext="${article.content}",而article.content来自用户输入,导致存储型XSS。

更深层的风险在SPEL表达式。Thymeleaf 3.x默认禁用T(java.lang.Runtime).getRuntime().exec('calc')这类危险调用,但若配置spring.thymeleaf.enable-spring-el=false,则启用原生SPEL,风险剧增。生产环境必须确保:

spring: thymeleaf: enable-spring-el: true # 允许Spring EL,但禁用危险类 # 同时在代码里配置SPEL白名单

ThymeleafAutoConfiguration里,可通过TemplateEngine.setAdditionalExpressionObjects()注入白名单对象:

@Bean public TemplateEngine templateEngine(TemplateResolver templateResolver) { SpringTemplateEngine engine = new SpringTemplateEngine(); engine.setTemplateResolver(templateResolver); // 只允许调用StringUtils工具类 engine.setAdditionalExpressionObjects(Collections.singletonMap( "strings", org.springframework.util.StringUtils.class)); return engine; }

这样模板里只能用${#strings.isEmpty(user.name)},无法调用Runtime.exec()

5. 常见问题速查表与独家排查技巧

问题现象根本原因排查命令/步骤解决方案
Template not found for template "index"templates/目录不在classpath下,或路径名大小写错误(Linux敏感)`jar -tf app.jargrep "templates/index.html"`
页面显示${user.name}而非实际值Controller未正确添加Model属性,或model.addAttribute("user", user)的key名与模板${user.name}不匹配在Controller里加log.info("Model keys: {}", model.asMap().keySet())检查model.addAttribute()的key名,Thymeleaf中${user.name}要求key为"user",不是"users"
CSS/JS 404,但文件确实在static/目录下spring.web.resources.static-locations被覆盖,或server.servlet.context-path导致路径偏移curl -I http://localhost:8080/css/app.css看响应头Content-Type检查application.yml是否误删了static-locations默认值,或用<link href="/css/app.css">代替@{}表达式
模板修改后不生效(开发模式)spring.thymeleaf.cache=true未关闭,或IDEA未开启Build project automaticallyps aux | grep java看进程参数是否有-Dspring.thymeleaf.cache=false开发时设spring.thymeleaf.cache=false,并确保IDEA的Settings → Build → Compiler勾选Build project automatically
th:each遍历空集合报错th:each="user : ${users}"users为null,而非空集合在Controller里加model.addAttribute("users", Optional.ofNullable(userList).orElse(Collections.emptyList()))永远传递空集合而非null,或在模板里用th:if="${not #lists.isEmpty(users)}"做判空

独家排查技巧

  • 模板路径调试法:在application.yml里加logging.level.org.thymeleaf=TRACE,启动时看日志中TemplateResolver.resolveTemplate()的完整路径输出,确认是否拼错了prefixsuffix
  • 内存泄漏定位:用jmap -histo:live <pid> \| grep Template查看模板缓存对象数量,若持续增长说明缓存未生效或cache.limit过小;
  • EL表达式断点调试:在StandardExpressionEvaluator.evaluate()方法打条件断点,条件为expressionString.contains("user.name"),可实时查看表达式解析过程。

最后分享个小技巧:Thymeleaf的#strings工具类有abbreviate(text, maxLen)方法,但maxLen单位是字符数而非字节数。中文字符在UTF-8下占3字节,若maxLen=10,实际可能截断半个汉字。安全做法是用#strings.substring(text, 0, 10),它按Unicode字符截取,不会出现乱码。这个细节在电商商品标题截断场景里救过我们三次。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 9:47:00

SpringBoot秒杀系统设计:高并发实战与优化策略

1. 项目概述&#xff1a;SpringBoot秒杀系统毕业设计全解析这个基于SpringBoot的秒杀系统毕业设计&#xff0c;是我指导过最典型的高并发实战案例。不同于普通的电商系统&#xff0c;秒杀场景对系统架构提出了三大核心挑战&#xff1a;瞬时高并发流量、库存准确性和服务稳定性。…

作者头像 李华
网站建设 2026/9/13 9:45:54

YOLOv7与PyQt5迁移PySide6:构建图像视频检测GUI的完整实践

简介&#xff1a;面向有图像处理与目标检测需求的开发者&#xff0c;这份资源提供了一套基于YOLOv7与PySide6/PyQt5的可视化检测工具&#xff0c;支持图像和视频两种输入方式。程序启动后会自动加载模型目录下的YOLOv7预训练权重&#xff0c;无需手动配置即可快速开展检测&…

作者头像 李华
网站建设 2026/9/13 9:44:40

SonarQube代码质量管理平台落地实践:从安装到质量门禁与CI/CD集成

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 9:43:34

C++ vector底层原理与高性能使用指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华