搞Java Web的人,十有八九都被JSP折磨过。早些年写页面,一段段Java代码直接堆在HTML里,数据获取、流程判断、界面渲染全搅和在一起,满屏幕都是<% %>脚本片段。那时候我接手过一个老项目,改个页面上的显示格式,愣是把后端查询逻辑给改出问题来——视图和业务边界完全模糊,维护成本高得离谱。后来逐步引入EL表达式(Expression Language,表达式语言)之后,JSP页面才真正清爽起来:页面只负责展示,取值、判断这种“脏活累活”全部让EL去干。这篇文章就结合这些年的实战经验,把EL表达式的语法细节、隐含对象、运算符、常见坑一次讲透。不管你是刚接触JSP的新手,还是写了一阵子但没系统梳理过的开发者,都能在这里找到能直接用的东西。
1. EL表达式到底解决了什么问题
1.1 从JSP脚本片段说起
理解EL表达式之前,得先搞清楚它诞生的背景。JSP本质上是一个Servlet的模板化封装,早期想在页面上展示后端数据,写法非常原始:
<% User user = (User) request.getAttribute("user"); if (user != null) { %> <div>姓名:<%= user.getName() %></div> <% } %>这段代码看起来没什么问题,但它有几个致命的毛病:第一,每次取数据都要强制类型转换,写起来啰嗦不说,类型写错了直接抛ClassCastException;第二,JSP页面里混入大量Java逻辑,前端设计师拿到页面根本无从下手;第三,嵌套的if判断和循环让HTML结构支离破碎,改一个标签都要小心翼翼。
我当时见过最离谱的一个老页面,一个简单的用户列表,愣是写了200多行脚本片段,循环、判断、字符串拼接全在里面。后来接手的同事几乎没人敢动那个文件,谁改谁背锅。这其实暴露了JSP早期设计里的一个核心矛盾:动态页面确实需要编程能力,但让Java代码直接裸露在HTML里,又让“视图”这个角色严重失职。
1.2 EL表达式的核心设计思想
EL表达式的设计目标很明确:用一套简洁的、贴近JavaScript的语法,替代页面里绝大多数Java取值和判断代码。它在JSP 2.0时代被正式纳入规范,核心目的就两个——简化数据访问、消除脚本片段。
它的基本语法是${表达式},在JSP页面的任意位置都能使用,比如前面那段脚本片段用EL来写就是:
<div>姓名:${user.name}</div>注意三个关键变化:不需要强制类型转换,因为EL自己会处理类型;不需要判空也能安全输出,EL对null值有自己的处理逻辑;不需要写分号、不需要声明变量类型,整体像在写模板。这一点对我来说体验提升是巨大的——写页面的思路从“怎么用Java代码实现”变成“我就是要在这一块展示什么数据”,整个心智模型都不一样了。
EL还有一个非常实用的特性:在普通文本和HTML属性中都可以直接使用。比如:
<input type="text" value="${user.name}" />这比用<%= %>拼接字符串要干净得多,也不容易因为引号嵌套出Bug。可以说EL表达式的出现,把JSP从“嵌着HTML的Java代码”真正拉回到了“能写逻辑的模板页面”这个定位上。
2. EL语法基础与核心细节解析
2.1 基础语法与两大取值操作符
EL表达式以${符号开头,以}结尾,中间写取值表达式。表达式里有两套取值操作符:点号(.)和方括号([]),它们的本质功能是一样的,都用来访问对象的属性、集合的元素、Map的键值。但细究起来各有擅长:
点号操作符适合访问命名规范的属性,比如${user.name}访问的是User对象的name属性,底层实际调用的是getName()方法。这个细节很关键——EL并不直接操作字段,而是走JavaBean的getter方法。换句话说,你的类里如果没有对应的getter,就算字段存在EL也取不到值。
方括号操作符则灵活得多,它内部可以放变量、放带特殊字符的字符串。最常见的场景是Map取值:
<%-- 键名里带点号,用点号操作符会出问题,方括号没问题 --%> ${map['user.name']}再比如动态索引。循环取数组元素时,方括号配合循环变量非常好用:
<c:forEach items="${list}" var="item" varStatus="st"> <span>${list[st.index]}</span> </c:forEach>当然这只是示例,现实中循环直接取item就行。我自己的经验是:能确定属性名的时候用点号,属性名是动态的、或包含特殊字符的时候用方括号。两者还有一个通用关系——${user.name}完全等价于${user["name"]},这点很多资料都没讲透。
2.2 四大作用域上的查找顺序
EL表达式取值时,不会只盯着某一个作用域死磕,而是沿一条固定的链条逐级查找:pageContext(页面作用域)→ request(请求作用域)→ session(会话作用域)→ application(应用作用域)。找到第一个非null值就返回,全部找不到就返回null。
举个例子,假设四个作用域里都放了一个名叫count的变量,那么${count}取到的一定是page作用域里那个值,前三个作用域压根没机会参与。这个查找规则有好处也有坑:
好处是写代码省心,request里setAttribute之后可以直接${xxx}来取,不需要关心它存在哪个作用域;坑也很明显——一旦不同作用域存在同名变量,你的页面可能静默取到了错误的数据。我自己就踩过一回,某次在session里缓存了当前用户信息,又在request里放了一个同名变量做临时覆盖,结果因为param的取值逻辑不对,页面上一直显示旧数据,排查了将近一天。
所以实操中我有一条铁律:需要精确取某个作用域的数据时,绝不省略作用域前缀。EL正好提供了隐式对象来精确指定作用域,比如${requestScope.count}、${sessionScope.user}、${applicationScope.config}。
| EL作用域对象 | 对应Java作用域 | 典型使用场景 |
|---|---|---|
| ${pageScope.xxx} | PageContext | 页面内部临时变量 |
| ${requestScope.xxx} | HttpServletRequest | 单次请求内的数据 |
| ${sessionScope.xxx} | HttpSession | 用户会话级的数据 |
| ${applicationScope.xxx} | ServletContext | 全局配置数据 |
2.3 运算符体系:计算和判断一次说清
EL的运算符整体上和Java很像,但有几个细微差异值得注意。
算术运算符支持+、-、*、/(或div)、%(或mod),基本和Java一致。但有一个新手容易踩的坑:EL里+只能做加法,不能拼接字符串。我见过有人写${user.id + '-' + user.name}想拼字符串,结果直接报错。EL 3.0之前也没有字符串拼接的运算符,习惯做法是先把字符串在后台拼好,或者用fn:join这类的函数来处理。
关系运算符支持==(或eq)、!=(或ne)、<(或lt)、>(或gt)、<=(或le)、>=(或ge)。这里要特别强调一下==在EL里的语义:EL的==比较的是值,不是对象的引用地址。也就是说${user.age == 18}这个判断背后是由EL自己去调用equals或数值比较逻辑,不需要你担心对象引用的问题。这个设计早年让很多从Java过来的人困惑过,但熟悉之后会觉得确实方便。
逻辑运算符包含&&(或and)、||(或or)、!(或not)。有一说一,我在项目里还是习惯写长单词形式,${empty user}和${not empty list}看起来语义更清晰,代码可读性更好。
条件运算符和Java保持一致:${condition ? value1 : value2}。实际项目里做表格隔行变色、状态值显示这种场景非常常用:
<tr class="${status.index % 2 == 0 ? 'even' : 'odd'}">最后是EL特有的empty运算符,这是出镜率最高的一个。它判断的是一组“空”的情况:null、空字符串""、空的集合或数组、空的Map都会被判定为true。这个运算符非常实用,典型场景是表单校验回显:
<%-- 如果用户没有传名字,显示默认文案 --%> <span>${empty user.name ? '未填写' : user.name}</span>3. EL隐含对象与常用场景实战
3.1 从请求参数到Cookie:隐含对象怎么用
EL的核心优势之一是有11个隐含对象,使用起来比在Servlet里手动拿request再层层取值方便得多。我把最常用的几个整理一下:
首先是param和paramValues,这两个就是request.getParameter和request.getParameterValues的简写。页面里接收URL参数、表单提交的参数,直接${param.username}就能拿到。注意param取到的是字符串类型,如果要做数值比较,EL会自动转换;但如果做加减运算,得确保它真能转成数字,否则返回null或0。
我在项目中经常把EL的取参能力和条件判断结合起来做回显,比如编辑页面:
<input type="text" name="username" value="${empty param.username ? user.username : param.username}" />这样一个表达式就完成了“表单提交失败后保留用户输入,首次加载回显用户已有数据”的双重效果。
header和headerValues用于获取HTTP请求头。比如打印客户端类型:${header['User-Agent']}。注意User-Agent这个键名带特殊字符,必须用方括号写法,这正好呼应之前说的方括号操作符的优势。
cookie隐含对象返回的是当前请求携带的Cookie集合,取值时注意它返回的是Cookie对象本身,要拿值还得再点一次value:
${cookie.JSESSIONID.value}initParam可以拿到web.xml里配置的上下文初始化参数,适合做全局配置项展示。举个例子,在web.xml里配了网站备案号这种全局信息,页面底部直接${initParam.icpNumber}就能输出,省得在后台代码里再读一次。
最后是pageContext,它比较特殊,能直接访问到JSP页面的核心对象,比如${pageContext.request.contextPath}是获取项目根路径最常用的写法。以前写<%=request.getContextPath()%>还得搞个变量,现在一个EL搞定,而且放在href、src属性里都特别顺手:
<script src="${pageContext.request.contextPath}/static/js/app.js"></script>3.2 访问JavaBean属性和集合数据
EL对JavaBean的访问走的是getter方法这个特性,前面已经提到过。这里展开说说它在真实项目中的威力。假设后台构造了一个完整的用户视图对象:
public class UserVO { private String name; private int age; private List<String> tags; private Map<String, String> extInfo; // getter/setter省略 }在JSP页面上展示这些数据,最常见的就是表格和详情页:
<tr> <td>${user.name}</td> <td>${user.age}</td> <td>${fn:join(user.tags, '、')}</td> <td>${user.extInfo.city}</td> </tr>${user.extInfo.city}这行是我特别想拿出来讲一下的——它展示的是多级嵌套取值能力:先拿到UserVO对象的extInfo属性,再把它当成Map取city这个键。级联.操作符可以一直往下点,只要每一层都能解析出来就没问题;但如果中间某一层是null,EL不会抛异常,而是整个表达式返回null。这个特性在页面渲染上是把双刃剑,后面常见问题环节我会再讲。
再来说List和Map的循环展示。单纯的EL表达式没法写循环,它只能单点取值,所以真正做列表页的时候要配合JSTL的c:forEach标签。这里给出一个完整的经典案例:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %> <c:forEach items="${userList}" var="user" varStatus="st"> <tr class="${st.index % 2 == 0 ? 'row-even' : 'row-odd'}"> <td>${st.count}</td> <td>${user.name}</td> <td>${user.age}</td> <td> <c:if test="${fn:length(user.tags) > 0}"> ${fn:join(user.tags, ' / ')} </c:if> </td> </tr> </c:forEach>3.3 EL函数与JSTL的配合
EL函数是EL表达式的一个扩展机制,它在JSTL里体现最明显。JSTL函数库(fn前缀)给EL补足了字符串处理、集合长度判断等能力,弥补了EL表达式语言本身功能有限的短板。
我平时用到最多的几个fn函数:
fn:length(collection)返回集合或字符串长度,这是做列表空判断的利器——虽然empty运算符也能判断空集合,但有时候你需要的是具体长度,比如“这个列表超过10条就分页显示”这种需求:
<c:if test="${fn:length(orderList) > 10}"> <a href="?page=2">查看全部订单</a> </c:if>fn:contains(str, substr)判断字符串包含关系。我在做搜索关键字高亮显示时经常配合fn:replace使用:
${fn:replace(article.title, keyword, '<span class="highlight">' + keyword + '</span>')}等等,这里有一个新手容易踩的坑:fn:replace的第三个参数不支持EL中直接做+拼接。如果非要高亮,得在后台先把span标签拼好,或者嵌套使用fn函数。我在项目里遇到这种情况,一般是在后台处理成封装好的对象,JSP页面只负责输出。记住一个朴素的原则:复杂业务逻辑永远不要指望在JSP页面里完成,EL和JSTL只是让页面变清爽,不是让页面变成业务逻辑的跑场。
JSTL核心标签库里的c:choose、c:when、c:otherwise也是EL的好搭档,可以做多分支判断。我在权限控制页面经常这么写:
<c:choose> <c:when test="${sessionScope.user.role == 'admin'}"> <button>管理控制台</button> </c:when> <c:when test="${sessionScope.user.role == 'editor'}"> <button>内容编辑</button> </c:when> <c:otherwise> <span>只读模式</span> </c:otherwise> </c:choose>4. 常见问题与排查技巧实录
4.1 页面数据静默消失的经典场景
EL表达式有个设计特性:取不到值时不报错,而是渲染成空字符串。这个特性平时挺好用,但在排错时就是噩梦来源。我自己遇过好几次“整页数据全没了”的求助,最后定位基本都是下面几种情况。
第一种是作用域里根本没有对应的key。最常见的就是拼写不一致,比如后台塞的是sessionScope.currentUser,页面写的是${user.name},那自然是空的。这种情况只能仔细对参数名,没有捷径。
第二种是JSP版本和web.xml配置导致EL被关闭。在JSP 2.0之前的Servlet规范里,EL默认是关闭的,如果web.xml里配置了isELIgnored且值为true:
<jsp-config> <jsp-property-group> <url-pattern>*.jsp</url-pattern> <el-ignored>true</el-ignored> </jsp-property-group> </jsp-config>那么页面上所有${}都会被当成普通文本直接输出,而不是被解析执行。那个场景下最明显的特征是:浏览器里能看到一个巨大的“${user.name}”字符串,而不是空白。我在一个老项目里见过这种情况,当时是别人把老版本的web.xml配置模板直接新项目里用,EL被全局关掉了。
第三种是变量被更高优先级的作用域覆盖了,这在2.2节里说过。这里再补充一个排查技巧:调试时优先用带有作用域前缀的写法,比如${requestScope.user}改成${sessionScope.user},看输出有没有变化,两分钟就能定位是不是作用域冲突。
排查这类“静默为空”问题时,我最常用的手段是临时在页面里用fn:length输出变量长度:
${fn:length(userList)}如果输出0那就是list本身为空,如果输出-1则说明userList这个key根本不存在。这个技巧在循环不渲染的时候尤其好用,半分钟就能把问题缩小到“数据没传”或“变量名拼错”。
4.2 空值判断和类型比较的坑
EL里empty运算符判断“空”时会同时覆盖null、空字符串、空集合、空数组这几种情况。但它有个容易被忽略的细节:字符串"null"并不是空。如果后台接口返回了字符串类型的"null"(有些老接口习惯把空值序列化成字符串null),${empty user.name}会判断为false,页面就会原样输出字符串null四个字母。
我在实际项目里处理这种情况,一般会在后端接口统一做清洗,把字符串"null"替换成真正的null值。如果接口改不了,就在JSP里做一个二次判断:
<c:set var="displayName" value="${empty user.name || user.name == 'null' ? '' : user.name}" />再说数值比较的情况。EL 3.0之前做${user.age > 18}这种数值比较,实际执行的是字符串比较逻辑而不是数值比较逻辑,因为EL会尝试自动转型为BigDecimal或Long。如果user.age本身没有被正确赋值(比如是字符串"18a"),EL不会直接报错,而是整个比较表达式返回false。这种问题隐藏性极强,页面看起来“没报错但结果不对”。我的排查经验是:先用一个临时输出看数据类型和值:
<%-- 输出原始值,检查是不是 "18a" 这种脏数据 --%> <input type="hidden" value="${user.age}" />4.3 特殊字符转义与其他注意事项
在使用EL表达式时,如果你确实要在页面上输出一个字面的“${”字符,而不让它被解析,那就需要做转义。标准做法是用${来转义:
<p>\${这个不会被解析}</p>还要注意EL表达式的嵌套问题。EL支持嵌套求值,这种特性在动态拼属性名时很有用。比如:
<c:set var="fieldName" value="age" /> ${user[fieldName]}这个特性非常强大,但也会带来阅读难度,我一般不推荐在复杂的场景里滥用嵌套EL。
性能方面也顺便说一嘴。EL表达式在渲染时会做一次解析和求值,理论上比直接输出Java变量要慢一点点。在百万级流量的核心页面上,如果仍然使用JSP作为视图层,那么频繁调用EL确实会造成一定的CPU开销。我的经验是:优先用EL处理数据展示,但不要在EL表达式里套非常复杂的嵌套调用,也不要把业务逻辑写成巨型表达式塞进JSP。把EL定位成“简化视图层取值”的工具,它才能发挥最大价值。
4.4 常见问题速查
| 症状 | 可能原因 | 处理建议 |
|---|---|---|
| 页面上直接显示${xxx}文本 | EL被配置为忽略 | 检查web.xml的el-ignored配置 |
| EL表达式返回空白 | 作用域中无此变量或变量为null | 用fn:length临时验证变量是否存在 |
| 数据取到了但不是想要的那个 | 多作用域同名变量导致优先级覆盖 | 使用requestScope/sessionScope精确指定 |
| 比较结果总是false | 数据类型不匹配或脏数据 | 临时输出原始值排查 |
| 循环标签不渲染 | 集合为null或未传给页面 | 确认后台setAttribute的key与页面一致 |
| 字符串拼接报错 | EL中+不能拼接字符串 | 用fn函数或后台拼好再传给页面 |
我个人在实际操作中还有一个习惯:写页面取值前,先在脑海里过一遍“这个数据是哪个作用域来的”,然后对应的作用域前缀写明确的。这样虽然表面上代码多用了一点点单词,但可读性和排错效率都高了不少。说到底,EL表达式的本质不是魔法,它不过是把取值逻辑从Java代码里转移到了模板语法里,咱们该把控的作用域、类型、空值问题,一个都跑不掉。但把这个工具用顺手了,JSP页面能从一个“混着大量Java代码的怪物”变成真正清爽的模板文件——这也是我前面那个老项目后来改造成功的关键一步。