做前端这些年,我越来越确定一件事:越是基础的东西,越容易在关键时刻给人一刀。HTML 表单和字符实体,这两块内容单看教程都是几页纸的简单事,可真到了项目现场,乱码、& 被吃掉、表单提交前后端字段对不上,哪一个都能让人查半宿。尤其是当你同时处理表单数据和富文本内容时,字符实体那串&xxx;简直就是隐形炸弹。
这篇文章想把我实际处理过的 HTML 表单相关经验理一遍:从 form 的提交链路、控件命名,到 enctype、FormData、校验规则,再到字符实体的转义原理和常见组合坑。适合刚接触 html+css+js 基础语法的新手,也适合已经写过一阵子页面、想补系统认知的同学。我不会只列标签,更多是告诉你它们为什么会这样工作。
1. 表单不是一堆 input 标签的堆叠:先把 form 的提交链路拉通
很多初学的人会把表单理解成“页面上能输入的控件集合”。其实你把它拆开看,每个 input 确实是控件,但真正把数据带到服务端的,是外面那层 form 标签。浏览器里的一次普通 HTML 表单提交,本质上就是对 form 属性做一次封装,然后发起一个 HTTP 请求。
1.1 method 和 action:表单请求的第一段路
先说两个最基础的属性。action 是数据要发往的地址,method 是请求方法,最常用 GET 和 POST。form 上还可以设置 autocomplete、novalidate 等辅助属性,但主心骨永远是 action + method + 控件 name。
当 method 是 GET 时,浏览器会构建一串 query string:把所有有 name 的控件以 name=value 的形式跟在 action 后面,用?连接,多个参数用&连接。比如 action="/search" 里有个<input name="keyword">,你输入“表单”,最终请求 URL 就是/search?keyword=%E8%A1%A8%E5%8D%95。值里的特殊字符会做 URL 编码。当 method 是 POST 时,参数不会出现在地址栏,而是放在请求体 body 里,但控件照样需要 name,否则服务端连键名都拿不到。
实际项目里怎么选?我一般按这个标准判断:请求只是查询、结果可以被收藏或分享、重复刷新不会产生副作用,用 GET;会改变服务器状态的提交、上传文件、包含密码等敏感信息,用 POST。注意 POST 本身不加密,敏感字段还需要 HTTPS 配合才能保证传输过程安全。
这里有一个无数人踩过的点:form 里没写 action,浏览器会默认提交到当前页面地址。于是你常常看到“点了一个按钮,页面突然刷新”,但完全不知道是谁把数据发出去了。建议每个 form 都显式写清楚 action,哪怕当前页面做单页应用也一样,避免默认行为带来的隐性刷新。
| 对比点 | GET | POST |
|---|---|---|
| 参数位置 | URL 查询串 | 请求体 |
| 可缓存 | 可以 | 默认不会 |
| 历史记录 | 地址栏留下参数 | 参数不留在地址栏 |
| 典型场景 | 搜索、筛选、分页 | 登录、注册、提交数据、上传 |
另一个容易忽略的是提交按钮。form 里输入框按回车会自动提交,默认触发第一个type="submit"按钮的 click。如果你有多个按钮,比如“保存草稿”和“正式提交”,记得给非提交按钮写type="button",否则它待在 form 里就是一个隐形提交器,用户按个回车就把草稿发出去了。
1.2 控件的语义与 name 属性:数据键名才是关键
name是被提交字段的核心键名,但它和id、class的职责经常被搞混。id给 label 关联、给 JS 查找节点用;class给样式用;name才是在 HTTP 请求里作为参数名的那个。你可以用 id 定位一个输入框,但服务端不认识 id,只认识 name。缺了 name 的控件,无论用户填了什么,浏览器都不会把它带出页面。
常见控件里,text、password、email、number、url、tel 这类单行输入框,通过 value 提供提交值。checkbox 和 radio 的提交值必须显式用 value 指定,否则 checkbox 选中时提交的默认值就是字符串on,这个值对大多数后端接口来说都毫无意义。radio 要同一组互斥,靠的就是同名 name,name 不一致它们就各选各的。select 下拉框根据 option 的 value 提交,option 没写 value 才退而用文本内容。textarea 比较特殊,它的默认内容写在标签体里,而不是 value 属性。
还有一点被很多教程一笔带过:label 的 for 一定要对上 input 的 id。这不仅是给屏幕阅读器读的,移动端上可点击区域会明显变大,用户体验差别非常大。我见过不少表单一行一个 input 却没写 label,结果复选框小到需要用指甲去点。真正做表单的时候,label 不是可有可无的装饰,它是控件语义的一部分。
2. 数据离开表单之后:enctype、序列化、动态表单
表单数据离开页面后,浏览器要做的一件关键事情就是按 enctype 指定的方式编码请求体。这块知识平时写页面时用不太上,但一旦前后端字段对不上、接口报 400,或者文件上传莫名其妙失败,回头查 enctype 往往能救命。
2.1 enctype 三种模式,以及把我坑过的 axios 报文变化
form 的 enctype 属性有三种:application/x-www-form-urlencoded是默认值,会把字段拼成key1=value1&key2=value2的形式,并对特殊字符做百分号编码。multipart/form-data是文件上传时的选择,每个字段会变成独立分块,中间用 boundary 分隔,二进制文件也能稳定传输。text/plain基本只用于调试或特定协议,正规项目很少用。
这里有个热搜词背后很典型的坑:老项目用 jQuery 或者原生表单序列化,后端接口通常按表单模式取值;升级到 axios 之后,前端直接传普通对象,axios 默认会 JSON.stringify 并设置 Content-Type 为application/json。于是同一个接口,升级前浏览器发送的报文是key=value&key=value的表单格式,升级后变成一段 JSON 字符串。后端如果不支持解析 JSON,就会收到一个光秃秃的 body,所有字段全部取不到,看起来像是前端参数传丢了。
解决办法要么后端兼容 JSON,要么前端用URLSearchParams或qs.stringify把数据转回表单格式再提交。文件上传更简单:直接把FormData交给 axios,不要手动指定 Content-Type,浏览器会自动补上 boundary。
// 组件里用表单格式提交时,最稳的一招 const formData = new FormData() formData.append('username', this.username) formData.append('avatar', this.avatarFile) axios.post('/api/user/save', formData)2.2 FormData、清空表单与动态增删行
动态表单这里单独说一下,因为它和 FormData、reset 的关系很容易被误解。new FormData(form)可以直接把现有表单里的控件读一遍,生成表单数据对象;然后你还能用 append、set、delete 继续改字段,最后直接交给 fetch 或 axios。注意它只读取有 name 的控件,name 缺了就会自动跳过。
清空表单的经典误区是:form.reset()并不是清空,而是恢复到 HTML 里的默认值。如果某个输入框写了value="默认值",reset 会还原成那个默认值;脚本塞进去的值会被抹掉;JS 动态创建的行也不会消失。想真正清空,得自己遍历控件,把 input/textarea 的 value 设为空串、checkbox/radio 的 checked 设为 false、select 的 selectedIndex 设为 0。
现在前端项目里最常见的动态表单是用数据驱动渲染。以 Vue3 为例,典型做法是维护一个数组,v-for 循环生成每一行,添加时 push 一个空对象,删除时 splice 掉对应下标。我提炼过一个小模板,可以直接参考:
<script setup> const lines = ref([{ id: Date.now(), name: '', age: '' }]) function addLine() { lines.value.push({ id: Date.now(), name: '', age: '' }) } function removeLine(index) { lines.value.splice(index, 1) } </script> <template> <div v-for="(row, index) in lines" :key="row.id"> <input v-model="row.name" placeholder="姓名" /> <input v-model="row.age" placeholder="年龄" /> <button type="button" @click="removeLine(index)">删除</button> </div> <button type="button" @click="addLine">添加一行</button> </template>这里按钮的 type 必须显式写成"button",否则默认 submit 会把整个表单提交一遍,这也是我在第五部分会单独展开的坑。再往上走一步,就是表单引擎:把字段类型、校验规则、默认值配成 JSON schema,前端拿到 schema 映射成控件。市面上 Formily、React JSON Schema Form 这类方案都走这条路线。理解了“数据驱动渲染”的核心,再看表单引擎就不玄乎了。
3. 表单校验:体验归前端,底线归后端
表单校验我建议按这个顺序想:先看 HTML5 原生能力能不能解决,再考虑 JS,最后一定要考虑服务端。原生校验不是鸡肋,很多时候它能省掉几十行代码,而且移动端兼容性远比想象中好。
3.1 HTML5 原生校验:能少写 JS 就少写
required表示必填;minlength/maxlength约束文本长度;number 类型有min/max/step;pattern用一个正则表达式约束格式;type="email"和type="url"会自动校验格式。组合起来可以覆盖大部分基础场景。
移动端表单里,必填项不要只靠 placeholder 提示。placeholder 一聚焦就消失,读屏用户更是根本感知不到。正确做法是配合 label、required,再加上必要的 aria 描述。另外一个容易被忽略的属性是inputmode:想弹数字键盘用inputmode="decimal"或"numeric",想弹邮箱键盘就用type="email"。这比单纯在pattern里限制数字字符串更贴近真实输入场景。
关于maxlength,有个细节我踩过:它只对 text 和 textarea 有效,对type="number"无效,因为 number 本质是范围输入,不按字符长度走。想限制手机号位数,合理做法是type="tel"加 pattern,而不是 number。
HTML5 原生校验的触发时机是表单提交,也可以通过checkValidity()手动触发。在动态表格里新增一行时,不需要给每个控件单独写 oninput 事件,提交时统一校验即可。如果想实时反馈,可以用 CSS 伪类:invalid或:user-invalid做样式提示,但要注意:invalid在页面初始加载时也会匹配空值,容易让整个表单看起来一片红。:user-invalid的浏览器支持已经不错,建议优先使用。
3.2 自定义校验与安全兜底:setCustomValidity 和防滥用
真正需要 JS 的时候,一般是两种:规则组合不满足业务,比如两次密码输入是否一致;或者要做异步校验,比如用户名是否已被注册、验证码是否正确。标准姿势是在 form 上监听 submit 事件,先e.preventDefault()阻断默认提交,再做校验,通过后手动发起请求。
自定义提示用setCustomValidity(message)最方便。但有个坑:这个 API 设置的错误信息不会自动清除,导致你明明已经修正了输入,提交时气泡依然提示旧错误。所以每次校验前先调setCustomValidity('')把状态重置,再根据当前值决定要不要重新设置。表单对象本身可以调用checkValidity()做整体判定,用reportValidity()把错误气泡显示出来。
再往外一层,任何一个生产环境的表单都要默认“前端校验可以被绕开”。这不是说前端不重要,而是服务端必须再做一遍参数合法性校验,长度、类型、枚举值都不能轻信发送端。登录注册这类敏感表单还要加验证码、失败次数限制、CSRF token 之类的防护,因为只要接口暴露,就有人能批量构造请求来试探。真正常见的做法是后端做完整校验,登录场景加频率限制,核心操作加一次性 token。前端能做的只是体验层提示,安全底线永远要落在服务端。
4. 字符实体:&xxx; 背后的设计意图
字符实体,也叫 HTML entity,本质是一种转义语法:以&开头,以;结尾,中间是实体名或数字编号。HTML 解析器把<看成标签的开始,把>看成标签的结束,把&看成实体的开始。所以当你想在网页正文里写一句a < b && c > d,如果不转义,解析器就会按自己的规则猜测,结果经常不是你想要的。
4.1 实体和编码是两件事
先厘清一个常见误解:字符实体和文档编码是两回事。meta charset="utf-8"告诉浏览器“这个文档的字节按 UTF-8 解码”,而<是一种与具体编码无关的写法,写进任何编码的文档里都代表小于号。UTF-8 本身能直接表示中文,所以中文不需要写成中 文那种数字实体;只有那些在 HTML 语法里有特殊含义的字符,比如<、>、&、引号、空格控制等,才需要认真考虑转义。
实体的形式可以是名字实体,比如&、©;也可以是数字实体,比如©或者十六进制©。HTML5 里定义的命名字体大概有两千多个,日常用到的其实就那么一二十个,没必要全背,但高频那组必须烂熟。
4.2 高频实体表与使用禁忌
日常开发里常用实体整理成一张表,建议收藏备用:
| 显示字符 | 实体写法 | 什么时候用 |
|---|---|---|
| & | & | 文本出现 & 时,防止后面连字母被误读为实体 |
| < | < | 正文要展示小于号 |
| > | > | 正文要展示大于号,很多编辑器会顺手转 |
| 空格 | | 需要非断行空格时;连续空格排版优先用 CSS |
| “ | " | 双引号出现在属性值内,或需要严格保留语义时 |
| ' | ' | 单引号;老环境不认可用'替代 |
| © | © | 版权符号 |
| ® | ® | 商标注册符号 |
| — | — | em dash,英文排版常用 |
| … | … | 省略号 |
| × | × | 乘号,也常用来做关闭按钮符号 |
用实体的时候有三个禁忌。第一,分号不能省。HTML4 里确实有些实体不写分号也能解析,但 HTML5 规范明确要求带分号,而且省略分号的规则很容易让后面的字母或数字被吞进实体名里,形成难以排查的脏数据。第二,不要在 CSS 的content属性和 JavaScript 字符串里使用实体,它们要的是字符本身,实体在那两个环境不会被解析。第三,连续空格不要用一串 撑排版。以前布局手段少,大家习惯用空格大法;现在white-space、flex 的 gap、grid 布局都成熟了,非断行空格只应该用在它真正该用的地方,比如英文姓名之间、数字和单位之间。
4.3 用户输入转义、HTML 转 Markdown 和邮件 HTML
真正容易出事的是动态内容的转义。用户输入是自由文本,里面可能带<、>、&、引号;如果直接把字符串拼进 innerHTML,等于把用户内容交给解析器“重新理解”。安全做法是用textContent插入文本节点,或者依赖 Vue、React 默认的插值转义。服务端模板的自动转义也要保持开启,别为了省事在页面上渲染原始 HTML。
HTML 转 Markdown 时,字符实体反过来要解码。大多数 HTML 解析器在构建 DOM 时已经把实体还原成字符了,所以 html2md 这类库输出的文本里&会变回&。如果你自己写解析器,注意顺序:先把实体解码,再去处理标签。别反过来先剥离标签,否则<script>这种被转义过的字符串会被误判成真实标签,内容直接丢光,甚至产生安全问题。
HTML 邮件里字符实体也有一席之地。邮件客户端大多是又老又封闭的渲染环境,对 HTML/CSS 的支持参差不齐,但实体这种最基础的语法基本都能识别。发送 HTML 邮件模板时,把&、<、>转义好,中文用 UTF-8,遇到特殊符号尽量用保守的常见实体,不要赌收件人客户端能支持最新命名字符集。这是我在做过几版邮件模板之后最深的感受,宁可用表格布局加最基础的实体,也不要搞花活。
5. 我在实战中踩过的几个“表单 + 字符实体”组合坑
如果说前四部分是地图,这一部分就是我在同一条路上摔过的坑。表单和字符实体分开看都还好理解,组合在一起之后,坑点经常出现在你没预料到的边界上。
第一个组合坑来自富文本内容。用户在前台编辑框里输入了一个标题,内容是Tom & Jerry,如果后端在某个页面上用 innerHTML 把它塞进 DOM,浏览器解析到&后面没跟实体名,通常还能原样显示。但用户一旦输入© 2025,浏览器就会给你变成© 2025,内容被吃掉一块还算轻的,真在属性值里发生这种情况,可能导致整个元素结构错乱。我的习惯是:所有用户内容进页面一律先转义,这个规则不能靠浏览器的好运气兜底。
第二个坑是 URL 里的&。在 HTML 源码里写一个跳转链接href="https://example.com/search?q=html&page=2",规范的写法应该是&而不是裸&,因为裸&在 HTML 里不合法。浏览器虽然经常容忍,但遇到后面的参数名不巧是copy之类的,解析结果就会变得很诡异。注意这只是源代码层面的转义。浏览器拿到 DOM 之后,href 属性其实已经是真实的&,页面跳转发出的请求也是?q=html&page=2,后端不会收到字符串&。
很多人以为后端收到的是&,那是把“HTML 文档里的写法”和“网络传输中的真实值”混为一谈了。真正在 URL 里传特殊字符时,用的是 URL 编码,比如空格写成%20、&写成%26,跟 HTML 实体完全是两码事。一个经验判断法:看到&,说明是在改 HTML 源码;看到%26,说明是在处理地址栏。两者不要互相替代。
第三个坑和 textarea 初始内容有关。我用服务端模板渲染一个编辑表单,后台数据是一段含<br>的文本,直接拼在<textarea>标签体里,结果页面上的 textarea 内容显示不完整。原因就是<被当成标签开始。正确做法是:动态内容在进入 HTML 之前做实体转义,或者用 JS 往textarea.value里赋值。后者不会经过实体解析,反而最省心。
第四个坑也是我前面反复强调的:form 里的动态行删除按钮。给动态表单每一行加“删除”,如果没写 type,删除按钮就是 submit,点击后先触发 HTML5 校验,再提交页面。表现就是某个必填项填了半天,一点删除却弹校验气泡,或者页面直接刷一下跳走。所有 form 内的非提交按钮,写代码时都请顺手带上type="button",把这个动作变成肌肉记忆。
最后分享一个我现在写代码的习惯清单,也算这篇内容的高度浓缩:只要是把数据往 HTML 里放,先问一句“这里有没有转义”;只要是 form 里的按钮,先看 type 是否显式声明;只要有人提单说“表单清空没生效”,先确认他是不是用了 reset;只要前后端字段对不上,先打开控制台看请求报文到底是表单格式还是 JSON。这几个习惯帮我挡掉了太多无意义的排查,你可以直接抄走。