1. 为什么今天还要回头啃 JSTL
刚入行那会儿,我接手的第一个老项目就是一套典型的 JSP + Servlet 架构,页面里到处是<% %>脚本片段,一个列表页能塞进去两百多行 Java 代码,改一个字段要在 HTML 和 Java 之间来回跳。后来带我的老哥甩给我一句“你去把 JSTL 学一下”,我才第一次系统接触这套标签库。十几年过去,前后端分离成了主流,JSTL 看起来像是上个时代的产物,但只要你还在维护存量系统、还在做 JSP 页面改造、还在给一些内网管理后台做迭代,它就绕不开。
JSTL 全称是 JSP Standard Tag Library,也就是 JSP 标准标签库。它做的事情说白了就一件:把原本写在 JSP 页面里的 Java 逻辑,用标签的形式表达出来,让页面回归“展示”这件事本身。它主要解决三类问题——条件与循环控制、数据格式化与国际化、对集合和字符串的常用操作。适合谁来学?如果你正在维护 JSP 老项目、需要读懂别人写的标签代码、或者要给一套遗留后台做小步改造,那这篇内容就是给你准备的。哪怕你是纯前端出身,理解 JSTL 也能帮你更快看懂服务端渲染那套思路。
我先把结论摆在这:JSTL 不难,难的是很多人学的时候只记住了标签怎么写,却没搞明白“为什么这么设计”“什么时候该用它、什么时候不该用”。下面我按自己实际踩过的路,把这块内容拆开讲透。
2. JSTL 的整体设计与选型思路
2.1 它到底想解决什么问题
在没有 JSTL 的年代,JSP 页面里写循环是这么个画风:
<% List<User> users = (List<User>) request.getAttribute("users"); for (int i = 0; i < users.size(); i++) { User u = users.get(i); %> <tr><td><%= u.getName() %></td></tr> <% } %>这段代码能跑,但问题一大堆。第一,页面里混入 Java 逻辑,前端同学看不懂,后端同学改起来也容易漏掉括号。第二,<%= %>这种表达式不做转义,用户输入里带个尖括号就可能出问题。第三,逻辑和展示耦合,想换个展示方式得动 Java 代码。
JSTL 的设计目标就是把这层逻辑“标签化”。同样一个循环,用 JSTL 写出来是这样:
<c:forEach var="u" items="${users}"> <tr><td><c:out value="${u.name}"/></td></tr> </c:forEach>标签是自闭合的、成对出现的,结构一眼能看清。c:out默认会做 HTML 转义,安全性也顺带提升了。这就是它存在的核心理由:让页面只负责展示,逻辑用声明式标签表达。
2.2 五大标签库的分工
JSTL 并不是一个单一标签库,它按功能拆成了五块,每块有自己的命名空间前缀。这个划分很多人一开始会搞混,我整理成表格方便对照:
| 前缀 | 名称 | 主要用途 | 典型标签 |
|---|---|---|---|
| c | 核心库 | 条件、循环、变量、输出、URL | c:if、c:forEach、c:set |
| fmt | 格式化库 | 日期、数字格式化,国际化 | fmt:formatDate、fmt:formatNumber |
| fn | 函数库 | 字符串、集合的常用函数 | fn:length、fn:contains |
| sql | 数据库库 | 页面内直接执行 SQL | sql:query、sql:update |
| xml | XML 库 | 解析和操作 XML | xml:parse、xml:out |
这里有个经验之谈:sql 和 xml 这两个库在实际项目里几乎不用。sql 库把数据库操作写到页面上,等于把分层架构彻底打碎,维护起来是灾难;xml 库在 JSON 当道的今天也基本退场。真正高频的是 c、fmt、fn 这三个,学习精力优先砸在这上面。
2.3 为什么选标签而不是别的方案
有人会问,既然要解耦,为什么不直接上模板引擎,比如 FreeMarker、Thymeleaf?这就要看场景了。JSTL 的最大优势是它是 JSP 规范的一部分,不需要额外引入依赖,只要你的容器支持 JSP,它就能用。对于存量项目改造,引入新模板引擎意味着要重写整个视图层,成本极高;而 JSTL 可以一个页面一个页面地渐进替换,风险可控。
另一个考量是学习曲线。JSTL 的标签数量有限,核心常用的也就十来个,一个下午就能上手。模板引擎虽然表达力更强,但要重新学一套语法和配置。所以在“老项目小步快跑”这个场景下,JSTL 是性价比最高的选择。
注意:新项目如果从零开始,我个人的建议是优先考虑前后端分离或现代模板引擎,JSTL 更适合作为存量系统的维护和改造工具,而不是新项目的首选。
3. 核心标签的实操要点与避坑
3.1 环境准备:两个 jar 包别搞错
JSTL 的使用需要引入实现包。这里有个经典坑:API 包和实现包是分开的。早期很多人只引了一个jstl.jar,结果运行时报ClassNotFoundException。正确的做法是引入 API 和实现两个包,或者直接用一个整合包。
如果你用 Maven 管理依赖,坐标大致是这样:
<dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency>引入之后,在 JSP 页面顶部声明要用的标签库:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>uri这一串看着长,但它是固定的,直接抄就行。prefix可以自己改,但强烈建议保持 c、fmt、fn 这套约定俗成的写法,否则别人接手你的代码会一脸懵。
3.2 c:forEach:用得最多也最容易出错
c:forEach是核心库里的绝对主力,几乎每个列表页都离不开它。它的基本属性有这么几个:
items:要遍历的集合或数组var:每次迭代的当前元素varStatus:迭代状态对象,能拿到索引、序号、是否首尾begin/end/step:控制遍历范围和步长
一个典型用法:
<c:forEach var="item" items="${orderList}" varStatus="st"> <tr class="${st.index % 2 == 0 ? 'even' : 'odd'}"> <td>${st.count}</td> <td><c:out value="${item.orderNo}"/></td> <td><fmt:formatDate value="${item.createTime}" pattern="yyyy-MM-dd HH:mm"/></td> </tr> </c:forEach>这里st.index从 0 开始,st.count从 1 开始,做斑马纹和序号时特别有用。st.first和st.last是布尔值,判断首尾行很方便。
踩过的坑:items为 null 时,c:forEach不会报错,而是直接不渲染。这个特性有时候是好事,但如果你期望它至少渲染个表头,就得配合c:if判断。另外,items传的是 EL 表达式${},千万别写成<%= %>,两者混用会出问题。
3.3 c:if 与 c:choose:条件判断的两种姿势
c:if只能处理单分支,没有else。想要多分支,得用c:choose+c:when+c:otherwise:
<c:choose> <c:when test="${user.level == 1}">普通用户</c:when> <c:when test="${user.level == 2}">高级用户</c:when> <c:otherwise>未知等级</c:otherwise> </c:choose>很多人第一次用c:if会习惯性想写else,结果发现没有,然后硬生生写两个c:if用相反条件。这样能跑,但可读性差,条件一多就容易出错。多分支场景一律用c:choose,这是更规范的做法。
test属性里写的是 EL 表达式,返回布尔值。注意字符串比较要用eq或==,别用=,=是赋值。这个坑我在代码评审里见过不止一次。
3.4 c:set 与 c:out:变量与安全输出
c:set用来在指定作用域里设值:
<c:set var="total" value="${price * count}" scope="request"/>scope可以是 page、request、session、application,默认是 page。设完之后用${total}就能取到。
c:out的重点在于转义。它有个escapeXml属性,默认是 true,会把<、>、&这些字符转成实体。这意味着用户输入里的恶意脚本会被当成普通文本显示,而不是被执行。所以凡是输出用户可控内容的地方,都应该用c:out而不是直接${}。
提示:
${}直接输出不会转义,c:out默认转义。安全敏感的场景,优先用c:out。
3.5 fmt 库:日期和数字的格式化
日期格式化是 fmt 库的高频用法:
<fmt:formatDate value="${order.createTime}" pattern="yyyy年MM月dd日"/> <fmt:formatNumber value="${order.amount}" pattern="#,##0.00"/>pattern的写法和 Java 的 SimpleDateFormat 一致。数字格式化里#,##0.00表示千分位加两位小数,金额展示特别常用。
国际化方面,fmt:setLocale和fmt:message配合资源文件使用,能实现多语言切换。不过说实话,在只做单一语言的后台系统里,这块用得不多,了解即可。
3.6 fn 库:字符串处理的利器
fn 库提供了一堆静态函数,调用方式是${fn:函数名(参数)}。常用的有:
| 函数 | 作用 | 示例 |
|---|---|---|
| fn:length | 取长度 | ${fn:length(list)} |
| fn:contains | 是否包含 | ${fn:contains(str, "abc")} |
| fn:substring | 截取 | ${fn:substring(str, 0, 5)} |
| fn:trim | 去空格 | ${fn:trim(str)} |
| fn:split | 分割 | ${fn:split(str, ",")} |
| fn:toUpperCase | 转大写 | ${fn:toUpperCase(str)} |
一个实用场景是列表展示时截断过长的文本:
<c:out value="${fn:length(article.title) > 20 ? fn:substring(article.title, 0, 20) : article.title}"/>这样超过 20 个字符的标题会被截断,避免撑破表格布局。
4. 完整实操流程与关键环节
4.1 从零搭一个可运行的 JSTL 页面
光看标签说明容易忘,我带你走一遍完整流程。假设你有一个 Servlet 往 request 里塞了一个用户列表,要在 JSP 里渲染出来。
第一步,Servlet 端准备数据:
List<User> users = userService.findAll(); request.setAttribute("users", users); request.getRequestDispatcher("/list.jsp").forward(request, response);第二步,JSP 页面声明标签库并渲染:
<%@ page contentType="text/html;charset=UTF-8" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <html> <body> <table> <thead> <tr><th>序号</th><th>姓名</th><th>注册时间</th></tr> </thead> <tbody> <c:forEach var="u" items="${users}" varStatus="st"> <tr> <td>${st.count}</td> <td><c:out value="${u.name}"/></td> <td><fmt:formatDate value="${u.regTime}" pattern="yyyy-MM-dd"/></td> </tr> </c:forEach> </tbody> </table> </body> </html>第三步,部署运行,访问对应 URL 就能看到渲染结果。整个过程不需要在页面里写一行 Java 代码,逻辑全在 Servlet,展示全在 JSP,职责清晰。
4.2 参数计算与选择过程
这里有个容易被忽略的细节:作用域的选择。c:set的scope默认是 page,意味着只在当前页面有效。如果你在 include 的片段里设了值,想在主页面用,就得设成 request。我见过有人设了 page 作用域,然后在另一个 include 文件里取不到值,排查半天。
再一个是集合为空时的处理。c:forEach遇到空集合不渲染,但页面可能显示一个空表格,体验不好。规范做法是配合c:if:
<c:if test="${empty users}"> <tr><td colspan="3">暂无数据</td></tr> </c:if>empty这个关键字能同时判断 null 和空集合,比users == null || users.size() == 0简洁得多。
4.3 实操现场记录:一次列表页改造
我之前改造过一个订单列表页,原页面用脚本片段写了 180 多行 Java 代码,包含分页计算、状态判断、日期格式化。改造思路是分三步走:
第一步,把数据准备逻辑全部挪到 Servlet,页面只留展示。第二步,用c:forEach替换 for 循环,用c:choose替换 if-else 链。第三步,用fmt:formatDate替换 SimpleDateFormat 调用。
改造后页面从 180 行降到 60 行,而且前端同学能直接看懂结构了。中间遇到一个坑:原来的状态判断用了枚举的ordinal()值,改成 EL 表达式后不能直接调方法,得在 Servlet 里把状态转成字符串再传过来。这个转换看着多余,但符合“页面不做逻辑”的原则,反而更清晰。
5. 常见问题与排查技巧实录
5.1 标签不生效的几种典型情况
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 标签原样输出 | 标签库未声明或 uri 写错 | 检查 taglib 指令 |
| 报 ClassNotFoundException | 缺 jstl 实现包 | 检查依赖是否完整 |
| EL 表达式不解析 | web.xml 版本过低或 isELIgnored | 检查 servlet 版本配置 |
| 中文乱码 | 页面编码未设置 | 检查 pageEncoding 和 contentType |
标签原样输出是最常见的,八成是uri抄错了或者前缀对不上。EL 不解析这个问题在老项目里也常见,有些老版本 web.xml 默认关闭了 EL,需要在 page 指令里加isELIgnored="false"。
5.2 EL 表达式取不到值的排查思路
取不到值,先按这个顺序查:作用域对不对、属性名拼写对不对、getter 方法存不存在。EL 表达式${user.name}本质是调用user.getName(),如果 User 类没有这个 getter,就会报属性找不到。这个错误信息有时候不明显,容易误以为是作用域问题。
还有一个隐蔽的坑:属性名大小写。EL 对属性名是大小写敏感的,${user.Name}和${user.name}是两回事。我见过有人数据库字段是user_name,映射到实体是userName,页面里写成${user.username},怎么都取不到。
5.3 性能相关的注意事项
JSTL 标签在页面渲染时会被编译成 Java 代码执行,性能上基本不用担心。但有两个点要注意:一是避免在循环里做复杂运算,比如在c:forEach里反复调用 fn 函数处理大字符串,会拖慢渲染;二是合理使用 c:set 缓存计算结果,如果一个值在页面里用多次,先 set 再引用,比每次重新计算划算。
提示:JSTL 的性能瓶颈通常不在标签本身,而在页面里嵌套了过多的数据库查询或远程调用。视图层就该干视图层的事。
5.4 独家避坑清单
- 别在页面里写业务逻辑:JSTL 能写条件判断,但不代表该把业务规则塞进来。状态码转文字这种事,能在后端转就在后端转。
- c:out 和 ${} 别混用:安全敏感的地方统一用 c:out,避免遗漏转义。
- fn:substring 注意越界:截取前先判断长度,否则字符串短于截取位置会抛异常。
- fmt 的 pattern 别写错:
mm是分钟,MM是月份,写反了日期就错了,这个坑新手必踩。 - 标签嵌套注意闭合:JSTL 标签必须成对闭合,嵌套层级多了容易漏,建议写完立刻检查缩进。
6. 从 JSTL 看视图层设计的取舍
学 JSTL 的过程,其实也是理解“视图层该承担什么职责”的过程。它用标签把逻辑从页面里抽出来,但抽得并不彻底——条件判断、循环这些控制流还是留在了页面。这是它的局限,也是它的定位:它不追求彻底消灭页面逻辑,只追求让页面逻辑变得可读、可控、可维护。
站在今天回看,JSTL 的很多设计思路被后来的模板引擎继承和发展了。Thymeleaf 的自然模板、Vue 的指令系统,本质上都在解决同一个问题:怎么让展示层既灵活又不失控。理解了 JSTL 的取舍,再看这些新工具,会更容易抓住它们的设计意图。
如果你手上正好有 JSP 老项目,我的建议是别急着推翻重写,先用 JSTL 把页面里的脚本片段一点点替换掉,把逻辑往 Servlet 或 Service 层挪。这个过程本身就是一次代码梳理,改完之后你会发现,哪怕不换技术栈,可维护性也能上一个台阶。至于新项目,JSTL 可以作为了解服务端渲染的入门砖,但选型时还是要结合团队和场景综合判断,别为了“统一”而硬上。