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 给覆盖掉哦. 更好的方式是用 QueryHelpersQueryBuilder 做一个完整的 

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)
    {

    }
}
View Code

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 的正则验证

rangeOverflowrangeUnderflow 对应 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 (参考上面的链接, 这个动作很大, 不推荐)

 

posted @ 2022-03-16 10:55  兴杰  阅读(644)  评论(0编辑  收藏  举报