在排查一个老JSP项目的时候,我发现接口返回的HTML页面肉眼看着不算大,但用浏览器F12一看,源码里从<head>到<body>之间全是一行一行的空白。再翻到JSP源文件,原来是为了排版可读性留了不少空行,有的空行前面还带着一串空格缩进。问题很典型:JSP里的空行会被容器原样写到响应里,想清除这些空白行,最直接的手段就是page指令里的trimDirectiveWhitespaces属性,把它设为true,容器在把JSP翻译成Servlet时,会把只包含空白字符的模板文本直接扔掉。
这篇文章不光是介绍这个属性的用法,我还会把它的原理、配置方式、能管和管不了的地方都讲清楚,同时给出我在实际项目里验证过的方法和一些坑。如果你正在为JSP响应体积虚高、页面源码不够干净而发愁,或者只是好奇trimDirectiveWhitespaces到底怎么生效的,都可以继续往下看。
1. 空白行到底是从哪来的:三类来源与定位方法
1.1 三类典型的空白行来源
要解决空白行,先得知道空白行的来源。我把平时见过的情况归纳成三类,这三类的处理方式完全不同。
第一类是JSP源码里为了可读性留的静态空行。比如一个列表页,模型和Controller之间空两行,标签和标签之间再空一行,这种空行在翻译后会被原样写进响应。第二类是JSP指令之间的空白,比如<%@ page %>和<%@ taglib %>周围如果留了空行,同样会变成响应里的空行。第三类是JSTL标签或EL表达式在运行时产生的空白,这类最隐蔽,哪怕源码里没有任何多余空行,<c:forEach>每循环一次都可能往输出里塞一个换行符。
这三类空白有个非常直观的区别:静态空行和指令周围的空白属于“编译期就能确认的模板文本”,而JSTL标签产生的空白属于“运行时才生成的内容”。trimDirectiveWhitespaces能处理的,主要是前两类,第三类得靠排版或过滤器解决,具体后面单独讲。
第二类和第一类其实可以合并理解,因为从JSP翻译器的视角看,指令、标签、HTML文本之间夹着的纯空白行都是“模板文本”。模板文本这个东西,在JSP里就是那些不被JSP标签和EL表达式处理的普通文字,包括HTML标签、缩进、换行。它们最终都会被翻译成Servlet源码里的out.write("...")调用。你留了一个空行,翻译之后就是一条写换行符的语句;留了十个空行,就是十条。响应体积就是这么被撑起来的。
1.2 快速定位空白行来自哪里
我排查这类问题时的习惯是分两步:先看响应HTML源码,再看JSP源码。
第一步,用curl把页面源码拉下来,看看空行的具体位置。假设页面返回的内容里,</div>后面连续跟着好几个空行,那就去JSP源码里找对应的</div>,看它后面跟着什么。如果源码里确实有空行,那多半是静态模板文本;如果源码里没有空行,但是响应里却有,那就要警惕JSTL标签和循环了。
第二步,可以看一下Tomcat翻译后的Java源码。在Tomcat的work目录下,一般能找到类似/work/Catalina/localhost/应用名/org/apache/jsp/index_jsp.java的文件。打开后搜索out.write,凡是只写了out.write("\r\n")或者out.write(" \r\n")的地方,就是空白行的来源。把这类write数量和JSP源码里的空行数量对照一下,就能确认到底是不是静态空行。这一步很直观,也是我验证配置有没有生效的常用手段。
2. trimDirectiveWhitespaces为什么只对“静态空白”生效
2.1 一次翻译期的“整行删除”操作
trimDirectiveWhitespaces是JSP 2.1引入的page指令属性,从Tomcat 6开始支持。它的作用可以用一句话概括:在JSP翻译成Servlet的阶段,把只包含空白字符的模板文本直接丢掉。这里有两个关键词需要注意,一个是“翻译期”,一个是“只包含空白字符”。
翻译期意味着这个属性不是在运行时过滤响应,而是在JSP编译器生成Java代码的时候做优化。JSP页面的执行过程分两段:先由JSP容器把.jsp文件翻译成Servlet的Java源码,再编译加载运行。模板文本在翻译期就被确定下来,所以编译器能在生成Java代码时就判断:这段文本是不是纯空白?如果是纯空白,就直接不生成对应的out.write语句。
“只包含空白字符”指的是空格、Tab、换行、回车这些字符组合在一起、没有其他可见内容的一整行。举个例子:
<h1>Hello</h1> <p>这里上面有一个空行。</p>第二行只有换行符,翻译后对应的out.write就会被跳过,最终输出里<h1>和<p>之间不再有空行。但注意,如果某一行是<p>这里上面有一个空行。</p>,它前面带着四个空格,这一行包含非空白字符,所以不会被删除。也就是说,trimDirectiveWhitespaces只删“整行都是空白”的模板文本,不会顺手把普通行的行首缩进也一起清掉。这个细节很多人会搞混,以为开了以后所有缩进都没了,其实不是。
2.2 为什么运行期生成的空白它管不了
要理解这个边界,就得对比一下JSTL标签和EL表达式。<c:forEach>、<c:if>这类标签在翻译期会被替换成Java控制逻辑,标签本身不直接生成文本;标签体里的模板文本则是在每次运行时通过out.write输出。
trimDirectiveWhitespaces只影响翻译期生成的out.write内容,它无法干预运行时循环体内部每轮迭代产生的输出。举个最常见的例子:
<c:forEach items="${list}" var="item"> <tr> <td>${item.name}</td> </tr> </c:forEach>就算你在页面顶部加了<%@ page trimDirectiveWhitespaces="true" %>,这个<c:forEach>每一次循环,仍然会把标签体里那几行的换行和缩进完整输出一遍。500行列表就是500份换行缩进。这不是说属性没生效,而是它天然管不到运行期才产生的模板文本输出。
所以看一个页面该不该依赖trimDirectiveWhitespaces,核心就一句话:先分清这些空白是写在JSP源码里的,还是由JSTL循环在运行期吐出来的。前者交给这个属性,后者必须换思路。
3. 落地方案:page指令、web.xml和验证手段
3.1 单页临时验证:page指令一行搞定
如果只是个别页面需要清空行,最省事的办法是在JSP顶部加一条page指令:
<%@ page trimDirectiveWhitespaces="true" %>它可以和language、contentType等属性写在同一条page指令里,也可以单独写一条。单独写的好处是动一处就能恢复,方便快速验证。
改完之后,直接刷新页面看响应源码,原来的整行空白会消失。这里有个细节:即便同一页里还存在JSTL循环产生的换行,静态空行消失后,响应源码的可读性已经好了很多。所以我的习惯是先用这个属性把静态空白清掉,再判断剩下那些动态空白值不值得继续处理。
如果配置之后发现没有效果,优先检查三件事:第一,页面的JSP版本够不够新,容器有没有实现JSP 2.1以上;第二,page指令有没有写错成trimDirectiveWhiteSpace或trimDirectiveWhitespaces="TRUE"这种写法;第三,Tomcat是不是还在用旧的翻译结果,开发模式下一般会自动重编译,但有些生产部署会设置JSP缓存,改完配置之后最好清一下work目录再试。
3.2 全站统一治理:web.xml的jsp-property-group
项目里几十上百个JSP,不可能一个一个加page指令,这种时候就走全局配置。在web.xml里加一段jsp-config:
<?xml version="1.0" encoding="UTF-8"?> <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd" version="3.1"> <jsp-config> <jsp-property-group> <url-pattern>*.jsp</url-pattern> <trim-directive-whitespaces>true</trim-directive-whitespaces> </jsp-property-group> </jsp-config> </web-app><url-pattern>*.jsp</url-pattern>表示对应用内所有JSP生效。之后新加的JSP页面不用自己写page指令,也能自动获得去空行能力。
这里要提醒两个配置上的坑。第一个是web.xml的schema版本,trim-directive-whitespaces是JSP 2.1时代的元素,建议web-app的version至少是2.5,我用的是3.1,更省心。第二个是元素顺序,web.xml里的子元素顺序有明确约束,jsp-config不能随便插到servlet前面或后面,必须放在符合schema的位置,否则容器启动直接报错。我把这个坑单独放到后面那章细说,但配置的时候心里要先有这根弦。
3.3 验证方式:换行数、字节数、生成的Java源码
配置全站之后,不能只看一两个页面就下结论。我一般会抓三五张典型页面做前后对比,比如列表页、详情页、表单页,统计行数和字节数。
# 修改前抓取页面 curl -s http://localhost:8080/your-app/list.jsp -o before.html wc -l before.html wc -c before.html # 修改后再次抓取 curl -s http://localhost:8080/your-app/list.jsp -o after.html wc -l after.html wc -c after.html如果页面在登录后面,curl需要带上有效的Cookie或Session标识,否则抓回来的是跳转页或登录页,对比就没有意义。我通常会在测试环境临时造一个免认证的页面,或者直接用联调环境的接口。
更底层的验证方式是回到Tomcat的work目录,找到list_jsp.java,对比配置前后的out.write数量。配置之前,源码里可能有几十条专门输出空白的out.write("\r\n");配置之后,这些语句应该消失。这一步虽然繁琐,但能让你彻底搞明白容器到底做了什么,排查问题时特别有用。
4. 它管不了的JSTL空白:排版、Filter与取舍
4.1 JSTL循环里逃不掉的换行
在实际项目里,JSTL空白往往比静态空行更恼人。静态空行数量有限,但<c:forEach>的循环体如果排版不规范,每行数据都会带出好几个换行。拿前面那个例子来说,列表有500行,循环体里有3个换行,最终响应里就多出1500个换行符。这些换行在浏览器里几乎看不出影响,但是会让响应源码变得非常难看,而且对传输和调试都不友好。
为什么会这样?因为<c:forEach>标签体里的换行和缩进不是“只包含空白的一整行”,它们连着<tr>、<td>这些非空字符,属于包含内容的模板文本,trimDirectiveWhitespaces不会去动。所以配置了全局trimDirectiveWhitespaces之后,静态空行没了,循环体内的换行照样还在。
4.2 排版改造是最朴素也最有效的办法
处理JSTL循环空白,我首先推荐的是改造循环体的排版。核心思想是:让标签体内部的换行尽量少,或者干脆把标签贴在一起。
<c:forEach items="${list}" var="item"><tr><td>${item.name}</td></tr></c:forEach>这样写,循环本身不会产生多余换行。缺点很明显,代码可读性差,维护的人看了想骂人。所以一般只针对那些循环次数多、性能敏感、或需要完整保留格式的页面局部使用,不适合作为全项目统一规范。
如果既要保持可读性,又要清理循环输出,就得考虑运行时HTML压缩Filter。这种Filter在响应输出之前,把HTML里的连续空白替换成单个换行,或直接删掉多余的空白行。好处是能同时处理静态空行和动态空行,坏处是要额外引入组件、处理Content-Type判断、还要担心压缩逻辑误伤<pre>标签或<textarea>里的内容,维护成本不低。我见过很多团队上了压缩Filter之后,页面上原本应该保密的日志格式化输出出了偏差,排错排半天。
4.3 取舍原则:先看影响面再决定
我的取舍原则很简单:如果页面的空白行主要来自JSTL循环,而且页面访问量不大,那排版改造的成本更低;如果页面访问量很大,或者响应体被当作接口数据处理,那运行期压缩Filter才值得考虑。大多数后台管理系统,循环体排版改一改就够了,实在不行就容忍这些换行——它们对浏览器渲染几乎没有影响。
5. 从字节数看性价比:到底省了多少流量
5.1 一个现实的数量级估算
很多人纠结要不要花时间清理空白行,我得给你一个真实的数字概念。
一个空行,如果是Unix换行符,就是1字节;Windows换行符则是2字节。如果空行还带了8个空格的缩进,那就是9到10字节。一个50行JSP源码里空了30行、每行还缩进10个字符的页面,响应里光空白部分就占了300字节左右。对单个请求来说,这简直微不足道。但如果页面有循环,情况就不一样了。一个500行表格,循环体里每个元素多输出2个换行符,那就是1000字节。
这里最关键的是看有没有开gzip。如果Tomcat或前端Nginx开了gzip压缩,连续空白符的压缩率极高,几千字节的空白可以在压缩后变成几十字节。也就是说,开了gzip之后,trimDirectiveWhitespaces能省下的实际传输流量其实很有限。它更大的价值在于让响应源码干净、便于排查问题、防止某些严格的内容解析逻辑被空行干扰。
那这个属性到底还有没有必要配?我个人觉得有必要,因为成本极低:一个属性或一段web.xml配置,不引入额外组件,不改动页面结构,顺手就让源码干净了。哪怕省下的流量不多,排查问题的时候一眼能看清返回结构,这个价值已经值回配置成本。
5.2 收益反而明显的几类场景
不过也有几类场景,trimDirectiveWhitespaces的收益是实打实的。第一类是没开gzip的HTTP服务,尤其是老项目里Tomcat自己扛请求、前面没有Nginx的情况,空白行每一字节都是真实传输成本。第二类是页面HTML需要被拼进JSON接口返回的场景,比如某些页面模板数据通过接口下发,空白行会直接扩大JSON体积,增加解析成本。第三类是JSP页面被用来生成邮件模板或静态文件落地的场景,落地的文件每一行空行都在占用磁盘和后续的处理时间。
在这些场景里,静态空行的消除效果非常明显,配完之后看一眼落地方案或接口响应就能感觉到差异。
6. 容易踩的坑与其它方案的横向对比
6.1 坑一:<pre>标签里的空行被误删
全局配置trimDirectiveWhitespaces之后,最容易出问题的地方是<pre>标签。它本来的作用就是保留空白格式,用来展示代码片段、日志、SQL等场景。如果<pre>内部恰好出现一行只有空白的行,trimDirectiveWhitespaces会把它当普通模板文本清掉,这就破坏了原本想展示的内容。
我实际遇到过的情况是,某个页面用<pre>展示多段日志,日志之间用空行分隔。全局配置之后,空行全部消失,日志变成一坨,排查时还以为模板数据出了错,折腾了好久才定位到是压缩属性干的。解决方式有两种:在这个页面单独加<%@ page trimDirectiveWhitespaces="false" %>覆盖全局配置;或者在<pre>内部用HTML实体或<br>来表示空行,避免真正的纯空白行出现。
6.2 坑二:web.xml元素顺序导致的容器启动失败
全站配置还有一个容易踩的坑,就是web.xml里jsp-config的位置。web.xml的web-app元素对子元素顺序有严格约束,jsp-config不是想放哪就放哪。如果项目用的是Servlet 2.5或3.x规范,jsp-config一般要放在error-page之后、security-constraint之前。很多人在原有web.xml的<servlet>前面直接插入jsp-config,容器启动时会报类似"cvc-complex-type.2.4.a: Invalid content was found starting with element 'jsp-config'"的错误。
这个坑的处理方式很简单:要么把jsp-config挪到合法位置,要么升级web-app头部的schema版本,并用IDE的校验功能检查一遍。我自己的习惯是配置完web.xml之后,先重启本地容器确认能正常启动,再提交代码,不要等到部署环境才暴露问题。
6.3 和其它方案怎么选
最后把几种常见做法放一起对比一下,方便你做决策。
| 方案 | 生效阶段 | 能解决的空白 | 主要代价 | 适用场景 |
|---|---|---|---|---|
| trimDirectiveWhitespaces属性 | 翻译期 | 源码里的静态整行空白 | 全局配置需注意pre等边界 | 大多数JSP项目,无脑推荐 |
| JSTL标签排版改造 | 编译期 | 循环体内部的换行缩进 | 代码可读性下降 | 循环次数多的局部页面 |
| HTML压缩Filter | 运行期 | 全量HTML空白和合并空白 | 额外组件、性能开销、脚本和pre风险 | 响应体积敏感且无gzip场景 |
| JSP源码规范与Code Review | 开发期 | 从源头减少空行 | 依赖团队规范执行 | 新项目或规范化改造时 |
我的建议是:先把trimDirectiveWhitespaces开了,这是标准化、成本最低的解法。如果页面循环多且响应体积敏感,再用排版改造或Filter作为补充。不要一上来就写一个复杂的HTML压缩Filter,很多项目根本没必要,还容易引入新问题。
最后分享一个我给自己定的处理流程:遇到JSP空白行问题,先查Tomcat或前端有没有开压缩;如果没开,先开trimDirectiveWhitespaces,把静态空行清掉;再抓几个典型页面统计行数和字节数,看看剩下的动态空白影响大不大;最后才决定要不要改JSTL排版或上压缩Filter。按照这个顺序下来,大多数项目根本不用写额外组件,一个属性就能解决一大半问题。