背景
最近在 vue-design-editor 开源项目中实现 psd 等多种文件格式上传解析成模板过程中, 发现搞定设计文件上传没有使用 input 实现文件上传, 所以我研究了一下相关技术, 总结了以下三种文件上传方法
- input 文件选择
- window.showOpenFilePicker 和 window.showDirectoryPicker 文件选择和文件夹选择
- 拖拽上传
为什么我不使用 file 里面的 type 字段直接当文件类型判断, 下图就是 psd 文件类型
大家发现是不是其实可以通过 type 字段进行判断, 但是告诉大家这是我把 png 图片直接修改了后缀名为 psd, 所以直接通过 type 进行文件类型校验是不准确的, 会有隐患, 如果我们通过二进制流及文件头校验就非常准确了
文件上传
input 文件上传
如果大家实现文件上传第一想到的应该是 input 文件上传, 这里就不多赘述了
<input type="file" accept=".png, .jpeg, .jpg, .psd" />
缺点就是样式很丑, 定制样式麻烦, 所以实现点击文件上传功能没有直接上手实现
window.showOpenFilePicker 和 window.showDirectoryPicker
支持唤起文件和文件夹, 属于浏览器全局方法,直接调用即可
showSaveFilePicker 保持文件功能
如果有以上三个 api 就可以在浏览器中实现代码编辑器功能
代码实现如下
const arrFileHandle = await window.showOpenFilePicker({
types: [
{
accept: {
"image/*": [".psd", ".png", ".gif", ".jpeg", ".jpg", ".webp"],
},
},
],
// 可以选择多个图片
multiple: false,
});
// 遍历选择的文件
for (const fileHandle of arrFileHandle) {
// 获取文件内容
const fileData = await fileHandle.getFile();
fileList.value.push(fileData);
}
不过使用该 api 有限制
- 需要 https 环境,如果是本地 localhost 不受此限制。
- 不能在 iframe 内使用,因为被认为不安全
所以我在使用该 api 过程中也有兜底逻辑
如果不支持也能点击唤起, 模拟 input 点击唤起
const input = document.createElement("input");
input.type = "file";
input.multiple = "multiple";
input.accept = ".png, .jpeg, .jpg, .psd";
input.click();
input.addEventListener("change", (file) => {});
拖拽上传
搞定设计支持拖拽上传文件, 所以也支持拖拽上传
import { tryOnMounted, useEventListener } from "@vueuse/core";
const dragArea = ref();
tryOnMounted(() => {
useEventListener(dragArea.value, "dragover", onDragOver);
useEventListener(dragArea.value, "dragleave", onDragLeave);
useEventListener(dragArea.value, "drop", onDrop);
});
function onDragOver(e: Event) {
e.preventDefault();
// 拖拽区域样式提示
// 也可以通过变量的形式控制dom
dragArea.value.classList.add("dragover");
isDrag.value = true;
}
function onDragLeave(e: Event) {
e.preventDefault();
dragArea.value.classList.remove("dragover");
isDrag.value = false;
}
async function onDrop(e: any) {
e.preventDefault();
dragArea.value.classList.remove("dragover");
isDrag.value = false;
const files = e.dataTransfer.files;
}
比 input 上传的优点是支持上传文件夹
二进制流及文件头校验文件类型
其他校验方法
在背景中也提过通过文件 type 字段校验类型的缺陷
还有一个方法也是有和 type 校验类型一样的缺陷
就是使用文件后缀来判断
const file = e.files[0];
//获取最后一个.的位置
const index = file.name.lastIndexOf(".");
//获取后缀
const ext = file.name.substr(index + 1);
console.log(ext);
文件后缀名
概念我们了解一下
文件扩展名是文件让电脑识别它的识别器,文件本身的格式是内在的,扩展名是外在的,一般情况下,他们是相互对应的,但如果扩展名被操作或修改,就不能与文件本身的格式对应,就会遇到打不开,打开乱码或无法显示,无法识别等情况。
解决
同样可以将其他类型的文件上传至服务器,或者文件压根就没有后缀,那又要怎么判断呢?因此前端需要使用一个更加合理的方式。
所以使用二进制及文件头的形式来校验文件格式
虽然文件后缀可以手动改,因此可以直接通过读取文件的二进制来判断。
通常来说固定类型的文件头都是相同的,比如说 jpeg 的文件头是 FF D8 FF E0。
枚举类型相关的文件头
const signatureList = [
{
mime: "video/mp4",
ext: "mp4",
offset: 4,
signature: [0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d],
},
{
mime: "video/mp4",
ext: "mp4",
offset: 4,
signature: [0x66, 0x74, 0x79, 0x70, 0x6d, 0x70, 0x34],
},
{
mime: "image/jpeg",
ext: "jpeg",
signature: [0xff, 0xd8, 0xff],
},
{
mime: "image/png",
ext: "png",
signature: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
},
{
mime: "image/gif",
ext: "gif",
signature: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61],
},
{
mime: "image/gif",
ext: "gif",
signature: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61],
},
{
mime: "image/vnd.adobe.photoshop",
ext: "psd",
signature: [0x38, 0x42, 0x50, 0x53],
},
];
FileReader 读取文件的二进制,之后判断二进制的前几位是否跟符合相应类型文件的文件头。
/**
* @description 校验给出的字节数据是否符合某种MIME Type的signature
* @param {Array} bufferss 字节数据
* @param {Object} typeItem 校验项 { signature, offset }
*/
const check = (bufferss: Buffer, { signature, offset = 0 }: any) => {
for (let i = 0, len = signature.length; i < len; i++) {
// 传入字节数据与文件signature不匹配
// 需考虑有offset的情况以及signature中有值为undefined的情况
if (bufferss[i + offset] !== signature[i] && signature[i] !== undefined) return false;
}
return true;
};
/**
* @description 获取文件二进制数据
* @param {File} file 文件对象实例
* @param {Object} options 配置项,指定读取的起止范围
*/
const getArrayBuffer = (file: File, { start, end }: any) => {
return new Promise((reslove, reject) => {
try {
const reader = new FileReader();
reader.onload = (e: any) => {
const buffers = new Uint8Array(e.target.result);
reslove(buffers);
};
reader.onerror = (err) => reject(err);
reader.onabort = (err) => reject(err);
reader.readAsArrayBuffer(file.slice(start, end));
} catch (err) {
reject(err);
}
});
};
/**
* @description 获取文件的真实类型
* @param {File} file 文件对象实例
* @param {Object} options 配置项,指定读取的起止范围
*/
const getFileType = (file: File, options = { start: 0, end: 32 }) =>
getArrayBuffer(file, options)
.then((buffers: any) => {
// 找出签名列表中定义好的类型,并返回
for (let i = 0, len = signatureList.length; i < len; i++) {
if (check(buffers, signatureList[i])) {
const { mime, ext } = signatureList[i];
return { mime, ext };
}
}
// 未找到则返回file对象中的信息
return { mime: file.type, ext: "" };
})
.catch((err) => err);