表单与用户输入
这是什么:HTML 表单(Form) = 网页上让用户填东西、点按钮提交的那块区域,GET 和 POST 是它两种送数据的姿势。
为什么用:前两章的网页只会「说」——输出文字、渲染页面,却听不见用户。表单让网页学会「听」,攒出一个真能留言的小应用。
如何用:本章先回顾 GET 与 POST 的区别,再写留言板控制器和带表单的页面,最后讲清处理完为什么要「再转一圈」。
先回顾:GET vs POST
浏览器和服务器打交道,最常用的两种请求方式:
| 方式 | 用途 | 特点 | 例子 |
|---|---|---|---|
| GET | 要数据 | 参数挂在网址 ? 后面,能收藏能分享 |
搜索、看页面 |
| POST | 交数据 | 参数藏在请求体里,不出现在网址上 | 提交表单、登录 |
原则一句话:只读用 GET,写入用 POST。留言显然是"写入",走 POST。
为什么"写入"不能走 GET?两个直观理由:一是 GET 的参数全写在网址上,密码、留言内容直接暴露在地址栏和历史记录里,等于把日记贴在大门口;二是地址能被收藏、被预取,用户没点提交,浏览器也可能"好心"替他再发一次——只读无所谓,写入就出大事了。POST 把参数装进请求体、不进历史记录,才配承担"交数据"的职责。
想亲眼看一次请求?F12 → Network → 提交一次表单,点开那条请求:GET 的参数在 Query String Parameters 里,POST 的在 Form Data 里——一眼看懂"藏"字的含义。
第一步:留言板控制器
沿用上一章的 Thymeleaf 项目。控制器里用一个"内存 List"当仓库——留言存内存,应用重启就清空,学习阶段够用。
先看路由的分工设计:GET /board 负责"看"(渲染留言板页面),POST /board/post 负责"收"(保存新留言)。一读一写、一 GET 一 POST,各守各的门。这个"列表页一个路由、提交一个路由"的两件套,就是所有增删改查页面的地基:
package com.example.demo;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.ArrayList;
import java.util.List;
@Controller
public class MessageController {
// 内存仓库:重启即清空,学习够用
private final List<String> messages = new ArrayList<>();
@GetMapping("/board")
public String board(Model model) {
model.addAttribute("messages", messages);
return "board";
}
@PostMapping("/board/post")
public String post(@RequestParam String content) {
if (!content.isBlank()) {
messages.add(content);
}
return "redirect:/board"; // 处理完,让浏览器回到留言板
}
}
留意两处:
- 接收表单数据用的还是
@RequestParam——POST 的参数只是换了个地方藏(请求体),Spring Boot 帮你接住。上一章的结论原样成立:参数名和表单里name属性的值对上,数据就自动流进来 redirect:/board叫重定向:处理完 POST 让浏览器重新访问/board,这样用户刷新页面也不会重复提交
参数还有进阶玩法:有的字段"可有可无",比如留言者昵称。用 defaultValue 给个兜底,不填也不报错:
@PostMapping("/board/post")
public String post(@RequestParam String content,
@RequestParam(defaultValue = "匿名青蛙") String author) {
if (!content.isBlank()) {
messages.add(author + ":" + content);
}
return "redirect:/board";
}
对比记忆:@RequestParam String content 是必填(缺了直接 400);加上 defaultValue 就变选填。必填管关键数据,选填管锦上添花——和上一章参数接法完全一套体系,只是这次数据从请求体来。
第二步:带表单的页面
templates/board.html。先认识表单的三大件,一个都不能少:
| 部件 | 作用 | 缺了会怎样 |
|---|---|---|
action="/board/post" |
数据提交到哪个网址 | 缺了提交到当前页,多半 404/405 |
method="post" |
用什么方式提交 | 缺了默认 GET,撞上 POST 路由就 405 |
name="content" |
输入框数据的"变量名" | 缺了这条数据直接失踪,后端 400 |
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>留言板</title>
</head>
<body>
<h1>🐸 留言板</h1>
<form action="/board/post" method="post">
<input type="text" name="content" placeholder="写点什么…" required>
<button type="submit">留言</button>
</form>
<ul>
<li th:each="msg : ${messages}" th:text="${msg}">示例留言</li>
</ul>
</body>
</html>
再逐个点一眼细节:placeholder 是输入框里的灰色提示文字;required 让浏览器在空提交时弹"请填写此字段"拦一道——这是前端校验,只能挡君子;后端那句 isBlank() 是后端校验,防的是绕过浏览器直接发请求的人。两道关卡都留,才是完整防御。
页面下半截就是上一章的复习现场:th:each 循环渲染留言列表,${messages} 是控制器塞进 Model 背包的 List。新学的表单负责"进",上一章的模板负责"出"——数据一进一出,应用就"活"了。
第三步:跑起来
重启应用,访问 http://localhost:8080/board,输入"青蛙编程网加油"点【留言】,页面回到留言板,列表多了一条(示意):
🐸 留言板
[ 写点什么… ] [留言]
• 青蛙编程网加油
提交 → 接收 → 存储 → 重定向回列表,这就是 Web 开发最经典的"表单四步舞"——注册、登录、下单,全是这个套路。
多留言几条试试手感:列表越来越长、地址栏始终停在 /board、刷新不会多出重复留言——三件事同时成立,说明四步舞跳对了。最后重启一次应用再看:留言全没了,内存仓库的"失忆症"如约而至,下一篇练习会告诉你正经项目怎么治它。
想想看:有了表单,你的网页第一次具备了"记住用户输入"的能力——虽然只记到重启为止,但"浏览 → 输入 → 反馈"的完整闭环已经打通,剩下的只是把内存换成数据库、把提示做得更友好而已。Web 应用开发的四梁八柱,到这里已经立起来了。
重定向:为什么处理完要"再转一圈"
如果 post 方法直接 return "board"(返回页面名)会怎样?功能看着也正常,但按 F5 刷新,浏览器会把刚才那次 POST 原样重发——留言又多一条。这就是著名的"重复提交"问题。
重定向的解法是让浏览器"再转一圈":
提交表单 → POST /board/post(服务器存好留言)
↓ return "redirect:/board"
服务器回话:302,请去访问 /board
↓
浏览器自动发 GET /board(看到新列表)
关键在最后一跳是 GET:刷新重放的只是这个无害的 GET,POST 早结束"使命"了。
还有一个副作用是惊喜:重定向后地址栏回到干净的 /board,用户可以把留言板地址收藏、发朋友,别人打开就是最新列表——网址始终反映"当前看到什么",而不是"刚才提交到哪"。这就是为什么几乎所有网站提交完都跳转而不是停在提交路由上。
两种收尾方式对比:
| 收尾写法 | 刷新后 | 地址栏 | 适用 |
|---|---|---|---|
return "页面名" |
重发 POST,重复提交 | 停在 /board/post | 只读查询页 |
return "redirect:/board" |
只重发 GET,安全 | 回到 /board | 写操作后统一用它 |
口诀:POST 完了必重定向。这条规矩在后面练习里会一直用。
🎯 这套写法什么时候用
- 注册 / 登录:表单 POST 提交账号密码,后端校验后重定向到首页——和留言板同一副骨架
- 搜索框:GET 提交(参数上网址,方便收藏搜索结果),路由用
@RequestParam接关键词 - 下单、投票、点赞:任何"改数据"的操作,POST + 重定向是标配
- 后台管理:列表页 + 表单页 + 提交路由,三件套复制粘贴改改名,就是一个新模块的雏形
换句话说:这一章学的不是"留言板怎么写",是一切 Web 表单交互的通用模板。
记忆口诀
只读 GET 写入 POST,表单 method 定路走;接收还是 RequestParam,redirect 挡住重复手。
❓ 常见问题
Q1:提交后报 405 Method Not Allowed?
路由在,但请求方式不对——十有八九是表单忘了写 method="post",浏览器默认走 GET,撞上了只认 POST 的 @PostMapping。补上 method 属性就好。
Q2:提交后报 400 Bad Request,提示参数缺失?
后端 @RequestParam String content 没接收到值。直奔模板检查 <input> 有没有写 name="content"——没有 name 属性的输入框,数据根本不会随表单发出;再对一对 name 值和参数名是否一字不差。
Q3:明明写了 required,还是收到了空留言?
required 是浏览器的前端校验,只能拦"正常用浏览器的人"。用命令行、脚本直接 POST 的人绕过它毫无压力——所以后端的 isBlank() 校验必须留着。前端校验管体验,后端校验管安全,别指望前者替你把关。
Q4:重启后留言全没了,是 bug 吗?
不是,是"内存 List 当仓库"的固有特性:数据存在应用进程里,进程一停数据就散。学习阶段它零成本、好上手;正经项目会换数据库存——那是后续话题,先把四步舞跳熟。
Q5:留言想写多行,输入框太小怎么办?
把 <input type="text"> 换成 <textarea name="content" rows="3"></textarea>——rows 控制可见行数。对后端来说毫无区别:name 还是那个 name,@RequestParam String content 原样接住,多行内容连换行符一起到货。表单控件的皮可以随便换,name 对上就万事大吉。
⚠️ 新手翻车现场
翻车一:action 网址写错
<form action="/board/add" method="post"> <!-- ❌ 后端路由是 /board/post -->
问题:提交后 404——表单发给了一个不存在的路由。
修正:action 与 @PostMapping 的值保持一致,最好两边复制粘贴,别手打。
翻车二:重定向少了 redirect: 前缀
return "board"; // ❌ 被当成页面名去找模板
问题:框架去 templates/ 找 board.html——页面是找到了,但地址栏停在 /board/post,一刷新 POST 重发,留言疯狂翻倍。
修正:写操作收尾统一 return "redirect:/board";。
翻车三:把 @GetMapping 用在提交路由上
@GetMapping("/board/post") // ❌ 表单是 POST 提交的
public String post(@RequestParam String content) { ... }
问题:提交直接 405——表单发 POST,路由只认 GET,方式对不上。
修正:接收表单提交的路由用 @PostMapping,一一对应。
📝 本章小结
- 只读用 GET(参数在网址上),写入用 POST(参数在请求体里),写入操作不给 GET 机会
- 表单三大件:
action定去处、method定方式、name定参数名——缺一个就断链 - 接参数还是
@RequestParam,必填靠缺省、选填加defaultValue - 前端
required管体验,后端isBlank()管安全,两道关卡都留 - 写操作收尾必重定向
redirect:/board,否则刷新就是重复提交 - 记忆口诀:只读 GET 写入 POST,表单 method 定路走;接收还是 RequestParam,redirect 挡住重复手。
动手改
三个小活层层递进:先亲手制造 bug 再修复(体会校验的价值),再扩展字段,最后玩转序号:
- 把
content.isBlank()的判断删掉,连点几次提交空留言看看效果,再把判断加回来 - 表单里加一个
name="author"的输入框,控制器再接一个@RequestParam String author,让留言显示成"作者:内容" - 给留言列表加序号:提示
th:each="msg, stat : ${messages}",然后用${stat.count}取"当前第几条"
下一章:综合练习,把六章所学缝成一个完整应用 🐸