ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JSTL 标签库实战:从脚本片段到标签封装的核心技巧

JSTL 标签库实战:从脚本片段到标签封装的核心技巧

1. 从脚本片段到标签封装:JSTL 到底解决了什么问题

很多人第一次接触 JSTL,是在维护一个老项目的时候。打开 JSP 文件,满屏的<% for(...) { %>、<% if(...) { %>和<%= request.getAttribute("xxx") %>,逻辑和页面混在一起,改一个字段名要在好几个文件里翻来覆去地找。这种写法在早期确实能跑,但一旦页面结构复杂起来,维护成本会急剧上升。JSTL 的出现,本质上就是为了把这种“Java 代码嵌在 HTML 里”的混乱局面收拾干净。

JSTL 全称是 JavaServer Pages Standard Tag Library,翻译过来就是 JSP 标准标签库。它做的事情说起来很简单:用一套类似 HTML 的标签,替代 JSP 页面里那些脚本片段。比如原本要写<% if(user != null) { %>,用 JSTL 就变成<c:if test="${not empty user}">。看起来只是换了个写法,但背后的意义完全不同——页面里不再有 Java 语法,前端人员也能看懂,逻辑判断和循环被封装成标签,职责边界清晰了。

我刚开始学的时候有个疑问:既然有 EL 表达式了,为什么还要 JSTL?后来才明白,EL 负责的是“取值”,比如${user.name}把数据从作用域里拿出来;而 JSTL 负责的是“控制”,比如循环、条件判断、格式化输出。两者配合使用,才能把 JSP 页面写得干净。只靠 EL,你没法做循环,也没法做条件分支,页面照样得靠脚本片段撑着。

JSTL 的核心价值可以归纳成三点。第一是可读性,标签的语义比 Java 代码更直观,<c:forEach>一眼就知道是循环,不用去数花括号。第二是可维护性,页面逻辑和 Java 代码分离,改样式的人不用碰逻辑,改逻辑的人不用管 HTML。第三是标准化,JSTL 是规范的一部分,不同容器(比如 Tomcat、Jetty)都支持,不像自定义标签那样绑死在某个项目里。

适合学 JSTL 的人其实很明确:如果你在维护传统的 JSP 项目,或者需要快速搭建一个基于 JSP 的后台管理页面,JSTL 是绕不开的。即使现在前后端分离是主流,理解 JSTL 也能帮你读懂大量遗留系统,这在接手老项目时特别有用。接下来我会从环境搭建开始,一步步把 JSTL 的核心标签、常见坑点和实战技巧讲清楚。

2. 环境准备中最容易被忽略的两个细节

2.1 依赖引入:不只是放一个 jar 包那么简单

JSTL 的使用需要引入对应的 jar 包。在早期版本中,通常需要两个 jar:jstl.jar和standard.jar。这两个包分别提供了 API 接口和具体实现。很多人第一次配置时只放了一个,结果页面报ClassNotFoundException或者标签完全不被解析,排查半天才发现是包不全。

如果你用的是 Maven 项目,依赖配置大概是这样:

<dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency>

注意版本号。JSTL 1.2 是 Java EE 6 之后比较通用的版本,支持 EL 2.2 的一些特性。如果你用的是更老的容器,可能需要 1.1 版本,这时候依赖坐标会不一样,通常还要额外引入standard标签库的实现包。我建议先确认你的 Servlet 容器版本,再决定用哪个版本的 JSTL,不要盲目复制网上的配置。

还有一个细节:jar 包必须放在WEB-INF/lib目录下,而不是项目的其他位置。有些人习惯把 jar 放在项目根目录的lib文件夹里,然后在 IDE 里配置构建路径,本地跑起来没问题,但部署到服务器上就找不到类了。原因是 Web 应用运行时只认WEB-INF/lib和容器的公共类路径,IDE 的构建路径配置不会被打包进去。这个坑我踩过不止一次,后来养成习惯,凡是 Web 项目的依赖,一律放WEB-INF/lib。

2.2 标签库声明:uri 写错一个字符都白搭

引入 jar 包之后,还需要在 JSP 页面顶部声明标签库。标准写法是:

<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>

这行代码里有两个关键点。prefix="c"是前缀,你可以改成别的,比如jstl,但通常大家都用c,因为 core 库的标签用得最多,短前缀写起来方便。uri是标签库的唯一标识,必须和 jar 包里META-INF目录下 tld 文件中定义的 uri 完全一致。我见过有人把http://java.sun.com/jsp/jstl/core写成http://java.sun.com/jstl/core,少了一个/jsp,结果标签全部失效,页面直接把<c:forEach>当普通文本输出。

不同标签库对应的 uri 也不一样。核心库是core,格式化库是fmt,函数库是functions,数据库操作库是sql,XML 处理库是xml。实际开发中,c和fmt用得最多,functions偶尔用来做字符串处理,sql和xml基本不建议在页面里用,原因后面会讲。

提示:如果你在页面里写了标签但没声明 uri,容器会直接忽略标签,把它当成普通 HTML 标签输出到浏览器。这时候你会在页面上看到<c:if>这样的文字,而不是执行结果。遇到这种情况,第一件事就是检查 taglib 声明。

3. 核心标签库的实战用法拆解

3.1 c:forEach:循环里的 varStatus 是个宝藏

<c:forEach>是 JSTL 里使用频率最高的标签,没有之一。基本用法很直观:

<c:forEach items="${userList}" var="user"> <tr> <td>${user.name}</td> <td>${user.email}</td> </tr> </c:forEach>

items指定要遍历的集合,var是每次迭代的变量名。这比脚本片段里的for(User user : userList)清爽多了。但很多人不知道的是,<c:forEach>还有一个varStatus属性,它提供了一个LoopTagStatus对象,里面藏着不少有用的信息。

varStatus常用的属性有四个:index表示当前索引,从 0 开始;count表示当前是第几次迭代,从 1 开始;first和last是布尔值,判断是不是第一条或最后一条。这些在渲染表格时特别有用。比如给奇数行和偶数行加不同的背景色:

<c:forEach items="${userList}" var="user" varStatus="status"> <tr class="${status.index % 2 == 0 ? 'even' : 'odd'}"> <td>${status.count}</td> <td>${user.name}</td> </tr> </c:forEach>

还有一个场景是分页导航。你可以在循环里用status.last判断是否到了最后一条,然后在最后一条后面追加“加载更多”的按钮。这种写法比在 Java 代码里拼 HTML 要灵活得多。

另外,<c:forEach>还可以指定begin、end和step,用来做固定次数的循环。比如你想渲染 1 到 10 的数字:

<c:forEach begin="1" end="10" var="i"> <span>${i}</span> </c:forEach>

这种用法在生成序号、分页页码时很常见。注意end是包含的,所以begin="1" end="10"会输出 1 到 10 共十个数。

3.2 c:if 与 c:choose:条件判断的两种姿势

<c:if>是最简单的条件标签,只有一个test属性,值为布尔表达式:

<c:if test="${not empty user}"> <p>欢迎,${user.name}</p> </c:if>

这里用了not empty来判断 user 是否为空。empty是 EL 里的关键字,能同时判断 null、空字符串和空集合,比写user != null && user.name != ""简洁得多。我建议在 JSTL 里判断空值时优先用empty,少用== null,因为empty的语义更准确,能覆盖更多边界情况。

<c:if>有个局限:它没有else分支。如果你需要 if-else 逻辑,就得用<c:choose>:

<c:choose> <c:when test="${user.role == 'admin'}"> <p>管理员面板</p> </c:when> <c:when test="${user.role == 'editor'}"> <p>编辑面板</p> </c:when> <c:otherwise> <p>普通用户面板</p> </c:otherwise> </c:choose>

<c:choose>相当于 switch 语句,<c:when>是 case,<c:otherwise>是 default。注意<c:choose>里只能直接放<c:when>和<c:otherwise>,不能放其他标签或文本,否则会报错。这个限制在写复杂页面时容易忽略,比如你想在 choose 外面包一层 div,得把 div 放在 choose 的外面,而不是里面。

还有一个细节:<c:when>的test属性是按顺序判断的,一旦某个条件为真,后面的 when 就不会再执行了。所以如果你有多个条件可能同时为真,要注意排列顺序,把最具体的条件放在前面。

3.3 c:set 与 c:remove:作用域操作的轻量方案

<c:set>用来在指定作用域里设置变量。基本用法:

<c:set var="total" value="${price * quantity}" scope="request" />

scope可以是page、request、session、application,默认是page。这个标签在页面里做临时计算时很有用,比如你从数据库查出一批数据,想在页面里算个总和,就可以用<c:set>存起来,后面直接引用。

但要注意,<c:set>设置的是作用域属性,不是局部变量。它的生命周期取决于 scope 的设置。如果你设成session,那这个值会一直保留到会话结束,可能造成内存泄漏。我见过有人在循环里用<c:set>往 session 里塞数据,结果每次请求都往 session 里加东西,最后内存爆掉。所以除非确实需要跨请求共享,否则尽量用默认的page作用域。

<c:remove>用来删除作用域里的变量:

<c:remove var="total" scope="request" />

这个标签用得不多,但在某些场景下有用。比如你在页面开头设置了一个临时变量,用完之后想清理掉,避免影响后续的页面片段。不过大多数情况下,page 作用域的变量在页面结束后会自动失效,不需要手动清理。

3.4 fmt 格式化库:日期和数字的显示问题

<fmt:formatDate>和<fmt:formatNumber>是格式化库里的两个核心标签。先说日期:

<fmt:formatDate value="${order.createTime}" pattern="yyyy-MM-dd HH:mm:ss" />

pattern属性和 Java 的SimpleDateFormat是一样的规则。这里有个坑:如果value是java.util.Date类型,直接格式化没问题;但如果是从数据库取出来的java.sql.Timestamp,在某些容器里可能会有时区问题。我遇到过页面显示的日期比实际时间少了 8 小时的情况,排查后发现是数据库连接串里没设时区,导致 JDBC 驱动把时间当成了 UTC。解决办法是在连接串里加上serverTimezone=Asia/Shanghai,或者在 Java 代码里统一转成Date再传给页面。

数字格式化也很实用:

<fmt:formatNumber value="${product.price}" pattern="#,##0.00" />

#,##0.00表示千分位分隔,保留两位小数。如果你要做货币显示,还可以用type="currency",它会根据 locale 自动加上货币符号。不过在实际项目里,我一般建议在 Java 层就把数字格式化好,页面只负责展示。原因是 JSTL 的格式化依赖 locale 设置,如果服务器和用户的 locale 不一致,显示结果可能不符合预期。

4. 自定义标签与 functions 库的进阶玩法

4.1 functions 库:字符串处理的快捷方式

functions库提供了一批静态方法,用来在 EL 表达式里做字符串操作。使用前需要声明:

<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>

常用的函数有fn:length()获取长度,fn:contains()判断包含,fn:substring()截取子串,fn:trim()去空格,fn:toUpperCase()转大写等。比如你想在页面上显示用户名的首字母:

${fn:toUpperCase(fn:substring(user.name, 0, 1))}

这比在 Java 代码里写一堆substring和toUpperCase要直观。但要注意,fn:length()对集合和字符串都有效,对 null 会返回 0,不会抛异常。这个特性在判断空集合时很有用,比如<c:if test="${fn:length(userList) > 0}">。

不过 functions 库的函数数量有限,复杂的字符串处理还是建议放在 Java 层。页面里做太多逻辑,维护起来照样头疼。我的原则是:简单的展示逻辑用 JSTL,复杂的业务逻辑回 Java。

4.2 自定义标签:什么时候值得自己写

JSTL 虽然覆盖了大部分常见场景,但总有它做不到的事情。比如你想在页面上渲染一个权限按钮,逻辑是“如果用户有权限就显示按钮,否则显示禁用状态”,这个逻辑在多个页面里重复出现。这时候可以考虑写一个自定义标签。

自定义标签的开发流程大致是:先写一个继承SimpleTagSupport的 Java 类,实现doTag()方法;然后写一个 tld 文件描述标签的属性和行为;最后在 JSP 里声明并使用。听起来步骤不少,但实际写起来并不复杂。关键是要判断值不值得——如果这个逻辑只在两三个地方用,直接复制 JSTL 代码更省事;如果到处都在用,封装成标签才能体现价值。

我个人的经验是,自定义标签适合封装“与页面结构强相关、但逻辑相对独立”的功能,比如分页条、权限按钮、树形菜单。不适合封装业务逻辑,因为业务逻辑应该放在 Service 层,页面标签只负责展示。

5. 那些年我踩过的 JSTL 坑

5.1 标签不解析:从现象到根因的排查链路

页面直接输出<c:forEach>标签文本,这是最典型的“标签不解析”问题。排查思路可以按这个顺序走:

第一步,检查 taglib 声明。uri 是否写对,prefix 是否和后面使用的一致。我见过有人声明了prefix="c",但页面里写的是<jstl:forEach>,这种低级错误在复制粘贴时很容易出现。

第二步,检查 jar 包。确认jstl.jar和standard.jar(如果版本需要)都在WEB-INF/lib下。可以解压 jar 包,看META-INF目录下有没有对应的 tld 文件。如果 tld 文件缺失,标签库就无法被识别。

第三步,检查 web.xml 的版本。有些老项目的web.xml声明的是 Servlet 2.3 规范,这个版本默认不解析 EL 和 JSTL。解决办法是把web.xml的 schema 声明升级到 2.4 或更高版本。这个坑特别隐蔽,因为项目能正常启动,只是标签不生效,很多人会往 jar 包方向排查,忽略了 web.xml 的版本问题。

第四步,检查容器版本。极老的容器可能不支持 JSTL 1.2,需要降级到 1.1。这种情况现在比较少见,但如果你的项目部署在很旧的环境里,还是要留意。

5.2 EL 表达式不生效:isELIgnored 的陷阱

EL 表达式${user.name}直接原样输出,没有被解析成实际值。这个问题通常和isELIgnored属性有关。在 JSP 页面的 page 指令里,可以设置isELIgnored="false"来强制启用 EL:

<%@ page isELIgnored="false" %>

如果这个属性设成了true,或者 web.xml 里的版本声明导致默认忽略 EL,那${...}就会被当成普通文本。解决办法有两个:一是在页面里显式设置isELIgnored="false",二是升级 web.xml 的版本声明。我一般推荐后者,因为统一在 web.xml 里配置,不用每个页面都写一遍。

还有一个可能是 EL 版本和容器不匹配。比如容器支持 EL 3.0,但项目里引入了旧版本的 EL 实现 jar,导致冲突。这种情况需要检查WEB-INF/lib下有没有多余的 EL 相关 jar,有的话删掉,让容器用自己的实现。

5.3 循环里的性能问题:别在页面里查数据库

JSTL 的<c:forEach>用起来太方便,以至于有人会在循环里调用数据库查询。比如遍历订单列表,每个订单再去查用户信息:

<c:forEach items="${orderList}" var="order"> <c:set var="user" value="${userService.findById(order.userId)}" /> <p>${user.name}</p> </c:forEach>

这种写法在数据量小的时候看不出问题,一旦订单上百条,页面加载就会变得极慢。原因是每次循环都发起一次数据库查询,N 条数据就是 N+1 次查询。正确的做法是在 Java 层一次性把关联数据查出来,组装成完整的对象再传给页面。页面只负责展示,不负责取数据。

这个坑的本质是把业务逻辑写到了页面里。JSTL 再方便,也只是展示层的工具,不该承担数据访问的职责。我后来给自己定了个规矩:页面里只出现${...}取值和<c:...>控制,不出现任何 Service 或 DAO 的调用。

6. 从 JSTL 到现代模板引擎的过渡思路

现在新项目基本不用 JSP 了,Thymeleaf、Freemarker、Velocity 这些模板引擎更受欢迎。但如果你手头有 JSP 项目要维护,或者需要快速改一个老系统,JSTL 还是得会。而且 JSTL 的很多设计思想,在现代模板引擎里都能找到影子。

比如 Thymeleaf 的th:each和 JSTL 的<c:forEach>思路一致,都是把循环逻辑封装成标签属性。Thymeleaf 的th:if和<c:if>也是同样的道理。区别在于 Thymeleaf 的模板可以直接在浏览器里打开预览,而 JSP 必须经过容器解析。这个差异决定了 Thymeleaf 更适合前后端协作,而 JSP 更适合纯后端渲染。

如果你已经熟悉 JSTL,过渡到 Thymeleaf 的成本其实不高。核心概念是相通的:作用域取值、条件判断、循环遍历、格式化输出。需要适应的主要是语法差异,比如 Thymeleaf 用th:*属性而不是标签,表达式用${...}但支持更复杂的 OGNL 语法。

我个人的建议是:老项目继续用 JSTL,不要为了技术升级而升级,稳定压倒一切;新项目直接上 Thymeleaf 或前后端分离方案,没必要再走 JSP 的老路。但无论用哪种技术,理解“展示层只负责展示”这个原则,比会用某个具体标签更重要。

最后分享一个我在实际维护 JSP 项目时的小技巧:把常用的 JSTL 代码片段整理成一个common.jsp,用<%@ include file="common.jsp" %>引入。这样 taglib 声明只需要写一次,页面里直接使用标签即可。这个做法能减少重复代码,也避免了每个页面都写一遍 taglib 声明容易写错的问题。不过要注意,include指令是在编译时合并的,如果common.jsp里有变量定义,可能会和页面里的变量冲突,所以common.jsp里最好只放 taglib 声明和常量定义,不要放逻辑代码。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进