我一直觉得JSP教科书不该是那种只讲语法、不告诉你什么时候别用的书。它更像一份在线指南——既要讲清JSP体系里Servlet、指令、内置对象这些骨架也要把EL表达式、JSTL标签库这些能把页面变干净的工具讲透最后还要落到JSP开发模型和分页查询这种真实项目天天用的场景上。如果你正准备系统学Java Web或者手头维护着老系统这篇内容值得从头到尾过一遍。前两天帮朋友看一个跑了快十年的老项目点开一个列表页一千多行JSP里面全是% %脚本片段一个字段的取值逻辑散落了七八处。说实话这种代码看着就脑仁疼。但转念一想当年写这些代码的人也不是不想用EL和JSTL而是很多人学JSP的时候教材上来就是语法从来没人告诉他什么场景该用什么。所以这篇我不打算按语法书的顺序平铺而是按我实际排查和重构项目时的思考路径把JSP体系彻底拆开。1. 为什么JSP体系至今还值得完整啃一遍1.1 JSP解决了什么历史问题JSP刚出来那几年Java Web开发的主流方式是Servlet。写一个带表格的HTML页面你得在Java代码里out.println(trtd user.getName() /td/tr)循环几行还好整个页面都是这种拼接代码写的人崩溃看的人更崩溃。JSP的思路是把这件事反过来先写HTML在需要动态插入数据的位置开个小口把Java片段嵌进去。容器会自动把JSP翻译成Servlet去执行开发者不用手动管理编译和输出。这个思路在当年是革命性的也是JSP体系一切特性的源头——你理解了JSP本质是Servlet这句话后面所有语法都好解释了。1.2 JSP与Servlet不是对立的是同一件事很多初学者会问JSP和Servlet到底选哪个这个问题本身就有问题。JSP文件第一次被访问时容器会把它解析成一个Servlet的Java源文件再编译成class文件。也就是说JSP是用写模板的方式写Servlet。所以你在JSP里能直接用request、response、session这些对象并不是什么魔法而是因为翻译出来的Servlet本来就提供了这些。理解这一点你就不会觉得JSP的九大内置对象是死记硬背的知识点而是Servlet编程模型里天然存在的东西。1.3 生命周期你的页面到底是怎么跑起来的JSP的生命周期可以拆成四段编译、初始化、服务、销毁。第一次访问某个.jsp时容器先把它翻译成_jsp.java再编译成_jsp.class然后实例化并调用jspInit()初始化。之后每次访问都走_jspService()方法这个方法就是那个被翻译出来的Servlet的service()逻辑。容器关闭或页面被替换时调用jspDestroy()收尾。这里有一个非常实际的体验部署后第一次访问JSP页面总觉得慢原因就是容器正在现场编译。如果页面很多首次启动后最好把主要页面都访问一遍热热身。另外你修改了JSP文件后容器会检测到文件时间戳变化自动重新编译不需要重启应用。但如果改了web.xml或者类路径下的jar包那还是得重启。这个坑我见过不少同事踩过页面改了没生效第一反应是重启服务器。2. JSP页面体系解剖指令、脚本元素与九大内置对象2.1 三大指令分别管什么JSP指令不产生任何输出只给容器下命令。常用的是三个page、include、taglib。page指令控制当前页面的属性。最常见的配置长这样% page languagejava contentTypetext/html; charsetUTF-8 pageEncodingUTF-8 importjava.util.* sessiontrue buffer8kb errorPage/error.jsp isErrorPagefalse isELIgnoredfalse %这几个属性里有两个特别容易踩坑第一个是pageEncoding和contentType的编码问题。pageEncoding管的是JSP文件本身在磁盘上的读取编码contentType管的是响应给浏览器的编码。两者不一致中文必乱码。如果是Maven项目还要确保文件在磁盘上的实际编码和pageEncoding一致否则编译直接报错。第二个是isErrorPage。只有设置成true的页面才能在内置对象里使用exception否则你在页面上写${exception}或者%exception%会直接编译失败。想统一处理异常需要在可能出错的Page上配errorPage/error.jsp而error.jsp要配isErrorPagetrue。include指令是静态包含代码长这样% include fileheader.jsp %它是在编译阶段把被包含文件的文本内容直接合并进来两个文件最终编译成一个Servlet。优点是性能好缺点是如果两个文件里定义了同名变量直接编译冲突。taglib指令用于引入标签库后面讲EL和JSTL时会专门展开。2.2 脚本元素用还是不用是个判断题JSP里的脚本元素有三种% %、% %、%! %。% %叫脚本片段里面写的是_jspService()方法里的Java语句可以做循环、判断、调用方法。% %叫表达式等价于out.print(...)用来输出一个值。%! %叫声明里面定义的变量和方法会成为Servlet类的成员。用%! %声明成员变量时要格外小心成员变量是多个请求线程共享的如果用它保存用户状态高并发下必然出问题。我在老项目里见过拿%! int count 0; %做访问计数的一压测数字全乱。我的经验是脚本元素能不用就不用。维护阶段最痛苦的就是看到一堆% %穿插在HTML标签里标签配对不齐编译报错都定位不到行。如果项目允许尽量用EL和JSTL替代实在有复杂逻辑放到Servlet或者JavaBean里页面上只留一行标签。2.3 include动作另一种包含方式除了% include %还有jsp:include动作元素jsp:include pageheader.jsp flushtrue /区别在于指令是文本级合并动作是请求级转发。jsp:include在运行时把另一个资源的输出拿过来相当于底层做了一次子请求被包含页面有自己独立的作用域也可以通过jsp:param传参数。那公共头部到底用哪种如果头部是不变的静态片段用% include %更省如果头部需要根据当前请求变化比如读取登录用户显示不同菜单那就用jsp:include。这个判断直接决定你做模板复用时的代码结构。2.4 九大内置对象一张表记住所有内置对象就是容器在_jspService()方法里帮你创建好的变量直接在JSP里用就行。内置对象类型典型用途requestHttpServletRequest获取请求参数、请求头、转发responseHttpServletResponse重定向、设置响应头outJspWriter直接输出内容sessionHttpSession会话级数据applicationServletContext应用级共享配置pageContextPageContext页面上下文可访问其他8个对象configServletConfig获取Servlet初始化参数pageObjectthis当前Servlet实例exceptionThrowable仅在isErrorPagetrue时可用其中pageContext最容易被忽略但它是EL表达式的关键枢纽。通过pageContext可以拿到request、session、application等所有对象而且它还维护了page作用域。后面EL表达式里的${pageContext.request.contextPath}就是通过它取应用根路径的标准写法。3. EL表达式把页面上的Java碎块清干净3.1 先看一组对比你就明白为什么要有EL不用EL时输出一个请求参数是这样的% request.getParameter(username) %用了EL之后是这样${param.username}前者的麻烦不止是代码长。request.getParameter()返回null时% %输出的结果是字符串null而EL表达式遇到空值直接输出空白串不会有莫名其妙的null出现在页面上。这一点在日常开发里太重要了它让页面在数据不完整时依然保持可读不会给用户看到一堆英文null。3.2 EL的取值路径与运算符EL语法核心就是${表达式}。取值可以用点号也可以用方括号${user.name} ${user[name]}两者大部分场景等价但方括号能做更多事比如Map的key里带特殊字符、List按下标取值、动态拼接key。取List元素是这样${list[0]}取Map元素是${map[key]}。EL的运算符也很紧凑算术-*/或div%或mod逻辑或and||或or!或not比较或eq!或ne或lt或gt或le或ge空值判断${empty var}empty不但能判断null还能判断空列表、空字符串特别提醒EL里的/是浮点除法${7 / 2}结果是3.5不是Java里的整数除法结果3。写价格计算类页面时要注意这个区别。3.3 四个作用域与查找顺序page、request、session、application四个作用域是JSP数据传递的基础EL访问变量时会按这个顺序依次查找pageScope → requestScope → sessionScope → applicationScope。找到第一个就直接返回后面就不再查了。这个顺序会带来一个隐蔽的坑如果session里存了一个user对象request里又放了一个同名的user你写${user.name}时拿到的是requestScope里的那个。想强制指定作用域必须显式写${requestScope.user.name}或${sessionScope.user.name}。3.4 EL隐式对象与常见陷阱EL提供了11个隐式对象最常用的有param/paramValues获取请求参数header/headerValues获取请求头cookie获取CookieinitParam获取web.xml里配置的context-param初始化参数pageContextPageContext对象本身不是pageScopepageScope/requestScope/sessionScope/applicationScope四个Map一个经典用法是${pageContext.request.contextPath}获取应用部署根路径拼接CSS、JS和链接时基本离不开它。相对路径在转发到/WEB-INF/jsp/xxx.jsp时会乱套绝对根路径最稳。容易踩的坑是initParam只对应ServletContext的初始化参数对应web.xml里的context-param如果你要获取某个Servlet自己的init-paramEL表达式是拿不到的必须通过config对象或者Java代码处理。4. JSTL标签库从页面拙劣脚本到标签化思维4.1 环境配置jar包和uri别搞混JSTL的英文全称是JavaServer Pages Standard Tag Library。要用它先引入依赖。老项目通常需要jstl.jar和standard.jar如果用较新的Servlet容器依赖坐标会变成jakarta.servlet.jsp.jstl。页面引入标签库的方式% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % % taglib prefixfmt urihttp://java.sun.com/jsp/jstl/fmt % % taglib prefixfn urihttp://java.sun.com/jsp/jstl/functions %新版的uri可能变成jakarta.tags.core、jakarta.tags.fmt、jakarta.tags.functions。如果你用的是Tomcat 10及以上还是写老版uri运行时会报找不到标签库的错这是现在很常见的环境配置问题。4.2 核心标签实测页面循环与条件分支JSTL核心标签库最常用的四个是c:out、c:if、c:choose、c:forEach。c:out输出变量默认会把XML特殊字符转义能防XSS推荐用它替代% %直接输出数据的地方。c:if做单分支判断没有else想写else要用c:choose组合c:choose c:when test${user.status 1} span classnormal正常/span /c:when c:otherwise span classdisabled停用/span /c:otherwise /c:choosec:forEach是循环主力遍历List渲染表格是它最典型的场景c:forEach items${userList} varuser varStatusst tr class${st.count % 2 0 ? even : odd} td${st.count}/td tdc:out value${user.username} //td td${user.realName}/td td fmt:formatDate value${user.createTime} patternyyyy-MM-dd HH:mm:ss / /td /tr /c:forEachvarStatus有很多好用的属性count从1开始计数index从0开始first和last判断首尾。利用它做表格斑马纹、序号列都非常方便完全不需要写Java代码。还应该提到c:url标签生成带参数的URL时c:param会自动编码避免中文参数把链接搞坏。这个放在分页查询部分会重点实践。4.3 fmt标签让日期和数字不再难看Java里格式化一个日期通常要new SimpleDateFormat(yyyy-MM-dd HH:mm:ss)然后手动调用方法。在JSTL里一行搞定fmt:formatDate value${user.createTime} patternyyyy-MM-dd HH:mm:ss /数字格式化用fmt:formatNumber比如保留两位小数、千分位fmt:formatNumber value${salary} pattern#,##0.00 /这个标签的价值不只是少写代码更在于模板的可维护性。你改日期格式时不需要动Java代码重新编译直接在JSP里改pattern就能生效。4.4 fn函数库字符串处理的轻量武器fn标签库提供了一批字符串函数最常用的是fn:length、fn:contains、fn:substring等。c:if test${fn:length(userList) 0}有数据/c:if ${fn:substring(user.name, 0, 2)}注意fn:length对List和数组都有效对字符串也能返回字符长度。fn:contains可以替代Java里的contains()判断。页面上遇到字符串截断、包含判断这些操作用fn标签比在Servlet里提前处理更灵活。5. JSP开发模型从Model1到Model2的职责革命5.1 Model1的快速原型优势与后期灾难Model1开发模型下浏览器请求直接打到JSPJSP里既负责接收参数、调用JavaBean获取数据又负责渲染页面。流程如下浏览器 → JSP → JavaBean → 数据库这种模型上手极快页面少、逻辑简单时开发效率很高。我自己早期做课程设计就喜欢这么写一个JSP页面里调Service、查数据库、表格循环输出全部塞在一个文件里改起来很爽。但需求一复杂就露馅。一个页面上百行% %脚本里面混着SQL、业务判断和HTML随便改一个字段都可能牵连出一堆逻辑。这个页面到底做什么过两个月连自己都看不懂。项目到后期修改频率越高Model1的维护成本指数级上升。5.2 Model2如何划分职责Model2就是经典的MVC分层职责划分如下Servlet当控制器接收请求、解析参数、调用Service、把数据放进request作用域、转发或重定向到JSPJavaBean或POJO当模型封装业务数据和业务逻辑JSP只当视图负责把控制器传过来的数据渲染成HTML流程是这样浏览器 → Servlet → Service/DAO → JavaBean → 转发到JSP → 渲染HTMLJSP里不再出现业务逻辑只剩下EL和JSTL做数据展示。好处非常明显改页面字体、布局不会影响业务逻辑改业务逻辑不会碰页面。前后端同事的分工边界一下子清晰了。5.3 WEB-INF目录视图保护的关键实现Model2后一个经典部署结构是src/main/webapp ├── WEB-INF │ ├── web.xml │ └── jsp │ ├── user │ │ ├── list.jsp │ │ └── edit.jsp │ └── common │ ├── header.jsp │ └── footer.jsp └── static ├── css └── jsJSP放入WEB-INF目录后浏览器无法通过URL直接访问只能通过Servlet内部forward跳转。这意味着用户不能绕开控制器直接打开页面权限校验、参数处理等逻辑必须在Servlet里完成。这是我特别想强调的一点Model2不是单纯的技术分层更是一种访问控制手段。5.4 我见过的模型混乱现场维护老项目时最让我头疼的不是JSP本身而是模型混用。一个系统里有的页面走Servlet转发有的页面直接用JSP访问WebRoot下的文件还有的Servlet不转发自己用out.write()输出整个HTML页面。这种混乱会让后来的维护者完全无法判断一条请求链路是怎么回事。所以选型的时候要么全系统统一Model2要么只在极小的内部工具页面用Model1千万不要边写边换。一个项目最怕的不是选了一个旧的模型而是没有模型。6. 分页查询从SQL到页面导航的完整落地6.1 分页SQL怎么写分页的第一件事是让数据库只返回当前页的数据。不同数据库语法不同MySQLSELECT * FROM t_user ORDER BY id LIMIT #{offset}, #{pageSize}这里的offset是偏移量计算规则是(pageNo - 1) * pageSize。Oracle 12c以上用OFFSET ... ROWS FETCH NEXT ... ROWS ONLYSQL Server 2012以上也是类似的OFFSET ... FETCH。老版本Oracle用ROWNUM子查询写起来比较绕。为什么强调分页要在SQL层做因为如果一次性把全表数据查出来到Java层再截取数据量一大内存直接扛不住。我见过有人用这种方式处理几千条数据页面卡得没法看。6.2 PageBean到底封装什么分页组件最好设计成一个通用的PageBean而不是每次手写一堆参数。核心字段这些字段含义计算/赋值方式pageNo当前页码从请求参数解析默认1pageSize每页条数可配置需设上限totalCount总记录数count查询得到totalPage总页数(totalCount pageSize - 1) / pageSizerecords当前页数据列表查询结果offsetSQL偏移量(pageNo - 1) * pageSizeJava代码长这样public class PageBeanT { private int pageNo; private int pageSize; private int totalCount; private int totalPage; private ListT records; public PageBean(int pageNo, int pageSize, int totalCount, ListT records) { this.pageNo pageNo; this.pageSize pageSize; this.totalCount totalCount; this.records records; this.totalPage (totalCount pageSize - 1) / pageSize; } // getter/setter... }为什么总页数不直接用除法因为Java整数除法会丢掉小数。(totalCount pageSize - 1) / pageSize这种写法是向上取整的标准技巧比如总记录数13条、每页5条结果是(13 4) / 5 3正好3页。6.3 三层代码串起来DAO层用MyBatis的话函数和XML配置大概是这样的思路public PageBeanUser queryUsersByCondition(String keyword, int pageNo, int pageSize) { int offset (pageNo - 1) * pageSize; ListUser records userMapper.selectByCondition(keyword, offset, pageSize); int totalCount userMapper.countByCondition(keyword); return new PageBean(pageNo, pageSize, totalCount, records); }对应的SQL用where和if动态拼接select idselectByCondition resultTypeuser SELECT * FROM t_user where if testkeyword ! null and keyword ! AND username LIKE CONCAT(%, #{keyword}, %) /if /where ORDER BY id LIMIT #{offset}, #{pageSize} /select select idcountByCondition resultTypeint SELECT COUNT(*) FROM t_user where if testkeyword ! null and keyword ! AND username LIKE CONCAT(%, #{keyword}, %) /if /where /select这里有个原则必须守住列表查询和totalCount查询的where条件要完全一致。否则列表只有5条数据总数却显示20条总页数全是错的。很多分页bug都是这边漏了一个条件那边多了一个条件。Servlet层负责接收参数、调用Service、把PageBean放进request并转发WebServlet(/user/list) public class UserListServlet extends HttpServlet { protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ... { int pageNo parsePageNo(req.getParameter(pageNo)); int pageSize 5; String keyword req.getParameter(keyword); PageBeanUser pageBean userService.queryUsersByCondition(keyword, pageNo, pageSize); req.setAttribute(pageBean, pageBean); req.setAttribute(keyword, keyword); req.getRequestDispatcher(/WEB-INF/jsp/user/list.jsp).forward(req, resp); } private int parsePageNo(String param) { int pageNo 1; try { pageNo Integer.parseInt(param); } catch (Exception e) { pageNo 1; } return Math.max(pageNo, 1); } }parsePageNo这一步很多人会忽略。用户手动改URL参数传一个非数字或者负数如果不处理Integer.parseInt直接抛异常。请求参数永远不可信这是写Servlet必须有的意识。6.4 JSP分页导航与搜索条件回显到了JSP页面用JSTL渲染分页导航条。最朴素的版本是首页、上一页、页码数字、下一页、末页div classpagination c:if test${pageBean.pageNo 1} a href${pageContext.request.contextPath}/user/list?pageNo1keyword${keyword}首页/a a href${pageContext.request.contextPath}/user/list?pageNo${pageBean.pageNo - 1}keyword${keyword}上一页/a /c:if span第 ${pageBean.pageNo} / ${pageBean.totalPage} 页共 ${pageBean.totalCount} 条/span c:if test${pageBean.pageNo pageBean.totalPage} a href${pageContext.request.contextPath}/user/list?pageNo${pageBean.pageNo 1}keyword${keyword}下一页/a a href${pageContext.request.contextPath}/user/list?pageNo${pageBean.totalPage}keyword${keyword}末页/a /c:if /div这里的关键是pageNo 1才显示首页和上一页pageNo totalPage才显示下一页和末页避免出现点击上一页还是停在第一页、点击下一页跑出有效范围的尴尬。但直接拼URL有个隐患如果keyword里包含中文、空格或字符链接会错乱。更稳妥的做法是用c:url和c:param生成链接c:url varpageLink value/user/list c:param namepageNo value${pageBean.pageNo 1} / c:param namekeyword value${keyword} / /c:url a href${pageLink}下一页/ac:url会自动拼上contextPathc:param自动URL编码等于帮我们处理了路径和特殊字符两件麻烦事。搜索条件回显是分页里最容易出体验问题的地方。用户在搜索框输入张三点第二页后搜索框还是张三吗答案是需要你在JSP的input里显式回显input namekeyword value${empty keyword ? : keyword} /如果不回显用户翻页后搜索框空了他会以为自己搜的条件被重置。这个细节看着小却直接影响搜索功能的可用性。6.5 分页里的常见翻车现场我挑四个高频问题给大家排雷第一个总数查询和列表查询的条件不一致。我排查过一个列表页总数显示有283条但点最后一页只有两行数据原因就是count语句漏了一个过滤条件。正确做法是把条件片段抽取成公共SQL片段在MyBatis里用sql标签复用避免copy-paste时改一处漏一处。第二个排序字段直接拼接进SQL。页面上传一个sortcreateTimeDAO就直接拼ORDER BY createTime。这种写法一旦被恶意传参等于把SQL注入的窗口打开。正确做法是做白名单映射只允许固定的字段集合。第三个pageNo没做边界处理。用户手动访问?pageNo999999数据库查询没问题但页面会渲染出一个巨大的页码数字。要么查完后把pageNo修正到totalPage以内要么在Service层直接限制。第四个pageSize不设上限。一个恶意请求把pageSize传成100000等于让数据库一次性返回全表接口性能瞬间被打满。建议在Servlet层加一个Math.min(pageSize, 100)的限制。最后再分享一点个人体会。我最初也觉得JSP这套体系过时但后来发现EL表达式的取值链、JSTL把循环和格式化从代码中抽出来的思路在后来的Thymeleaf、甚至前端框架的模板语法里都能看到影子。你把JSP体系学透了再学别的模板引擎会很顺因为它们解决的是同一个问题怎么把动态数据干净地渲染到页面上。而且遇到那种跑了十几年的老系统你至少有底气打开一个.jsp文件三分钟内定位到问题在哪一层——这在真实项目里是很值钱的能力。