如果你写过 JSP,大概率经历过这种场面:页面顶部堆着一排 <%@ page import="..." %>,HTML 中间穿插着 <% for (...) { %>,循环结束还得记着补一个 <% } %>。改一个字段,要在几十行标签和 Java 语法之间来回找括号。EL 表达式和 JSTL 标签库,就是专门把这种痛苦从 JSP 里剥离出去的一套组合方案。今天这篇想聊的就是它俩怎么配合,EL 负责取数据、算表达式,JSTL 负责控制流程、搞定循环,两者一起把 JSP 从混乱脚本拉回干净模板。适合两类人看:一类是刚学到 Java Web 阶段的开发者,正在被 scriptlet 折磨;另一类是写过后端但持续被 JSP 页面维护困扰的开发者,想找一套更干净的写法。
1. JSP 里最让人头大的事
1.1 scriptlet 把页面变成了“意大利面”
JSP 本身的设计初衷是好的:让页面能够动态显示数据。但很多项目写着写着就偏离了,开始在页面里写大量 Java 逻辑。最常见的就是 scriptlet,也就是用 <% %> 包裹的代码块。早年很多后台管理系统的列表页是这个画风:
<% List<User> userList = userService.findUsers(); for (User u : userList) { if (u.getStatus() == 1) { %> <li class="online"><%= u.getNickname() %> 在线</li> <% } } %>这种写法初看还能跑,一旦需求变多,问题就全冒出来了。
最大的问题是排查困难。JSP 本质上是把 Java 代码和 HTML 模板混在一起交给服务器编译,很多报错信息只给到一个编译行号,但实际原因是前面某个 <% %> 没闭合,或者某个 Java 判断块被 HTML 标签切成了两半。不少接手这种页面的人,排查起来都很崩溃:服务端日志只给出一行编译错误,浏览器直接一片空白,根本不知道问题出在第几层括号里。
第二个问题是维护成本。scriptlet 写多了,页面结构肉眼根本看不出来。你想调整 HTML 结构,可能不小心动到 Java 循环;你想加一个字段,得先数清楚当前在第几层括号里。更别说如果页面里有多个循环嵌套,负责改页面的前端同事面对这堆代码基本无从下手。时间一长,这种 JSP 就变成了“能不动就不动”的禁区,谁改谁踩坑。
第三个问题是业务逻辑和展示逻辑完全耦合。正常的分层开发中,查询、判断、计算这些事情应该提前在 Servlet 或 Service 层准备好,JSP 只负责把结果展示出来。但 scriptlet 允许你在页面上直接写业务代码,一旦开了这个口子,后面就会越写越多,最终整个页面变成一锅意大利面。
1.2 EL + JSTL 到底干了什么
解决上面这些问题,靠的就是 EL 表达式和 JSTL 标签库这对组合。
两者分工非常明确:EL 负责“取数据、算表达式”,核心语法是 ${};JSTL 负责“流程控制、循环、格式化、URL 处理”,核心是一组类似 HTML 标签的 c 标签。打个比方,EL 是查数据的取件员,你告诉它“把 request 里 userList 拿出来”,它就去拿;JSTL 是流水线上的操作员,你告诉它“循环遍历执行、满足条件才输出”,它就去执行。
这个分工的意义在于:JSP 页面里不再出现任何一个 Java 关键字。没有 import,没有 for,没有 if,没有变量类型转换,取而代之的是 ${user.name}、<c:forEach>、<c:if> 这种既像模板又像标签的东西。
对比一下就很直观。同样的一个列表展示,scriptlet 版本里 Java 代码占了三分之一,标签结构支离破碎;换成 EL + JSTL 后,页面看起来就是一个完整的 HTML 模板,眼尖的话还能看出哪些数据是动态的,哪些区域是条件控制的。
当然,EL 和 JSTL 也不是万能的。它俩解决的是“JSP 页面内代码混乱”的问题,而不是“后端架构混乱”的问题。业务逻辑该在 Service 层还是在 Service 层,数据组装该在 Servlet 还是在 Servlet,只是最后一步展示和简单遍历判断可以放心交给页面模板。理解到这一层,你才算是真正用对了这对组合。
2. EL 表达式:让 JSP 学会“自己取值”
2.1 ${} 语法是怎么工作的
EL 表达式的基本语法就是 ${},花括号里写你要获取的数据或者要做的运算。最简单的用法是 ${user.name},它表示:在四个作用域里依次找名为 user 的对象,找到之后访问它的 name 属性。
这里要特别说清楚“访问属性”的底层逻辑。${user.name} 并不是直接读 user 对象里的 name 字段,而是调用 user 对象的 getName() 方法。这是 JavaBean 规范决定的,所以你的实体类必须有对应的 getter 方法,否则 EL 表达式取到的就是空字符串,而且不会报错。这一点很多新手都没注意到,排查半天以为是作用域问题,结果是实体类少了 getter。
除了点号访问,EL 还支持方括号访问,两者是等价的:
${user.name} ${user["name"]} ${user['name']}方括号写法在两种场景下更顺手:一是 Map 的 key 不是合法标识符时,比如 ${map["user-name"]};二是 key 本身是变量时,${map[keyVar]} 这种动态取值。数组和 List 的下标访问也是用方括号,比如 ${userList[0].name}。
容器查找顺序也是一个绕不开的知识点。${user.username} 默认会按 pageScope → requestScope → sessionScope → applicationScope 的顺序找名称为 user 的属性,一旦找到就停止,找不到就返回空。
这个顺序背后是作用域的生命周期:page 最小,只管当前页面;request 只管一次请求;session 覆盖一个会话;application 是全局共享。范围从小到大,查找自然也是从小到大,查不到就往外扩大。
因为这个机制,我见过不少“EL 取不到值”的案例,十有八九是 Servlet 里把数据存到了 session,而页面里没有带 sessionScope 前缀,结果 request 里正好有同名的 key,导致取到的是另一个值。所以页面里建议写明确的作用域前缀,特别是业务数据,比如 ${sessionScope.loginUser.nickname},这样一眼就能看出数据来源,也能避免同名覆盖。
2.2 内置对象与运算符,一张表看明白
EL 内置对象是它取数的“入口清单”,日常开发最常用的我整理成一张表:
| EL 对象 | 作用 | 典型场景 |
|---|---|---|
| pageScope | 当前页面范围的属性 | 页面内部传递临时值 |
| requestScope | 当前请求范围的属性 | Servlet 转发数据到 JSP |
| sessionScope | 会话范围的属性 | 登录用户、购物车 |
| applicationScope | 应用范围的属性 | 全站配置参数 |
| param | 获取单个请求参数 | 表单提交值回显 |
| paramValues | 获取同名多值参数 | 复选框多选 |
| header | 获取单个请求头 | 客户端信息 |
| headerValues | 获取同名多个请求头 | 特殊排查场景 |
| cookie | 读取浏览器 Cookie | 记住登录状态 |
| initParam | 读取 web.xml 初始化参数 | 全局配置项 |
| pageContext | 页面上下文对象 | 访问 request、session 等 |
常用的几个我给你配了实际写法:
${param.username} ${paramValues.hobby[0]} ${cookie.rememberMe.value} ${initParam.pageSize}param 这组特别常用。很多搜索页表单提交后,用户输入的关键词要回显到输入框里,直接用 ${param.keyword} 就省去 request.getParameter("keyword") 这一长串,而且 JSP 里塞 Java 代码的动机又少了一个。
EL 的运算符也是日常要用的,算术、关系、逻辑、条件、空值判断都有。比较有特点的是它提供了一套英文写法:eq 等于、ne 不等于、lt 小于、gt 大于、le 小于等于、ge 大于等于,以及 and、or、not,写出来更接近自然语言。
empty 是我最常用的运算符,它判断一个值是否为空,但“空”的定义很宽:null、空字符串、长度为零的数组,以及没有元素的 List、Map,都会被判定为空。
${empty userList} ${empty keyword}这在列表页太好用了:数据没查到、搜索关键词没传、某对象还没初始化,一个 empty 全部兜住,不用再写一堆 null 判断和空集合判断。类似地,三元表达式也很实用,比如状态列显示:
${user.status == 1 ? '在线' : '离线'}2.3 EL 的限制,恰恰是 JSTL 存在的理由
EL 虽然写起来爽,但它本质上只是表达式语言,能力边界很清楚:它能读取数据、能计算、能判断空值,但不能定义复杂变量、不能写循环、不能做多分支流程控制。你可以用三元表达式做二选一,但“多个条件逐个判断”这种逻辑,EL 是搞不定的。
这不是 EL 的缺陷,而是设计上的刻意取舍。表达式语言本来就只该负责“读”和“算”,如果让 EL 也能写完整逻辑,那 JSP 又会退回脚本化开发的老路。所以 EL 的克制,恰恰是 JSTL 存在的理由:流程控制交给标签。
另外说一下 EL 3.0 之后的一个特性:新版本支持直接调用 Java 方法,比如 ${user.getName()},在 Tomcat 8.5 以上的环境里可以正常执行。但在实际页面里我不太建议这么写,一方面方法调用会引入副作用,另一方面页面看起来又像 Java 代码了,不如在 Servlet 层把数据准备好,页面只做展示。
3. JSTL 标签库:流程控制交给标签
3.1 环境准备与版本选择,别一上来就踩坑
JSTL 全称是 JSP Standard Tag Library,是官方标准标签库,核心是 c 开头的标签组,比如 c:out、c:set、c:if、c:forEach。引入方式不难,但版本问题很容易坑人,而且大多是在部署阶段才爆发。
你需要先搞清楚项目跑在哪个 Servlet 容器版本上。Tomcat 9 及以下对应的是 javax 命名空间,用的是 JSTL 1.2,taglib uri 是老地址 java.sun.com;Tomcat 10 开始整个 Servlet 规范把 javax 换成了 jakarta,JSTL 也升级到 2.x,taglib uri 变成了 jakarta.tags.core。
我把两种环境的配置整理在一起,方便对照。
Tomcat 9 及以下,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="fn" uri="http://java.sun.com/jsp/jstl/functions" %>Tomcat 10 及以上,Maven 依赖要引入两个:
<dependency> <groupId>jakarta.servlet.jsp.jstl</groupId> <artifactId>jakarta.servlet.jsp.jstl-api</artifactId> <version>2.0.0</version> </dependency> <dependency> <groupId>org.glassfish.web</groupId> <artifactId>jakarta.servlet.jsp.jstl</artifactId> <version>2.0.0</version> </dependency>JSP 页面开头则写:
<%@ taglib prefix="c" uri="jakarta.tags.core" %> <%@ taglib prefix="fn" uri="jakarta.tags.functions" %>为什么会出现两套?因为 Java EE 后来改成了 Jakarta EE,整个技术体系都从 javax 迁移到了 jakarta,JSTL 作为标准库自然也要跟着迁移。对开发者来说,最直接的影响就是:老项目升到 Tomcat 10 的时候,JSTL 相关依赖必须同步升级,否则运行期直接抛类找不到的异常。
关于环境,我再多提醒一点:IDEA 里新建 JSP 后,如果 c 标签一直没有代码提示,优先检查 Maven 依赖是否已加载成功,以及部署到 Tomcat 的产物里是否包含了 jstl 相关 jar。很多时候不是代码问题,而是包没打进去。
3.2 核心标签逐个拆解:out / set / if / choose
JSTL 核心标签里,日常占用率最高的是这么几个:c:out、c:set、c:if、c:choose。我按使用频率逐个说。
c:out 负责输出,功能类似 <%= %>,但它有一个重要特性:默认对输出的字符串做 HTML 转义。同样输出一个用户昵称,如果昵称里含有