一、写在前面
文件上传是前端很常见的一类场景。图片、视频和文档等等都属于文件范畴,每个文件则是通过 File.Type 进行更细的划分。本文将针对文件上传的一些通用维度场景做简单的剖析和尝试,抛砖引玉,希望共同学习,共同成长。
本文案例里使用的组件来源于组件库 [email protected]
二、常见的上传场景及实现
上传的形式或场景各式各样,除了业务级别的封装外,常遇到的通用场景有如下:
- 重复上传
- 上传预览
- 拖拽上传
- 上传裁剪
- 上传进度可视化
- 文件压缩
- 上传前置校验
- 切片上传
- 上传加密
- 暂停&断网续传 ...
我们抽取部分场景进行实现:
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 获取上传文件信息
通过调用 input
的实例,打开选择文件弹窗并获取上传文件信息。
图8 获取上传文件信息
完整代码
/** html */
添加文件
/** constants */
export const uploadStatusMap = {
'pending': 0,
'uploading': 1,
'done': 2,
'pause': 3,
'error': 4,
}
export const uploadStatus = {
0: '未上传',
1: '上传中',
2: '已上传',
3: '暂停中',
};
/** js */
const inputRef = React.useRef(null);
const [fileList, setFileList] = React.useState([]);
// 打开文件选择框
const handleAddFile = () => {
const inputEv = inputRef.current;
inputEv.click();
};
// 上传文件后回调
const handleFileSelect = async e => {
const File = e.target.files[0];
// 存储文件相关信息
let filesToCurrent = {
id: createUploadId(),
fileName: File.name,
fileType: File.type,
fileSize: File.size,
File,
chunkCount,
uploadSingleProgress: 0,
currentChunk: 0,
uploadStatus: uploadStatusMap.pending,
};
// 表格里显示文件信息
setFileList([...fileList, filesToCurrent]);
}
2.2.2 生成切片
这里提供了2种生成切片数量的方式,可以根据具体情况选择:
1.根据默认的切片数量切割
- 好处: 限定了http请求的数量
- 坏处: 文件过大时有可能导致每块切片大小依然很大,失去了切片的意义
2.根据默认的切片大小切割
- 好处: 限定了切片的大小
- 坏处: 切片数量过多容易造成http负担
通过切片数量来计算每个切片大小。例如一张图片总大小为15M,切割成5份后每份切片大小为15 / 3 = 3M。因为合并切片的时候需要按切割顺序进行,所以需要记录当前切片的索引值,在上传切片时带上(本场景把 index 拼接到切片名字里)。
// index从1开始计算
`${File.name}-chunk-${fileChunkList.length + 1}`
切片索引值除了合并切片时使用外,在读取上传进度等地方也发挥了很大作用。
图9 前端生成切片信息
2.2.3 上传切片
需要使用post方法结合multipart/form-data头才能将文件内容填充到body中。
const formData = new FormData();
formData.append('name', name);
formData.append('file', file);
axios({
method: 'post',
data: formData,
header: {
'Content-type': 'multipart/form-data',
},
// ...
})
2.2.4 展示上传进度
为了演示方便,本场景里暂时使用发送所有请求的方案,会并发无序执行所有请求,直到所有的请求响应完成再发送合并切片请求。所以需要记录每个切片上传的进度,并通过其占有的进度比计算出最终的文件上传进度。例如:
一个文件15M,分成5份切片,每个切片大小占比20%,发送上传请求一段时间,假设切片1返回进度是50%,其他4个切片返回进度均为25%,则文件总上传进度是多少?
(0.5 * 0.2 + 0.25 * 0.8) * 100 = 30(%)
图10 每个切片返回上传的进度
完整代码
// index.js
const uploadPromise = uploadChunkList.map(async ({ name, file }) => {
return axios({
// ...
// 记录上传进度
onUploadProgress: uploadInfo => {
let chunkUploadInfo = {};
// 计算当前切片上传百分比 已上传数/总共需要上传数(这里计算的是每个切片的上传进度)
const chunkProgress = Number((uploadInfo.loaded / uploadInfo.total));
console.log('当前上传切片序号:', index);
console.log('当前上传切片进度', `${(chunkProgress * 100).toFixed(2)}%`);
chunkUploadInfo[index] = chunkProgress;
currentUploadItem.isSingle = false;
/**
* 总的上传百分比是由 切片上传进度 * 切片分数占比
* chunkUploadInfo的格式为{[index]: progress1, [index1]: progress2, ...} index为切片索引值
*/
currentUploadItem.chunkUploadInfo = {
...currentUploadItem.chunkUploadInfo,
[index]: chunkProgress,
};
// 切片上传进度100%时,更新当前上传切片的索引值
if (chunkProgress === 1) {
currentUploadItem.currentChunk = index + 1;
}
setFileList([...newFileList]);
},
// ...
})
}
// utils.js 计算表格里展示的总进度
export const getSliceFileUpload = (chunkUploadInfo = {}) => {
let progress = 0;
// chunkUploadInfo数据格式为: {0: 0, 1: 0, [切片索引值]: [切片上传进度], ...}
const chunkCountArr = Object.keys(chunkUploadInfo);
chunkCountArr.forEach(chunkIdx => {
progress += chunkUploadInfo[chunkIdx] * (100 / chunkCountArr.length)
})
return progress;
}
2.2.5 服务器端接收切片
node层要接收解析二进制文件流。提取的实现比较麻烦,这边直接使用[@koa](/user/koa)/[email protected](版本不一样使用方式可能也不一样,具体可查看官方文档),当然还有其他非常多优秀的npm包可以选择formidable
[@koa](/user/koa)/multer
允许用户设定一个存放文件的位置。其实例对象提供了几种模式,为方便演示,本文案例统一使用 single。具体区别可以查看其定义。
/** 流存放位置 */
const chunksPath = path.join(__dirname, '../static/stream');
[@koa](/user/koa)/multer
允许用户通过不同方法接收上传的文件
interface Instance {
/** Accept a single file with the name fieldName. The single file will be stored in req.file. */
single(fieldName?: string): Koa.Middleware;
/** Accept an array of files, all with the name fieldName. Optionally error out if more than maxCount files are uploaded. The array of files will be stored in req.files. */
array(fieldName: string, maxCount?: number): Koa.Middleware;
/** Accept a mix of files, specified by fields. An object with arrays of files will be stored in req.files. */
fields(fields: Field[]): Koa.Middleware;
/** Accepts all files that comes over the wire. An array of files will be stored in req.files. */
any(): Koa.Middleware;
}
// 实例
router.post('/upload-chunk', koaMulterUpload.single('file'), async (ctx) => {
const file = ctx.req.file;
})
[@koa](/user/koa)/multer
会默认为接收到的文件生成如下信息:
{
fieldname: 'file',
originalname: 'blob',
encoding: '7bit',
mimetype: 'application/octet-stream',
destination:
'/YourLocalPath/static/stream',
filename: 'ff7cd26c15305dbfd9173be5f80f9770',
path:
'/YourLocalPath/static/stream/ff7cd26c15305dbfd9173be5f80f9770',
size: 14161959
}
为了方便后续合并切片,将切片名重命名为特定的格式
/**
* 重命名二进制流文件
* 注意路径需要对齐
*/
// 从前端接收到的重命名格式,例如`${fileName}-chunk-${index}`
const { name } = ctx.req.body;
const file = ctx.req.file;
const chunkName = `${chunksPath}/${name}`;
fs.renameSync(file.path, chunkName);
图11 切片存放位置以及切片合并后生成的文件
完整代码
const koaMulter = require('koa-multer');
/** fs的封装模块 */
const fs = require('fs-extra');
/** 流存放位置 */
const chunksPath = path.join(__dirname, '../static/stream');
const koaMulterUpload = koaMulter({ dest: chunksPath });
router.post('/upload-chunk', koaMulterUpload.single('file'), async (ctx) => {
/**
* axios方法
* ctx.req.file 文件流信息
* ctx.req.body 请求参数
*/
const { name } = ctx.req.body;
const file = ctx.req.file;
const chunkName = `${chunksPath}/${name}`;
/**
* 重命名切片文件名
* 注意路径需要对齐
*/
fs.renameSync(file.path, chunkName);
ctx.status = 200;
ctx.res.end(`upload chunk: ${name} success!`);
});
2.2.6 服务器端合并切片
node根据约定的切片名字格式去读取已存储的切片文件,合并之后清除切片文件。
完整代码
// node.js
router.post('/merge-chunk', async (ctx) => {
/**
* axios.post方法
* ctx.request.body 请求参数
*/
// 由前端告诉服务端生成切片数量
const { fileName = '未命名', chunkCount } = ctx.request.body || {};
// 1.创建存储文件,初始为空
const filePath = `${uploadFilePath}/${fileName}`;
fs.writeFileSync(filePath, '');
console.log('chunkCount', chunkCount);
// 2.读取所有chunk数据
// 3.开始写入数据
for (let idx = 1; idx <= chunkCount; idx++) {
/**
* 约定的chunk文件名格式: fileName + '-' + index
*/
const chunkFile = `${chunksPath}/${fileName}-chunk-${idx}`;
fs.appendFileSync(filePath, fs.readFileSync(chunkFile));
}
/** 删除chunk文件 */
fs.emptyDirSync(`${chunksPath}/${fileName}`);
ctx.status = 200;
ctx.res.end('successful');
});
2.3 大文件上传之断点续传
断点续传的核心是在已经上传切片数后面续传,为了更好地实现,我们对上面切片上传的逻辑做几个优化的点:
- 切片需要按顺序上传。即在上传切片1后再上传切片2,解决同时上传出现后面的切片比前面的切片先上传成功的情况,避免续传时重新切割切片无法找到起点。
- 为方便找到上传文件已上传的切片,在切片完全上传更换名字的时候存放到特定文件夹里(案例里会以文件本名为存放 chunks 的文件名)。
图12 断点续传切片存放位置
node返回切片信息后,只需要从已存在切片数+1位置进行切割。文章案例是会在切片完全上传后进行重命名,所以根据重命名后的切片数量判断重新切割位置能保证最后合成的文件信息无误。虽然会导致未完全上传的切片在续传的时候丢失(可能会出现上传进度86%,暂停重启后进度变为80%),本案例暂不考虑该情况。
针对以上优化的点做代码优化:
2.3.1 切片按顺序上传
由于 map
是js的同步方法,去掉 map
和 axios.all
,使用 for...of
代替, for...of
是ES6推出的具有iterator(可迭代)特性的方法,受控于方法里的异步操作(await等),详细可查看for...of 循环
但 for...of
无法拿到索引值,因为我们需要对原数组做处理,这里使用 Object.entries
,数组的索引值会被填入内容里转化成 [a,b]=>[[index1,a],[index2,b]]
格式,注意获取的index类型为 string
。
for (let [indexStr, { name, file, fileName }] of Object.entries(uploadChunkList)) {
//...
}
图13 按顺序上传切片
2.3.2 切片存放位置
在切片上传成功后重命名至以该文件名为命名的文件夹里
// node/index.js
const chunksContinuePath = `${chunksPath}/${fileName}`;
if (!fs.existsSync(chunksContinuePath)) {
await fs.mkdirs(chunksContinuePath);
}
const chunkName = `${chunksPath}/${fileName}/${name}`;
fs.renameSync(file.path, chunkName);
2.3.3 获取已上传切片
在上传切片前,客户端需要先从node里读取已上传切片数量,并依据此切割新的切片。通过文件名去读取存放文件夹下是否有对应的切片。
// client/index.js
// 查找文件是否已经存在上传的切片信息
const existChunksList = await axios.get(`/chekck-file_chunk-upload?fileName=${File.name}`).then(({ data = [] }) => data);
// 存在切片信息
if (existChunksList.length) {
// 状态更改为暂停
filesToCurrent.uploadStatus = uploadStatusMap.pause;
// 存储切片最后的索引值
filesToCurrent.currentChunk = existChunksList.length;
// 读取到的切片上传进度都设置为100%
filesToCurrent.chunkUploadInfo = { ...new Array(chunkCount).fill('').map((_, index) => index < existChunksList.length ? 1 : 0) };
};
// node/index.js
router.get('/chekck-file_chunk-upload', async (ctx) => {
const {
query: { fileName },
} = ctx;
// 切片读取位置和重命名的路径要一致
const chunksContinuePath = `${chunksPath}/${fileName}`;
let uploadedChunksList = [];
if (fs.existsSync(chunksContinuePath)) {
uploadedChunksList = fs.readdirSync(chunksContinuePath);
}
ctx.body = uploadedChunksList
});
2.3.4 重新切割
前端拿到已上传切片数量后只需要从索引值位置开始切割即可,再将剩余的切片上传完成进行合并即可。
const currentSize = chunkSize * currentChunk; // 计算剩余切片大小
for (let current = currentSize; current < File.size; current += chunkSize) {
fileChunkList.push({
fileName: File.name,
// 注意名字里的索引值应该是从已上传切片数量+1开始
name: `${File.name}-chunk-${currentChunk + fileChunkList.length + 1}`,
// 使用Blob.slice方法来对文件进行分割。
file: File.slice(current, current + chunkSize),
});
}
这边通过axios的 CancelToken
简单模拟一下断点的操作
const
axiosCancelToken = axios.
CancelToken
;
const
axiosSourceCancel = axiosCancelToken.source();
// 暂停上传
const
handleStopUpload = ({ id }) => {
axiosSourceCancel.cancel(
'中断上传'
);
// ...
};
因为要模拟继续上传的场景,因此不能在原取消的请求上继续发起,我们需要发起新的请求实例,简单地做一下封装,并使用每次生成的CancelToken去做取消操作。
图14 断点续传
三、结尾
对于切片上传、断点续传等功能其实很多第三方CDN服务已经提供了成熟的方案,例如七牛云的文件上传。除了上传前置校验、断点续传等场景外,还有上传压缩、上传加密等场景各式各样。以及经常与其绑定一起的文件下载相关技术(例如有趣的HLS)都是非常值得去学习的。
感兴趣的商家,可点击 → 免费试用有赞店铺~