点击关注“有赞coder”
获取更多技术干货哦~
本文案例里使用的组件来源于组件库 zent@7.4.4
重复上传
上传预览
拖拽上传
上传裁剪
上传进度可视化
文件压缩
上传前置校验
切片上传
上传加密
暂停&断网续传 ...
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<ArrayBuffer>;
slice(start?: number, end?: number, contentType?: string): Blob;
stream(): ReadableStream;
text(): Promise<string>;
}
/** 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
访问其文件信息。File
对象还额外返回 lastModified (返回文件最后修改日期)和 name (文件名)属性。File
实例信息{
lastModified: 1581424451211
lastModifiedDate: Tue Feb 11 2020 20:34:11 GMT+0800 (中国标准时间)
name: "计算机网络.pdf"
size: 70809807
type: "application/pdf"
webkitRelativePath: ""
}
accept
Mime 类型列表属性 | 描述 | 值 | 例子 |
图1 Input限制上传类型
// ...
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('通过文件类型校验!');
};
//...
image/jpeg
(虽然想要绕过前端的规则校验有非常多的方法)图2 通过更改png图片后缀绕过前端上传规则
图3 后缀和类型不一致
文件类型 | 规则 | hex(十六进制) |
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);
// 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));
})
}
图5 判断上传文件是否为png格式
File
对象直接获得,我们可以使用以下方法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;
}
通过找到图片信息的前置标志,然后再进行字节偏移
文件类型 | 前置标志 | 读取方式 |
高度: 21-24字节(4 bytes) | ||
宽度:第8字节+第7字节(2 bytes) 高度: 第10字节+第9字节(2 bytes) | ||
高度:(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 大文件上传之切片上传
获取上传文件信息。
前端根据实际情况进行切片。如果是断点续传,则需要从已上传的切片数后面开始切割。(注:需要给每个切片的名字带上唯一标志,一般为索引值)
上传切片至服务端。
通过 ajax 的 ProgressEvent
读取上传进度,前端展示。(注:ProgressEvent返回的是每个切片上传的进度,总进度应该是所有切片上传的进度)
服务器接收切片。
切片上传完毕后,前端发送请求通知服务器端合并切片,最后清除切片缓存。
返回上传结果 & 文件路径。
input
的实例,打开选择文件弹窗并获取上传文件信息。/** html */
<input type="file" ref={inputRef} style={{ display: 'none' }} onChange={handleFileSelect} />
<Button type="primary" onClick={handleAddFile}>
添加文件
</Button>
/** 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]);
}
好处: 限定了http请求的数量
坏处: 文件过大时有可能导致每块切片大小依然很大,失去了切片的意义
好处: 限定了切片的大小
坏处: 切片数量过多容易造成http负担
// index从1开始计算
`${File.name}-chunk-${fileChunkList.length + 1}`
切片索引值除了合并切片时使用外,在读取上传进度等地方也发挥了很大作用。
图9 前端生成切片信息
const formData = new FormData();
formData.append('name', name);
formData.append('file', file);
axios({
method: 'post',
data: formData,
header: {
'Content-type': 'multipart/form-data',
},
// ...
})
(0.5 * 0.2 + 0.25 * 0.8) * 100 = 30(%)
// 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;
}
@koa/multer
允许用户设定一个存放文件的位置。其实例对象提供了几种模式,为方便演示,本文案例统一使用 single。具体区别可以查看其定义。/** 流存放位置 */
const chunksPath = path.join(__dirname, '../static/stream');
@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/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);
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!`);
});
// 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');
});
切片需要按顺序上传。即在上传切片1后再上传切片2,解决同时上传出现后面的切片比前面的切片先上传成功的情况,避免续传时重新切割切片无法找到起点。
为方便找到上传文件已上传的切片,在切片完全上传更换名字的时候存放到特定文件夹里(案例里会以文件本名为存放 chunks 的文件名)。
node返回切片信息后,只需要从已存在切片数+1位置进行切割。文章案例是会在切片完全上传后进行重命名,所以根据重命名后的切片数量判断重新切割位置能保证最后合成的文件信息无误。虽然会导致未完全上传的切片在续传的时候丢失(可能会出现上传进度86%,暂停重启后进度变为80%),本案例暂不考虑该情况。
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)) {
//...
}
// 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);
// 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
});
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),
});
}
CancelToken
简单模拟一下断点的操作
const axiosCancelToken = axios.CancelToken;
const axiosSourceCancel = axiosCancelToken.source();
// 暂停上传
const handleStopUpload = ({ id }) => {
axiosSourceCancel.cancel('中断上传');
// ...
};