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 发送, 下面会说到细节.

注:如果 form 里面有 invalid 的 accessor,那 submit 就不会触发,因为会 popup error。

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 或 field-set

<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 等等。

invalid event

input.addEventListener('invalid', () => console.log('invalid'));

accessor 可以监听 invalid 事件,每当 checkValidity invalid 时它就会触发。

需要特别注意的是,这个事件不会冒泡。

如果我们要在 form 一次监听所有 accessor 任何一个的 invalid,需要使用 capture 机制

form.addEventListener('invalid', e => console.log('invalid', e.target), { capture: true });

如果我们在 invalid 事件中 preventDefault(),它可以阻止浏览器后续的 reportValidity()。

比如说,user 点击 submit button,浏览器会 for loop accessor 调用 checkValidity,如果有 invalid 它就 reportValidity,这时就会 scroll to 这个 accessor 并且 popup error message。

我们拦截 invalid event 就可以阻止掉后续的 reportValidity scroll to element popup error message。

 

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  兴杰  阅读(742)  评论(0)    收藏  举报