传统原生表单上传样式已经不满足现在前端发展的需求,现在市场通常采用各种第三方UI组件库满足日常所需。但在开发中包括我在内的不少开发者知其然却不知其所以然,这篇文章让我们彻底搞懂文件上传的各个场景
单一文件上传
实现思路
- 实现上传功能仍然需要借助 input 的 file 属性,并隐藏隐藏元素
- 使用 js 手动触发 input 事件
- 用 html 元素优化代替原生的 input 的样式
优化结果
实现代码
html
<div class="container">
<div class="buttonContainer">
<input type="file" id="inputInput" style="display:none">
<div class="button-select button">选择文件</div>
<div class="button-submit button">上传文件</div>
</div>
<div>文件仅支持png格式的文件</div>
<div>
<ul id="list"></ul>
</div>
</div>
HTML 部分的样式内容忽略,但其中的技术点在于 隐藏了 input 元素 ,用 div 模拟了选择文件和上传文件按钮
javaScript
let inputInput = document.querySelector("#inputInput")
let select = document.querySelector(".button-select")
let submit = document.querySelector(".button-submit")
let list = document.querySelector("#list")
let filesData;
// 获取本地文件
inputInput.onchange=()=>{
let files = inputInput.files[0]
console.log(files)
if(files.type !== "image/png"){
alert("文件只支持png格式")
return
}
if(files.size>2*1024*1024){
alert("文件最大支持2MB")
return
}
filesData = files
list.innerHTML = `
<li>${files.name} <span class="del">移除</span></li>
`
}
// 时间委托
list.onclick = (event)=>{
if(event.target.innerHTML === "移除"){
list.innerHTML = "";
filesData = null
}
}
// 触发上传的click事件
select.onclick = ()=>{
inputInput.click()
}
// 提交
submit.onclick = ()=>{
if(filesData){
let formData = new FormData();
formData.append("files",filesData)
fetch("www.baidu.com",{
method:"POST",
body:formData
}).then((response)=>{
console.log(response)
})
}
}
上面的 javascript 代码, 获取“选择文件” select ,在 select 点击的时候触发 input 的点击事件,弹出文件选择器; 绑定 input 的 change 事件 监听选中的文件对象, 通过 files 属性获取对象全部信息并作相应的业务判断,比如 文件类型、文件大小、文件上传列表等。通过点击 submit 元素手动创建 formData 实例 将文件元素添加进去,传给后端
单一文件上传BASE64
单一文件上传将文件转为 base64编码 , 主要借助 FileReader 实例对象,该对象不仅可以转化为 base64,还支持 readAsArrayBuffer 的转换。
实现结果
以上代码的实现形式,点击 选择文件 按钮,选择文件后自动上传。
实现方式
html
<div class="container">
<div class="buttonContainer">
<input type="file" id="inputInput">
<div class="button-select button">选择文件</div>
</div>
<div>文件仅支持png格式的文件</div>
</div>
javascript
let inputInput = document.querySelector("#inputInput")
let select = document.querySelector(".button-select")
let fileReader =(files)=>{
return new Promise((resolve,reject)=>{
let fileReader = new FileReader();
fileReader.readAsDataURL(files)
fileReader.onload=(event)=>{
resolve(event.target.result)
}
})
}
// 获取本地文件
inputInput.onchange=async ()=>{
let files = inputInput.files[0]
if(files.size>2*1024*1024){
alert("文件最大支持2MB")
return
}
let base64 = await fileReader(files)
fetch("http://www.baidu.com",{
method:"POST",
headers:{
'Content-Type': 'application/x-www-form-urlencoded'
},
body:JSON.stringify({
files:base64,
filesName:files.name
})
})
}
// 触发上传的click事件
select.onclick = ()=>{
inputInput.click()
}
实现自动上传的逻辑,主要是监听 input file 的 change 事件,在 change 事件中直接处理图片为 base64 编码,然后请求后台接口。
值得注意的是 FileReader 的实例为异步方法,所以上面代码对 FileReacer 做了 Promise 的封装,至于不了解 Promise 的小伙伴请自行百度知识盲区。
文件上传缩略图
借助 base64 编码 实现文件缩略图效果
实现结果
实现逻辑
将文件 编码为base64,复制给 img 元素
html
<div class="container">
<div class="buttonContainer">
<input type="file" id="inputInput">
<div class="button-select button">选择文件</div>
<div class="button-submit button">上传文件</div>
</div>
<div>
<img src="" alt="" id="img">
</div>
</div>
javascript
let inputInput = document.querySelector("#inputInput")
let select = document.querySelector(".button-select")
let submit = document.querySelector(".button-submit")
let img = document.querySelector("#img")
let filesData;
// 将文件转成base64
const toBase64 = (files)=>{
return new Promise((resolve,reject)=>{
let fileReader = new FileReader();
fileReader.readAsDataURL(files)
fileReader.onload=(event)=>{
resolve(event.target.result)
}
})
}
// 获取本地文件
inputInput.onchange=async ()=>{
let files = inputInput.files[0]
console.log(files)
if(files.size>2*1024*1024){
alert("文件最大支持2MB")
return
}
filesData = files;
let base64 = await toBase64(files);
console.log("base64",base64)
img.src = base64
}
// 触发上传的click事件
select.onclick = ()=>{
inputInput.click()
}
// 提交
submit.onclick = ()=>{
if(filesData){
let formData = new FormData();
formData.append("files",filesData)
fetch("www.baidu.com",{
method:"POST",
body:formData
}).then((response)=>{
console.log(response)
})
}
}
文件上传进度管控
使用 axios 或者 原生 XHR 发起接口请求,其中这两个实例中提供了 onUploadProgress 属性, onUploadProgress 为一个回调函数,监听上传状态 其中 loaded 为当前上传的数量,total 为文件总量。
值得注意的是新的 fetch 请求方式,无法监听文件上传和下载进度状态
实现结果
实现方式
html
<div class="container">
<div class="buttonContainer">
<input type="file" id="inputInput">
<div class="button-select button">选择文件</div>
<div class="button-submit button">上传文件</div>
</div>
<div>文件仅支持png格式的文件</div>
<div class="progress">
<div class="progressBar" style="width: 10%;"></div>
</div>
</div>
javascript
let inputInput = document.querySelector("#inputInput")
let select = document.querySelector(".button-select")
let submit = document.querySelector(".button-submit")
let progressBar = document.querySelector(".progressBar")
let filesData;
// 获取本地文件
inputInput.onchange=()=>{
let files = inputInput.files[0]
console.log(files)
if(files.type !== "text/plain"){
alert("文件只支持text格式")
return
}
if(files.size>2*1024*1024){
alert("文件最大支持2MB")
return
}
filesData = files
}
// 触发上传的click事件
select.onclick = ()=>{
inputInput.click()
}
// 提交
submit.onclick = ()=>{
if(filesData){
let formData = new FormData();
formData.append("files",filesData)
axios({
method:"POST",
url:"https://jsonplaceholder.typicode.com/posts/",
data:formData,
onUploadProgress:(ev)=>{
let {loaded,total} = ev;
progressBar.style.width = `${loaded/total*100}%`
}
}).then((response)=>{
console.log(response)
})
}
}
多文件上传及进度管控
同单文件实现思路一样,多文件上传针对每个文件进行进度管控的方案在于,每一个文件都会单独请求一次接口。
优点:可以对每一个文件进行管控,并且会降低文件上传到容量
缺点:会重复多次的调用一个接口,对接口和服务器造成的压力较大
针对于合并多文件请求的方式之前在 element 模块中《文件上传》中总结过 实现的方法, 感兴趣的小伙伴可以移步去另一篇文章
实现结果:
实现方式
html
<div class="container">
<div class="buttonContainer">
<input type="file" id="inputInput" multiple>
<div class="button-select button">选择文件</div>
<div class="button-submit button">上传文件</div>
</div>
<div>文件仅支持png格式的文件</div>
<div>
<ul id="list">
</ul>
</div>
</div>
javascript
let inputInput = document.querySelector("#inputInput")
let select = document.querySelector(".button-select")
let submit = document.querySelector(".button-submit")
let list = document.querySelector("#list")
let filesData;
// 获取本地文件
inputInput.onchange=()=>{
filesData =Array.from(inputInput.files)
console.log("多文件上传",filesData)
filesData.map((item)=>{
list.innerHTML+= `
<li>
${item.name}
<div class="progress">
<div class="progressBar" data-name="${item.name}" style="width:10%"></div>
</div>
</li>
`
})
}
// 触发上传的click事件
select.onclick = ()=>{
inputInput.click()
}
// 提交
submit.onclick = ()=>{
console.log(filesData)
if(filesData.length){
filesData.map((item)=>{
let formData = new FormData();
formData.append("files",item)
axios({
method:"POST",
url:"https://jsonplaceholder.typicode.com/posts/",
data:formData,
onUploadProgress:(ev)=>{
let el = document.querySelector(`div[data-name="${item.name}"]`)
let {loaded,total} = ev;
el.style.width = `${loaded/total*100}%`
}
}).then((response)=>{
console.log(response)
})
})
}
}
上面 JS 的实现逻辑为 每一个 li 元素绑定唯一的 自定义属性目前以为文件名称作为唯一值,在调用接口的中监听 onUploadProgress 利用当前的 file 对象的 name 属性,找到相对的 DOM 节点,计算进度并赋值。
拖拽上传
拖拽上传的实现方式 主要于借助了 H5 的API dragover \ drop 方法,用来监听在此区域内的鼠标移动和放下事件。
实现结果
实现方式
html
<div class="container" id="upWrapper">
<div class="content">
<img src="./img/上传.png" alt="">\
</div>
</div>
javascript
let upWrapper = document.querySelector("#upWrapper")
// 进入事件
upWrapper.addEventListener("dragover",(event)=>{
event.preventDefault();
})
// 放置事件
upWrapper.addEventListener("drop",(event)=>{
event.preventDefault();
submit(event.dataTransfer.files)
})
// 提交事件
const submit = (filesData)=>{
let formData = new FormData();
formData.append("files",filesData)
axios({
method:"POST",
url:"https://jsonplaceholder.typicode.com/posts/",
data:formData,
}).then((response)=>{
console.log(response)
})
}
上面 js 的主要逻辑为, 在 drop 事件中获取 文件的属性 event.dataTransfer.files ,然后将文件属性传给服务端。
大文件上传和断点续传
在一些业务需求中,需要用户上传一些大容量视频,这时用传统的文件上传方式会造成传输效率变慢的问题。在上传文件过程中一旦出现上传失败,就需要重新将文件整个重新上传一遍。这样在无论是用户提体验还是服务端都是一种时间和资源的浪费。
在观察一些存储大厂,比如七牛云、百度云盘等服务商,他们在上传和下载的时候会把文件碎片化,这样不仅可以大大提高传输速度,且也兼容的断电续传的功能,即文件上传失败,系统对自动检索失败的文件碎片,等待用户重新上传该文件时会自动真甄别失败的文件碎片进行上传。
下面就介绍下前端是如何实现大文件上传和断点续传的
实现的技术点
- 需要借助 FileReader 实例对象进行 Budder 的转换
- 需要使用第三放 Spark-md5 来生成HASH
- 需要使用正则获取文件的扩展名,后期和结合 HASH 值合并成一个新的文件名称(上图所示)
- 需要借助 file 对象中的 slice 方法对文件切割;使用方法和数组的 slice 参数一致
代码实现
HTML
<div class="container">
<div class="buttonContainer">
<input type="file" id="inputInput">
<div class="button-select button">选择文件</div>
<div class="button-submit button">上传文件</div>
</div>
<div>文件仅支持png格式的文件</div>
<div class="progress">
<div class="progressBar" style="width: 10%;"></div>
</div>
</div>
javascript
let inputInput = document.querySelector("#inputInput")
let select = document.querySelector(".button-select")
let submit = document.querySelector(".button-submit")
let progressBar = document.querySelector(".progressBar")
let chunks=[]; // 切片的集合
// 生成文件Buffer的封装
const changeBuffer = (file)=>{
return new Promise((resolve)=>{
let fileReader = new FileReader();
fileReader.readAsArrayBuffer(file);
fileReader.onload = (event)=>{
// 根据文件内容生成HASH值
let bufferData = event.target.result;
let spark = new SparkMD5.ArrayBuffer();
let HASH; // 文件的Hash名
let suffix; //获取文件后缀名
spark.append(bufferData);
HASH = spark.end();
suffix = file.name.replace(/.+\./, "");
resolve({
bufferData,
HASH,
suffix,
fileName:`${HASH}.${suffix}`
})
}
})
}
// 获取本地文件
inputInput.onchange= async ()=>{
let files = inputInput.files[0]
let {HASH,suffix,bufferData,fileName} = await changeBuffer(files);
let section = []; //文件切片上传的记录
/*文件切片处理(以固定大小上传优先,如果切片数量过大,就采用固定数量)*/
let max = 1024*100 // 每个文件最大在100KB
let count = Math.ceil(files.size / max); // 一共要有多少切片
/*超过100个切片,就采用固定数量方式上传*/
if(count>100){
max = files.size / 100;
count = 100;
}
for(let index=0;index<count;index++){
chunks.push({
file:files.slice(index*max,max*(index+1)),
fileName:`${HASH}_${index+1}.${suffix}`
})
}
}
// 触发上传的click事件
select.onclick = ()=>{
inputInput.click()
}
// 提交
submit.onclick = ()=>{
// 上传成功的处理
let ChunkState = [];
const upSuccess = (state)=>{
ChunkState.push(state);
// 判断是否全部为true
let ChunkStateEvery = ChunkState.every((item)=>{return item==true})
if(chunks.length === ChunkState.length){
if(ChunkStateEvery){
// 调用后台接口 通知服务器可以对文件进行合并
//...
alter("文件上传成功")
}else{
alter("文件上传出错成重新上传")
}
}
// 更新进度条
progressBar.style.width = `${count(ChunkState,true)/chunks.length*100}%`
}
// 循环切片集合逐个请求
chunks.map((item)=>{
let formData = new FormData();
formData.append("files",item.file)
formData.append("fileName",item.fileName)
axios({
method:"POST",
url:"https://jsonplaceholder.typicode.com/posts/",
data:formData,
}).then((response)=>{
if(response.code == 200){
upSuccess(true);
}
}).catch(()=>{
upSuccess(false);
})
})
}
// 算法封装查找数组中某个元素出现的次数
const count = (arr, item)=>{
var count = 0;
arr.forEach((e)=> item === e ? count++:0);
return count;
}
大文件切片上传前后端整体实现逻辑
整体流程可参考上方流程图,文件采取 steam 流方式传递给后端
实现技术栈 vue3 + KOA2 demo地址
文件切片上传
关键字
- 文件切片
- 并发上传
- 合并文件
- 数据流式传输
- fs 依赖增强
- 进度管控
- 暂停上传
前端完整部分
<template>
<div>
<el-upload
ref="uploadRef"
class="upload-demo"
drag
action="https://run.mocky.io/v3/9d059bf9-4660-45f2-925d-ce80ad6c4d15"
multiple
:on-change="handleUPload"
:auto-upload="false"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
Drop file here or <em>click to upload</em>
</div>
<template #tip>
<div class="el-upload__tip">
jpg/png files with a size less than 500kb
</div>
</template>
</el-upload>
</div>
<img :src="imgUrl" alt="">
<div>
<video :src="imgUrl" controls></video>
</div>
<!-- 进度 -->
<div>总进度: {{progressAllNum}}</div>
<div v-for="(value, key) in progressList" :key="key">
{{ key }}: {{ value }}
</div>
<el-button class="ml-3" type="success" @click="handleSubmit">
上传
</el-button>
<el-button class="ml-3" type="success" @click="handleClose">
取消上传
</el-button>
</template>
<script lang="ts" setup>
import { UploadFilled } from '@element-plus/icons-vue'
import {ref,reactive} from "vue"
import { ElMessage, type UploadInstance } from 'element-plus'
import axios from "axios"
/**上传表单实例 */
const uploadRef = ref<UploadInstance>();
/**上传文件实例 */
const fileInfo = ref<any>();
/**axios token */
const axiosTokenList = reactive<any[]>([]);
/**进度条 */
const progressList = reactive({})
const progressAllNum = ref(0)
/**预览本地路径 */
const imgUrl = ref("")
/*文件 hash 名称 */
let fileHashName = "";
/*手动提交*/
const handleSubmit = async ()=>{
/**生成hash */
fileHashName = await createHash(fileInfo.value.raw)
/*根据文件的 hash 值,请求服务端文件是否存在实现大文件秒传*/
const result:any = await verifyFileUpload(fileHashName)
console.log("测试文件秒传",result)
if(result.data.code===101){
ElMessage({
message: '文件传输完成.',
type: 'success',
})
return
}
/*文件切片*/
const chunks = fileChunk(fileInfo.value.raw,fileHashName)
console.log(chunks)
/*并行上传切片文件*/
const requestList = chunks.map((item)=>{
/*记录每一个请求的 axios token 用于取消请求*/
const createToken = axios.CancelToken.source();
axiosTokenList.push(createToken)
return createFileUploadRequest(fileHashName,item.chunkFileName,item.chunk,createToken)
})
try {
await Promise.all(requestList);
/**请求成功后通知服务端合并文件*/
await axios.get(`/api/marge/${fileHashName}`)
console.log("上传成功")
} catch (error) {
console.log("上传失败",error)
}
}
const handleUPload = async (file)=>{
console.log(file)
fileInfo.value = file;
/**预览 */
imgUrl.value = URL.createObjectURL(file.raw)
}
/**获取 hash 值,该方法为手动获取hash值,可以是用 Spark-md5 替代*/
const createHash = async (file)=>{
const arrayBuffer = await file.arrayBuffer()
console.log(arrayBuffer)
/**
* crypto.subtle:这是浏览器提供的Web Cryptography API的一个对象,用于进行底层密码学操作,如加密、解密、哈希等。
* 'SHA-256' 表示哈希算法的一种格式,可以将任务长度的数据映射成固定长度(32字节)
* */
const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer);
const hashResult = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join("")
return hashResult;
}
/**文件切片 */
const fileChunk = (file,fileHashName)=>{
let chunks:any[] = [];
let chunkSize = 1024 * 1024 * 100;
let count = Math.ceil(file.size / chunkSize);
for(let i=0;i<count;i++){
let chunk = file.slice(i * chunkSize , (i + 1) * chunkSize)
chunks.push({
chunk,
chunkFileName:`${fileHashName}-${i}`
})
}
return chunks;
}
/**创建文件上传请求 */
const createFileUploadRequest = (fileName,chunkFileName,chunk,createToken)=>{
return axios({
url:`/api/upload/${fileName}`,
method:"post",
data:chunk,
headers:{
"Content-Type": "application/octet-stream"
},
params:{
chunkFileName
},
/*根据 axios 计算上传进入*/
onUploadProgress:(progressEvent:any)=>{
const percentCompleted = Math.round(progressEvent.loaded * 100 / progressEvent.total);
progressList[chunkFileName] = percentCompleted;
var progressAll = calculateAverage();
progressAllNum.value = progressAll;
},
cancelToken:createToken.token
})
}
/*计算上传总进度*/
const calculateAverage = ()=>{
// 获取对象的值数组
const values = Object.values(progressList);
// 如果对象为空,则返回 0
if (values.length === 0) {
return 0;
}
// 使用reduce方法计算总和
const sum:any = values.reduce((acc:any, curr:any) => acc + curr, 0);
// 返回平均值
return sum / values.length;
}
/**文件秒传验证 */
const verifyFileUpload = async (fileName)=>{
return await axios.get(`/api/quickUpload/${fileName}`);
}
/**取消上传 */
const handleClose = ()=>{
axiosTokenList.forEach((item,index)=>{
item.cancel('请求被用户取消');
})
}
</script>
上述代码中并没有采用传统的 formData 的上传方式,而是 application/octet-stream
,因为后者直接将数据转换成二进制流传给后台,这种方式针对传输的数据更加灵活,避免了被 formData 类型的限制。
node端 koa
避免使用 koa-body 和 koa-bodyparser 中间件,因为该中间件屏蔽了二进制流数据传输格式
清除掉 app.js 中的
const bodyparser = require('koa-bodyparser')
app.use(bodyparser({
enableTypes:['json', 'form', 'text']
}))
创建路由,接受文件流
const router = require('koa-router')()
const fs = require("fs-extra");
const path = require('path');
/**上传文件*/
router.post('/upload/:fileName', async (ctx, next) => {
const chunkFileName = ctx.params.fileName;
const fileName = ctx.request.query;
// 确保块文件夹存在
await fs.ensureDir(`./uploads/${chunkFileName}`);
/*文件写入方法封装*/
const result = await fileInit(ctx,chunkFileName,fileName);
if(result.code === 101){
ctx.body = {
code:101,
data:{
chunkFileName,
fileName,
body:ctx.request.body
},
message:"请求成功"
}
}else{
ctx.body = {
code:102,
data:{
chunkFileName,
fileName,
body:ctx.request.body
},
message:"请求失败"
}
}
})
/**文件处理 */
const fileInit = (ctx,chunkFileName,fileName)=>{
return new Promise((resolve,reject)=>{
/*创建一个 buffer 对象*/
let bufferData = Buffer.alloc(0);
ctx.req.on('data', async (chunk) => {
/*将请求的 chunk 字符串,转换成 budder 对象*/
const chunkData = Buffer.from(chunk);
/*由于 chunk 会被请求分成不同的数据包,所以在接受的同时合并到 bufferData 变量中存储*/
bufferData = Buffer.concat([bufferData, chunkData]);
})
/*当传输完成后触发 end 监听*/
ctx.req.on('end', async () => {
try {
/*将临时存储的 buffer 对象到文件中*/
await fs.writeFileSync(`./uploads/${chunkFileName}/${fileName.chunkFileName}`,bufferData);
console.log("传输完成")
resolve({code:101,message:"文件上传成功"})
} catch (error) {
console.log(error)
resolve({code:102,message:"文件上传失败"})
}
})
/*请求中断后会触发该监听*/
ctx.req.on("close",async ()=>{
console.log("请求被中断")
ctx.body = {
code:103,
message:"请求被中断"
}
})
})
}
/**合并文件接口 */
router.get("/marge/:fileName",async (ctx,next)=>{
const chunkFileName = ctx.params.fileName;
/**上传目录路径*/
const uploadsDirectory = `./uploads/${chunkFileName}`;
/**合并后的文件路径和文件名*/
const mergedFilePath = `./uploads/${chunkFileName}.mp4`;
/**创建一个空的合并文件*/
fs.ensureFileSync(mergedFilePath);
/**读取上传目录中的所有文件*/
fs.readdir(uploadsDirectory,async (err, files) => {
const storedFiles = files.sort(compare)
/**对每个文件进行迭代*/
storedFiles.forEach((file) => {
const filePath = path.join(uploadsDirectory, file);
/**将当前文件的内容追加到合并文件中*/
fs.appendFileSync(mergedFilePath, fs.readFileSync(filePath));
/**删除原始文件*/
fs.unlinkSync(filePath);
console.log(`${file} 合并完成,已被删除`);
});
await fs.remove(uploadsDirectory)
console.log('文件合并完成');
});
/**根据文件名排序 */
function compare(a, b) {
const numA = parseInt(a.split('-')[1]);
const numB = parseInt(b.split('-')[1]);
return numA - numB;
}
ctx.body = {
code:101,
data:{},
message:"请求成功"
}
})
/*验证当前文件是否存在,如果存在则不需要客户端重新上传,直接返回相对的成功结果*/
router.get("/quickUpload/:fileName",async (ctx,next)=>{
const fileName = ctx.params.fileName;
const result = await fs.pathExists(`./uploads/${fileName}.mp4`);
if(result){
ctx.body = {
code:101,
message:"请求成功"
}
}else{
ctx.body = {
code:102,
message:"请求成功"
}
}
})
module.exports = router