在JSP页面里渲染数据列表,十个人有九个会先想到c:forEach。这是JSTL核心标签库里出镜率最高的一个标签,专门用来迭代集合、数组、Map以及生成固定次数的循环。如果你刚从scriptlet时代过来,第一次看到c:forEach会觉得它像Java里的for循环,但实际用下来会发现,它的设计思路和for循环很不一样,踩过的坑也不一样。这篇文章把我这些年用c:forEach做过的示例、踩过的坑、排错的过程都整理一遍,给正在用JSP做Java Web项目的朋友一个参考。
1. 为什么我在JSP里不再用Java for循环写页面了
1.1 从一段"原始"页面代码说起
早些年做JSP,页面里经常能看到这种代码:
jsp复制<%
List<User> userList = (List<User>) request.getAttribute("userList");
for (int i = 0; i < userList.size(); i++) {
User user = userList.get(i);
%>
<tr>
<td><%= user.getId() %></td>
<td><%= user.getName() %></td>
<td><%= user.getEmail() %></td>
</tr>
<%
}
%>
这段代码本身可以跑,但问题很直观:
- 页面上出现大量
<% %>、<%= %>标签,HTML和Java代码混在一起,视觉上非常混乱。 - 如果要做"偶数行变色"这种需求,得自己维护循环变量i,再嵌套三元表达式,代码越堆越脏。
- 嵌套循环的时候,scriptlet的括号、百分号堆在一起,维护起来极易出现括号不匹配、缩进混乱的问题。
我之前带项目时,要求成员尽量不在JSP里写Java代码,但总有人嫌麻烦,说"就这么几行,写个for怎么了"。直到有一次,一个页面因为scriptlet里不小心把变量名写错,导致运行时NullPointerException,而且异常发生在渲染阶段,排查了大半天才找到是页面里的Java代码出了问题。那次之后,我们彻底把JSP页面里的scriptlet清零,全部换成JSTL和EL表达式。
1.2 JSTL的设计意图:把逻辑和展示分开
JSTL(JavaServer Pages Standard Tag Library)是JSP标准标签库,c:forEach是核心库core中最核心的迭代标签。它的设计意图很简单:把"控制逻辑"做成标签,让页面只负责"展示结构"。
这个设计的好处在于:
- 标签的语义清晰,看到
<c:forEach>就知道这是一个循环块,不需要去解析Java语法。 - 循环体内的变量通过EL表达式访问,不接触Java对象引用的细节。
- 更关键的是,一旦循环逻辑出了问题,错误信息往往能直接定位到标签,而不是埋在几百行HTML片段中间的Java代码里。
我在实际项目中,把前面的scriptlet示例改成了:
jsp复制<c:forEach items="${userList}" var="user">
<tr>
<td>${user.id}</td>
<td>${user.name}</td>
<td>${user.email}</td>
</tr>
</c:forEach>
从"数据获取"和"页面展示"分离的角度看,这段代码的优势非常明显:Controller只负责把userList放进request,页面只负责循环输出。后面如果要在列表里加序号、加操作列、加条件判断,只需要在标签内部调整即可。
不少初学者有个误区,觉得JSTL是"过时技术"。但实际情况是,直到今天,仍有大量Java Web项目跑在JSP上,尤其是传统企业管理系统、内部后台、政务类系统。即使你平时用Thymeleaf或前端模板,理解c:forEach背后的标签化迭代思想,也有助于你在不同模板技术之间切换时更快上手。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. c:forEach的6个属性,每个都值得单独说清楚
c:forEach总共支持6个属性:items、var、begin、end、step、varStatus。很多文章只是把表格列一遍,但实际使用中,每一个属性的行为都有不少细节。
2.1 items和var:数据从哪来、变量叫什么
items指定被遍历的集合对象,通常是EL表达式。var是循环体内用的局部变量名,也就是说,每次循环都会把当前元素赋值给var指定的变量。
这里有一个非常容易忽略的细节:var定义的变量只在c:forEach标签体内有效,出了循环体就访问不到。所以不要把var当作可以在页面其他地方复用的变量。
另一个容易被忽略的是items支持的类型范围。我整理了一下:
| 类型 | 是否支持 | 说明 |
|---|---|---|
| Java数组 | 支持 | 包括对象数组和基本类型数组 |
| List、Set等Collection | 支持 | 最常用的场景 |
| Iterator、Enumeration | 支持 | 注意只能遍历一次 |
| Map | 支持 | 遍历的是Map.Entry |
| 逗号分隔字符串 | 部分支持 | 依赖JSTL版本和EL版本 |
我在项目里试过直接遍历一个字符串:
jsp复制<c:forEach items="${'a,b,c'}" var="item">
${item}
</c:forEach>
这个用法在JSTL 1.2下可以跑,输出a、b、c。但在旧项目里如果jar包比较老,行为可能不一致。所以我的建议是:规规矩矩传集合对象,不要在这种边界玩法上浪费调试时间。
2.2 begin、end、step:别只会用完整遍历
当你不传begin和end时,c:forEach会遍历整个集合。但有些场景需要循环一部分,这时候begin和end就派上用场了。
begin是起始索引,包含;end是结束索引,也包含。step是步长,默认是1,如果写成2,就会隔一个取一个。
常见的用法:
jsp复制<c:forEach items="${list}" var="item" begin="0" end="4">
${item}
</c:forEach>
这段代码只输出list的前5个元素。用在大列表的"只看前几条"场景非常方便。
但是,这里有一个坑:begin和end是基于索引下标的,如果items是Set,因为Set本身没有顺序概念,begin/end取出来的是哪几个元素是不确定的。所以在对顺序有要求的场景,items一定不要传Set,要传List或数组。
另外,begin和end也可以不依赖items,单独配合var生成连续整数序列。这是c:forEach一个很有用的变体,我后面会在分页条示例里详细演示。
2.3 varStatus:被大多数人忽略的"状态机"
varStatus是c:forEach里我觉得最实用的一个属性。它并不绑定到集合元素本身,而是绑定到当前循环的一个状态对象,类型是javax.servlet.jsp.jstl.core.LoopTagStatus。
这个对象上可以取到很多信息:
- index:当前元素的索引,从0开始
- count:当前循环计数,从1开始
- first:是否是第一个元素
- last:是否是最后一个元素
- begin、end、step:当前循环的起始值、结束值和步长
- current:当前元素本身(和var指向的内容一样)
用varStatus最常见的效果是隔行变色:
jsp复制<c:forEach items="${list}" var="item" varStatus="status">
<tr class="${status.count % 2 == 0 ? 'even' : 'odd'}">
<td>${status.count}</td>
<td>${item.name}</td>
</tr>
</c:forEach>
这个例子用一个三元表达式完成奇偶行判断,非常简洁。
还有首行和末行的判断,常见于列表项之间加分隔线:
jsp复制<c:forEach items="${list}" var="item" varStatus="status">
${item.name}
<c:if test="${!status.last}">, </c:if>
</c:forEach>
这段代码会在每两个名字之间输出一个逗号,但最后一个元素后面没有逗号。这种逻辑用scriptlet写也没几行,但用varStatus表达更直观,更重要的是不会出现index溢出之类的边界判断。
3. 从实际页面需求出发的完整示例
这一部分我会模拟一个后台管理系统中真实会遇到的场景,把c:forEach的用法放在具体需求里来展开。
3.1 用户列表展示:最基础的forEach渲染
假设Controller已经往request里放了一个属性叫userList,类型是List
jsp复制<table border="1" cellpadding="8" cellspacing="0">
<thead>
<tr>
<th>ID</th>
<th>姓名</th>
<th>邮箱</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<c:forEach items="${userList}" var="user">
<tr>
<td>${user.id}</td>
<td>${user.name}</td>
<td>${user.email}</td>
<td>
<c:choose>
<c:when test="${user.status == 1}">启用</c:when>
<c:otherwise>禁用</c:otherwise>
</c:choose>
</td>
</tr>
</c:forEach>
</tbody>
</table>
这里有几个细节值得说。
第一,${user.id}不直接访问私有属性,而是通过JavaBean规范的getter方法。也就是说User类里必须有getId()方法,否则这里会报错。
第二,c:choose和c:when配合使用,等价于Java里的if-else if-else结构。这里用c:choose而不是两个c:if,是因为c:choose有"互斥"语义:一旦某个when匹配,后面的分支不会再判断。这在逻辑上有轻微的性能差异,但更重要的是语义更清晰。
第三,如果userList是null,这段代码会不会报错?答案是:不会。c:forEach对null的items是宽容的,它会直接不执行循环体。这个特性在后面的坑里我会再展开讲。
3.2 带序号和隔行变色的表格
用户列表通常需要序号和隔行变色,方便阅读。这个需求用varStatus实现是最合适的:
jsp复制<tbody>
<c:forEach items="${userList}" var="user" varStatus="status">
<tr class="${status.count % 2 == 0 ? 'row-even' : 'row-odd'}">
<td>${status.count}</td>
<td>${user.name}</td>
<td>
<c:if test="${status.first}">新</c:if>
${user.email}
</td>
<td>${user.status == 1 ? '启用' : '禁用'}</td>
</tr>
</c:forEach>
</tbody>
这里status.count从1开始,正好作为序号。如果你希望序号从0开始,可以用status.index。
注意,在EL表达式中三元表达式是支持的,所以 ${user.status == 1 ? '启用' : '禁用'} 可以正常工作。这一点很多刚从Java转过来的新手容易忘记,以为EL只能做简单取值。实际上,EL 3.0还支持更复杂的方法调用和集合操作,但在JSP 2.x时代,三元表达式已经非常好用了。
3.3 下拉框、复选框组的生成
后台系统中,"编辑用户"页面往往需要一个角色下拉框,并且要回显用户当前的角色。这个需求用c:forEach可以做得非常优雅。
假设Controller放了一个roleList,类型是List
jsp复制<select name="roleId">
<option value="">请选择角色</option>
<c:forEach items="${roleList}" var="role">
<option value="${role.id}" ${role.id == currentRoleId ? 'selected' : ''}>
${role.name}
</option>
</c:forEach>
</select>
这里最核心的技巧就是在option标签里用EL三元表达式判断selected属性。回显逻辑不再需要单独写一段Java代码。再看复选框组。比如权限列表,每个权限项后面跟一个checkbox,如果当前角色已经拥有该权限,就需要勾选。
jsp复制<c:forEach items="${permissionList}" var="perm">
<label>
<input type="checkbox" name="permIds" value="${perm.id}"
${fn:contains(permIdsStr, perm.id) ? 'checked' : ''} />
${perm.name}
</label>
</c:forEach>
这里用到了fn:contains这个函数,它属于JSTL的函数库。permIdsStr是Controller放进来的一串以逗号分隔的权限ID。如果权限ID较多,强烈建议不要在页面里用字符串contains做判断,直接在Controller里构造一个包含权限ID的Set,再放进去,页面用 ${permIdSet.contains(perm.id)} 判断即可。
3.4 嵌套循环生成二维数据
再复杂一点的需求是嵌套循环,比如部门-用户两级树形结构。部门列表departmentList里的每个Department对象有一个users属性,类型是List
jsp复制<c:forEach items="${departmentList}" var="dept">
<h3>${dept.name}</h3>
<table>
<c:forEach items="${dept.users}" var="user">
<tr>
<td>${user.name}</td>
<td>${user.email}</td>
</tr>
</c:forEach>
</table>
</c:forEach>
嵌套循环本身不复杂,复杂的是变量命名。外层用var="dept",内层用var="user",两者不同名,所以不会冲突。但如果内层循环也用var="dept",就会出现内层覆盖外层的情况,导致后面无法再访问外层部门对象。这部分的坑我后面会单独讲。
4. 我在项目里踩过的c:forEach的坑
光看示例,c:forEach好像很简单,但真实项目里碰到的坑远比示例多。我把踩过的几个有代表性的问题列出来。
4.1 空集合和null的处理
我刚用c:forEach时,以为items为null会报错,后来发现它不会报错,只是会渲染一个空列表。这算是一个"惊喜",但也带来一个问题:页面什么都不显示,用户根本不知道是"没有数据"还是"数据加载失败"。
比较好的做法是加一个empty判断:
jsp复制<c:choose>
<c:when test="${empty userList}">
<tr><td colspan="4">暂无数据</td></tr>
</c:when>
<c:otherwise>
<c:forEach items="${userList}" var="user">
<tr>
<td>${user.id}</td>
<td>${user.name}</td>
</tr>
</c:forEach>
</c:otherwise>
</c:choose>
empty是EL内置的关键字,可以同时判断null、空字符串、空集合、空数组。用这个写法,页面至少能给出一个明确的"没有数据"提示,避免用户面对一张空白表格。
4.2 内层循环varName冲突
这是嵌套循环里最容易踩的坑。先看一个错误示例:
jsp复制<c:forEach items="${departmentList}" var="dept">
<h3>${dept.name}</h3>
<c:forEach items="${dept.users}" var="dept">
<p>${dept.name}</p>
</c:forEach>
部门名称:${dept.name}
</c:forEach>
内层循环把变量名也命名为dept,就会导致内层结束后,外层循环体后续的 ${dept.name} 访问到的是内层最后一次循环的user,而不是部门对象。因为var的作用域虽然在标签内,但内层标签的var会覆盖外层同名var。
正确的做法是:外层var和内层var必须使用不同的名字。
我有一次在实际项目中排查这个bug,表象是"部门列表展示正确,但每个部门下面多了一段奇怪的部门名",查了很久才发现是变量名重名。从那以后,我要求项目里嵌套循环必须按层级命名,比如外层var="dept",内层var="user",如果还有第三层,就var="perm"。一旦层级深,宁可名字长一点,也不要重名。
4.3 EL表达式取不到值:作用域问题
c:forEach配合EL表达式取值时,EL会按page、request、session、application的顺序去搜索。如果同一个名字在request和session里都存在,EL默认取request里的值。
这个规则平时不会出问题,但有一种情况很隐蔽:在c:forEach循环体内部,如果你定义了一个var,名字恰好和session里的某个属性名一样,那循环体内后续用到这个名字的EL表达式,都会优先取当前循环的var,而不是session里的值。
遇到这种情况,可以用 ${sessionScope.xxx} 显式指定从session中取值。这个显式指定的习惯,在多人协作的项目里尤其重要,因为可以避免两个人分别往不同作用域放了同名属性而互相干扰。
4.4 循环下标和集合删除的配合
c:forEach本身不支持在循环过程中直接删除元素。Java里我们用Iterator可以在遍历时安全地remove,但c:forEach做不到这一点。
如果确实有"过滤掉某些元素"的需求,我的建议是:
- 在Controller层提前过滤好,只把需要展示的数据放进request。
- 或者在页面用c:if跳过不需要展示的元素。
比如:
jsp复制<c:forEach items="${userList}" var="user">
<c:if test="${user.status == 1}">
<!-- 只渲染启用的用户 -->
</c:if>
</c:forEach>
这个方案可以"不输出"一部分元素,但性能上,遍历仍然会走一遍所有数据。如果数据量很大,应该考虑在Controller层过滤,而不是在页面层过滤。
4.5 性能注意点:大列表的分页处理
c:forEach只是展示循环,本身没有分页能力。如果在一个页面上直接循环渲染几千条数据,页面加载会变慢,浏览器渲染也会卡顿。
我见过一个内部系统,直接在页面里把几万条用户数据全部遍历出来,页面打开要十几秒。最后优化方案很简单:后端分页,每页20条,c:forEach只循环20条。
所以我的经验是:
- c:forEach适合循环展示小批量数据,通常几百条以内。
- 数据量一大,就必须在Controller层做分页或查询条件限制。
- 不要在页面里做"先用c:forEach遍历所有,再用c:if过滤"这种事情,那是非常糟糕的做法。
5. 排查过程复盘:一次"列表只显示一行"的完整排错链路
这里我想复盘一次真实的排错经历,因为这种问题排查链路非常典型,能帮大家少走弯路。
5.1 现象描述
当时需求是展示一个订单列表。页面写好后,后端接口返回的数据列表里明明有10条记录,但页面上只显示了第一行。更奇怪的是,如果清空浏览器缓存再刷新,有时候又能显示2行、3行,但始终不是完整的10行。
5.2 排查步骤
第一步:看浏览器Network面板,确认接口返回的数据里确实有10条记录。
第二步:看JSP页面代码,发现写的是:
jsp复制<c:forEach items="${orderList}" varStatus="status">
<tr>
<td>${status.current.id}</td>
</tr>
</c:forEach>
看到这里我马上怀疑:items是orderList,但var属性没有写。也就是说,循环确实会执行10次,但循环体内没有var定义的局部变量,所以只能通过varStatus.current来访问当前元素。
这个写法本身可以工作,但我继续往下看,发现循环体里有一处 </table> 被错误地放到了循环内部。也就是说,第一次循环渲染完一行后,表格就被关闭了,后面的行实际上渲染到了表格外面,因为HTML结构已经关闭,浏览器无法正确显示这些行。
所以现象是:数据确实遍历了,但只有第一行在表格里,后面的行虽然在DOM里,但因为表格已经闭合,显示效果就是"只有一行"。
第三步:检查循环外是否还有多余标签。果然,在c:forEach后面还有一个额外的 </table>,而循环体里也错误地多了一个 </table>。
5.3 根因
根因有两个:
- 写页面时,没有先设计好HTML结构,直接把 放在了循环内,导致浏览器解析表格结构时提前关闭。
- 没有写var属性,只依赖varStatus.current去访问当前元素,虽然能跑,但让后面的排查更难定位问题。
5.4 修复方案
把var="order"补上,循环体内部使用 ${order.xxx} 访问属性;同时把错误的 移出循环,确保循环体里只保留
修改后:
jsp复制<table>
<c:forEach items="${orderList}" var="order">
<tr>
<td>${order.id}</td>
<td>${order.amount}</td>
</tr>
</c:forEach>
</table>
页面立刻恢复正常,10条数据都出来了。
这个排错过程让我意识到,页面渲染和Java后台调bug有一个很大的不同:页面上的问题很多是先渲染成了"错误的HTML",然后浏览器再纠正,所以看到的表象往往和真实原因相差很远。排查时一定要先看最终输出的HTML源码,而不是盯着JSP源码猜。
6. 进阶:把forEach用出花来的几个技巧
6.1 配合fn函数做字符串处理
JSTL除了核心标签库,还有一个函数库fn,提供了String相关操作。配合c:forEach可以实现很多细节需求。
比如,用户列表里要展示用户姓名的前两个字符加省略号:
jsp复制<c:forEach items="${userList}" var="user">
<td>${fn:substring(user.name, 0, 2)}...</td>
</c:forEach>
又比如,数字格式化、日期格式化,可以配合fmt标签库:
jsp复制<c:forEach items="${orderList}" var="order">
<td><fmt:formatDate value="${order.createTime}" pattern="yyyy-MM-dd HH:mm:ss" /></td>
<td><fmt:formatNumber value="${order.amount}" type="currency" /></td>
</c:forEach>
这些都是c:forEach循环体内的常见搭配,能让页面输出的内容更友好。
6.2 用varStatus实现数据分列
有时候不想把数据都放在一个纵向列表里,而是想每4个元素占一行,类似九宫格。这个需求如果用Java写,需要判断index对4取模。用c:forEach加varStatus也很方便:
jsp复制<c:forEach items="${goodsList}" var="goods" varStatus="status">
<c:if test="${status.index % 4 == 0}">
<div class="row">
</c:if>
<div class="col">${goods.name}</div>
<c:if test="${status.index % 4 == 3 || status.last}">
</div>
</c:if>
</c:forEach>
这算是一个"重操作"页面结构的小技巧,适合商品分类、图片墙这类网格布局。不过要注意:这里分开输出开标签和闭标签,HTML结构上虽然可以用,但可读性一般。如果项目前端框架比较强,更推荐用前端模板去做这种网格布局。
6.3 结合自定义标签封装通用模板
再往后进阶,就是把c:forEach封装成自定义标签,或者做成JSP片段(jspf)复用。
典型的场景是分页条。每个列表页面都要一个分页条,本质上也属于"固定次数的迭代"。虽然分页条本身不一定要用c:forEach,但页码列表可以用c:forEach生成:
jsp复制<c:forEach begin="1" end="${totalPages}" var="pageNum">
<a href="?page=${pageNum}" class="${pageNum == currentPage ? 'active' : ''}">
${pageNum}
</a>
</c:forEach>
注意,这里items没有传集合,只传了begin和end,c:forEach会生成一个从begin到end的整数序列,用于固定次数循环。
然后把这个片段提取成pagebar.jspf,页面里用 <jsp:include> 引入,传入totalPages和currentPage两个参数,分页条就能复用了。
我自己在项目里的习惯是:凡是两处以上用到的循环模板,就提取成公共片段。这样页面里真正的业务逻辑就只剩数据和结构,看起来很清爽。
最后再分享一个小技巧:在JSP页面里调试c:forEach的时候,如果碰到"为什么这个元素没显示"的问题,可以在Controller里临时把集合数据打印出来看一眼,再对照页面循环条件。很多时候问题不在标签本身,而是放进来的集合就被处理成了空,或者是字段名拼写不一致。先确认数据源,再怀疑标签,能省掉大量无效排查时间。
