一、进阶知识
在前一篇博客中,我讲解了FormData
对象的基础概念、相关方法和基本用法,本篇博客我将讲解一些FormDate
对象相关的进阶知识,主要包含FormData
与其他对象结合使用的各类场景,以及一些使用技巧。
1、FormData对象、JSON字符串、key=value字符串 三种参数形式对比
① 使用FormData对象传递参数
该参数形式对应请求头Content-type
类型中的 multipart/form-data
,以键值对的形式存储参数数据,参数中允许包含File
、Blob
类型的数据。
// 发送FormData对象参数
function ajaxFormData () {
// 创建空的 FormData 对象
const formData = new FormData()
// 创建Blob对象
var aFileParts = ['<a id="a"><b id="b">hey!</b></a>']; // 一个包含 DOMString 的数组
var blob = new Blob(aFileParts, { type: 'text/html' });
// 添加一个Blob键值对数据 并设置文件名称
formData.append('content', blob)
// 添加一个字符串键值对数据
formData.append('name', '张三')
// 添加一个字符串键值对数据
formData.append('age', '18')
// 创建 XMLHttpRequest 实例对象
const xhr = new XMLHttpRequest();
// 设置发送POST请求的URL地址
const url = 'http://example.com/api/user';
// 配置请求对象
xhr.open('POST', url);
// 无需设置请求头信息 浏览器会自动设置 Content-type 为 multipart/form-data
// 设置请求完成后的回调
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200) {
console.log(xhr.responseText);
}
};
// 发送请求并将参数加入进去
xhr.send(formData);
}
浏览器查看请求头和请求参数:
② 使用JSON字符串传递参数
该参数形式对应请求头Content-type
类型中的 application/json
,参数中的File
、Blob
类型的数据会被转换成{}
,数据会丢失。当然我们也可以通过将File
、Blob
对象转成base64格式的方式来传递数据,但是不够优雅,而且在转换格式的时候,如果文件过大,会占用大量内存,影响浏览器性能,因此并不推荐采用这种形式来传递File
、Blob
类型数据。
function ajaxJSON () {
// 创建Blob对象
var aFileParts = ['<a id="a"><b id="b">hey!</b></a>']; // 一个包含 DOMString 的数组
var blob = new Blob(aFileParts, { type: 'text/html' });
// 创建一个 FileReader 对象
var reader = new FileReader();
// 当以 DataURL 格式读取成功后,执行回调函数
reader.onload = (event) => {
// 将blob对象转换为bas64字符串
var blobBase64 = event.target.result
// 创建要发送的参数对象
var params = {
name: '张三',
age: 18,
content: blob,
contentBase64: blobBase64
}
// 将参数对象转换为JSON字符串
var JSONParams = JSON.stringify(params)
// 创建 XMLHttpRequest 实例对象
const xhr = new XMLHttpRequest();
// 设置发送POST请求的URL地址
const url = 'http://example.com/api/user';
// 配置请求对象
xhr.open('POST', url);
// 设置请求头信息
xhr.setRequestHeader('Content-type', 'application/json')
// 设置请求完成后的回调
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200) {
console.log(xhr.responseText);
}
};
// 发送请求并将参数加入进去
xhr.send(JSONParams);
};
// 以 DataURL 的形式读取 Blob 数据
reader.readAsDataURL(blob);
}
浏览器查看请求头和请求参数:
③ 使用key=value字符串传递参数
该参数形式对应请求头Content-type
类型中的 application/x-www-form-urlencoded
,传递过程中只能传递字符串类型的参数,参数组成key=value
格式字符串,多个参数之间通过&
进行连接。参数中的File
、Blob
类型的数据会被转换成[object File]
、[object Blob]
字符串,数据会丢失。同理,我们也可以通过将File
、Blob
对象转成base64格式的方式来传递数据,但缺点也相同,在转换格式的时候,如果文件过大,会占用大量内存,影响浏览器性能,因此并不推荐采用这种形式来传递File
、Blob
类型数据。
function ajaxString () {
// 创建Blob对象
var aFileParts = ['<a id="a"><b id="b">hey!</b></a>']; // 一个包含 DOMString 的数组
var blob = new Blob(aFileParts, { type: 'text/html' });
// 创建一个 FileReader 对象
var reader = new FileReader();
// 当以 DataURL 格式读取成功后,执行回调函数
reader.onload = (event) => {
// 将blob对象转换为bas64字符串
var blobBase64 = event.target.result
// 创建要发送的参数字符串
var params = 'name=张三&age=18&content=' + blob + '&contentBase64=' + blobBase64
// 创建 XMLHttpRequest 实例对象
const xhr = new XMLHttpRequest();
// 设置发送POST请求的URL地址
const url = 'http://example.com/api/user';
// 配置请求对象
xhr.open('POST', url);
// 设置请求头信息
xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded')
// 设置请求完成后的回调
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200) {
console.log(xhr.responseText);
}
};
// 发送请求并将参数加入进去
xhr.send(params);
}
// 以 DataURL 的形式读取 Blob 数据
reader.readAsDataURL(blob);
}
浏览器查看请求头和请求参数:
2、对FormData对象中的数据进行过滤
我们使用FormData对象向服务端传输数据时,通常是为了进行文件上传,以及一些相关数据的上传。为了数据安全,我们需要对要加入到FormData中的数据进行校验过滤,比如对文件名、文件类型、文件内容等等进行过滤,只有过滤后的数据才能加入到FormData中,并发送到服务端。
① 文件名过滤
文件名过滤可以防止用户上传的文件名中包含违规内容和字符等,具体实现可以结合正则表达式和字符串操作两者来实现。
例如:上传文件的文件名不能包含&
、#
和%
三个特殊字符,且文件名不能包含sb
和2b
两个违规词。
// 声明FormData
const formData = new FormData();
// 省略...
// 获取文件对象
var file = e.target.files[0]
// 获取文件名
var fileName = file.name
// 校验文件名中是否包含sb或2b 两个违规词
const pattern = /^(?!.*([sS]b|2[Bb])).*$/i;
// 进行文件名过滤 不能含有违规词 且不能含有特殊字符
if (pattern.test(fileName) && fileName.indexOf('&') === -1 && fileName.indexOf('#') === -1 && fileName.indexOf('%') === -1) {
formData.append('file',file)
} else {
alert('文件名不符合规范,请修改后再上传~');
}
② 文件类型过滤
文件类型过滤可以防止用户上传不支持的文件类型,虽然前端可以通过<input>
标签的accept
属性来限制用户选择的文件类型,但是这并不严谨,用户可以通过操作文件选择框的选项来解除限制,所以在文件上传之前对文件类型进行过滤是有必要的。
例如:上传文件的类型限制为图片类型,且只能为.jpg
、.png
、.gif
三种类型的文件。
// 创建空的 FormData 对象
const formData = new FormData()
// 省略...
// 获取文件对象
var file = e.target.files[0]
// 获取文件名
var fileName = file.name
// 获取文件类型
var fileType = file.type
// 校验文件名是否以规定格式 jpg、png、gif 结尾
const pattern = /\.jpg$|\.png$|\.gif$/i;
// 进行文件类型过滤
if (pattern.test(fileName) && fileType.indexOf('image') === 0) {
formData.append('file', file)
} else {
alert('文件类型不符,请修改后再上传~');
}
③ 文件内容过滤
文件内容过滤可以防止用户上传包含恶意代码和违规内容的文件,可以使用JS来过滤部分文件的内容,也可以借助一些完善第三方的库来检查文件内容,如:js-xss
、Filter.js
等等。
例如:对用户上传的.txt
文件,进行简单的敏感词汇校验过滤。
// 创建空的 FormData 对象
const formData = new FormData()
// 调起文件选择框
document.getElementById('file').click()
// 监听文件选择框的change事件
document.getElementById('file').onchange = function (e) {
// 获取文件对象
var file = e.target.files[0]
// 声明一个 FileReader 对象
const reader = new FileReader();
// 当以文本形式读取成功后,执行回调函数
reader.onload = (event) => {
const content = event.target.result;
console.log('原文件内容---', content);
// 过滤敏感词汇
const filteredContent = content.replace(/sb|智障|2B/gi, '**');
// 显示过滤后的内容
console.log('过滤后的文件内容---', filteredContent);
// 将过滤后的内容写入FormData
formData.append('fileText', filteredContent)
};
// 以文本形式读取文件内容
reader.readAsText(file);
}
④ 白名单过滤
白名单过滤是指根据过滤条件批量设置允许名单,只有符合白名单的数据才能通过过滤。
例如:设置文件类型白名单,只允许jpg
、png
、gif
类型的图片文件加入到FormData
中。
// 创建空的 FormData 对象
var formData = new FormData()
// 声明一个白名单数组
const whitelist = ['image/jpg', 'image/png', 'image/gif'];
// 调起文件选择框
document.getElementById('file').click()
// 监听文件选择框的change事件
document.getElementById('file').onchange = function (e) {
// 获取文件对象
var file = e.target.files[0]
// 获取文件类型
var fileType = file.type
// 判断文件类型是否在白名单中
if (whitelist.indexOf(fileType) > -1) {
formData.append('file', file)
} else {
alert('文件类型不符,请修改后再上传~');
}
}
⑤ 黑名单过滤
黑名单过滤是指根据过滤条件批量设置禁止名单,凡是符合黑名单的数据都禁止通过。
例如:设置文件类型黑名单,禁止.exe
和.bat
类型的文件加入到FormData
中。
// 创建空的 FormData 对象
var formData = new FormData()
// 声明一个黑名单数组
const blackList = ['application/x-msdownload', 'application/x-msdos-program',];
// 调起文件选择框
document.getElementById('file').click()
// 监听文件选择框的change事件
document.getElementById('file').onchange = function (e) {
// 获取文件对象
var file = e.target.files[0]
// 获取文件类型
var fileType = file.type
// 判断文件类型是否在黑名单中
if (blackList.indexOf(fileType) === -1) {
formData.append('file', file)
} else {
alert('文件类型不允许上传~');
}
}
3、FormData对象结合同步token预防CSRF攻击
CSRF(Cross-site Request Forgery,跨站请求伪造)攻击是一种常见的网络攻击,攻击者通过伪造用户的身份,利用用户在某些站点上的登录状态,来构造并发送篡改数据的请求。防范CSRF攻击方式有很多,在涉及表单提交的页面中,我们常用的是FormData
对象结合同步token
(又称CSRF token
)的防范策略,来防范攻击者恶意伪造表单数据提交,具体操作步骤如下:
① 当用户请求访问表单页面时,服务端生成一个随机且唯一的token
,服务端存储一份,并将该token
存储在cookie
之中,发送给前端。
② 前端从cookie
中获取token
,然后将token
添加到要提交的FormData
对象中。
③ 前端触发表单提交接口,发送FormData
对象,服务端收到请求后,对比FormData
对象中的token
与服务端存储的token
是否一致,如果一致,则认为是合法请求,否则,认为是CSRF攻击,拒绝请求。
该防范策略的核心在于攻击者虽然在调用提交接口时能携带相关的cookie
信息(接口携带的cookie
取决于接口的域名),但是无法通过js获取相关cookie
的值(js只能获取当前页面域名下的cookie
),因而也就拿不到有效的token
,无法构造有效的表单数据,请求就会被服务端所拒绝。
该防范策略的优点在于安全性高、操作简单、支持性好,缺点在于需要增加额外的计算量和存储开销。
除此之外,我们还可以给存储token
的那个cookie
设置SameSite=Strict或lax
,进一步防范CSRF攻击。
4、FormData对象结合input实现选择文件夹,批量上传文件
之前我们批量上传文件时,都是让用户一个个的去选择文件,操作繁多;或者就是让用户将文件放到文件夹下,统一打包成压缩包,作为一个文件上传,但是文件的压缩格式有很多,服务端基本不可能全部支持,因此也有一定的局限性。所以我想到了另一种方案就是:让用户直接去选择文件夹,然后前端获取文件夹中的所有文件,逐一加入到FormData
对象中,最后统一上传到服务端。我们还可以结合黑白名单过滤的方式,对文件夹中的文件进行过滤,只保留允许上传的文件,发送到服务端。
想要通过<input>
实现文件夹上传需要借助该元素的webkitdirectory
属性,设置该属性后,将限制用户只能选择文件夹,而无法选择文件。但是该属性并非标准属性,所以请慎用!!!
浏览器兼容性:
示例代码:
<input type="file" id="folder" name="folder" webkitdirectory />
<div id="showBox">
文件夹内文件展示区域
</div>
// 创建空的 FormData 对象
var formData = new FormData()
// 声明一个白名单数组 表示可以上传的文件后缀名
const whiteList = ['ppt', 'pptx', 'txt', 'xlsx'];
// 调起文件选择框
document.getElementById('folder').click()
// 监听文件选择框的change事件
document.getElementById('folder').onchange = function (e) {
// 获取文件列表类数组对象
let files = e.target.files
// 输出文件列表类数组对象
console.log(files);
// 将类数组对象转换为数组 且对文件后缀名进行过滤
files = Array.from(files).filter(item => {
// 过滤掉文件夹对象
if (item.type !== "" && item.name !== '.DS_Store') {
// 获取文件后缀名
const suffix = item.name.split('.').pop()
// 过滤掉不足在白名单中的文件
if (whiteList.indexOf(suffix) > -1) {
return true
}
}
})
// 输出过滤后的文件对象列表
console.log('选择文件夹中的所有文件过滤后的结果---', files);
// 用于显示的html字符串
let html = ''
// 遍历文件对象列表
files.forEach(item => {
// 将文件对象的信息拼接到html字符串中
html = html + `<p>文件名:${item.name} <br />文件路径:${item.webkitRelativePath}</p>`
// 将文件对象添加到FormData中
formData.append('file', item)
})
// 将html字符串渲染到页面中
document.getElementById('showBox').innerHTML = html
// 后续上传文件的逻辑
...
选择文件夹上传后,首先浏览器会弹窗获取用户授权(Safari浏览器在本地环境时无需授权,线上环境未验证):
用户授权之后,我们可以监听<input>
标签的onchange
事件,然后通过event.target.files
获取所选文件夹本身及其的所有子文件和子文件夹组成的文件类数组,在进行相关处理时,建议使用Array.from()
转换真正的数组类型。
原始目录层级:
获取的文件类数组以及过滤后的文件结果:
页面渲染结果:
从上面的示例中可以看出获取文件列表中,包含一种name
为.DS_Store
并且type
为""
的特殊文件,这类特殊文件文件表示的就是文件夹,我们可以通过该文件的webkitdirectory
属性来获取文件夹的真实名称。
而且此时获取的各文件之间无法体现原始目录层级关系,但是我们可以通过每个file
文件的webkitRelativePath
属性来得知每个文件的层级关系,各级路径之间通过 /
连接,我们可以通过/
分割webkitRelativePath
属性值,从而还原文件夹的原始层级关系。
注意: 文件名和文件夹名最好不要包含/
、\
等特殊字符,因为获取的File
中的name
和webkitRelativePath
属性,会将他们转义,很有可能会影响层级的拆分和判断。例如:/
在File
中的name
和webkitRelativePath
中都会被转义为:
,\
在File
中的name
中会被转义为\\
,在webkitRelativePath
中会被转义为/
?(奇奇怪怪的规则(╯°□°)╯︵┻━┻)。
5、FormData对象结合dataTransfer实现拖拽文件夹,批量上传文件
可以实现,但其中涉及知识点太多,暂时还没完全搞懂,想了解的建议查阅最后一篇相关资料。
二、相关资料
前端FileReader对象
CSRF攻击及常用防范手段
Cookie相关
webkitdirectory
文件夹上传相关