从 Form 表单到 Ajax

平时的前端开发中基本上都是使用 XMLHttpRequestfetch 来发送 HTTP 请求,提交表单数据,很少使用 <form> 元素自身的 action/method/enctype 等属性。本文让我们来回顾一下这两种写法。

1 通过 <form> 元素提交表单

1.1 基本用法

<form
  action="http://localhost:8080/api/login"
  method="post"
  enctype="application/x-www-form-urlencoded"
  target="_self"
>
  <div>
    <label>姓名</label>
    <input type="text" name="name" placeholder="请输入姓名" />
  </div>
  <div>
    <label>密码</label>
    <input type="password" name="password" placeholder="请输入密码" />
  </div>
  <button type="submit">提交</button>
</form>
  • action 表单提交的 URL,默认为当前页面地址,表单提交后会跳转到该地址
    • action="URL" 提交到 URL
    • action="" 提交到当前页面地址
    • action="#" 提交到当前页面地址+#(当然,实际上#号后的内容并不会被提交到服务端)
    • action="javascript:;" 提交时避免跳转,类似 <a href="javascript:;">
    • action="javascript:void(0);" 提交时避免跳转,类似 <a href="javascript:void(0);">
  • method 提交时使用的 HTTP 请求方法
    • get 表单数据会被追加到 action 对应的 URL 后(<URL>?<Query String Parameters>
    • post 表单数据会被作为请求体发送
  • enctype 表单提交时的 Content-Typemethod 需要设置为 post
    • application/x-www-form-urlencoded 默认值,提交的内容会经过 URL 编码处理
    • multipart/form-data 上传文件时使用
    • text/plain 调试时使用
  • target 用于指定表单提交后在哪儿展示响应内容
    • _self 默认值,默认在当前浏览器上下文(页面)展示
    • _blank 在新的浏览器上下文(标签页)中展示
    • _parent 在父级浏览器上下文中展示,如果没有,则相当于 _self
    • _top 在顶级浏览器上下文中展示,如果没有,则相当于 _self
    • 除此以外,还可以是 window 或者 <iframe>name(见 form - MDNiframe - MDN

<form> 的优势在于是纯 HTML,即使用户禁用浏览器的 JavaScript 功能,依然可以正常提交表单。

1.2 特殊用法一

<form> 的一个特殊用处是实现搜索框时让手机输入法软键盘的回车键显示为“搜索”(必要条件:<form action="url"><input type="search" />)。

<form action="http://localhost:8080/api/search" id="my-form">
  <input type="search" name="keywords" placeholder="搜索" />
  <button type="submit">搜索</button>
</form>

注意,表单提交时,默认会跳转到 action 对应地址,可以监听 submit 事件,通过调用 preventDefault() 阻止该行为。

document.getElementById("my-form").addEventListener("submit", function(event) {
  event.preventDefault();
  return false;
});

谨慎使用如下形式来避免自动跳转:

<form action="javascript:void(0);">
  <input type="search" name="keywords" placeholder="搜索" />
  <button type="submit">搜索</button>
</form>

这种写法在 React 中,可能会被取消支持。

Warning: A future version of React will block javascript: URLs as a security precaution. Use event handlers instead if you can. If you need to generate unsafe HTML try using dangerouslySetInnerHTML instead. React was passed "javascript:void(0);".

更值得注意的是,在 iOS 系统上,使用 action="javascript:void(0);" 或其他等价写法,会产生一次多余的不可见的跳转(具体现象是需要点击两次搜索按钮才触发事件),所以更推荐使用事件处理函数。

其他示例
<!-- 普通 div 容器 -->
<div>
  <h2>div 容器</h1>
  <p>键盘回车键显示 return (换行)</p>
  <div>
    <label>type="text"</label>
    <input type="text" placeholder="请输入文字">
  </div>
  <div>
    <label>type="number"</label>
    <input type="number" placeholder="请输入数字">
  </div>
  <div>
    <label>type="search"</label>
    <input type="search" placeholder="搜索">
  </div>
</div>

<!-- form 容器 -->
<form action="javascript:;">
  <h2>form 容器,配合 action 属性(必需)</h1>
  <p>键盘回车键显示 go (前往), search (搜索)</p>
  <div>
    <label>type="text"</label>
    <input type="text" placeholder="请输入文字">
  </div>
  <div>
    <label>type="number"</label>
    <input type="number" placeholder="请输入数字">
  </div>
  <div>
    <label>type="search"</label>
    <input type="search" placeholder="搜索">
  </div>
  <div>
    <button type="submit">提交</button>
  </div>
</form>

1.3 特殊用法二

<form> 的另一个特殊用处是实现通过 POST 方法打开页面。

<form action="http://localhost:8080/posts" method="post" id="my-form">
  <input type="hidden" name="name" value="name" />
  <input type="hidden" name="password" value="password" />
</form>
<button type="button" id="my-btn">打开一个只能通过 POST 方法访问的页面</button>
document.getElementById('my-btn').addEventListener('click', function() {
  document.getElementById('my-form').submit();
});

简单封装一下,动态创建 <form> 和隐藏的 <input>,便可以像发送 HTTP 请求一样打开页面。

interface NavigateConfig {
  /** 跳转地址 */
  url?: string;
  /** 请求方法 */
  method?: 'get' | 'post';
  /** 参数 */
  params?: {[key: string]: any};
  /** 打开方式 */
  target?: '_self' | '_blank' | '_parent' | '_top';
}

/**
 * 页面跳转
 * @param config - 页面跳转配置
 * @param config.url - 跳转地址
 * @param config.method - 请求方法
 * @param config.params - 参数
 * @param config.target - 打开方式
 */
function navigateTo(config: NavigateConfig) {
  const {
    url = window.location.href,
    method = 'post',
    params = {},
    target = '_self'
  } = { ...config };
  const inputGroup = document.createDocumentFragment();
  const form = document.createElement('form');

  Object.keys(params || {}).forEach((key) => {
    const input = document.createElement('input');
    input.type = 'hidden';
    input.name = key;
    input.value = params[key];
    inputGroup.appendChild(input);
  });

  form.action = url;
  form.method = method;
  form.target = target;
  form.appendChild(inputGroup);
  document.body.appendChild(form);

  form.submit();
}

navigateTo({
  url: 'http://localhost:8080/posts',
  method: 'post',
  params: {
    name: '阿里巴巴',
    password: '芝麻开门'
  }
});

2 通过 Ajax 提交表单

2.1 <form> 表单和 Ajax 的关系

传统的 <form> 表单在提交时会刷新页面或者跳转到新页面,所以 XMLHttpRequest 才有机会得到广泛应用。 后来 XMLHttpRequest Level 2 标准中新增 FormData 对象,使得通过 Ajax 也可以实现文件上传,<form> 的功能都可以借助 Ajax 实现。

以下是一个使用 XMLHttpRequest 的示例:

const formData = new FormData();
formData.append("name", name);
formData.append("password", password);
formData.append("avatar", avatarInputElement.files[0]);

const request = new XMLHttpRequest();
request.open("POST", "http://localhost:8080/api/login");
request.send(formData);

在 2020 年这个时间点,主流已经是使用 Fetch API,为了简洁,以下代码示例跳过 XMLHttpRequest API,直接采用 Fetch API。

注意:在旧版本浏览器上使用 fetch/URL/URLSearchParams/FormData 等 API 时可能需要酌情引入相应 polyfill。

2.2 GET 请求提交表单

const url = new URL("http://localhost:8080/api/login");
url.searchParams.append("name", name);
url.searchParams.append("password", password);

fetch(url, {
  method: "GET"
})
  .then(function(response) {
    return response.json();
  })
  .then(function(response) {
    console.log(response);
  });
GET http://localhost:8080/api/login?name=name&password=password HTTP/1.1

请求参数最终拼在 URL 后发送到服务端。在 Chrome 中被标注为查询字符串(Query String)。

URLSearchParams 会自动将空格 " " 被处理成 + 号,自动进行 URL 编码。

2.3 enctype="application/x-www-form-urlencoded" 等价写法

const params = new URLSearchParams();
params.append("name", name);
params.append("password", password);

fetch("http://localhost:8080/api/login", {
  method: "POST",
  body: params
})
  .then(function(response) {
    return response.json();
  })
  .then(function(response) {
    console.log(response);
  });
POST http://localhost:8080/api/login HTTP/1.1
Content-Type: application/x-www-form-urlencoded;charset=utf-8

name=name&password=password

请求参数最终放在请求体中发送到服务端。在 Chrome 中被标注为表单数据(Form Data)。

默认会自动设置 Content-Type 请求头 application/x-www-form-urlencoded;charset=utf-8

2.4 enctype="multipart/form-data" 等价写法

const formData = new FormData();
formData.append("name", name);
formData.append("password", password);

fetch("http://localhost:8080/api/login", {
  method: "POST",
  body: formData
})
  .then(function(response) {
    return response.json();
  })
  .then(function(response) {
    console.log(response);
  });

Firefox

POST http://localhost:8080/api/login HTTP/1.1
Content-Type: multipart/form-data; boundary=---------------------------160693386017892497023111825902

-----------------------------160693386017892497023111825902
Content-Disposition: form-data; name="name"

name
-----------------------------160693386017892497023111825902
Content-Disposition: form-data; name="password"

password
-----------------------------160693386017892497023111825902--

Chrome

POST http://localhost:8080/api/login HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryp8gr8UmAVYJEVKFc

------WebKitFormBoundaryp8gr8UmAVYJEVKFc
Content-Disposition: form-data; name="name"

name
------WebKitFormBoundaryp8gr8UmAVYJEVKFc
Content-Disposition: form-data; name="password"

password
------WebKitFormBoundaryp8gr8UmAVYJEVKFc--

请求参数最终放在请求体中发送到服务端。在 Chrome 中被标注为表单数据(Form Data)。

默认会自动设置 Content-Type 请求头 multipart/form-data; boundary=${boundary}

2.5 application/json

如果要提交 JSON,只能通过 Ajax 来实现,<form> 不支持 enctype="application/json"

fetch("http://localhost:8080/api/login", {
  method: "POST",
  headers: {
    "Content-Type": "application/json;charset=utf-8"
  },
  body: JSON.stringify({
    name: "name",
    password: "password"
  })
})
  .then(function(response) {
    return response.json();
  })
  .then(function(response) {
    console.log(response);
  });
POST http://localhost:8080/api/login HTTP/1.1
Content-Type: application/json;charset=utf-8

{"name":"name","password":"password"}

请求参数最终放在请求体中发送到服务端。在 Chrome 中被标注为请求负载(Request Payload)。

由于 fetchbody 目前只支持 BlobBufferSource(如 ArrayBuffer/DataView 等对象)、FormDataURLSearchParams、字符串、ReadableStream,因此需要使用 JSON.stringify 将待发送的 JSON 数据序列化成字符串。 同时指定 Content-Typeapplication/json;charset=UTF-8(未指定的情况下,字符串类型默认的 Content-Typetext/plain;charset=UTF-8)。另外,字符集的名称必须是取自 US-ASCII 中的可打印字符,不区分大小写,charset=utf-8 等价于 charset=UTF-8

2.6 小节:几种常见请求参数

  1. Query String Parameters: URL 参数,追加到 URL 后面
  2. Form Data:
    • 2.1 application/x-www-form-urlencoded
    • 2.2 multipart/form-data
  3. Request Payload: application/json

说明和思考:

  • URL 参数对应的数据被编码成以 & 分隔的键值对, 键和值以 = 分隔,非字母数字的字符会被 URL 编码。
  • FormData 参数对应的数据不会被编码,通过 boundary 分隔,最终发送二进制数据。
  • application/x-www-form-urlencoded 参数虽然和 URL 参数一样编码,但是是放在请求体中而不是拼在 URL 后。
  • URL 参数对数组和对象的编码方式不统一,需要多加注意。
  • application/json 可读性更好,但实际使用时,要比 application/x-www-form-urlencoded 传输的数据量要大。
  • application/json 不是万能的,要实现文件上传应该使用 multipart/form-data(如果是 BlobContent-Type 可能需要设置为它的 type 属性) 。

3 HTTP 请求的组成

重新复习下 HTTP 请求的组成:

  • 请求方法(Method)
  • 请求 URL
  • HTTP 版本
  • 请求头(Request Headers)
  • 请求体(Request Body)
<method> <request-URL> <version>
<headers>

<entity-body>

如上面的 GET/POST 请求报文:

GET http://localhost:8080/api/login?name=name&password=password HTTP/1.1
POST http://localhost:8080/api/login HTTP/1.1
Content-Type: application/json;charset=utf-8

{"name":"name","password":"password"}

4 总结

技术是不断变化和进步的,无论是传统的 <form> 还是 Ajax,最核心的部分在于 HTTP 请求,可见熟悉 HTTP 标准是前端的基础。

5 相关链接