现在,当你去copilot.github.com时,迎接你的是这个例子:
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';
}
这很糟糕,可能会导致安全问题。这里是事情出错的地方:body: `text=${text}` 。未转码的文本被添加到一个有定义编码的格式中。这类似于SQL/HTML注入,因为打算作为 "值 "的东西被直接与格式交互。
我将会挖掘正确的方法,但也会在一些相关的、不太知名的 API 上漫步。
URLSearchParams
URLSearchParams 处理编码和解码 数据。这是很方便的,因为,嗯...application/x-www-form-urlencoded
application/x-www-form-urlencoded格式在很多方面都是一个畸形的怪胎,是多年来实施意外和妥协的结果,导致了一套互操作性所需的要求,但绝不代表良好的设计实践。特别是,我们提醒读者要密切注意涉及字符编码和字节序列之间重复(在某些情况下是嵌套)转换的扭曲细节。不幸的是,由于HTML表格的盛行,这种格式得到了广泛的使用。
...所以是的,自己尝试编码/解码是个坏主意。下面是它的工作原理:
const searchParams = new URLSearchParams();
searchParams.set('foo', 'bar');
searchParams.set('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());
或者一个字符串:
const searchParams = new URLSearchParams('foo=bar&hello=world');
// Logs 'foo=bar&hello=world'
console.log(searchParams.toString());
读取 URLSearchParams
有多种方法来读取和突变URLSearchParams ,这些方法在 MDN 上都有记录,但如果你想处理所有的数据,那么它的迭代器就会派上用场:
for (const [key, value] of searchParams) {
console.log(key, value);
}
这意味着你可以很容易地把它转换成一个名/值对的数组:
// To [['foo', 'bar'], ['hello', 'world']]
const keyValuePairs = [...searchParams];
或者与支持产生名/值对的迭代器的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'));
不幸的是,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 的主体使用,所以文章开头的代码的 "正确 "版本是:
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 作为主体,Content-Type 头部会自动设置为application/x-www-form-urlencoded ,这很好,因为即使做了20多年的网络开发者,我也永远记不住这种内容类型。你仍然可以提供你自己的Content-Type 标头来覆盖默认的内容。
你不能把Request 或Response 的正文读成URLSearchParams ,但也有办法解决这个问题......
表格数据
FormData 对象可以代表一个HTML表单的名称/值状态。这意味着这些值可以是文件,就像它们可以是 。<input type="file">
你可以直接填充FormData 状态:
const formData = new FormData();
formData.set('foo', 'bar');
formData.set('hello', 'world');
它也是一个迭代器,所以它可以被转换为一个名/值对的数组,或者一个对象,就像你可以用URLSearchParams 。但是,与URLSearchParams 不同的是,你可以直接读取一个HTML表单,作为FormData:
const formElement = document.querySelector('form');
const formData = new FormData(formElement);
console.log(formData.get('username'));
这样你就可以得到表单提交的数据。我经常发现这比从每个元素中获取数据要容易得多:
将FormData作为Fetch主体
与URLSearchParams 类似,你可以直接使用FormData 作为获取主体:
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());
......它记录的东西是:
------WebKitFormBoundaryUekOXqmLphEavsu5
Content-Disposition: form-data; name="foo"
bar
------WebKitFormBoundaryUekOXqmLphEavsu5
Content-Disposition: form-data; name="hello"
world
------WebKitFormBoundaryUekOXqmLphEavsu5--
这就是multipart/form-data 的样子。它比application/x-www-form-urlencoded 更复杂,但它可以包括文件数据。然而,有些服务器不能处理multipart/form-data ,包括Express。如果你想在Express中支持multipart/form-data ,你需要使用像busboy或formidable这样的东西。
但是,如果你想把一个表单作为application/x-www-form-urlencoded 发送呢? 那么......
转换为 URLSearchParams
由于URLSearchParams 构造函数接受一个产生名/值对的迭代器,而FormData 的迭代器正是这样做的,你可以从一个转换到另一个:
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体
你也可以把Request 或Response 对象作为FormData:
const formData = await request.formData();
如果请求/响应体是multipart/form-data 或application/x-www-form-urlencoded ,这就可以了。这对于处理服务工作者中的表单提交特别有用。
其他获取体
还有一些其他格式可以作为获取体。
BLOB
Blob 对象(因此 ,因为它继承自 )可以成为获取体。File Blob:
fetch(url, {
method: 'POST',
body: blob,
});
这将自动把Content-Type 设置为blob.type 的值。
字符串
fetch(url, {
method: 'POST',
body: JSON.stringify({ hello: 'world' }),
headers: { 'Content-Type': 'application/json' },
});
这将自动把Content-Type 设置为text/plain;charset=UTF-8 ,但正如我在上面所做的那样,它总是可以被覆盖。
缓冲器
ArrayBuffer 对象,以及任何由数组缓冲区支持的东西,比如 ,都可以作为一个获取主体。Uint8Array
fetch(url, {
method: 'POST',
body: new Uint8Array([
// …
]),
headers: { 'Content-Type': 'image/png' },
});
这不会自动设置Content-Type 头部,所以你需要自己做这个。
流
最后,获取体可以是流对于Response 对象,这允许与服务工作者一起使用各种乐趣,而且最近他们也可以与请求一起使用。
所以,不要试图自己处理multipart/form-data 或application/x-www-form-urlencoded ,让FormData 和URLSearchParams 来做这些艰苦的工作!
我也不反对像GitHub Copilot这样的东西。只要把输出结果当作StackOverflow上的答案,并在提交之前审查它。
奖励回合。将FormData转换为JSON
Nicholas Mendez在推特上问我,如何在不丢失数据的情况下将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);
有几种方法可以避免数据丢失,并最终得到可被JSON字符串化的东西。首先,有一个名/值对的数组:
// [['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)]),
);
...这样你就可以得到:
{
"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)],
),
);
......这样就可以了:
{
"foo": "bar",
"tvShows": ["Motherland", "Taskmaster"]
}
记住,如果表单包含文件,不要试图将其转换为JSON。如果是这种情况,你最好使用multipart/form-data 。