HTML – Native Form 原生表单功能集
前言
以前写过 form 表单, 但很不齐全, 这篇想做一个大整理. 主要讲讲在网站中使用原生 Form 的功能, 不足和扩展.
前端是原生的 HTML/JS, 后端是 ASP.NET Core Razor Pages.
Simplest Form Overview
form 的职责是让 user 可以把信息传递到服务端. 常见的使用场景是 contact / enquiry form.
结构大概长这样
<form method="post"> <input type="text" name="username"> <button type="submit">Submit</button> </form>
一个 form tag 把所有信息包裹在里面
input text 作为 accessor 信息读写器. 还有很多种 accessor 用来输出输入不同种类的信息. 后面会详细讲到.
一个 submit button.
ASP.NET Core
public class FormData { public string Username { get; set; } = ""; } public class IndexModel : PageModel { public void OnGet() { } public void OnPost([FromForm] FormData formData) { var value = formData.Username; } }
一个对象从 form 获取信息, 然后就可以做各做操作了, 比如存入数据库, 发电邮等等.
Form Attribute
form 有 3 个常用的 attribute
method
有 3 种 get, post, dialog
get 我没有用过, 也不知道什么时候会用到.
post 是每次用的
dialog 很新, Safari 15.4 (14-03-2022) 才支持, 我没有用过这篇就不介绍了 (看这篇)
action
action 用来声明 post 去服务端的地址. 没有填写的话就是和当前页面相同地址.
比如 current url : /contact, 那么就是 post to /contact
Razor Pages 注意事项
在 Razor Pages, 如果 post to other page 需要加多一个 attribute asp-antiforgery, 不然会报错 400 error 哦, 详情可以看这篇 ASP.NET Core – CSRF
<form method="post" action="/other-page" asp-antiforgery="true">
在 Razor Pages, 如果 post to same page 但不同 method 的话, 要添加 query params handler
<form method="post" action="?Handler=ExternalLogin" asp-antiforgery="true">
注: 直接添加 handler 会把其它 query params 给覆盖掉哦. 更好的方式是用 QueryHelpers 和 QueryBuilder 做一个完整的
enctype
声明 form 的格式, 类似 Content-Type
application/x-www-form-urlencoded (默认): 信息会以 key-value encodeURIComponent 方式发送出去, 这个格式不支持文件上传哦.
multipart/form-data: 想上传文件就要用这个 (即使没有文件也是可以用的, 只是会有一些多余的信息, 比如分隔符号, 那个是为了 upload file 才需要的)
例子
<form action="/" method="post" enctype="multipart/form-data" asp-antiforgery="true"> <input type="text" name="username"> <input type="file" name="attachment"> <input type="submit" value="Submit"> </form>
ASP.Net Core
public class FormData { public string Username { get; set; } = ""; public IFormFile Attachment { get; set; } = null!; } public class IndexModel : PageModel { public void OnGet() { } public void OnPost([FromForm] FormData formData) { var value = formData.Username; var fileSize = formData.Attachment.Length; } }
Form Submission
Submit Button
<button>Submit</button> <button type="submit">Submit</button> <input type="submit" value="Submit">
这 3 个效果是一样的.
button 默认的 type 就是 submit, 所以 1 和 2 是一样的.
input:submit 和 button 也是一样的, style 都一样.
通常我会用第 2 个. 比较明确.
multiple button in form
复杂的 form 里面会有多个 button 出现. 但通常只会有一个用来 submit.
所以其余的记得要写上 type="button".
input enter trigger submit button click
在任何一个 input (accessor) 按 enter 键, 游览器会找到 form 里面第一个 submit button (type=submit / image / no defined) 点击.
multiple submit button
一个常见的例子是让用户做简单的选择提交. 比如 external login.
<form method="post"> <button type="submit" name="provider" value="Google">Login with Google</button> <button type="submit" name="provider" value="Microsoft">Login with Microsoft Account</button> </form>
在 button 声明 name 和 value, 用户点击后, 被点击的那一个 submit value 会被放入信息中, 一起发送出去
还有一个用法是这样
<form method="post"> <input type="text" name="dataName"> <button type="submit" name="deleteType" value="SoftDelete">Soft Delete Data</button> <button type="submit" name="deleteType" value="HardDelete">Hard Delete Data</button> </form>
这个比较少见. 而且交互有点复杂. 不鼓励使用.
ASP.NET Core
public enum DeleteType { SoftDelete, HardDelete } public class FormData { public string? DataName { get; set; } public DeleteType? DeleteType { get; set; } } public class IndexModel : PageModel { public void OnGet() { } public void OnPost([FromForm] FormData formData) { } }
listen to submission event
通过 JS 可以拦截 submit event
document.querySelector("form").addEventListener("submit", (e) => { e.preventDefault(); alert("submit"); });
通过 preventDefault 可以阻止游览器发送到服务端. 通常目的是想改成使用 Ajax 发送, 下面会说到细节.
JS trigger submission
document.querySelector("form").submit(); document.querySelector("form").dispatchEvent(new Event("submit"));
.submit() 不会触发 submission event, 它会直接发送到服务端.
相反, dispatch 只会触发 submit event, 而不会发送到服务端 (不管有没有 prevent default).
所以 2 个的用法是不同的哦, 要依据场景来运用. 一般上 Ajax Form 会用 dispatchEvent, 刷新 form 会用 .submit().
Ajax Form
所谓 Ajax Form 就是替代原本的 form submit 刷新体验, 改成通过 Ajax 发送 form 的信息.
XMLHttpRequest
这是平常用来发 Ajax 的方式, Content-Type: application/json
var request = new XMLHttpRequest(); request.onreadystatechange = () => { if (request.readyState == 4 && request.status == 200) { alert("done"); } }; request.open("POST", "/api/create-enquiry"); request.setRequestHeader("Content-Type", "application/json"); const dataJson = JSON.stringify({ username: "Derrick" }); request.send(dataJson);
ASP.NET Core
public class CreateEnquiryData { public string Username { get; set; } = ""; } public class EnquiryController { [HttpPost("api/create-enquiry")] public void CreateEnquiry([FromBody] CreateEnquiryData createEnquiryData) { } }
form 的话, 它不是 JSON
request.open("POST", "/api/create-enquiry"); const searchParams = new URLSearchParams({ username: "Derrick" }); request.send(searchParams);
直接发送 URLSearchParams 相等于是 application/x-www-form-urlencoded.
multipart/form-data 则是发送 FormData
request.open("POST", "/api/create-enquiry"); const formData = new FormData(); formData.append("username", "Derrick"); request.send(formData);
注:
1.在发送 URLSearchParams 和 FormData 时, 不要去设置 Content-Type Header, 游览器会依据类型自动设定好. 诺我们自己去设置反而会破坏掉游览器的机制, 比如 FormData 会自动创建分隔符, 如果手动设置 Content-Type 则不会.
2.JSON 则需要手动设置 Content-Type, 不然会是 text/plain.
3. 小总结
<form> 必须去手动设置 application/x-www-form-urlencoded 或者 multipart/form-data
JSON 必须手动设置 application/json
request.send(formData or searchParams) 不要手动设置, 让游览器自己判断.
FormData From Form Tag
const formData = new FormData(document.querySelector("form"));
直接把 element 丢进去就可以了. (注: disabled 的 input 会自动过滤掉, 不会放入 FormData 的 key value 里)
要添加额外的信息也可以
formData.append('extraInfo', 'value');
有一种 upload 体验是这样的
它的原理是 HTML file 不要放 name, 这样它就排除在 submit 信息里
然后用 JS 去拦截它, 存取来
const files = []; document.querySelector('input[type="file"]').addEventListener("input", (e) => { files.push(e.target.files[0]); // save the file });
最后, 通过 extra info 的方式 append 进去 formData
const formData = new FormData(document.querySelector("form")); for (const file of files) { formData.append("Attachments", file); } request.send(formData);
ASP.NET Core
public class CreateEnquiryData { public string Username { get; set; } = ""; public List<IFormFile> Attachments { get; set; } = new List<IFormFile>(); } public class EnquiryController { [HttpPost("api/create-enquiry")] public void CreateEnquiry([FromForm] CreateEnquiryData createEnquiryData) { } }
FormData value
FormData 的 value 只能是 string | File
通常服务端会 convert from string, 比如 parse string number, parse string date
另外, ASP.NET Core parse boolean 是 "True", "False", 而不是 "0", "1" 哦.
Accessor
原生 form accessor 虽然挺多的, 但是一般上网站的 form 不会太复杂 (不像 control panel), 所以常用到的就那几个.
input: text, email, number, date, checkbox, file
textarea, select
radio group
checkbox group
input text
<input type="text" name="username" autofocus autocomplete="on" readonly disabled placeholder="e.g. example.com">
排除 validation attribute (下面会详细讲 validation part), 常用的 attribute 有
autofocus 进入页面后自动 focus 到当前的 input
autocomplete 游览器会缓存之前填写过的记入, name, phone, email, address 都会, 如果不希望这样就通过 off 把它关掉, 也可以直接在 <form autocomplete="off" > 把所有的关掉.
readonly & disabled
这 2 个共同点是让用户只能看无法修改, 比较大的区别是 readonly 的信息会提交到服务端, 而 disabled 则不会. (注: select 是没有 readonly 的哦).
另外 CSS display none 只能让元素看不到, 依然会提交到服务端哦, 只有 disabled 或者把 name 去掉才能阻止提交.
placeholder 用来写提示的
maxlength 通过 UI 限制 value length, 注: 它不是 validation 哦, 如果通过 JS input.value = 'value' 是可以超过 max length 的, 而且 submit 也不会有 validation error.
size 用来设置 input 的 width, size="4" 不等于 style: 4ch 哦, 游览器有自己的算法, 大约是 8ch.
input text + datalist
类似 autocomplete 的效果, 但是资料由自己设定而不是游览器缓存.
<input list="browsers"> <datalist id="browsers"> <option value="Internet Explorer"> <option value="Firefox"> <option value="Chrome"> <option value="Opera"> <option value="Safari"> </datalist>
input email
email 的特色就是自动加了一个 email 的 validation, 其余的和 text 一样.
input number
number 限制了 value 只能是数字, a-z, 符号都不能输入, JS input.value 也输入不了. 但它接收 e, 因为这个是合格的数字, 但大部分情况业务是不允许 e 的.
右边多了一个上下箭头, 可以 increase 和 decrease value, 很方便
step 设置上下箭头按一下 +- 多少. 比如 step="30" 那么按 2 下加 value 就是 60.
input date
min, max 限制 UI 不能选大过或效果 min, max, 同时 validation 不允许大过或小过 min, max.
注: 限制往往有 2 种方式, 一种是 block from UI, 操作上无法输入不允许的值, 另一种是 validation, 可以输入, 但是提交的时候会 error.
注: date format 是 yyyy-mm-dd 只支持一种 format, 输入其它的会被无视. 参考这篇
input file
multiple 允许一次 select 多个文件
accept 支持的类型, 常用的有:
<input type="file" name="attachment" accept="image/png, image/jpeg, image/*, .sql">
用逗号做分隔符 (可以放空格, 比较好看, 它会清掉的), 也可以写 extension 哦
textarea
<textarea name="text" cols="20" rows="2"></textarea>
rows, cols 类似 input 的 size, 用来控制 width, height
textarea 默认是可以 resize 的, 想关掉可以通过 CSS style resize:none; 或者 vertical / horizontal 表示只允许某一边 resize.
select
<select name="cars"> <option value="">--Select--</option> <optgroup label="Swedish Cars"> <option value="volvo">Volvo</option> <option value="saab">Saab</option> </optgroup> <optgroup label="German Cars"> <option value="mercedes">Mercedes</option> <option value="audi">Audi</option> </optgroup> </select>
select 有几个局限, 所以不是很好用, 如果内容不多建议用 radio group 替代.
1. --select-- 由于它不能 cancel 所以只能通过一个 select empty 让用户清空.
2. value must be string, 不接受 null, number
3. multiple 在 PC 很丑, 手机还可以
4. search 体验差
checkbox
<input type="checkbox" name="rememberMe" value="True" checked>
for ASP.NET Core value 放 "True" 去到 C# 会 binding 成 bool. 当 checked 时, 它才会有 key 传到服务器. 所以服务端不可以 set default true 哦.
radio group
<input type="radio" id="html" name="fav_language" value="HTML"> <label for="html">HTML</label><br> <input type="radio" id="css" name="fav_language" value="CSS"> <label for="css">CSS</label><br> <input type="radio" id="javascript" name="fav_language" value="JavaScript"> <label for="javascript">JavaScript</label>
same name 表示 same group, 最终被选中的 radio 会成为唯一的 fav_language 值
checkbox group
<input type="checkbox" name="phones" value="iPhone"> <input type="checkbox" name="phones" value="HuaWei">
当多选时, 最终的会有多个 phones key-vlaue, 游览器其实并没有正真的 checkbox group, 它只是允许放重复名字的 checkbox 而已.
ASP.NET Core 会把这些放入 List 中
public class FormData { public List<string> Phones { get; set; } = new List<string>(); }
Semantic HTML
参考: MDN – How to structure a web form
所有内容包裹在 form 里, form 不要嵌套 (以前好像 ok, 现在不鼓励了)
<form></form>
分组使用 section
<form> <section> <h2>Contact Information</h2> <!-- accessor here --> </section> <section> <h2>Contact Information</h2> <!-- accessor here --> </section> <p><button type="submit">Submit</button></p> </form>
submit button 用 p 抱着. 如果没有分组则可以不需要 section (不过我觉得 p > button 感觉怪怪的)
accessor 和 label 用 p 包裹 (也有人用 div 或者 ul > li 来包, Exabyte 是用 p 哦)
参考: stackoverflow1 和 stackoverflow2
<p> <label for="number"> <span>Card number:</span> <strong><abbr title="required">*</abbr></strong> </label> <input type="tel" id="number" name="cardnumber"> </p>
input, select 做法一样.
radio, checkbox list 用 fieldset > ul > li
<fieldset> <legend>Title</legend> <ul> <li> <label for="title_1"> <input type="radio" id="title_1" name="title" value="K"> King </label> </li> <li> <label for="title_2"> <input type="radio" id="title_2" name="title" value="Q"> Queen </label> </li> <li> <label for="title_3"> <input type="radio" id="title_3" name="title" value="J"> Joker </label> </li> </ul> </fieldset>
fieldset 长这样, 一个大筐筐加一个 legend title
Validation
参考: W3Schools – JavaScript Validation API
Overview
validation 的玩法大概是这样:
1. 声明条件, 比如在 input 写上 required, pattern="\d" type="number | email" 这些都是条件.
2. 游览器会依据条件来限制用户的输入, 比如 type="number" keydown 不接受 a-z (除了 e) 和符号.
3. 除了阻止用户输入, 另一种方式是 popup error message 告诉用户虽然你成功输入了值, 但值我不接受请你修改, 不然就无法提交.
虽然游览器 build-in 了许多条件, 限制,验证 error, 但依然满足不了所有的需求, 所以我们可以通过 JS 去完善它.
manual trigger validation
虽然可以 manual trigger, 但有些时候会失灵, 比如 minlength, maxlength 只能通过 UI 才会有 error (挺奇葩的)
const valid = input.checkValidity(); // boolean
get error message
console.log(input.validationMessage);
set error or error message
不管是 custom error 还是想修改原本的 error message 都可以用这个接口
input.setCustomValidity('error message');
popup error message
input.reportValidity();
check validation state
console.log('input.validity', input.validity); // validation info
所有条件都在这里了
interface ValidityState { readonly badInput: boolean; readonly customError: boolean; readonly patternMismatch: boolean; readonly rangeOverflow: boolean; readonly rangeUnderflow: boolean; readonly stepMismatch: boolean; readonly tooLong: boolean; readonly tooShort: boolean; readonly typeMismatch: boolean; readonly valid: boolean; readonly valueMissing: boolean; }
badInput input type="number" UI 输入 e 就会是 badInput. 但是 input.value = 'e' 这样是输入不到值的哦, 只有通过 UI 才可以输入 e.
customError 如果有调用 setCustomValidity 就会是 true
patternMismatch 对应 pattern=“\d” input text 的正则验证
rangeOverflow 和 rangeUnderflow 对应 min, max (date or number)
stepMismatch 对应 type="number" step="20" value="18"
tooLong 和 tooShort 对应 maxlength 和 minlength
typeMismatch 对应 type="email"
valueMissing 对应 required。
注: badInput 和 valueMissing 是可能同时发生的,比如 input type number 输入 'e'
由于格式不对,所以 badInput = true,同时格式不对会导致 value = empty,所以 valueMissing 也等于 true。
valid 表示是否全部条件都满足,valid 和 checkValidity 的区别是 checkValidity 会 trigger invalid event,而 valid 只是一个属性。
注: 这些 value 都是 live 的哦, 并不需要调用 checkValidity()。比如我突然 required = true; valueMissing 会立马变成 true,valid 会立马变成 false。
奇葩现象
有时候 validation 会失灵, 唯一确保它可以跑的方式是通过 UI 去操作. JS 输入值会有许多奇葩现象:
JS 无法输入 e
const input = document.createElement('input'); input.type = 'number'; input.value = 'e'; console.log(input.value); // '' console.log(input.valueAsNumber); // 'NaN'
UI 是可以输入 e 的, 但是会 badInput
stepMismatch 完全正常
const input = document.createElement('input'); input.type = 'number'; input.step = '20'; input.value = '19'; console.log('validity', input.validity.stepMismatch); // true
甚至不需要调用 checkValidity
typeMismatch 完全正常
const input = document.createElement('input'); input.type = 'email'; input.value = 'abc'; console.log('validity', input.validity.typeMismatch); // true
tooShort, tooLong 完成失灵
const input = document.createElement('input'); input.type = 'text'; input.minLength = 3; input.maxLength = 5; input.value = 'a'; console.log('validity', input.validity.tooShort); // false input.value = '123456'; console.log('validity', input.validity.tooLong); // false input.checkValidity(); console.log(input.value); // 123456 console.log('validity', input.validity.tooLong); // false
如果通过 UI 操作的话是可以弄出 error 的.
替换 error message
原生的 error message 有 3 个大问题,
language problem
它依据游览器的设置来提供语言, 但通常网站的语言是通过 URL 控制的.
consistency problem
不同游览器出的 error message 是不同的. 统一管理会比较方便
checkbox group no build-in
上面有提到, checkbox list 是没有 build-in 的, 它只是没有阻止你 multiple same name 而已
如果想做一个 required 它就不会像 radio group 那样. 所以只能自己处理.
Bootstrap 有 2 种 validation, 一种是原生, 一种是完全用它的 custom
但它的原生也是支持 set error message 的. 由此推断这是很 popular 的需求.
拦截 input event 然后判断当前 validity state 再通过 setCustomValidaity 替换掉 message.
input.setCustomValidity('error message');
注: 如果遇到 conditional validation,那还需要用到 MutationObserver 监听 attribute 变化,比如 required 等等。
ASP.NET Core form string empty become null
踩到一个坑, 前端 submit form string value = '' empty string, 后端接收以后变成了 null
原来是 [FromForm] 搞的鬼, JSON 则不会.
参考:
Asp.Net Core Model binding, how to get empty field to bind as a blank string?
Handling multipart requests with JSON and file uploads in ASP.NET Core
ConvertEmptyStringToNull stopped being honored after upgrading from RC1
2 个 solution, 一个是在 property 上面加 attribute
public class FormData { [DisplayFormat(ConvertEmptyStringToNull = false)] public string Name { get; set; } = ""; }
另一个是在 Program.cs 配置 MvcOptions (参考上面的链接, 这个动作很大, 不推荐)