文件上传杂谈
一、写在前面
文件上传是前端很常见的一类场景。图片、视频和文档等等都属于文件范畴,每个文件则是通过 File.Type 进行更细的划分。本文将针对文件上传的一些通用维度场景做简单的剖析和尝试,抛砖引玉,希望共同学习,共同成长。
本文案例里使用的组件来源于组件库 zent@7.4.4
二、常见的上传场景及实现
上传的形式或场景各式各样,除了业务级别的封装外,常遇到的通用场景有如下:
- 重复上传
- 上传预览
- 拖拽上传
- 上传裁剪
- 上传进度可视化
- 文件压缩
- 上传前置校验
- 切片上传
- 上传加密
- 暂停&断网续传 ...
我们抽取部分场景进行实现:
2.1 上传前置校验
在文件上传前,经常会需要对文件格式进行校验,我们需要在文件上传/展示预览图前提示用户图片是否完成校验。
常用的格式校验:文件类型、文件大小、上传的尺寸
我们先看看和文件相关的两个对象的定义:Blob 和 File
/** A file-like object of immutable, raw data.Blobs represent data that isn't necessarily in a JavaScript-native format. The File interface is based on Blob, inheriting blob functionality and expanding it to support files on the user's system. */
interface Blob {
readonly size: number;
readonly type: string;
arrayBuffer(): Promise;
slice(start?: number, end?: number, contentType?: string): Blob;
stream(): ReadableStream;
text(): Promise;
}
/** Provides information about files and allows JavaScript in a web page to access their content. */
interface File extends Blob {
readonly lastModified: number;
readonly name: string;
}
通过定义我们知道, Blob是一个不可变、存储文件原数据的一个类文件,但其并非是JS的原生数据,而 File继承于 Blob,使得 Blob信息扩展为用户操作系统可支持的文件,并使得页面里可以使用 Javascript访问其文件信息。
除了继承与原有的 size 和 type 属性, File对象还额外返回 lastModified (返回文件最后修改日期)和 name (文件名)属性。
以下是某个文件的 File实例信息
{
lastModified: 1581424451211
lastModifiedDate: Tue Feb 11 2020 20:34:11 GMT+0800 (中国标准时间)
name: "计算机网络.pdf"
size: 70809807
type: "application/pdf"
webkitRelativePath: ""
}
通过上面信息,我们可以很轻松地校验文件类型和文件大小。具体的实现我们接着看下去。
2.1.1 限制文件上传类型
1.使用 input 自带属性 accept Mime 类型列表
属性描述值例子accept
期望文件类型
image/* , audio/* , video/* ...
image/jpeg ...

图1 Input限制上传类型
2.使用文件后缀或 MIME-TYPE
// ...
const acceptTypes = ['image/png', 'image/jpeg'];
const picSlipt = name.split('.');
// 切割文件名后缀
const picSuffix = `image/${picSlipt[picSlipt.length - 1]}`;
// 直接使用解析的文件信息
const fileType = file.type;
if (acceptTypes.includes(picSuffix) || acceptTypes.includes(fileType)) {
console.log('通过文件类型校验!');
};
//...
3.使用二进制文件信息流读取
但我们知道直接更改文件后缀并不会改变文件类型的本质。比如以下我直接更改一张 png 图片后缀为 jpg,那么它就很有可能绕过了我们的规则 image/jpeg(虽然想要绕过前端的规则校验有非常多的方法)

图2 通过更改png图片后缀绕过前端上传规则
但实际上它还是png图片,我们可以通过图像信息查询网站可以得出该图片信息实际如下:

图3 后缀和类型不一致
上传校验的绕过会给服务器带来很多潜在危险,因此我们可能需要通过更严格的类型校验:文件头信息进行格式鉴别👇
文件类型的信息一般是头文件里前8个字节
我们看一下上面那张图avatar.jpg,第一行头信息里有什么?

图4 文件的16进制信息
通过vscode的插件hexdump可以查看到该文件头部信息前8个字节为:89 50 4E 47 0D 0A 1A 0A。这其实是 png 图片的头部信息,前8个字节属于 png 图片的头标识,后4个字节为数据域长度,最后4个字节为 png 的 IHDR 标识,是图片宽高等数据流前的第一个数据块。通过前8个字节证明了即使图片后缀为 jpg,但文件类型仍然为 png
以下列举一些常见的文件格式前字节标识信息(可从网上查阅或用 vscode 读取)
文件类型规则hex(十六进制)png
前8个字节
89 50 4E 47 0D 0A 1A 0A
jpg
前2个字节
FF D8
gif
前6个字节
47 49 46 38 39 61
bmp
前2个字节
42 4D
ES6已经支持我们我们直接通过 ArrayBuffer对象存储文件的二进制数据,并通过 DataView去读取。
const reader = new FileReader();
reader.onload = function () {
// 这里从0开始获取文件二进制数据的前8个字节
const dataView = new DataView(this.result, 0, 8);
for (let i = 0; i < dataView.byteLength; i++) {
// 读取 1 个字节,返回一个无符号的 8 位整数
bufferUint8Array.push(dataView.getUint8(i))
}
}
// 这里生成包含文件信息的二进制数据,但不允许直接读写
reader.readAsArrayBuffer(file);
也可以在生成ArrayBuffer时先对文件进行切割:
// 1.生成对象
reader.readAsArrayBuffer(file.slice(0, 8)));
// 2.提取头部信息
new DataView(this.result);
完整代码
// index.js
const handleChange = async e => {
const files = e.target.files;
const isPNG = await checkType(files[0]);
}
// utils.js
export const checkType = file => {
return new Promise(resolve => {
const reader = new FileReader();
reader.onload = function () {
// PNG文件头标识(16进制)
const PNG_HEADER_HEX = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
const dataView = new DataView(this.result);
const bufferUint8Array = new Array(dataView.byteLength).fill('').map((_, index) => dataView.getUint8(index))
console.log(`文件: ${file.name} 的前8个字节十进制为, ${bufferUint8Array}`);
// 用获取到的字节和图片头信息进行对比
const isPNG = PNG_HEADER_HEX.every((hex, index) => {
return hex === bufferUint8Array[index];
});
resolve(isPNG);
}
reader.readAsArrayBuffer(file.slice(0, 8));
})
}
现在我们分别上传一张标准 png 图片、一张更改后缀为 jpg 的 png 图片和一张标准 jpg 判断其是否符合标准的 png 头部信息。

图5 判断上传文件是否为png格式
可以看到:
avatar.png:
十进制: [137, 80, 78, 71, 13, 10, 26, 10]
十六进制为:0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A
avatar.jpg:
十进制: [137, 80, 78, 71, 13, 10, 26, 10]
十六进制为:0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A
banner.jpg:
十进制: [255, 216, 255, 224, 0, 16, 74, 70]
十六进制: 0xFF, 0xD8, 0xFF, 0xE1, 0x00, 0x18, 0x45, 0x78
2.1.2 文件尺寸的校验
因为文件的尺寸无法通过 File对象直接获得,我们可以使用以下方法
1.使用 Image获取上传图片尺寸
const reader = new FileReader();
const widthLimit = 100;
const heightLimit = 100;
console.log('限制图片的宽度 & 高度', `${widthLimit}px`, `${heightLimit}px`);
reader.readAsDataURL(file);
reader.onload = async function () {
// 加载图片获取图片真实高度和上传
const src = reader.result;
const image = new Image();
image.onload = await function () {
const width = image.width;
const height = image.height;
console.log('上传图片的宽度 & 高度', `${width}px`, `${height}px`);
if (Number(widthLimit) !== width || Number(heightLimit) !== height) {
console.log(` %c x 校验不通过 ,请上传${widthLimit}*${heightLimit}的尺寸图片`, 'color: #ed6a0c');
resolve(false)
} else {
console.log('%c y 校验通过!', 'color: #2da641');
resolve(true);
}
}
// 放置onload后
image.src = src;
}
2.使用二进制文件信息流读取通过下面两种方式可以利用文件的头信息找到宽高:
- 通过找到图片信息的前置标志,然后再进行字节偏移
- 直接进行字节偏移(仅适用于信息在固定位的格式,例如 png、gif 等。jpg 的前置标志没有固定的位置,只能通过第一种方式)
文件类型前置标志读取方式png
IHDR(13-16字节)
宽度:17-20字节(4 bytes)
高度: 21-24字节(4 bytes)
gif
GIF89(1-6字节)
GIF尺寸是反着存储
宽度:第8字节+第7字节(2 bytes)
高度: 第10字节+第9字节(2 bytes)
bmp
-
宽度:18-21字节(4 bytes) 高度: 22-25字节(4 bytes)
jpg
SOF0、SOF1...
偏移3个字节后(n)开始计算
高度:(n, n+1)(2 bytes)
宽度:(n+2,n+3)(2 bytes)
完整代码
export const checkPxByHeader = file => {
console.log('文件信息', file);
const reader = new FileReader();
reader.onload = function () {
const dataView = new DataView(this.result);
isPNG(dataView);
}
// 如果是判断jpg图片需要遍历整个Buffer,不能切割
// png的前置标志固定在13-16字节
reader.readAsArrayBuffer(file.slice(0, 50));
}
// png文件信息第一块数据表示 IHDR(49 48 44 52)
const isPNG = dataView => {
const IHDR_HEX = [0x49, 0x48, 0x44, 0x52];
// 方法一 查找数据块标志
new Array(dataView.byteLength - 4).fill('').map((_, index) => {
const fourBytesArr = [index, index + 1, index + 2, index + 3].map(num => dataView.getUint8(num));
// 通过提取的4位无符号的8-bit整数与标准的PNG-IHDR16进制对比,判断是否遍历到了IHDR位置
const isTouchIHDR = fourBytesArr.every((hex, index) => {
return hex === IHDR_HEX[index];
});
if (isTouchIHDR) {
// 找到IHDR位置,偏移4个字节后获取4个字节的32位整数即可获取宽度
const width = dataView.getInt32(index + 4);
const height = dataView.getInt32(index + 8);
console.log('方法一获取 width', width);
console.log('方法一获取 height', height);
}
if (!isTouchIHDR && index === dataView.byteLength - 4) {
console.log('方法一获取 上传文件并非png');
}
})
// 方法二 直接偏移字节
// 从第17个字节开始读取
const width = dataView.getInt32(16);
const height = dataView.getInt32(20);
console.log('方法二获取 width', width);
console.log('方法二获取 height', height);
}

图6 通过文件信息获取宽高
2.2 大文件上传之切片上传
接口超时、上传失败后又从零开始上传等是大文件上传经常要面临的问题,通过切片上传、断点续传等方式可以很好地解决以上痛点,改善交互体验。我们先来看一下基础的大文件上传最终效果:

图7 切片上传完整演示图
其实切片上传和单文件上传没有很大的区别,切片上传实际上就是一个个小切片的单文件上传。可以归纳成以下几步操作,我们一一实现:
- 获取上传文件信息。
- 前端根据实际情况进行切片。如果是断点续传,则需要从已上传的切片数后面开始切割。(注:需要给每个切片的名字带上唯一标志,一般为索引值)
- 上传切片至服务端。
- 通过 ajax 的
ProgressEvent读取上传进度,前端展示。(注:ProgressEvent返回的是每个切片上传的进度,总进度应该是所有切片上传的进度) - 服务器接收切片。
- 切片上传完毕后,前端发送请求通知服务器端合并切片,最后清除切片缓存。
- 返回上传结果 & 文件路径。
2.2.1 获取上传文件信息
...
