POST请求 之 对数据进行编码处理

<!-- TOC -->

<!-- /TOC -->
好,来。我们先来先来看个代码例子:

async function isPositive(text) {
  const response = await fetch(`http://text-processing.com/api/sentiment/`, {
    method: 'POST',
    body: `text=${text}`,
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
  });
  const json = await response.json();
  return json.label === 'pos';
}

这块代码写得比较糟糕,可能会导致安全问题。 为什么呢?因为:text=${text} 这块地方存在问题:

未转义的文本被添加到具有定义编码的格式中。就是说,这里的text变量,它是没有经过转义(或者说是编码)就直接被写到了请求体中,而在这个请求中,是有要求编码格式的'Content-Type': 'application/x-www-form-urlencoded'

这种写法有点类似于 SQL/HTML 注入,因为某种旨在作为“值”的东西(指类似text变量的一些值)可以直接与格式进行交互。

所以,我将深入研究正确的方法,同时也会浏览一些相关的、鲜为人知的 API:

URLSearchParams

URLSearchParams 可以用来 处理编码和解码 application/x-www-form-urlencoded 数据。 它非常方便,因为,嗯……

......所以,是的,非常不建议你自己 对 application/x-www-form-urlencoded 的数据进行编码/解码。

下面是URLSearchParams的工作原理:

const searchParams = new URLSearchParams();
searchParams.set('foo', 'bar');
searchParams.set('hello', 'world');

// Logs 'foo=bar&hello=world'
console.log(searchParams.toString());

URLSearchParams这个构造函数还可以接受一个[key, value]对的数组,或一个产生[key, value]对的迭代器:

const searchParams = new URLSearchParams([
  ['foo', 'bar'],
  ['hello', 'world'],
]);

// Logs 'foo=bar&hello=world'
console.log(searchParams.toString());

或者是一个对象:

const searchParams = new URLSearchParams({
  foo: 'bar',
  hello: 'world',
});

// Logs 'foo=bar&hello=world'
console.log(searchParams.toString());

或者是一个字符串:

const searchParams = new URLSearchParams('foo=bar&hello=world');

// Logs 'foo=bar&hello=world'
console.log(searchParams.toString());

URLSearchParams 的读取和转换操作

读取(指对数据进行枚举等读取操作)和转换(指将其转为数组或者对象等) URLSearchParams 的方法还是很多的,MDN 上都有详细说明。

如果在某些场景下,您想处理所有数据,那么它的迭代器就派上用场了:

const searchParams = new URLSearchParams('foo=bar&hello=world');

for (const [key, value] of searchParams) {
  console.log(key, value);
}

这同时意味着您可以轻松地将其转换为[key, value]对数组:

// To [['foo', 'bar'], ['hello', 'world']]
const keyValuePairs = [...searchParams];

或者将它与支持生成key-value对的迭代器的 API 一起使用,例如 Object.fromEntries,可以把它转换为一个对象:

// To { foo: 'bar', hello: 'world' }
const data = Object.fromEntries(searchParams);

但是,请注意,转换为对象有时是有损转换的哦:就是可能会造成某些值得丢失

const searchParams = new URLSearchParams([
  ['foo', 'bar'],
  ['foo', 'hello'],
]);

// Logs "foo=bar&foo=hello"
console.log(searchParams.toString());

// To { foo: 'hello' }
const data = Object.fromEntries(searchParams);

url.searchParams

URL 对象上有一个 searchParams 属性,非常方便地获取到请求参数:

const url = new URL('https://jakearchibald.com/?foo=bar&hello=world');

// Logs 'world'
console.log(url.searchParams.get('hello'));

不幸的是,在window.location上没有location.searchParams这个属性。

这是因为 window.location 由于它的某些属性如何跨源工作而变得复杂。 例如设置 otherWindow.location.href 可以跨源工作,但不允许获取它。

但是无论如何,我们都要解决它,让我们能比较容易地从地址栏中获取到请求参数:

// Boo, undefined
location.searchParams;

const url = new URL(location.href);
// Yay, defined!
url.searchParams;

// Or:
const searchParams = new URLSearchParams(location.search);

让 URLSearchParams 作为Fetch的请求体(body)

好的,现在我们进入正题。 文章开头示例中的代码存在一些问题,因为它没有进行转义输入:

const value = 'hello&world';
const badEncoding = `text=${value}`;

// 😬 Logs [['text', 'hello'], ['world', '']]
console.log([...new URLSearchParams(badEncoding)]);

const correctEncoding = new URLSearchParams({ text: value });

// Logs 'text=hello%26world'
console.log(correctEncoding.toString());

为方便使用,URLSearchParams对象 是可以直接被用作请求Request响应Response主体body,因此文章开头的“正确”代码版本是:

async function isPositive(text) {
  const response = await fetch(`http://text-processing.com/api/sentiment/`, {
    method: 'POST',
    body: new URLSearchParams({ text }),
  });
  const json = await response.json();
  return json.label === 'pos';
}

如果使用 URLSearchParams 作为body,则 Content-Type 字段会自动设置application/x-www-form-urlencoded

您不能将请求Request响应Response主体body读取为 URLSearchParams对象,但我们有一些方法可以解决这个问题……

FormData

FormData 对象可以表示 HTML 表单的一组key-value的数据。 同时key值也可以是文件,就像 <input type="file"> 一样。

您可以直接给 FormData 对象 添加数据:

const formData = new FormData();
formData.set('foo', 'bar');
formData.set('hello', 'world');

FormData对象也是一个迭代器,因此您可以将它转换为键值对数组或对象,就像使用 URLSearchParams 一样。但是,与 URLSearchParams 不同的是,您可以将 HTML 表单直接读取为 FormData:

const formElement = document.querySelector('form');
const formData = new FormData(formElement);
console.log(formData.get('username'));

这样,您就可以轻松地从表单中获取到数据了。 我经常使用这种方式,所以发现这比单独从每个元素中获取数据要容易得多。

让 FormData 作为Fetch的请求体(body)

与 URLSearchParams 类似,您可以直接使用 FormData 作为 fetch body:

const formData = new FormData();
formData.set('foo', 'bar');
formData.set('hello', 'world');

fetch(url, {
  method: 'POST',
  body: formData,
});

这会自动将 Content-Type 标头设置为 multipart/form-data,并以这种格式发送数据:

const formData = new FormData();
formData.set('foo', 'bar');
formData.set('hello', 'world');

const request = new Request('', { method: 'POST', body: formData });
console.log(await request.text());

...console.log打印出如下内容:

------WebKitFormBoundaryUekOXqmLphEavsu5
Content-Disposition: form-data; name="foo"

bar
------WebKitFormBoundaryUekOXqmLphEavsu5
Content-Disposition: form-data; name="hello"

world
------WebKitFormBoundaryUekOXqmLphEavsu5--

这就是使用 multipart/form-data 格式发送数据时body的样子。 它比 application/x-www-form-urlencoded 更复杂,但它可以包含文件数据。但是,某些服务器无法处理multipart/form-data ,比如:Express。 如果你想在 Express 中支持 multipart/form-data,你需要使用一些库来帮忙了比如: busboyformidable

但是,如果您想将表单作为 application/x-www-form-urlencoded 发送怎么办? 嗯…

转换为 URLSearchParams

因为 URLSearchParams 构造函数可以接受一个生成键值对的迭代器,而 FormData 的迭代器正是这样做的,它可以生成键值对,因此您可以将 FormData 转换为 URLSearchParams:

const formElement = document.querySelector('form');
const formData = new FormData(formElement);
const searchParams = new URLSearchParams(formData);

fetch(url, {
  method: 'POST',
  body: searchParams,
});

但是,如果表单数据包含文件数据,则此转换过程将抛出错误。 因为application/x-www-form-urlencoded 不能表示文件数据,所以 URLSearchParams 也不能。

将Fetch的body读取为 FormData

您还可以将 Request 或 Response 对象读取为 FormData:

const formData = await request.formData();

如果Request 或 Response的body是 multipart/form-dataapplication/x-www-form-urlencoded,这个方法是很有效。 它对于服务器中处理表单提交特别有用。

其他可以作为Fetch的body的格式

还有一些其他格式format可以作为Fetch的body:

Blobs

Blob 对象(同时,File也可以作为Fetch的body, 因为它继承自 Blob)可以作为Fetch的body:

fetch(url, {
  method: 'POST',
  body: blob,
});

这会自动将 Content-Type 设置为 blob.type 的值。

Strings

fetch(url, {
  method: 'POST',
  body: JSON.stringify({ hello: 'world' }),
  headers: { 'Content-Type': 'application/json' },
});

这会自动将 Content-Type 设置为 text/plain;charset=UTF-8,但它可以被覆盖,就像我上面所做的那样,将 Content-Type 设置为 application/json

Buffers

ArrayBuffer 对象,以及由数组缓冲区支持的任何东西,例如 Uint8Array,都可以用作Fetch的body:

fetch(url, {
  method: 'POST',
  body: new Uint8Array([
    // …
  ]),
  headers: { 'Content-Type': 'image/png' },
});

这不会自动设置 Content-Type 字段,因此您需要自己进行设置。

Streams

最后,获取主体可以是流(stream)! 对于 Response 对象,这可以让服务端获取不一样的开发体验,而且它们也可以与request一起使用

所以,千万不要尝试自己处理 multipart/form-dataapplication/x-www-form-urlencoded 格式的数据,让 FormData 和 URLSearchParams 来帮我们完成这项艰苦的工作!

最后的福利:将 FormData 转换为 JSON

目前有个问题,就是:

如何将 FormData 序列化为 JSON 而不会丢失数据?

表单可以包含这样的字段:

<select multiple name="tvShows">
  <option>Motherland</option>
  <option>Taskmaster</option>
  …
</select>

当然,您可以选择多个值,或者您可以有多个具有相同名称的输入:

<fieldset>
  <legend>TV Shows</legend>
  <label>
    <input type="checkbox" name="tvShows" value="Motherland" />
    Motherland
  </label>
  <label>
    <input type="checkbox" name="tvShows" value="Taskmaster" />
    Taskmaster
  </label>
  …
</fieldset>

最后获取到数据的结果是一个具有多个同名字段的 FormData 对象,如下所示:

const formData = new FormData();
formData.append('foo', 'bar');
formData.append('tvShows', 'Motherland');
formData.append('tvShows', 'Taskmaster');

就像我们在 URLSearchParams 中看到的,一些对象的转换是有损的(部分属性是会被剔除丢的):

// { foo: 'bar', tvShows: 'Taskmaster' }
const data = Object.fromEntries(formData);

有以下几种方法可以避免数据丢失,而且最终仍然可以将fromData数据序列化 JSON。

首先,转为[key, value]对数组:

// [['foo', 'bar'], ['tvShows', 'Motherland'], ['tvShows', 'Taskmaster']]
const data = [...formData];

但是如果你想要转为一个对象而不是一个数组,你可以这样做:

const data = Object.fromEntries(
  // Get a de-duped set of keys
  [...new Set(formData.keys())]
    // Map to [key, arrayOfValues]
    .map((key) => [key, formData.getAll(key)]),
);

...上诉代码的data变量,最终是:

{
  "foo": ["bar"],
  "tvShows": ["Motherland", "Taskmaster"]
}

我比较倾向于数据中每个值都是一个数组,即使它只有一个项目。 因为这可以防止服务器上的大量代码分支,并可以简化验证。 虽然,您有可能更倾向于 PHP/Perl 约定,其中以 [] 结尾的字段名称表示“这应该生成一个数组“, 如下:

<select multiple name="tvShows[]">
  …
</select>

并我们来转换它:

const data = Object.fromEntries(
  // Get a de-duped set of keys
  [...new Set(formData.keys())].map((key) =>
    key.endsWith('[]')
      ? // Remove [] from the end and get an array of values
        [key.slice(0, -2), formData.getAll(key)]
      : // Use the key as-is and get a single value
        [key, formData.get(key)],
  ),
);

...上诉代码的data变量,最终是:

{
  "foo": "bar",
  "tvShows": ["Motherland", "Taskmaster"]
}

参考

最后,欢迎关注我的公众号:前端学长Joshua

03-05 16:04