本节摘要:复合标签是标签车间的进阶产品:属性收参数、标签体装模板、父子标签通过作用域协作,三者组合起来能表达 c:forEach 级别的结构能力。本站以青梧书肆的表格场景为成品线,完整走一遍父标签循环沉数据、子标签取值渲染的开发过程,顺带拿下动态属性这个灵巧配件。学完本站,标准标签库覆盖不了的业务构件你都能自己铸。
先立靶子。青梧书肆的后台有一批"数据表格"页面:每页一张表格、若干列、行数据不同。用 c:forEach 能写,但表头样式、斑马纹、空数据提示这些细节每个页面都要复制一遍。目标产品是这样一组标签:
<my:table items="${requestScope.orders}" var="order" emptyText="暂无订单"> <tr><td>${order.orderNo}</td><td>${order.status}</td></tr> </my:table>
拆解这个调用,复合标签的三要素全在场上。属性收参:items 收集合、var 收变量名、emptyText 收空提示,与简单标签无异。标签体装模板:tr 那行不是写死的,而是"每一行数据都要执行一遍的模板"——父标签拿到体片段,循环触发执行。父子协作:体里的 order 变量从哪来?父标签每轮循环把当前行沉进页面作用域,体内 EL 自然取到。
父标签的实现是本站的核心代码,值得逐行读:
public class TableTag extends SimpleTagSupport { private Object items; private String var = "row"; private String emptyText = ""; // 三个属性的写入方法照例各一行…… @Override public void doTag() throws JspException, IOException { Collection<?> rows = toCollection(items); if (rows == null || rows.isEmpty()) { getJspContext().getOut().print(emptyText); // 空数据礼貌提示 return; } for (Object row : rows) { getJspContext().setAttribute(var, row); // 沉变量供体取用 getJspBody().invoke(null); // 触发体执行 输出直达响应 } getJspContext().removeAttribute(var); // 用完清场 } }
两个细节是复合标签的命门。其一,invoke(null) 与 invoke(缓冲) 的区别:传空时体内容直接写进响应流,效率最高;喂缓冲则可以捕获结果再做加工——高亮标签用的是后者,循环渲染用前者。其二,沉进作用域的变量用完要清场,否则下一个无关标签可能在同一作用域里捡到残留数据——这类污染排查起来极其费神,写标签时就别埋。
子标签则体现"向上找父、按名取数"的协作模式。单元格标签想拿到父标签的配置(比如统一加的数据属性),通过父链查找实现:
public class CellTag extends SimpleTagSupport { @Override public void doTag() throws JspException, IOException { TableTag parent = findAncestor(this, TableTag.class); if (parent == null) { throw new JspException("cell 必须用在 table 之内"); } getJspContext().getOut().print("<td>"); getJspBody().invoke(null); getJspContext().getOut().print("</td>"); } }
整条渲染流水画出来,父子协作的时序一目了然。

复合标签常有这样的需求:页面想把样式类、数据属性等额外参数随手塞给标签,逐个声明属性太啰嗦。动态属性机制就是为这个准备的:处理器声明支持动态属性后,所有未在契约里登记的属性会统一涌进一个回调,集中存取:
public class CellTag extends SimpleTagSupport implements DynamicAttributes { private final Map<String, Object> extra = new LinkedHashMap<>(); @Override public void setDynamicAttribute(String uri, String name, Object value) { extra.put(name, value); // 来者不拒 先收下 } @Override public void doTag() throws JspException, IOException { StringBuilder td = new StringBuilder("<td"); extra.forEach((k, v) -> td.append(' ').append(k).append("=\"").append(v).append('"')); td.append('>'); // ……输出体内容…… } }
页面侧从此可以写 <my:cell class="num" data-field="price">${order.price}</my:cell>,class 与 data-field 都没在契约里登记,照样生效。这是灵活性与契约性的折中:核心属性逐个声明拿类型检查,装饰属性动态收发拿自由度。但这条通道要慎用——动态属性绕过了契约审查,滥用它就等于放弃了 TLD 的全部好处,只把真正"装饰性"的参数放进来。
背景:大扫除后发现,十几个列表页各粘着一份分页条代码:页码计算、当前页高亮、边界省略号,每份三十来行,早已改得七零八落——有的有省略号有的没有,样式更是五花八门。决定铸成分页标签。
操作:先写标本——挑两个现有分页条固定住行为基线;再定契约——属性留 current、total、pageSize、urlPattern 四个,体内容声明为空;然后写处理器,页码窗口计算放纯静态方法里方便单测,输出拼装放执行方法;最后配契约文件、逐页替换调用、与标本比对。
结果:全站分页条归一,页码窗口的展示规则改动从"全站搜索替换再逐个验证"变成"改一处方法、跑一遍单测"。
解读:这单活儿的收益结构很有代表性——第一收益不是省代码,而是行为归一:十几份漂移的副本变成一份有契约的实现。第二收益才是可测试性:页码窗口这种纯计算逻辑终于可以脱离容器做单元测试。铸标签的动力排序,与第 4 章分层的收益排序惊人一致。
变式:不是所有重复都该铸标签。只在两个页面出现的片段,用静态包含就够了;纯展示格式化,第 5 章的 fmt 与 fn 已经覆盖。铸标签的入场券始终是那条:跨页面复用加非平凡逻辑。青梧书肆最终只铸了分页、表格、徽章三组标签——克制是车间得以维护的前提。
💡 关键直觉:自定义标签是 JSP 时代的组件化雏形——属性即入参、体即插槽、父子协作即状态下沉。日后你在任何现代前端框架里看到组件 props、默认插槽与作用域插槽,都会认出这套机制的影子。学它不是怀旧,是补一门通用的组件语言。
车间出师。下一章换挡到安全频道:站点要真正上线了,注入与会话这些公网上的经典攻击,必须在上线前夜补完课。