HTML – Native 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">Submitbutton>
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, 如果 post to other page 需要加多一个 attribute asp-antiforgery, 不然会报错 400 error 哦, 详情可以看这篇 

<form method="post" action="/other-page" asp-antiforgery="true">

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>Submitbutton>
<button type="submit">Submitbutton>
<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

这个不顺风随, 比较好的做法是用 select 让用户选择提交类型, 而不是使用 button 作为类型. 但我还是给个例子

<form method="post">
  <input type="text" name="dataName">
  <button type="submit" name="deleteType" value="SoftDelete">Soft Delete Databutton>
  <button type="submit" name="deleteType" value="HardDelete">Hard Delete Databutton>
form>

在 button 声明 name 和 value, 用户点击后, 被点击的那一个 submit value 会被放入信息中, 一起发送出去

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. 小总结

必须去手动设置 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 丢进去就可以了.

要添加额外的信息也可以

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 Attachments { get; set; } = new List();
}
public class EnquiryController
{
    [HttpPost("api/create-enquiry")]
    public void CreateEnquiry([FromForm] CreateEnquiryData createEnquiryData)
    {

    }
}

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 把它关掉, 也可以直接在 把所有的关掉.

readonly & disabled 这 2 个共同点是让用户只能看无法修改, 比较大的区别是 readonly 的信息会提交到服务端, 而 disabled 则不会. (注: select 是没有 readonly 的哦)

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.

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;

select

<select name="cars">
  <option value="">--Select--option>
  <optgroup label="Swedish Cars">
    <option value="volvo">Volvooption>
    <option value="saab">Saaboption>
  optgroup>
  <optgroup label="German Cars">
    <option value="mercedes">Mercedesoption>
    <option value="audi">Audioption>
  optgroup>
select>

select 有几个局限, 所以不是很好用, 如果内容不多建议用 radio group 替代.

1. --select-- 由于它不能 cancel 所以只能通过一个 select empty 让用户清空.

2. value must be string, 不接受 null, number

3. multiple 在 PC 很丑, 手机还可以

4. search 体验差

radio group

  <input type="radio" id="html" name="fav_language" value="HTML">
  <label for="html">HTMLlabel><br>
  <input type="radio" id="css" name="fav_language" value="CSS">
  <label for="css">CSSlabel><br>
  <input type="radio" id="javascript" name="fav_language" value="JavaScript">
  <label for="javascript">JavaScriptlabel>

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 Informationh2>
    
  section>
  <section>
    <h2>Contact Informationh2>
    
  section>
  <p><button type="submit">Submitbutton>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>Titlelegend>
  <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 的正则验证

rangeOverflowrangeUnderflow 对应 min, max (date or number)

stepMismatch 对应 type="number" step="20" value="18"

tooLong 和 tooShort 对应 maxlength 和 minlength

typeMismatch 对应 type="email"

valueMission 对应 required

奇葩现象

有时候 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');

相关