Stories

Detail Return Return

POST請求 之 對數據進行編碼處理 - Stories Detail

POST請求 之 對數據進行編碼處理

<!-- TOC -->

  • URLSearchParams

    • URLSearchParams 的讀取和轉換操作
    • url.searchParams
    • 讓 URLSearchParams 作為Fetch的請求體(body)
  • FormData

    • 讓 FormData 作為Fetch的請求體(body)
    • 轉換為 URLSearchParams
    • 將Fetch的body讀取為 FormData
  • 其他可以作為Fetch的body的格式

    • Blobs
    • Strings
    • Buffers
    • Streams
  • 最後的福利:將 FormData 轉換為 JSON
  • 參考

<!-- /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 數據。 它非常方便,因為,嗯……

The application/x-www-form-urlencoded format is in many ways an aberrant monstrosity, the result of many years of implementation accidents and compromises leading to a set of requirements necessary for interoperability, but in no way representing good design practices. In particular, readers are cautioned to pay close attention to the twisted details involving repeated (and in some cases nested) conversions between character encodings and byte sequences. Unfortunately the format is in widespread use due to the prevalence of HTML forms. — The URL standard

......所以,是的,非常不建議你自己 對 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,你需要使用一些庫來幫忙了比如: busboy 或 formidable

但是,如果您想將表單作為 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"]
}
注意:如果form表單中包含文件數據,請不要嘗試將表單轉換為 JSON。 如果是這種form表單中包含文件數據的情況,那麼使用 multipart/form-data 會好得多。

參考

  • Encoding data for POST requests

最後,歡迎關注我的公眾號:前端學長Joshua

Add a new Comments

Some HTML is okay.