开篇先说个真实感受:JSP 这门技术,在社区里的口碑一直很分裂。新项目里基本看不到它的影子,但在大量存量系统的维护、学校教学和传统企业级开发中,它依然是绕不开的一环。很多人学 JSP 时,要么被各种“过时了赶紧学 Spring Boot”的声音劝退,要么就是死记硬背<jsp:useBean>之类的标签,却始终没搞明白 JSP、Servlet、JavaBean、EL 表达式和 JSTL 标签库到底是怎么配合工作的。
这个“JSP知识在线指南”项目,本质上不是一个功能复杂的业务系统,而是一个“知识的组织与呈现系统”。它的难点不在 CRUD,而在如何把 JSP 体系里割裂的知识点,按照它们在实际开发中的协作关系串起来。项目里明确提到了四条主线:EL 表达式、JSTL 标签库、JSP 开发模型、分页查询。这四条线正好对应了一个人在 JSP 里从“看懂页面”到“能写完整功能”的进阶路径。
我在拆解这个项目标题的时候,最大的感受是:它想解决的不是“某个函数怎么写”,而是“JSP 知识点之间的逻辑关系”。所以博文的重点,也会围绕这条知识主线展开,手把手把每个环节的来龙去脉和实操细节说清楚。
1. 内容整体设计与思路拆解
1.1 为什么选这四个模块作为知识主线
先认真想想这个标题里每个关键词的意义。
EL 表达式(Expression Language)解决的是“在页面上怎么优雅地取数据”。传统 JSP 里用<%= request.getAttribute("user") %>这种 scriptlet 方式取值,页面里密密麻麻全是 Java 代码,前端和后端完全耦合在同一个文件里。EL 表达式出现之后,取值变成了${user.name},这不仅是语法简化,更是 JSP 从“Java 代码嵌套 HTML”向“HTML 为主,Java 逻辑在后台”的结构性转变。
JSTL 标签库(JSP Standard Tag Library)解决的是“页面上怎么写逻辑”。EL 只管取值,但循环、判断、格式化这些事总得有人做。JSTL 的核心标签<c:forEach>、<c:if>、<fmt:formatDate>,让页面里的流程控制不再依赖 scriptlet,这对于维护静态结构和动态数据混合的页面极其重要。
JSP 开发模型解决的是“代码怎么分层”。这是整个体系里最容易让新手懵掉的部分。JSP Model 1 和 Model 2 的区别,本质上不是复杂的技术问题,而是“职责划分”的问题。Model 1 里 JSP 页面既要负责展示又要负责逻辑处理,适合极小的项目;Model 2 引入了 Servlet 作为控制器,JSP 退回到纯视图的位置,这是 MVC 思想的落地,也是旧式 Java 企业项目里最常见的架构。标题里专门把开发模型单独拎出来,说明这个项目希望学习者不只停留在“能用”,还要理解“为什么这么用”。
分页查询解决的是“真实系统中的数据展示问题”。几乎任何一个管理系统里,列表页不做分页都是不可接受的。分页本身不复杂,但它在 JSP 体系里有特殊价值,因为它同时用到了后端的数据处理逻辑和前端 EL + JSTL 的数据展示逻辑,是一条天然的“综合练习”。
这四个模块组合在一起,恰好形成了一条完整的链路:数据怎么存(JavaBean / 数据库)→ 数据怎么取(Servlet / DAO)→ 数据怎么传(Request 域 / EL)→ 数据怎么展示(JSTL)→ 数据太多怎么处理(分页)。所以这个在线指南项目,设计的知识结构比单纯写一个 Demo 要科学得多。
1.2 零散知识点怎么组成一条学习链路
在真正动手组织内容之前,我先梳理了一下这些知识点在 Tomcat 运行环境里的协作顺序。理解这条链路,远比背标签语法重要。
一次最普通的 JSP 请求,在 Web 容器里是这样流转的:
用户浏览器请求某个页面(比如list.jsp)→ Tomcat 把.jsp文件翻译成.java文件(它本质上是一个继承了 HttpServlet 的类)→ 编译成 Class → 执行_jspService()方法 → 页面通过 EL 表达式和 JSTL 标签读取后端放入域对象中的数据 → 把生成的 HTML 响应回浏览器。
这里面有一个很多人忽略的底层事实:JSP 本质就是 Servlet。不管你在页面上写的是 HTML、EL 还是 JSTL,最终都会被 Tomcat 翻译成 Java 代码在服务端执行。比如${user.name}在翻译时,本质上会被转换成类似pageContext.findAttribute("user")的 Java 调用,再通过反射触发getName()方法。这也就是为什么 EL 表达式能够直接访问对象的属性,因为底层走的就是 JavaBean 的 getter 方法。
这条链路串起来之后,每个知识点的位置就清晰了:
- JSP 指令和动作(如
<%@ page %>、<jsp:include>)负责控制页面本身的编译和使用方式。 - 内置对象(request、response、session、application)负责在不同范围内传递数据。
- EL 表达式负责在页面层按作用域顺序查找并展示数据。
- JSTL 标签负责对拿到的数据做逻辑处理和格式化。
- Servlet 和 JavaBean在 Model 2 架构里负责请求分发和业务数据封装。
所以这个在线指南项目在组织内容时,也最好按这个依赖顺序排章节:先讲 JSP 本身的语法和内置对象,再讲 EL、JSTL,然后讲 Model 1 / Model 2,最后引入分页这种综合性案例。这样读的人是一步步搭建认知体系,而不是在一堆孤立标签里迷路。
1.3 知识点组织与页面功能规划的对应关系
具体落地到这个在线指南项目时,需要把上面的知识链路映射到真实的页面结构上。合理的规划是这样的:
- 一级页面(导航页/首页):展示 JSP 知识体系的全局地图,将四大模块分别做成选项卡或侧边栏菜单。
- 二级页面(模块页):分别展示 EL 语法速查表、JSTL 核心标签使用说明、Model 1 与 Model 2 对比图,以及分页查询的完整代码示例。
- 三级页面(示例页):点开某个示例时,能看到“代码展示 + 运行结果 + 关键点注释 + 常见报错”四个区块。
这种内容组织方式的优势是:学习者可以按需检索,而不是被迫从头到尾读完一份文档。同时它自身就是一个 JSP 项目,意味着每个页面里的导航区、内容区、目录区,正好可以用<%@ include %>或<jsp:include>做成公用片段,把“知识点的组织方式”和“JSP 的复用技巧”结合在一起。
如果你在实际开发中接受了一个“用 JSP 维护在线教程类网站”的任务,我的建议是先不要急着写页面,而是先把知识地图画在纸上——确定哪些内容是静态展示(直接写死),哪些内容需要从数据库或配置文件读取(比如分类信息、标签列表),哪些内容需要独立成一个可复用模块(比如代码高亮组件、分页组件)。这个思路和项目标题里强调的知识体系化是完全一致的。
2. 核心细节解析与实操要点:EL 表达式与 JSTL 标签库
2.1 EL 表达式的作用域查找顺序,踩过一次就忘不掉
EL 表达式最大的坑不是语法,而是“作用域查找”的规则。
在 JSP 里,数据可以放在 page、request、session、application 四个作用域中。EL 表达式${name}在取值时,会按照page → request → session → application的顺序依次查找,找到第一个就返回,找不到就返回空字符串。
这里就有个非常典型的实际问题:如果我在 request 里存放了一个String name = "张三",同时在 session 里也存放了一个name = "李四",那么页面上的${name}会输出什么?
答案是“张三”,因为 request 的优先级高于 session。很多人在这个细节上报错后百思不得其解,实际上就是没理解 EL 的查找顺序。
要强制指定某个作用域的值,可以这样写:
${requestScope.name}—— 只从 request 里取${sessionScope.name}—— 只从 session 里取${applicationScope.name}—— 只从 application 里取
这个细节在分页查询中尤其重要。比如分页时常见的做法是:把“当前页的数据列表”放在 request 里,把“当前页码、总页数”放在 request 里,但如果页面上存在导航菜单的数据(比如全部栏目列表),为了防止每个子页面重复查库,开发者经常会把菜单数据塞到 application 或 session 里。如果你在页面上写${pageList}而不是${requestScope.pageList},一旦 session 或 application 里有同名属性,就会取到完全不相关的数据,现象就是“页面显示的数据不刷新”。
2.2 动态取值与运算符:不只是 getter
EL 表达式除了取值,还有几个可以实际派上用场的运算能力。
比较实用的是三元表达式。分页场景里经常要控制“上一页”“下一页”按钮的可用状态,用 EL 写就是:
<a href="${currentPage > 1 ? 'list.jsp?page=' . (currentPage - 1) : 'javascript:void(0)'}">上一页</a>不过写成这样,页面的可读性其实不高。更推荐的做法是在后端就把上一页、下一页的 URL 或可用状态算好,JSP 页面里只做展示。这也是一个从业者的经验之谈:EL 的运算能力是为了“轻量展示”服务的,不应该承载复杂业务计算。
另一个经常被忽略但很实用的细节是 EL 的 empty 运算符。判断一个集合为空,如果用 Java 代码写,需要同时判断null和isEmpty()。用 EL 写就一行:
${empty pageList ? '暂无数据' : ''}empty可以同时判断 null、空字符串、空数组、空集合,非常省事。在分页查询里,当用户访问了一个超出数据范围的大页码时(比如总共只有 5 页,用户手动输入?page=99),后端返回的列表就是空的,此时用 empty 来判断就非常合适。
还有一个细节是 EL 中.与[]的等价性。${user.name}完全等价于${user["name"]}。如果属性名本身是从变量传过来的,就必须用[]形式。这个需求常见于动态表格列的处理,在 JSP 项目中不常用,但知道这个机制能帮助你理解 EL 反射调用的底层逻辑。
2.3 JSTL 标签库部署时最容易犯的错误
项目标题中明确提到 JSTL 标签库,这是很多人在学习时最头疼的部分,不是因为标签本身难,而是环境配置问题。
在 Servlet 3.0 之前的时代,使用 JSTL 需要做三件事:下载jstl.jar和standard.jar放到WEB-INF/lib下,在 JSP 页面顶部声明<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>,确保 web.xml 声明版本正确。在实际维护旧项目的过程中,我发现有相当比例的“JSTL 标签不生效”问题,根源都是 jar 包版本和 web.xml 头版本不匹配。
如果你接手的是一个 Tomcat 7+ 的项目,比较稳妥的做法是在 Maven 的pom.xml中显式声明依赖版本(比如javax.servlet:jstl:1.2),而不是依赖服务器自带的实现。有些 Tomcat 版本内置了 JSTL 实现,有些没有,混用就会出现“本地能跑,部署到服务器上就报 500”的经典问题。
页面上最常见的 JSTL 写法,是配合 EL 完成列表渲染:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <table> <c:forEach items="${pageList}" var="item" varStatus="st"> <tr class="${st.index % 2 == 0 ? 'even' : 'odd'}"> <td>${item.id}</td> <td><c:out value="${item.title}" escapeXml="true" /></td> </tr> </c:forEach> </table>这里有一个安全经验:输出到页面上的用户内容,务必通过<c:out>配合escapeXml="true"输出,或者使用 EL 表达式配合框架的自动转义机制。直接使用<%= item.getTitle() %>等方式输出未转义的用户输入,一旦内容里包含<script>标签,就可能留下脚本注入风险。这是很多旧系统里真实存在的隐患。
2.4 格式化标签:时间与数字的展示问题
在 JSTL 标签里,<fmt:formatDate>和<fmt:formatNumber>的使用场景也是分页列表里的刚需。
从数据库中查询出的时间字段,直接输出到页面上时,格式通常是2025-06-01 12:30:45.0这类数据库默认格式,很不美观,也容易解析出错。用 JSTL 格式化的方式如下:
<fmt:formatDate value="${item.createTime}" pattern="yyyy-MM-dd HH:mm" />这里有一个前置条件容易踩坑:value属性接收的必须是java.util.Date类型。如果后端的实体类把时间字段定义成String,或者从数据库查出来的是java.sql.Timestamp,直接传给<fmt:formatDate>会得到令人困惑的结果。java.sql.Timestamp本身是Date的子类,所以还能正常工作;但若是String,页面就会直接报错或输出空白。
我处理过的一个真实场景是:某老旧系统的 Oracle 数据库返回的日期时间,经过某些 JDBC 驱动处理后,在 JavaBean 里以java.sql.Timestamp存在,在页面上用<fmt:formatDate>格式化时发现时间少了 8 小时。排查到最后是时区设置问题,处理方式是调整数据库连接参数或在 Java 侧统一设置时区。这种问题,教科书上很少写,但真实项目里特别折磨人。
数字格式化也有类似情况。比如金额字段,数据库里的BigDecimal直接展示时可能输出1234.5而不是1,234.50。格式化可以直接用 pattern:
<fmt:formatNumber value="${item.amount}" type="currency" />type 属性内部会根据系统语言环境判断货币符号。如果项目里出现货币符号不对的情况,要记得检查 JSP 页面page指令里的language属性和服务器默认 Locale。
3. 实操过程与核心环节实现:开发模型与分页查询
3.1 Model 1 和 Model 2,选谁不选谁不是拍脑袋
JSP 开发模型这个知识点,放在“技术框架满天飞”的今天看,仍然有它的价值,因为它是理解 MVC 思想在 Java Web 里演进的起点。
Model 1 架构非常简单:浏览器直接请求 JSP 页面,JSP 页面里既写 Java 代码(访问数据库),又写 HTML 代码(展示数据)。好处是开发快,适合原型系统、内部工具、页面数很少的站点。坏处也很明显——页面里逻辑一多,维护成本会成倍上涨,前端改一个样式都要担心会不会弄坏 Java 代码。
Model 2 架构引入了 Servlet 作为控制器:浏览器请求先到 Servlet,Servlet 负责接收参数、调用业务逻辑(Service / DAO)、把结果存到 request 的 attribute 里,然后 forward 到 JSP。JSP 此时只负责取数据和渲染视图。
分页查询这个功能,放在 Model 1 里写和放在 Model 2 里写,代码量差异非常大。
Model 1 风格的简陋写法,所有代码都堆在 JSP 里(此处为演示逻辑,不建议照抄):
<% String pageStr = request.getParameter("page"); int currentPage = pageStr == null ? 1 : Integer.parseInt(pageStr); int pageSize = 10; List<Article> list = new ArticleDAO().findByPage(currentPage, pageSize); int totalCount = new ArticleDAO().countAll(); int totalPages = (int) Math.ceil((double) totalCount / pageSize); %>这种写法在一个几十行的小页面里勉强能接受,但一旦页面要同时展示热门文章、分类列表、推荐阅读等多个区块,JSP 文件很快就会变成几百行的“代码泥潭”。
Model 2 的正确做法是拆分:
- JSP 只负责显示数据:通过 EL + JSTL 遍历
pageList。 - Servlet 只负责处理请求:获取
page参数,设置pageSize,调用 Service。 - JavaBean / DTO 负责承载数据:一个分页对象,包含
list、currentPage、totalPage等属性。
这种拆分带来的直接好处是:如果你需要把 JSP 从 Model 2 迁移到其他模板引擎,只需要重写视图层,逻辑不受影响。这才是 JSP 开发模型这个知识点的核心价值所在。
3.2 分页查询的参数计算与前端展示联动
分页查询算得上是 JSP 知识体系里面最需要“前后端配合”的环节。后端计算要严谨,前端显示要友好,中间的参数传递有一丁点对不上,页码就会错乱。
先看后端参数设计和计算。一个最标准的分页查询需要四个关键参数:
| 参数 | 含义 | 计算方式 |
|---|---|---|
currentPage | 当前页码 | 从请求参数获取,默认 1 |
pageSize | 每页记录数 | 固定值或用户可配置 |
totalCount | 总记录数 | 数据库SELECT COUNT(*)查询 |
totalPages | 总页数 | (int) Math.ceil((double) totalCount / pageSize) |
这里有一个小陷阱:如果用户传入的currentPage小于 1,需要重置为 1;如果大于总页数,需要重置为总页数。忘记做这个边界处理的系统,翻到最后一页时经常出现空白页面。
SQL 层做分页的写法依据数据库不同有差异。MySQL 里最标准的是LIMIT offset, pageSize,其中offset = (currentPage - 1) * pageSize。这个-1特别容易错——很多人第一次写会直接写成currentPage * pageSize,结果第一页数据就少了一页。原因很简单:第 1 页应该从第 0 条记录开始查,而不是第 10 条。
前端显示分页条的核心代码,用 JSTL + EL 可以写成这样:
<c:if test="${currentPage > 1}"> <a href="list?page=${currentPage - 1}">上一页</a> </c:if> <span>第 ${currentPage} / ${totalPages} 页</span> <c:if test="${currentPage < totalPages}"> <a href="list?page=${currentPage + 1}">下一页</a> </c:if>这段代码逻辑很清晰,但在真实项目中会遇到一个体验问题:当总页数很多(比如 100 页)时,这种“上一页 / 下一页”的交互效率很低。更好的方案是数字页码条。
数字页码条的计算思路是这样的:设置一个窗口大小(比如maxDisplayPages = 5),计算出起始页和结束页,保证当前页始终在中间附近,同时不超出 1 到 totalPages 的范围。用 EL 表达式来写这个计算会非常痛苦,我强烈建议在后端就把“需要显示的页码列表”算好,直接放进一个List<Integer>属性中,JSP 里做一次<c:forEach>循环渲染即可。
// Java 侧计算页码列表,放入 request int startPage = Math.max(1, currentPage - 2); int endPage = Math.min(totalPages, currentPage + 2); List<Integer> pageNumbers = new ArrayList<>(); for (int i = startPage; i <= endPage; i++) { pageNumbers.add(i); } request.setAttribute("pageNumbers", pageNumbers);<c:forEach items="${pageNumbers}" var="p"> <c:choose> <c:when test="${p == currentPage}"> <span class="current">${p}</span> </c:when> <c:otherwise> <a href="list?page=${p}">${p}</a> </c:otherwise> </c:choose> </c:forEach>把页码列表的计算放在后端,页面里就不用写复杂的运算逻辑。这条经验在维护老系统时特别有价值——当页面出现数字页码错乱时,先去看后端的startPage/endPage计算逻辑,而不是在 JSP 里猜。
3.3 一个可直接参考的分页查询完整结构
为了把上面这些理论串起来,我整理了一个基于 Model 2 模式的分页查询完整结构。这个结构可直接套用到大多数旧系统改造或课程设计项目中。
层级结构约定:
src/main/java ├── com.example.dao ArticleDAO.java # 负责数据查询 ├── com.example.service ArticleService.java # 业务逻辑,组织分页数据 ├── com.example.entity Article.java # JavaBean,对应数据表 ├── com.example.model PageBean.java # 分页封装对象 └── com.example.servlet ListArticleServlet.java # 控制器 src/main/webapp/WEB-INF/views ├── articleList.jsp # 视图层,仅展示数据PageBean 的核心封装:
public class PageBean<T> { private int currentPage; // 当前页码 private int pageSize; // 每页记录数 private int totalCount; // 总记录数 private int totalPages; // 总页数 private List<T> list; // 当前页数据集合 private List<Integer> pageNumbers; // 用于前端展示的页码列表 public PageBean(int currentPage, int pageSize, int totalCount) { this.currentPage = currentPage; this.pageSize = pageSize; this.totalCount = totalCount; this.totalPages = (int) Math.ceil((double) totalCount / pageSize); // 简化的页码列表生成,可按需扩展 this.pageNumbers = new ArrayList<>(); int startPage = Math.max(1, currentPage - 2); int endPage = Math.min(this.totalPages, currentPage + 2); for (int i = startPage; i <= endPage; i++) { pageNumbers.add(i); } } // getter / setter 省略 }Servlet 控制器的核心逻辑:
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String pageParam = request.getParameter("page"); int page = 1; try { page = Integer.parseInt(pageParam); } catch (NumberFormatException e) { page = 1; // 参数非法时兜底 } int pageSize = 10; ArticleService service = new ArticleService(); // 假设 service 内部完成 count 和 list 查询 PageBean<Article> pageBean = service.findByPage(page, pageSize); request.setAttribute("pageBean", pageBean); request.getRequestDispatcher("/WEB-INF/views/articleList.jsp") .forward(request, response); }视图层访问分页对象:
<c:forEach items="${pageBean.list}" var="item"> <tr> <td>${item.id}</td> <td>${item.title}</td> <td><fmt:formatDate value="${item.createTime}" pattern="yyyy-MM-dd" /></td> </tr> </c:forEach> <div class="pagination"> <c:if test="${pageBean.currentPage > 1}"> <a href="list?page=${pageBean.currentPage - 1}">上一页</a> </c:if> <c:forEach items="${pageBean.pageNumbers}" var="p"> <c:choose> <c:when test="${p == pageBean.currentPage}"> <span class="current">${p}</span> </c:when> <c:otherwise> <a href="list?page=${p}">${p}</a> </c:otherwise> </c:choose> </c:forEach> <c:if test="${pageBean.currentPage < pageBean.totalPages}"> <a href="list?page=${pageBean.currentPage + 1}">下一页</a> </c:if> 共 ${pageBean.totalCount} 条 </div>这个结构里最值得关注的细节是:Servlet 里对page参数做了NumberFormatException捕获。不要小看这几行代码,它避免了一个常见问题——当用户手动修改 URL 为?page=abc时,未捕获异常会直接导致整个请求 500。在真实系统里,这个场景真的会发生。
3.4 查询参数的传递与中文乱码处理
分页查询往往伴随搜索条件,比如“按标题模糊查询”“按分类筛选”。当搜索条件和分页同时出现时,有一个容易忽略的细节:翻页时,搜索条件必须在 URL 中传递。
假设用户搜索了“JSP”,进入了第 1 页结果。点击“第 2 页”时,如果 URL 变成list?page=2,那么 Servlet 收到的搜索关键词就是 null,查询结果就变成了全部文章的分页,搜索条件丢失了。这是分页功能里最常见的体验问题。
处理方式不复杂:一种是在界面上把隐藏字段放到表单里,一种是把查询参数拼接到分页链接中。
在 JSP 项目中,我见过一种比较实用的处理:在查询列表页面使用同一个 URL 模式,Servlet 从 request 中获取keyword和page,然后把keyword原样回传到视图层,分页链接由后端生成或由 JSP 拼接:
<a href="list?page=${pageBean.currentPage - 1}&keyword=${keyword}">上一页</a>这时候就会遇到另一个经典问题——中文乱码。如果 keyword 是中文,直接拼接在 URL 里,浏览器发起请求后 Servlet 拿到的参数可能是乱码。乱码的根因在于 Tomcat 8 及以下版本,URL 参数的默认编码是 ISO-8859-1,而不是 UTF-8。
处理方式有两个层面:
- 修改 Tomcat 的
server.xml,给 Connector 加URIEncoding="UTF-8"。 - 在 Servlet 里手动转码:
new String(keyword.getBytes("ISO-8859-1"), "UTF-8")。
此外,使用response.encodeURL()方法处理分页链接,可以确保在客户端禁用了 Cookie 时,请求中仍能正确携带 Session ID。这个函数不是用来防止乱码的,而是为了保证会话连续性。在维护旧系统时,被“明明登录了,但翻页之后就掉会话”这种问题折磨过的同学,看到这行代码应该会深有感触。
4. 在线指南项目的设计细节与页面组织
4.1 导航、目录、代码示例区的设计思考
一个“在线指南”类 JSP 项目,和普通业务系统的差异在于:页面的信息架构比交互流程更重要。学习者打开指南,最关心三件事——当前学的是什么、能快速找到示例代码、代码为什么这么写。
所以在设计页面时,我建议把每个模块页面分成三个区域:
- 知识概念区:用通俗方式讲清“这是什么”,避免堆砌教科书定义。比如讲 EL 表达式时,先抛出一个问题“JSP 里怎么优雅地从 request 中取值”,再引出 EL。
- 语法速查区:用表格列出常用语法、用途和示例。这是在线指南的核心价值之一,因为文档类内容的检索效率非常重要。
- 代码运行区:展示完整代码和预期输出结果。这里可以贴出“正确示例”和“错误示例”对比,让读者在动手练习之前先建立条件反射。
用 JSP 来实现这三个区域,需要合理利用文件包含机制。页面顶部和底部通常是一样的,可以用静态包含:
<%@ include file="/common/header.jsp" %>静态包含和动态包含的区别在于:静态包含发生在编译阶段,新的页面最终只生成一个 Java 类;动态包含<jsp:include>发生在运行时,可以传参数。如果顶部导航栏需要根据当前模块高亮显示,用<jsp:include>配合<jsp:param>传一个 “当前模块标识” 会更灵活:
<jsp:include page="/common/header.jsp"> <jsp:param name="currentModule" value="el" /> </jsp:include>在header.jsp里通过${param.currentModule}判断并高亮对应菜单,这样就避免了每个页面里重复写导航高亮的 Java 代码。
4.2 代码高亮与示例管理的小技巧
做一个代码示例类页面,绕不开“代码高亮”的需求。但基于 JSP 这种服务端渲染技术,代码高亮和现代前端框架里那种完全的客户端渲染方案不太一样,需要权衡一下。
如果项目允许引入前端静态资源,推荐使用社区成熟的前端高亮库(代码高亮方面常用的是 highlight.js 或 Prism),将代码片段包在特定结构的标签里,在页面加载时完成高亮。由于 JSP 最终输出的还是静态 HTML+CSS+JavaScript,这种方式是完全可行的。
要注意的是:把 Java 代码放进去之前,需要对特殊字符做转义。否则<>会被浏览器解析为标签,代码显示就完全乱掉。在 JSP 页面里展示代码时,有两种做法:
- 使用 HTML 实体字符:把
<写成<,把>写成>。优点是直观,缺点是手动改非常痛苦。 - 使用
<c:out>标签输出:后端把代码样例作为字符串存在 JavaBean 里,页面用<c:out>输出,自动转义。
方案 2 在维护性上更优——代码样例维护在 Java 侧或数据库里,页面层负责统一转义渲染。当你需要在多个页面复用同一段示例代码时,只要改一处数据即可。
我见过一些维护得很好的旧系统,其中一种实用做法是在数据库里建一张“代码示例表”,字段包括示例标题、语言类型、代码内容、所属模块、排序号。页面通过 JSTL 从表中读取并循环输出,每个示例块用<pre>标签包裹,前端用 Prism 做高亮。这样整个在线指南就真的“数据驱动”了,新增知识点示例完全不需要改 JSP 页面。
4.3 旧浏览器兼容与 Web 容器版本相关的避坑
JSP 在线指南如果作为内网知识平台使用,经常会面临一种现实情况:用户的浏览器版本老旧,可能还在使用早期版本的 Edge、老版本 Chrome,甚至在政企内网环境下使用特定定制版浏览器。这会给 JSP 页面设计带来几个实际约束:
- 尽量不使用比较新的 ES6+ 语法。客户端高亮脚本如果用到了新语法,一旦解析失败会导致整个页面脚本报错,代码高亮完全失效。
- CSS 不要过度依赖 Flex 布局的高级用法,因为早期浏览器对部分 Flex 属性的解析存在差异,页面错位问题很难排查。
- 表格布局在某些老环境下比 Div+CSS 还要稳定,虽然这在现代前端开发者看来难以接受,但在维护旧系统时,“稳定优先”永远是第一原则。
另外要留意 Web 容器版本与 JSP 版本的支持关系。Tomcat 9 对应 JSP 2.3,Tomcat 8 对应 JSP 2.2,Tomcat 7 对应 JSP 2.1。不同的 JSP 版本对 EL 表达式的支持有细微差异,比如 EL 3.0 增加了 lambda 表达式支持,但在老版本容器上用了就会直接报错。如果一个团队里有人用新语法写了一段 JSP,部署到老容器上,排查起来非常痛苦。在做在线指南项目时,建议在首页明显位置标注“本指南基于 Tomcat 版本 XX,JSP 规范版本 XX”,让学习者一开始就有版本意识。
5. 常见问题与排查技巧实录
5.1 页面输出${...}原样而没有解析
现象:JSP 页面上写了${user.name},浏览器里看到的却是字符串{user.name},完全没有解析。
排查顺序:
- 检查文件名后缀:JSP 文件扩展名必须为
.jsp,如果是.html,Tomcat 不会执行 JSP 编译。 - 检查
web.xml中的 servlet-mapping:如果配置了把*.html也交给 JSP Servlet 处理,那另说;但大多数情况确实是因为后缀名问题。 - 检查是否引入了奇怪的 taglib 声明:某些升级后的项目里,
isELIgnored属性被显式设置为true,页面会直接忽略 EL。在page指令中检查是否存在isELIgnored="true",如有则改为false。
<%@ page isELIgnored="false" %>在较新版本的规范中,isELIgnored默认是 false,但老项目可能因为升级而在配置中显式加入了这项设置。一旦遇到“EL 不生效”而且没有语法报错的诡异情况,优先查这里。
5.2 JSTL 标签被当成普通文本
现象:页面上写了<c:forEach>,结果浏览器直接把它当文本显示了,或者直接报 500 提示 URI 无法解析。
原因:JSP 页面没有引入 taglib 指令。
解决:检查页面顶部是否包含:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>如果已经声明了,但仍然报错,那大概率是 jar 包缺失或版本不匹配。把jstl.jar和standard.jar(或 Maven 依赖)放到正确位置后,重启服务即可。
5.3 分页页码总数显示错误
现象:总共有 13 条数据,每页 5 条则应有 3 页,但页面显示总页数为 2。
原因:总页数计算使用了整数除法,直接写成totalCount / pageSize。当totalCount=13时,13/5在 Java 的整数除法结果是 2,而不是 3。
正确写法:
int totalPages = (int) Math.ceil((double) totalCount / pageSize);这里必须先转为double,否则Math.ceil也救不了。这个错误在初学者里极其常见,也很适合写进题库作为应用题,因为它是“看起来没报错但结果不对”的典型情况。
5.4 点击下一页后乱码,或不带查询条件
现象:列表页带 keyword 搜索条件时,点击翻页后,Keyword 条件丢失或出现中文乱码。
原因:分页链接没有带上 keyword 参数;或者 Tomcat URL 编码未设置 UTF-8。
处理:
- 携带查询参数:分页链接统一拼接
keyword参数,推荐后端封装,避免页面手写字符串拼接出错。 - 乱码:修改 Tomcat Connector 的 URIEncoding,或在 Servlet 中手动解码。
- 如果用 POST 查询,可以考虑把查询条件和当前页放在 session 中,翻页时只传 page,不加 keyword。这样 URL 干净,也不会有编码问题。但要注意 session 中数据过期后重新查询时的处理,容易引发“查询结果与搜索条件不一致”的问题。
5.5 本地正常部署后服务器上报 500
现象:项目在本地 Tomcat 运行正常,部署到另一台服务器的 Tomcat 后访问 JSP 页面 500。
排查思路:
- 对比两台服务器的 Tomcat 版本和 JDK 版本。不同版本的 EL 表达式支持范围、JSP 编译器的默认设置可能不同。
- 检查
WEB-INF/lib下是否包含本地有但服务器缺失的 jar 包,尤其是jstl-api、jstl-impl。 - 检查 web.xml 中声明的 servlet 版本和实际 Tomcat 支持的版本是否匹配。web.xml 声明 3.0,但服务器是 Tomcat 6,直接出问题。
- 确认项目是否包含本地绝对路径文件(比如在代码里写死了 Windows 风格路径),Linux 环境下会直接抛异常。
5.6 表单提交数据到 JSP 页面后解析异常
现象:表单提交到submit.jsp后,中文内容出现乱码,或读取不到参数。
原因:POST 请求的字符编码未设置。在 JSP 早期项目中,request.setCharacterEncoding("UTF-8")不是默认行为。
处理:在 Servlet 的doPost方法最前面写:
request.setCharacterEncoding("UTF-8");如果项目有很多 Servlet,建议在父类或 Filter 中统一处理,而不是每个 Servlet 写一遍。老项目中最常出问题的情况是:Filter 处理了 POST 编码,但 GET 参数还是乱码。这两条处理链路是独立的,分别排查非常关键。
5.7 分页链接点击后页面样式丢失
现象:首页显示正常,点击分页第二页后,页面排版乱七八糟。
原因:CSS 使用了相对路径,比如<link href="css/style.css">。第一页时 URL 是/list,浏览器解析到根目录下的 css;翻页后 URL 变成/list?page=2,这个 URL 路径不变,通常没影响;但当分页链接是list/page/2这种 RESTful 风格时,相对路径就会解析错乱。
处理:JSP 页面里的静态资源路径,统一使用绝对根路径:
<link href="${pageContext.request.contextPath}/css/style.css" rel="stylesheet">这句话在旧项目里几乎是万能修正法。凡是“页面跳转了目录层级之后样式全丢”的问题,十有八九是这里没写pageContext.request.contextPath。
6. 延伸思考:这个指南项目还能怎么扩展
说完了实操细节,最后聊聊这个“JSP知识在线指南”项目真正的延展空间。
这个项目最妙的地方在于:它本身就是一个可以持续维护的“元项目”。当你学完了 JSP 体系,你的 PDF 笔记可能丢了,视频收藏可能过期了,但如果这个在线指南项目是活在自己的服务器上的,那么每一次更新知识点,都是在真实 JSP 环境里完成一次验证。
指导这个项目的时候,我自己的一些经验是:
内容组织上,不要试图把 JSP 体系的所有知识点写成一个巨型文档,那只会复刻教科书的老路。更好的方式是按“应用场景”组织:请求过来了,我需要怎么取值?列表要展示,怎么循环?数据太多,怎么分页?让读者每学一个模块都能立刻用到,而不是为了学而学。
代码示例上,不要只贴“能运行”的代码,更要刻意展示“常见错误”和错误现象。在指南里专门留一个“报错排查”板块,把困扰过你的问题按“现象—原因—解决”三层结构记录下来。这种内容别的网站不整理,读了的人会觉得钱花得值。
技术选型上,即使这是一个给“旧技术”做知识指南的项目,也不代表它不可以引入前端组件。代码高亮、响应式布局、Markdown 渲染这些能力,可以大胆借力现代前端工具链。让老技术没有老味道,也是知识指南本身的定位价值所在。
我实际维护过的类似项目里,最后都发现同一件事:真正让这种在线指南持续产生价值的,不是某个页面写了多少先进特性,而是“示例代码真的能跑”“问题记录真的说到点子上”“内容更新真的及时”。做到这三点,哪怕底下是 JSP,也比很多花架子知识站有用得多。
如果你打算动手写一个类似的东西,我最后的一句建议是:先把目录定死,再写页面。目录就是知识地图,地图画对了,后面的工作都是填充,不会迷路。这个思路,和学 JSP 本身的路径,其实是一回事。