做Java Web开发的朋友对JSTL标签库应该都不陌生c:forEach、c:if这些标签几乎是每个JSP页面的常客。但很多人用了几年JSTL却从来没写过自己的标签遇到页面逻辑复杂、重复代码多的时候还是老老实实地在JSP里堆Java脚本片段。其实自定义标签才是JSTL真正有意思的地方——它让你能把页面里的重复逻辑抽出来封装成类似my:tag这样的自定义标签既干净又复用。我这次就把JSTL的核心标签体系梳理一遍然后重点讲讲怎么从零写一个自定义标签包括TLD文件的配置、标签处理类的继承关系、带属性的标签怎么处理、带标签体的标签怎么迭代以及实际项目中踩过的那些坑。不管你是刚接触JSP的新手还是想把手头老项目里的脚本片段清理掉的老手这篇内容应该都能帮到你。1. 先搞清楚JSTL到底解决了什么问题1.1 JSP脚本片段的原罪早年的JSP页面里到处都是% %这种脚本片段取个值要写% out.println(request.getAttribute(name)); %循环个列表要写% for(int i0;ilist.size();i){ %页面里混杂着Java代码和HTML标签读起来极其痛苦。这种写法有几个致命问题第一前端和后端的职责完全混在一起改个样式都可能不小心动到Java逻辑第二代码没法复用同样的循环逻辑在十个页面里就得抄十遍第三调试困难JSP编译后的Servlet报错信息往往指向莫名其妙的位置。JSTLJSP Standard Tag Library就是在这个背景下出来的。它把常见的页面逻辑——条件判断、循环遍历、格式化输出、字符串处理——全部封装成标签用XML风格的语法替代脚本片段。比如取一个请求域里的属性原来要写脚本片段现在直接c:out value${name}/就完事了。这背后的核心思想是把页面逻辑声明化你告诉页面我要输出这个值而不是我要调用out对象的println方法。1.2 JSTL五大标签库的分工JSTL标准标签库其实分了好几块每块负责不同的功能域。核心标签库c前缀是最常用的管条件、循环、URL处理、输出格式化标签库fmt前缀管日期和数字的格式化、国际化函数标签库fn前缀提供了一堆字符串处理函数比如fn:length()、fn:contains()SQL标签库sql前缀和XML标签库x前缀用得相对少前者直接操作数据库后者处理XML文档在分层架构里一般不推荐在页面层直接写SQL。实际项目里核心标签库和函数标签库是绝对的主力格式化标签库在处理日期展示时也经常用到。SQL和XML标签库我个人的建议是尽量别在JSP里用数据访问应该交给后端的DAO层页面层只负责展示。这个分工想清楚了后面写自定义标签的时候才知道自己该往哪个方向封装。1.3 为什么还要学自定义标签有人会问JSTL都这么全了还写自定义标签干嘛这个问题我在实际项目里遇到过太多次。举个典型场景后台管理系统的列表页几乎每个模块都有状态字段需要根据状态值显示不同颜色的标签——启用是绿色、禁用是灰色、待审核是橙色。用JSTL的c:choose写也不是不行但每个页面都得写一遍这一坨判断改个颜色要改几十个文件。这时候自定义标签的价值就出来了。写一个app:status value${item.status}/内部封装好所有判断和样式逻辑页面里一行搞定改样式只改标签处理类一个地方。这就是自定义标签的核心价值把页面层的重复逻辑收敛到可复用的组件里。JSTL是通用能力自定义标签是业务能力两者配合才能把JSP页面写干净。2. JSTL核心标签的实操要点2.1 c:forEach的几种典型用法c:forEach是使用频率最高的标签但很多人只用过最基础的遍历。它其实支持好几种形态遍历集合、遍历Map、按次数循环、带状态变量的循环。遍历集合是最常见的items指定集合var指定循环变量名。遍历Map的时候var拿到的是Map.Entry对象可以通过${entry.key}和${entry.value}取值。按次数循环用begin和end比如c:forEach begin1 end10 vari就是从1循环到10。这里有个细节要注意end是包含的跟Java的for循环i10不一样别搞混了。带状态变量用varStatus它是个LoopTagStatus对象能拿到index当前索引从0开始、count当前第几个从1开始、first是否第一个、last是否最后一个。做列表隔行变色或者最后一行特殊处理的时候varStatus特别好用。c:forEach items${userList} varuser varStatusst tr class${st.index % 2 0 ? even : odd} td${st.count}/td td${user.name}/td /tr /c:forEach2.2 c:if与c:choose的取舍c:if只能处理单分支没有else。需要if-else的时候得用c:choose配合c:when和c:otherwise。这里有个常见的坑c:if的test属性虽然叫test但它接受的是EL表达式直接写${user.age 18}就行不用写成% user.getAge() 18 %。另外c:if没有else这个设计经常被吐槽但换个角度想它其实是在逼你把复杂的分支逻辑往上抽——如果一个页面里if-else嵌套超过三层那说明这段逻辑本来就该封装成自定义标签或者放到后端去。c:choose里只能有c:when和c:otherwise不能直接写其他内容c:when的test为true就执行全都不匹配就走c:otherwise。注意c:otherwise必须放在最后而且只能有一个。2.3 fn函数库的实用技巧函数标签库用fn:前缀调用方式是${fn:length(list)}这种。常用的有fn:length取长度、fn:contains判断包含、fn:startsWith和fn:endsWith判断前后缀、fn:trim去空格、fn:substring截取、fn:replace替换、fn:split分割、fn:join拼接。这些函数在EL表达式里直接调用比写脚本片段优雅得多。有个细节值得说fn:length对集合和字符串都管用但传null会返回0不会抛异常这点比直接调list.size()安全。fn:substring的索引是从0开始的跟Java的String.substring一致。fn:split返回的是字符串数组配合c:forEach遍历很方便。实际项目里我经常用fn:contains做权限判断比如${fn:contains(sessionScope.user.permissions, user:delete)}简单直接。3. 自定义标签的开发全流程3.1 自定义标签的三种实现方式写自定义标签有三条路实现SimpleTag接口JSP 2.0之后推荐、继承SimpleTagSupport类最省事、继承TagSupport类老方式JSP 1.2时代。现在新项目一律推荐用SimpleTagSupport它把doStartTag、doEndTag这些老接口的复杂度都封装了你只需要重写doTag()方法所有逻辑写在一个方法里清爽很多。TagSupport那套老接口要处理EVAL_BODY_INCLUDE、SKIP_BODY、EVAL_PAGE、SKIP_PAGE这些返回值逻辑分散在doStartTag和doEndTag两个方法里容易写乱。SimpleTagSupport的doTag方法里想输出内容直接往getJspContext().getOut()写想跳过标签体就干脆不调getJspBody().invoke()控制流一目了然。所以下面的实操我都以SimpleTagSupport为主线。3.2 一个最简单的自定义标签先写个最基础的输出一句问候语。第一步建标签处理类package com.example.tags; import javax.servlet.jsp.JspException; import javax.servlet.jsp.tagext.SimpleTagSupport; import java.io.IOException; public class HelloTag extends SimpleTagSupport { Override public void doTag() throws JspException, IOException { getJspContext().getOut().write(你好自定义标签); } }第二步写TLD文件Tag Library Descriptor放在WEB-INF/tlds/目录下文件名随意比如mytags.tld?xml version1.0 encodingUTF-8? taglib xmlnshttp://java.sun.com/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-jsptaglibrary_2_1.xsd version2.1 tlib-version1.0/tlib-version short-namemy/short-name urihttp://example.com/mytags/uri tag namehello/name tag-classcom.example.tags.HelloTag/tag-class body-contentempty/body-content /tag /taglib第三步在JSP页面里引入并使用% taglib prefixmy urihttp://example.com/mytags % my:hello/这里几个关键点uri是标签库的唯一标识JSP里通过它来引用short-name是默认前缀但页面里可以用prefix覆盖body-content指定标签体类型empty表示不能有标签体scriptless表示可以有标签体但不能有脚本片段JSP表示可以有脚本片段不推荐tagdependent表示标签体内容原样传给标签处理类。3.3 带属性的标签怎么写实际项目里标签基本都要带属性。给HelloTag加个name属性需要两步在类里加字段和setter方法在TLD里声明attribute。public class HelloTag extends SimpleTagSupport { private String name; public void setName(String name) { this.name name; } Override public void doTag() throws JspException, IOException { getJspContext().getOut().write(你好 name); } }TLD里加tag namehello/name tag-classcom.example.tags.HelloTag/tag-class body-contentempty/body-content attribute namename/name requiredtrue/required rtexprvaluetrue/rtexprvalue /attribute /tagrequired表示是否必填rtexprvalue表示是否支持运行时表达式也就是能不能写${}或者% %。这个属性一定要设成true否则页面里传EL表达式会报错。这是新手最容易踩的坑之一——TLD里忘了配rtexprvalue页面里写my:hello name${user.name}/直接抛异常报错信息还不明显排查半天。3.4 带标签体的标签怎么处理标签体就是标签中间的内容比如my:box这里的内容/my:box。要处理标签体TLD里body-content设成scriptless然后在doTag里通过getJspBody()拿到JspFragment对象调用invoke(Writer)执行标签体。如果传null默认输出到当前页面的输出流。public class BoxTag extends SimpleTagSupport { Override public void doTag() throws JspException, IOException { JspWriter out getJspContext().getOut(); out.write(div classbox); getJspBody().invoke(null); out.write(/div); } }这个模式特别适合做布局组件比如卡片、面板、弹窗。标签体里可以放任意JSP内容包括其他标签灵活性很高。注意invoke方法可能被调用多次每次都会重新执行标签体做循环输出的时候会用到这个特性。3.5 迭代型标签的实现迭代型标签是自定义标签里最有价值的一类典型场景是遍历一个集合然后对每个元素执行标签体。实现思路是标签接收一个集合属性在doTag里遍历集合每次遍历把当前元素设置到pageContext的某个属性里然后调用getJspBody().invoke(null)执行标签体。public class RepeatTag extends SimpleTagSupport { private List? items; private String var; public void setItems(List? items) { this.items items; } public void setVar(String var) { this.var var; } Override public void doTag() throws JspException, IOException { if (items null) return; JspContext ctx getJspContext(); for (Object item : items) { ctx.setAttribute(var, item); getJspBody().invoke(null); } } }TLD配置tag namerepeat/name tag-classcom.example.tags.RepeatTag/tag-class body-contentscriptless/body-content attribute nameitems/name requiredtrue/required rtexprvaluetrue/rtexprvalue /attribute attribute namevar/name requiredtrue/required rtexprvaluefalse/rtexprvalue /attribute /tag页面里这样用my:repeat items${userList} varuser p${user.name}/p /my:repeat这里var的rtexprvalue设成false因为它是个变量名不需要运行时求值。items设成true因为要接收EL表达式。这个区分很关键配错了要么报错要么行为异常。4. 实际项目中的避坑经验4.1 TLD文件位置与uri的坑TLD文件放哪儿有讲究。放WEB-INF/tlds/下是标准做法容器会自动扫描这个目录。但如果你在web.xml里配了jsp-config的taglib映射那位置可以灵活些。uri的写法也有坑如果你在TLD里写的是完整URL比如http://example.com/mytags页面里就必须原样引用如果写的是相对路径容器会去WEB-INF下找。我见过有人TLD里写/mytags页面里写http://example.com/mytags死活引不进来排查半天才发现是uri不匹配。还有个隐蔽的坑同一个uri在多个TLD文件里重复定义容器加载时可能随机选一个行为不可预测。项目里TLD多了之后建议给每个TLD分配独立的uri命名空间比如带上模块名。4.2 属性类型转换的陷阱自定义标签的属性如果是基本类型int、boolean容器会自动做类型转换但转换失败时抛的异常信息很不友好。比如你声明一个int属性页面里传了个空字符串容器会抛NumberFormatException堆栈指向容器内部根本看不出是哪个标签出的问题。我的做法是属性一律用包装类型Integer、Boolean在doTag里自己做判空和转换出错时抛带明确信息的JspException。另外如果属性是自定义对象类型容器不会自动转换必须保证传进来的就是那个类型。EL表达式取出来的值类型可能和预期不符比如从Map里取出来的可能是String这时候在标签处理类里要做类型检查别直接强转。4.3 标签体执行顺序与异常处理带标签体的标签getJspBody().invoke()的执行时机很关键。如果你在invoke之前写了输出那输出会先于标签体内容出现invoke之后写的输出会跟在标签体后面。做包裹型标签比如加个div的时候开始标签在invoke前写结束标签在invoke后写这个顺序不能乱。异常处理方面doTag里抛出的异常会被容器包装成JspException往上抛页面会显示500错误。生产环境建议在doTag里捕获业务异常转成友好的提示输出而不是让整个页面崩掉。但要注意别把异常吞了至少记个日志不然出了问题无从排查。4.4 性能相关的注意事项自定义标签每次执行都会创建标签处理类的实例容器可能做池化但不保证所以标签处理类里别放重量级的状态。如果标签需要访问数据库或者做复杂计算考虑加缓存别每次渲染都查一遍。另外标签体invoke的次数要控制在循环里嵌套循环标签的时候执行次数是乘积关系很容易指数级膨胀。还有一个容易忽略的点标签处理类里通过getJspContext().setAttribute()设置的属性默认作用域是page循环结束后这些属性还在pageContext里。如果var名起得跟页面里其他变量冲突会互相覆盖。建议var名加个前缀比如_repeat_user这种降低冲突概率。4.5 常见问题速查表问题现象可能原因解决方向页面报找不到标签库uri不匹配或TLD位置不对检查TLD的uri与页面taglib声明是否一致确认TLD在WEB-INF/tlds下标签属性传EL表达式报错TLD里rtexprvalue没设成true在attribute里加rtexprvaluetrue/rtexprvalue标签体内容不显示body-content配错或没调invoke确认body-content为scriptlessdoTag里调用了getJspBody().invoke()属性类型转换异常传了空值或类型不匹配属性用包装类型doTag里做判空和类型检查标签输出顺序错乱invoke调用时机不对开始标签在invoke前输出结束标签在invoke后输出循环标签变量冲突var名与页面变量重名var名加前缀或用pageContext的独立作用域5. 把自定义标签用出花来的几个思路5.1 权限控制标签后台系统里按钮级别的权限控制是刚需。写一个auth:has permissionuser:delete标签内部从session里取当前用户的权限集合判断是否包含指定权限包含就执行标签体不包含就跳过。这样页面里写权限判断就一行而且权限逻辑集中在一处改起来方便。实现上就是doTag里判断不满足条件直接return不调invoke。5.2 分页组件标签列表页的分页条每个页面都要写完全可以封装成标签。标签接收当前页、总页数、每页条数、请求URL等属性内部生成上一页、下一页、页码链接的HTML。这样所有列表页的分页样式和逻辑统一改分页交互只改一个标签处理类。这个标签稍微复杂点涉及URL参数拼接但逻辑是纯字符串处理不难。5.3 数据字典翻译标签系统里状态码、类型码这类字典值展示的时候要翻译成中文。写一个dict:translate typeuserStatus value${user.status}/标签内部查字典缓存输出对应的中文描述。字典数据可以启动时加载到内存标签里直接查Map性能没问题。这个标签能消灭页面里大量的c:choose判断。5.4 格式化输出标签日期格式化、金额格式化、手机号脱敏这些虽然JSTL的fmt库能覆盖一部分但业务特定的格式化比如金额显示成1.2万这种还是得自己写。封装成标签之后页面里fmt2:money value${order.amount}/一行搞定比在每个页面写格式化逻辑干净得多。6. 从JSTL到自定义标签的演进路径我个人的经验是一个项目从纯JSTL过渡到自定义标签最好按这个顺序来先把页面里重复三次以上的c:choose逻辑抽成标签这是收益最明显的然后把带业务含义的展示逻辑状态、类型、金额封装成标签最后把布局组件卡片、面板、弹窗也标签化。每抽一个标签页面就干净一分而且抽出来的标签是跨页面复用的投入产出比很高。需要注意的是别为了抽标签而抽标签。如果一个逻辑只在一个页面用一次抽成标签反而增加了维护成本——改逻辑要改标签类、重新编译、重新部署不如直接在页面里改。判断标准很简单这段逻辑会不会在多个页面出现或者会不会频繁修改。两个条件满足一个就值得抽都不满足就放着。另外自定义标签和JSP的EL表达式、JSTL是互补关系不是替代关系。标签处理类里照样可以用EL求值标签体里照样可以写JSTL标签组合起来用才灵活。我见过有人把所有逻辑都塞进自定义标签结果标签类写得跟Servlet一样臃肿那就走偏了。标签的定位是页面层的轻量组件重逻辑还是应该往后端放。最后分享一个我在实际项目里的小技巧给自定义标签写单元测试其实不难SimpleTagSupport的子类可以脱离容器测试手动构造一个JspContext的mock对象调doTag方法断言输出内容就行。这样标签逻辑的回归测试就有了保障改标签的时候心里有底。这个做法在老项目里推广开之后标签相关的线上问题少了很多。