大文件上传
文件上传业务涉及到很多细节,比如样式相关的,对文件名的生成,大小格式校验,图片的预览,文件列表的显示,文件的移除,上传过程中的防抖,进度管控,多文件上传,大文件上传等。
后端主要负责对文件的接收处理,涉及到文件的读写,文件夹下文件列表的读取等。
通过multer模块,对上传的文件进行接收,通过spark-md5对接收的文件buffer生成唯一的hash,这样当客户端上传同一份文件时,最终在服务器端只会保留一份,节省了资源空间。
下文代码都在本仓库
yexiyue/large-file-upload (opens new window)
下文详细介绍了文件上传
#1.axios基本配置
请求配置 | Axios 中文文档 | Axios 中文网 (axios-http.cn)(opens new window)
import axios,{AxiosError} from 'axios'
import qs from 'qs'
//创建实例
const api=axios.create()
api.defaults.baseURL='http://localhost:8888'
//设置请求头 数据格式为multipart/form-data
api.defaults.headers["Content-Type"]="multipart/form-data"
//设置请求主体格式化
api.defaults.transformRequest=(data,headers)=>{
const ContentType=headers['Content-Type']
//只有这种格式才进行转换
if(ContentType==='application/x-www-form-urlencoded'){
return qs.stringify(data)
}
return data
}
//设置超时时间
api.defaults.timeout=1500
//响应拦截器
api.interceptors.response.use((res)=>{
return res.data
},(error:AxiosError)=>{
//统一做失败提示处理
console.log(error.message)
return Promise.reject(error)
})
export {api}
基本项目结构
#样式文件
.upload_box {
position: relative;
box-sizing: border-box;
padding: 10px;
width: 400px;
min-height: 150px;
border: 1px dashed #DDD;
}
.upload_box .upload_inp {
display: none;
}
.upload_box .upload_button {
position: relative;
box-sizing: border-box;
margin-right: 10px;
padding: 0 10px;
min-width: 80px;
height: 30px;
line-height: 30px;
text-align: center;
border: none;
cursor: pointer;
background-color: #DDD;
/* 设置溢出隐藏,让伪类隐藏起来 */
overflow: hidden;
}
/* 让after 伪类位置偏下,这样就隐藏起来了 */
.upload_box .upload_button:after {
position: absolute;
top: 30px;
left: 0;
z-index: 999;
transition: top .1s;
box-sizing: border-box;
padding-left: 25px;
width: 100%;
height: 100%;
content: 'loading...';
text-align: left;
background: url('./loading.gif') no-repeat 5px center #EEE;
background-size: 15px 15px;
color: #999;
}
.upload_box .upload_button.loading {
cursor: inherit;
}
/* 添加loading 类之后让after 伪类位置为0显示出来 */
.upload_box .upload_button.loading:after {
top: 0;
}
.upload_box .upload_button.select {
background: #409eff;
color: #FFF;
}
.upload_box .upload_button.upload {
background: #67c23a;
color: #FFF;
}
.upload_box .upload_button.disable {
background: #EEE;
color: #999;
cursor: inherit;
}
/* 选择框下面的提示文字样式 */
.upload_box .upload_tip {
margin-top: 10px;
line-height: 25px;
color: #999;
font-size: 12px;
}
.upload_box .upload_list,
.upload_box .upload_abbre,
.upload_box .upload_progress,
.upload_box .upload_mark {
display: none;
}
.upload_box .upload_list {
margin-top: 10px;
}
.upload_box .upload_list li {
line-height: 25px;
font-size: 0;
}
.upload_box .upload_list li span {
display: inline-block;
margin-right: 10px;
max-width: 70%;
color: #999;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.upload_box .upload_list li span em {
padding: 0 5px;
color: lightcoral;
cursor: pointer;
font-style: normal;
}
.upload_box .upload_abbre,
.upload_box .upload_progress {
margin-top: 10px;
}
.upload_box .upload_abbre img {
display: block;
width: 100%;
}
.upload_box .upload_progress {
position: relative;
height: 5px;
background: #EEE;
}
.upload_box .upload_progress .value {
position: absolute;
top: 0;
left: 0;
z-index: 999;
height: 100%;
width: 0%;
background: #67c23a;
transition: width .3s;
}
.upload_box .upload_drag {
height: 130px;
position: relative;
}
.upload_box .upload_drag .icon,
.upload_box .upload_drag .text {
position: absolute;
left: 50%;
transform: translate(-50%, -50%);
}
.upload_box .upload_drag .icon {
top: 40%;
width: 80px;
height: 62px;
background: url("upload.png") no-repeat;
background-size: 100% 100%;
}
.upload_box .upload_drag .text {
top: 80%;
line-height: 25px;
color: #999;
font-size: 12px;
}
.upload_box .upload_drag .text a {
color: #409eff;
}
.upload_box .upload_mark {
position: absolute;
top: 0;
left: 0;
z-index: 999;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, .8);
font-size: 12px;
color: #FFF;
text-align: center;
line-height: 150px;
}
.container {
padding: 20px 100px;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.container .item h3 {
line-height: 40px;
}
#html文件
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>大文件上传</title>
<!-- IMPORT CSS -->
<link rel="stylesheet" href="css/reset.min.css">
<link rel="stylesheet" href="css/upload.css">
</head>
<body>
<div class="container">
<div class="item">
<h3>单一文件上传「FORM-DATA」</h3>
<section class="upload_box" id="upload1">
<!-- accept=".png" 限定上传文件的格式 -->
<input type="file" class="upload_inp" accept=".jpg,.png,.jpeg">
<div class="upload_button_box">
<button class="upload_button select">选择文件</button>
<button class="upload_button upload">上传到服务器</button>
</div>
<div class="upload_tip">只能上传 PNG/JPG/JPEG 格式图片,且大小不能超过2MB</div>
<ul class="upload_list">
<!-- <li>
<span>文件:...</span>
<span><em>移除</em></span>
</li> -->
</ul>
</section>
</div>
<div class="item">
<h3>单一文件上传「BASE64」,只适合图片</h3>
<section class="upload_box" id="upload2">
<input type="file" class="upload_inp" accept=".jpg,.jpeg,.png">
<div class="upload_button_box">
<button class="upload_button select">上传图片</button>
</div>
<div class="upload_tip">只能上传jpg/png格式图片,且大小不能超过2mb</div>
</section>
</div>
<div class="item">
<h3>单一文件上传「缩略图处理」</h3>
<section class="upload_box" id="upload3">
<input type="file" class="upload_inp" accept=".jpg,.jpeg,.png">
<div class="upload_button_box">
<button class="upload_button select">选择文件</button>
<button class="upload_button upload">上传到服务器</button>
</div>
<div class="upload_abbre">
<img src="" alt="">
</div>
</section>
</div>
</div>
<div class="container">
<div class="item">
<h3>单一文件上传「进度管控」</h3>
<section class="upload_box" id="upload4">
<input type="file" class="upload_inp">
<div class="upload_button_box">
<button class="upload_button select">上传文件</button>
</div>
<div class="upload_progress">
<div class="value"></div>
</div>
</section>
</div>
<div class="item">
<h3>多文件上传</h3>
<section class="upload_box" id="upload5">
<input type="file" class="upload_inp" multiple>
<div class="upload_button_box">
<button class="upload_button select">选择文件</button>
<button class="upload_button upload">上传到服务器</button>
</div>
<ul class="upload_list">
<!-- <li key='xx'>
<span>文件:xxxxx</span>
<span><em>移除</em></span>
</li> -->
</ul>
</section>
</div>
<div class="item">
<h3>拖拽上传</h3>
<section class="upload_box" id="upload6">
<input type="file" class="upload_inp">
<div class="upload_drag">
<i class="icon"></i>
<span class="text">将文件拖到此处,或<a href="javascript:;" class="upload_submit">点击上传</a></span>
</div>
<div class="upload_mark">正在上传中,请稍等...</div>
</section>
</div>
</div>
<div class="container">
<div class="item">
<h3>大文件上传</h3>
<section class="upload_box" id="upload7">
<input type="file" class="upload_inp">
<div class="upload_button_box">
<button class="upload_button select">上传图片</button>
</div>
<div class="upload_progress">
<div class="value"></div>
</div>
</section>
</div>
</div>
<script src="./src/main.ts" type="module"></script>
</body>
</html>
该学习项目采用vite构建打包
运行结果
#2.单文件上传
#1.获取要操作的dom
src/utils/index.ts
interface Options {
inputSelector: string;
selectSelector: string;
uploadSelector: string;
tipSelector: string;
listSelector: string;
}
//获取要操作的dom元素
export function getUploadDom(
id: string,
{
inputSelector,
selectSelector,
uploadSelector,
tipSelector,
listSelector,
}: Options = {
inputSelector: ".upload_inp",
selectSelector: ".upload_button.select",
uploadSelector: ".upload_button.upload",
tipSelector: ".upload_tip",
listSelector: ".upload_list",
}
) {
const section = document.getElementById(id);
return {
input: section?.querySelector(inputSelector),
select: section?.querySelector(selectSelector),
uploadSelector: section?.querySelector(uploadSelector),
tipSelector: section?.querySelector(tipSelector),
listSelector: section?.querySelector(listSelector),
};
}
#2.格式校验
import { getUploadDom } from "../utils";
const {select,input}=getUploadDom('upload1')
//......
//监听用户选择的文件
input.addEventListener('change',()=>{
const file=input.files![0]
//如果没文件就返回,下面代码不执行
if(!file)return;
//限制上传格式 可以配合input accept属性
if(!/(jpg|png|jpeg)/i.test(file.type)){
alert('上传的图片只能是png,jpg,jpeg等格式')
return;
}
})
#3.限制文件大小
import { getUploadDom } from "../utils";
const {select,input}=getUploadDom('upload1')
//监听用户选择的文件
input.addEventListener('change',()=>{
const file=input.files![0]
//如果没文件就返回,下面代码不执行
if(!file)return;
//......
//限制文件上传大小
if(file.size>2*1024*1024){
alert('上传的图片不能大于2MB')
return
}
})
#4.显示文件列表
import { getUploadDom } from "../utils";
const {select,input,tip,list}=getUploadDom('upload1')
//监听用户选择的文件
input.addEventListener('change',()=>{
const file=input.files![0]
//如果没文件就返回,下面代码不执行
if(!file)return;
//......
//显示上传的文件在列表中
tip.style.display='none'
list.style.display='block'
//1.使用fragment方式添加子节点
/* const item=document.createDocumentFragment()
const span1=document.createElement('span')
span1.innerText=file.name
const span2=document.createElement('span')
span2.innerHTML=`<em>移除</em>`
const li=document.createElement('li')
li.appendChild(span1)
li.appendChild(span2)
item.appendChild(li)
list.appendChild(item) */
//2.使用innerHtml
list.innerHTML=`
<li>
<span>文件:${file.name}</span>
<span><em>移除</em></span>
</li>
`
})
使用闭包柯里化函数,避免重复创建节点
import { getUploadDom } from "../utils";
const {select,input,tip,list}=getUploadDom('upload1')
//3.节省性能版本
const show=showList(list)//新增
//监听用户选择的文件
input.addEventListener('change',()=>{
const file=input.files![0]
//如果没文件就返回,下面代码不执行
if(!file)return;
//.....
//显示上传的文件在列表中
tip.style.display='none'
list.style.display='block'
//3.使用闭包,避免重复创建节点
show(file.name)//新增
})
//新增
function showList(list:HTMLUListElement){
let span1!:HTMLSpanElement
return (filename:string)=>{
//第一次时创建
if(list.childElementCount===0){
console.log('11111111')
const item=document.createDocumentFragment()
const li=document.createElement('li')
item.appendChild(li)
span1=document.createElement('span')
span1.innerText=filename
const span2=document.createElement('span')
span2.innerHTML=`<em>移除</em>`
li.appendChild(span1)
li.appendChild(span2)
list.appendChild(item)
}else{
//第二次直接修改显示内容,避免重复创建节点
span1.innerText=filename
}
}
}
#5.移除文件列表
把showList抽离到了utils文件中
import { getUploadDom, showList } from "../utils";
const {select,input,tip,list}=getUploadDom('upload1')
//1.点击选择按钮触发input的文件选择行为
select?.addEventListener('click',()=>{
input.click()
})
const show=showList(list)//节省性能版本
//2.监听用户选择的文件
input.addEventListener('change',()=>{
const file=input.files![0]
//如果没文件就返回,下面代码不执行
if(!file)return;
//3.限制上传格式 可以配合input accept属性
if(!/(jpg|png|jpeg)/i.test(file.type)){
alert('上传的图片只能是png,jpg,jpeg等格式')
return;
}
//4.限制文件上传大小
if(file.size>2*1024*1024){
alert('上传的图片不能大于2MB')
return
}
//5.显示上传的文件在列表中
tip.style.display='none'
list.style.display='block'
show(file.name)//使用闭包,避免重复创建节点
})
//6.移除按钮的点击处理
list.addEventListener('click',(ev)=>{//使用事件委托机制,对动态绑定的事件处理比较容易
let target=ev.target as HTMLElement
if(target.tagName==='EM'){//如果事件源是em就进行移除
tip.style.display='block'
list.style.display='none'
list.innerHTML=''
}
})
#6.调用接口上传文件
upload/single.ts
import { getUploadDom, showList } from "../utils";
import {api} from '../api/instance'
const {select,input,tip,list,upload}=getUploadDom('upload1')
let _file:File|null=null
input.addEventListener('change',()=>{
//......
//保存文件
_file=file
})
//6.移除按钮的点击处理
list.addEventListener('click',(ev)=>{//使用事件委托机制,对动态绑定的事件处理比较容易
let target=ev.target as HTMLElement
if(target.tagName==='EM'){//如果事件源是em就进行移除
tip.style.display='block'
list.style.display='none'
list.innerHTML=''
//移除文件
_file=null
}
})
//7.上传按钮上传文件
upload?.addEventListener('click',async()=>{
if(!_file){
alert('请选择要上传的文件')
return;
}
//把文件上传到服务器
const formData=new FormData()
formData.append('file',_file)
formData.append('filename',_file.name)
try {
const res=await api.post('/upload_single',formData)
if(res.data.code===0){
alert(`文件上传成功,地址:${res.data.servicePath}`)
}else{
console.log(res.data)
alert('文件上传失败,请您稍后再试')
}
} catch (error) {
console.log(error)
//可以把错误返回给服务器,做一个错误跟踪
}
})
把请求单独封装成一个函数
import { getUploadDom, showList } from "../utils";
import {api} from '../api/instance'
import { uploadSingleFormData } from "../api";
//.....
//7.上传按钮上传文件
upload?.addEventListener('click',async()=>{
if(!_file){
alert('请选择要上传的文件')
return;
}
const res=await uploadSingleFormData(_file)
if(res.data.code===0){
alert(`文件上传成功,地址:${res.data.servicePath}`)
}else{
console.log(res.data)
alert('文件上传失败,请您稍后再试')
}
})
api/single-formData.ts
import { api } from "./instance";
export const uploadSingleFormData=async (file:File)=>{
//把文件上传到服务器
const formData=new FormData()
formData.append('file',file)
formData.append('filename',file.name)
//用拦截器做了统一的错误处理
const res=await api.post('/upload_single',formData)
return res
}
#7.细节处理(防抖等)
import { getUploadDom, showList } from "../utils";
import { uploadSingleFormData } from "../api";
const { select, input, tip, list, upload } = getUploadDom("upload1");
let _file: File | null = null;
//1.点击选择按钮触发input的文件选择行为
select?.addEventListener("click", () => {
//当图片在上传时,阻止选择图片
if (select.classList.contains("disable")||select?.classList.contains("loading")) {
return;
}
input.click();
});
//7.上传按钮上传文件
upload?.addEventListener("click", async () => {
//当图片在上传时,阻止上传图片
if (upload?.classList.contains("loading")||upload?.classList.contains("disable")) {
return;
}
if (!_file) {
alert("请选择要上传的文件");
return;
}
try {
//8.设置按钮样式
disableHandle(true);
const res = await uploadSingleFormData(_file);
if (res.data.code === 0) {
alert(`文件上传成功,地址:${res.data.servicePath}`);
} else {
console.log(res.data);
alert("文件上传失败,请您稍后再试");
}
} catch (error) {
console.log(error);
} finally {
disableHandle(false);
//最后重置状态
clear();
}
});
function disableHandle(flag: boolean) {
if (!flag) {
select.classList.remove("disable");
upload?.classList.remove("loading");
} else {
select.classList.add("disable");
upload?.classList.add("loading");
}
}
#8.客户端完整代码
/* upload/single.ts */
import { getUploadDom, showList } from "../utils";
import { uploadSingleFormData } from "../api";
const { select, input, tip, list, upload } = getUploadDom("upload1");
let _file: File | null = null;
//1.点击选择按钮触发input的文件选择行为
select?.addEventListener("click", () => {
//当图片在上传时,阻止选择图片
if (select.classList.contains("disable")||select?.classList.contains("loading")) {
return;
}
input.click();
});
const show = showList(list); //节省性能版本
//2.监听用户选择的文件
input.addEventListener("change", () => {
const file = input.files![0];
//如果没文件就返回,下面代码不执行
if (!file) return;
//3.限制上传格式 可以配合input accept属性
if (!/(jpg|png|jpeg)/i.test(file.type)) {
alert("上传的图片只能是png,jpg,jpeg等格式");
return;
}
//4.限制文件上传大小
if (file.size > 2 * 1024 * 1024) {
alert("上传的图片不能大于2MB");
return;
}
//5.显示上传的文件在列表中
tip.style.display = "none";
list.style.display = "block";
show(file.name); //使用闭包,避免重复创建节点
//保存文件
_file = file;
});
function clear() {
tip.style.display = "block";
list.style.display = "none";
list.innerHTML = "";
//移除文件
_file = null;
}
//6.移除按钮的点击处理
list.addEventListener("click", (ev) => {
//使用事件委托机制,对动态绑定的事件处理比较容易
let target = ev.target as HTMLElement;
if (target.tagName === "EM") {
//如果事件源是em就进行移除
clear();
}
});
//7.上传按钮上传文件
upload?.addEventListener("click", async () => {
//当图片在上传时,阻止上传图片
if (upload?.classList.contains("loading")||upload?.classList.contains("disable")) {
return;
}
if (!_file) {
alert("请选择要上传的文件");
return;
}
try {
//8.设置按钮样式
disableHandle(true);
const res = await uploadSingleFormData(_file);
if (res.data.code === 0) {
alert(`文件上传成功,地址:${res.data.servicePath}`);
} else {
console.log(res.data);
alert("文件上传失败,请您稍后再试");
}
} catch (error) {
console.log(error);
} finally {
disableHandle(false);
//最后重置状态
clear();
}
});
function disableHandle(flag: boolean) {
if (!flag) {
select.classList.remove("disable");
upload?.classList.remove("loading");
} else {
select.classList.add("disable");
upload?.classList.add("loading");
}
}
/* utils/index.ts */
interface Options {
inputSelector: string;
selectSelector: string;
uploadSelector: string;
tipSelector: string;
listSelector: string;
}
//获取要操作的dom元素
export function getUploadDom(
id: string,
{
inputSelector,
selectSelector,
uploadSelector,
tipSelector,
listSelector,
}: Options = {
inputSelector: ".upload_inp",
selectSelector: ".upload_button.select",
uploadSelector: ".upload_button.upload",
tipSelector: ".upload_tip",
listSelector: ".upload_list",
}
) {
const section = document.getElementById(id);
return {
input: section?.querySelector(inputSelector) as HTMLInputElement,
select: section?.querySelector(selectSelector) as HTMLButtonElement,
upload: section?.querySelector(uploadSelector),
tip: section?.querySelector(tipSelector) as HTMLDivElement,
list: section?.querySelector(listSelector) as HTMLUListElement,
};
}
export function showList(list:HTMLUListElement){
let span1!:HTMLSpanElement
return (filename:string)=>{
//第一次时创建
if(list.childElementCount===0){
/**
* 格式如下
* <li>
* <span>文件:...</span>
* <span><em>移除</em></span>
* </li>
*/
const item=document.createDocumentFragment()
const li=document.createElement('li')
item.appendChild(li)
span1=document.createElement('span')
span1.innerText='文件:'+filename
const span2=document.createElement('span')
span2.innerHTML=`<em>移除</em>`
li.appendChild(span1)
li.appendChild(span2)
list.appendChild(item)
}else{
//第二次直接修改显示内容,避免重复创建节点
span1.innerText='文件:'+filename
}
}
}
/* api/single-formData.ts */
import { api } from "./instance";
export const uploadSingleFormData=async (file:File)=>{
//把文件上传到服务器
const formData=new FormData()
formData.append('file',file)
formData.append('filename',file.name)
//用拦截器做了统一的错误处理
const res=await api.post('/upload_single',formData)
return res
}
#9.nest.js后端处理
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Post('/upload_single')
@UseInterceptors(FileInterceptor('file'))
async single(@UploadedFile() file: Express.Multer.File, @Body() body: any) {
await this.appService.delay(1000);
console.log(body);
return {
code: 0,
message: 'ok',
servicePath: `http://localhost:3000/${file.filename}`,
file,
};
}
}
#总结:
客户端使用application/form-data格式,用formData把文件和其他信息传给后端,
后端通过multer插件进行处理,不需要手动写入文件
#3.base64上传图片
#1.服务器配置请求体大小限制
安装依赖
pnpm add express
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { resolve } from 'path';
import { AppModule } from './app.module';
import { json, urlencoded } from 'express';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.useStaticAssets(resolve(__dirname, '../public'));
app.enableCors();
/* 配置请求体大小限制 */
app.use(
json({
limit: '50mb',
}),
);
app.use(
urlencoded({
extended: true,
limit: '50mb',
}),
);
await app.listen(3000);
}
bootstrap();
#2.服务器端流程
import {
Body,
Controller,
Get,
Post,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { extname } from 'path';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
//......
/* 使用base64和application/x-www-form-urlencoded方式上传图片 */
@Post('/upload_single_base64')
async singleBase64(@Body() body: { filename: string; file: string }) {
await this.appService.delay(2000);
/* 1.将base64进行url转义,然后截取,最后转buffer */
const buffer = this.appService.base64ToFile(body.file);
/* 2.使用spark-MD5模块,对文件进行加密返回唯一的hash值 */
const hashName = this.appService.getHashName(buffer);
/* 3.获取源文件的扩展名 */
const extension = extname(body.filename);
/* 4.拼接路径,返回将要保存文件的地址 */
const path = this.appService.getFileSavePath(hashName, extension);
/* 5.判断文件是否已经存在 */
if (await this.appService.exists(path)) {
return {
code: 1,
message: '文件已经存在',
originFilename: body.filename,
servicePath: `http://localhost:3000/${hashName}${extension}`,
};
}
/* 6.将buffer写入文件中 */
try {
const res = await this.appService.writeFileByBuffer(buffer, path);
if (res === true) {
return {
code: 0,
message: 'ok',
originFilename: body.filename,
servicePath: `http://localhost:3000/${hashName}${extension}`,
};
}
} catch (error) {
console.log(error);
return {
code: 1,
message: JSON.stringify(error),
};
}
}
}
app.server.ts
import { Injectable } from '@nestjs/common';
import { createWriteStream, access, constants } from 'fs';
import { resolve } from 'path';
import { ArrayBuffer } from 'spark-md5';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
async delay(time: number): Promise<boolean> {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(true);
}, time);
});
}
/* 检查文件是否存在,即访问文件读写权限 */
exists(path: string): Promise<boolean> {
return new Promise((resolve) => {
access(path, constants.F_OK, (err) => {
if (err) {
resolve(false);
}
resolve(true);
});
});
}
/* 将base64解码转为buffer */
base64ToFile(file: string) {
let res = decodeURIComponent(file);
res = res.replace(/data:image/\w+;base64,/, '');
return Buffer.from(res, 'base64');
}
/* 获取唯一的hash name */
getHashName(buffer: Buffer) {
const spark = new ArrayBuffer();
spark.append(buffer);
return spark.end();
}
/* 文件保存路径 */
getFileSavePath(hashName: string, extension: string) {
return resolve(__dirname, '../public', `${hashName}${extension}`);
}
/* 写入文件到指定路径 */
writeFileByBuffer(file: Buffer, path: string): Promise<boolean> {
return new Promise((resolve, reject) => {
const writeStream = createWriteStream(path);
writeStream.write(file, (err) => {
if (err) {
reject(err);
}
resolve(true);
});
});
}
}
#3.客户端主要逻辑
/* upload/base64.ts */
import { uploadSingleBase64 } from './../api/base64';
import { getUploadDom, checkIsDisable } from "../utils";
import { uploadSingleFormData } from "../api";
const { select, input, } = getUploadDom("upload2");
//1.点击选择按钮触发input的文件选择行为
select?.addEventListener("click", () => {
//当图片在上传时,阻止选择图片
if(checkIsDisable(select))return;
input.click();
});
//2.监听用户选择的文件
input.addEventListener("change", async() => {
const file = input.files![0];
//如果没文件就返回,下面代码不执行
if (!file) return;
//3.限制上传格式 可以配合input accept属性
if (!/(jpg|png|jpeg)/i.test(file.type)) {
alert("上传的图片只能是png,jpg,jpeg等格式");
return;
}
//4.限制文件上传大小
if (file.size > 2 * 1024 * 1024) {
alert("上传的图片不能大于2MB");
return;
}
//5.转base64
const base64=await fileToBase64(file)
disableHandle(true)
try {
const res=await uploadSingleBase64(encodeURIComponent(base64 as string) ,file.name)
if(res.code===0){
return alert(`文件上传成功,预览地址:${res.servicePath}`)
}
//抛出异常继续走catch
throw res
} catch (error) {
alert(JSON.stringify(error))
}finally{
disableHandle(false)
}
});
//把文件读取成base64
function fileToBase64(file:File):Promise<FileReader['result']>{
return new Promise((resolve,reject)=>{
const fileReader=new FileReader()
fileReader.readAsDataURL(file)
fileReader.onload=(ev)=>{
resolve(ev.target?.result!)
}
fileReader.onerror=(ev)=>{
reject(ev.target?.error)
}
})
}
function disableHandle(flag: boolean) {
if (!flag) {
select.classList.remove("loading");
} else {
select.classList.add("loading");
}
}
/* api/base64.ts */
import { api } from "./instance";
export const uploadSingleBase64=async (file:string,filename:string)=>{
return (await api.post('/upload_single_base64',{
file,
filename
},{
headers:{
'Content-Type':'application/x-www-form-urlencoded'
}
})).data
}
#总结:
前端通过FileReader将文件读取为base64,通过application/x-www-form-urlencoded格式发给后端,后端通过body读取到base64,先进行转义,然后截取声明信息,然后将剩下的转换为buffer格式,然后通过spark-md5模块生成唯一的名字,最后写入固定路径
#4.文件缩略图和前端生成hash名
#1.客户端
/* upload/abbre.ts */
import { uploadSingleName } from "./../api";
import SparkMD5 from "spark-md5";
import {
getUploadDom,
checkIsDisable,
disableHandle,
fileToArrayBuffer,
arrayBufferToHash,
getExtension
} from "../utils";
const { select, input, abbreviation, upload } = getUploadDom("upload3");
const img = abbreviation.getElementsByTagName("img")[0];
let _file: File | null = null;
//1.点击选择按钮触发input的文件选择行为
select?.addEventListener("click", () => {
//当图片在上传时,阻止选择图片
if (checkIsDisable(select)) return;
input.click();
});
//2.监听用户选择的文件
input.addEventListener("change", async () => {
const file = input.files![0];
//如果没文件就返回,下面代码不执行
if (!file) return;
//3.限制上传格式 可以配合input accept属性
if (!/(jpg|png|jpeg)/i.test(file.type)) {
alert("上传的图片只能是png,jpg,jpeg等格式");
return;
}
//4.限制文件上传大小
if (file.size > 2 * 1024 * 1024) {
alert("上传的图片不能大于2MB");
return;
}
_file = file; //保存文件
//5.文件预览
const preview = URL.createObjectURL(file);
abbreviation.style.display = "block";
img.src = preview;
//(也可以使用base64)
/* const base64 = await fileToBase64(file);
img.src = base64 as string; */
});
//6.上传按钮上传文件
upload?.addEventListener("click", async () => {
//当图片在上传时,阻止上传图片
if (checkIsDisable(upload)) {
return;
}
if (!_file) {
alert("请选择要上传的文件");
return;
}
disableHandle(true, select, upload);
try {
//把文件转换成arraybuffer
const arrayBuffer=await fileToArrayBuffer(_file) as ArrayBuffer
//根据文件生成唯一的hash
const hash=arrayBufferToHash(arrayBuffer)
//获取文件后缀
const extension=getExtension(_file.name)
//组合成唯一的文件名
const filename=hash+extension
//7.调用接口上传到服务器
const res=await uploadSingleName(_file,filename)
if (res.code === 0) {
return alert(`文件上传成功,地址:${res.servicePath}`);
}
throw res
} catch (error) {
alert(JSON.stringify(error))
}finally{
//恢复初始状态
disableHandle(false, select, upload);
abbreviation.style.display='none'
img.src=''
_file=null
input.files=null
}
});
/* utils/index.ts */
//把文件读成buffer格式
export function fileToArrayBuffer(file: File): Promise<FileReader["result"]> {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.readAsArrayBuffer(file);
fileReader.onload = (ev) => {
resolve(ev.target?.result!);
};
fileReader.onerror = (ev) => {
reject(ev.target?.error);
};
});
}
//文件经过md5生成唯一的hash
export function arrayBufferToHash(arrayBuffer: ArrayBuffer) {
const spark = new SparkMD5.ArrayBuffer();
spark.append(arrayBuffer);
return spark.end();
}
//获取文件后缀
export function getExtension(str:string){
return /.([A-z0-9]+)$/.exec(str)![0]
}
/* api/single-formData.ts */
export const uploadSingleName=async (file:File,filename:string)=>{
//把文件上传到服务器
const formData=new FormData()
formData.append('file',file)
formData.append('filename',filename)
//用拦截器做了统一的错误处理
return (await api.post('/upload_single_name',formData)).data
}
#2.后端
import {
Body,
Controller,
Get,
Post,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { extname, resolve } from 'path';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
//......
/* 使用formData上传单文件 */
@Post('/upload_single_name')
@UseInterceptors(
FileInterceptor('file', {
/* 可以配置storage选项为false就不会自动保存了 */
storage: false,
}),
)
async singleName(
@UploadedFile() file: Express.Multer.File,
@Body('filename') filename: string,
) {
await this.appService.delay(1000);
const path = resolve(__dirname, '../public', filename);
/* 判断文件是否已经存在 */
if (await this.appService.exists(path)) {
return {
code: 1,
message: '文件已经存在',
originFilename: filename,
servicePath: `http://localhost:3000/${filename}`,
};
}
try {
await this.appService.writeFileByBuffer(file.buffer, path);
return {
code: 0,
message: 'ok',
originFilename: filename,
servicePath: `http://localhost:3000/${filename}`,
file,
};
} catch (error) {
throw error;
}
}
}
/* app.server.ts */
import { Injectable } from '@nestjs/common';
import { createWriteStream, access, constants } from 'fs';
import { resolve } from 'path';
import { ArrayBuffer } from 'spark-md5';
@Injectable()
export class AppService {
/* 写入文件到指定路径 */
writeFileByBuffer(file: Buffer, path: string): Promise<boolean> {
return new Promise((resolve, reject) => {
const writeStream = createWriteStream(path);
writeStream.write(file, (err) => {
if (err) {
reject(err);
}
resolve(true);
});
});
}
}
#总结
前端通过fileReader将文件读取成arrayBuffer格式,然后通过spark-md5生成唯一的hash当作文件名,一起上传到后端,后端通过FileInterceptor文件拦截器,并配置storage选项为false这样文件就不会自动保存了,对上传的文件先判断是否存在,如果不存在,通过File.buffer将文件写入到静态目录下
#5.进度显示
#1.请求
import { AxiosProgressEvent } from "axios";
import { api } from "./instance";
export const uploadSingleProgress = async (
file: File,
cb?: (progressEvent: AxiosProgressEvent) => void
) => {
//把文件上传到服务器
const formData = new FormData();
formData.append("file", file);
formData.append("filename", file.name);
//用拦截器做了统一的错误处理
const res = await api.post("/upload_single", formData, {
onUploadProgress(progressEvent) {
cb && cb(progressEvent)
},
});
return res;
};
#2.样式处理
import { uploadSingleProgress } from "./../api";
import { getUploadDom, checkIsDisable } from "../utils";
const { select, input, progress } = getUploadDom("upload4");
const value = progress.children[0] as HTMLDivElement;
//1.点击选择按钮触发input的文件选择行为
select?.addEventListener("click", () => {
//当图片在上传时,阻止选择图片
if (checkIsDisable(select)) return;
input.click();
});
//2.监听用户选择的文件
input.addEventListener("change", async () => {
const file = input.files![0];
//如果没文件就返回,下面代码不执行
if (!file) return;
disableHandle(true, select);
try {
const res = await uploadSingleProgress(file, ({ loaded, total }) => {
progress.style.display = "block";
value.style.width = Math.ceil(loaded / total!) * 100 + "%";
});
if (res.data.code === 0) {
value.style.width = "100%";
await new Promise((resolve) => {
setTimeout(() => {
resolve(true);
}, 1000);
});
return alert(`文件上传成功,地址:${res.data.servicePath}`);
}
throw res.data;
} catch (error) {
alert(JSON.stringify(error));
} finally {
disableHandle(false, select);
progress.style.display = "none";
value.style.width = "0%";
input.files = null;
}
});
function disableHandle(flag: boolean, select: HTMLButtonElement) {
if (!flag) {
select.classList.remove("loading");
} else {
select.classList.add("loading");
}
}
#总结
进度展示利用了axios的onUploadProgress来处理xhr对象的进度事件
基于此进行进度的监听
#6.多文件上传
#1.服务器端统一接受
import {
Body,
Controller,
Get,
Post,
UploadedFile,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
import { extname, resolve } from 'path';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
//......
/* 多文件上传 */
@Post('upload_multipart')
@UseInterceptors(
FilesInterceptor('file', 10, {
storage: false,
}),
)
async multiple(@UploadedFiles() files: Express.Multer.File[]) {
const saveFiles = files.map((item) => {
return {
buffer: item.buffer,
path: this.appService.getFileSavePath(
this.appService.getHashName(item.buffer),
extname(item.originalname),
),
};
});
const res = await Promise.all(
saveFiles.map(
async (item) =>
(await this.appService.exists(item.path)) ||
this.appService.writeFileByBuffer(item.buffer, item.path),
),
);
return {
code: 0,
message: 'ok',
originFilename: files.map((item) => item.originalname),
servicePath: res,
};
}
}
#2.一个请求上传全部文件
import { api } from "./instance";
export const uploadMultiple=async (file:File[])=>{
//把文件上传到服务器
const formData=new FormData()
file.forEach(item=>{
formData.append('file',item)
})
//用拦截器做了统一的错误处理
const res=await api.post('/upload_multipart',formData,{
onUploadProgress(progressEvent) {
console.log(progressEvent)
},
})
return res
}
import { checkIsDisable, getUploadDom } from "../utils";
import { uploadMultiple } from "../api";
const { select, input, list, upload } = getUploadDom("upload5");
let _file: File[] = [];
//1.点击选择按钮触发input的文件选择行为
select?.addEventListener("click", () => {
//当图片在上传时,阻止选择图片
if (checkIsDisable(select)) {
return;
}
input.click();
});
//2.监听用户选择的文件
input.addEventListener("change", () => {
const file = input.files;
//如果没文件就返回,下面代码不执行
if (file?.length===0) return;
//保存文件
_file = Array.from(file!);
//3.显示上传的文件在列表中
let str=``
_file.forEach((item,index)=>{
str+=`<li key='${item.name}'>
<span>文件${index+1}:${item.name}</span>
<span><em>移除</em></span>
</li>`
})
list.innerHTML=str
list.style.display = "block";
});
//4.移除按钮的点击处理
list.addEventListener("click", (ev) => {
//使用事件委托机制,对动态绑定的事件处理比较容易
let target = ev.target as HTMLElement;
if (target.tagName === "EM") {
//如果事件源是em就进行移除
const li=target.parentNode?.parentNode as HTMLElement
const key=li.getAttribute('key')
li.parentNode?.removeChild(li)
_file.splice(_file.findIndex(item=>item.name===key),1)
if(_file.length===0){
list.style.display = "none";
}
}
});
//5.上传按钮上传文件
upload?.addEventListener("click", async () => {
//当图片在上传时,阻止上传图片
if (checkIsDisable(upload)) {
return;
}
if (_file.length===0) {
alert("请选择要上传的文件");
return;
}
try {
//6.设置按钮样式
disableHandle(true);
/* 放一个请求里进行上传 */
const res = await uploadMultiple(_file);
if (res.data.code === 0) {
return alert(`文件上传成功,地址:${res.data.servicePath}`);
}
throw res.data
} catch (error) {
alert(JSON.stringify(error));
} finally {
disableHandle(false);
list.style.display = "none";
list.innerHTML=''
}
});
function disableHandle(flag: boolean) {
if (!flag) {
select.classList.remove("disable");
upload?.classList.remove("loading");
} else {
select.classList.add("disable");
upload?.classList.add("loading");
}
}
#总结
上面这种是把所有文件放一个formData里的同一个字段里,用一个请求进行发送
服务器端通过files数组拿到buffer然后写入到指定目录
#3.每个请求上传一个文件
import { checkIsDisable, getUploadDom } from "../utils";
import { uploadSingleProgress } from "../api";
const { select, input, list, upload } = getUploadDom("upload5");
let _file: File[] = [];
//1.点击选择按钮触发input的文件选择行为
select?.addEventListener("click", () => {
//当图片在上传时,阻止选择图片
if (checkIsDisable(select)) {
return;
}
input.click();
});
//2.监听用户选择的文件
input.addEventListener("change", () => {
const file = input.files;
//如果没文件就返回,下面代码不执行
if (file?.length === 0) return;
//保存文件
_file = Array.from(file!);
//3.显示上传的文件在列表中
let str = ``;
_file.forEach((item, index) => {
str += `<li key='${item.name}'>
<span>文件${index + 1}:${item.name}</span>
<span><em>移除</em></span>
</li>`;
});
list.innerHTML = str;
list.style.display = "block";
});
//4.移除按钮的点击处理
list.addEventListener("click", (ev) => {
//使用事件委托机制,对动态绑定的事件处理比较容易
let target = ev.target as HTMLElement;
if (target.tagName === "EM") {
//如果事件源是em就进行移除
const li = target.parentNode?.parentNode as HTMLElement;
const key = li.getAttribute("key");
li.parentNode?.removeChild(li);
_file.splice(
_file.findIndex((item) => item.name === key),
1
);
if (_file.length === 0) {
list.style.display = "none";
}
}
});
//5.上传按钮上传文件
upload?.addEventListener("click", async () => {
//当图片在上传时,阻止上传图片
if (checkIsDisable(upload)) {
return;
}
if (_file.length === 0) {
alert("请选择要上传的文件");
return;
}
//6.设置按钮样式
disableHandle(true);
//获取li
let liArr = Array.from(list.querySelectorAll("li"));
//循环发送请求
let requests = _file.map((item) => {
const li = liArr.find((l) => l.getAttribute("key") === item.name);
const span = li?.children[1];
return uploadSingleProgress(item, ({ loaded, total }) => {
span!.innerHTML = `${((loaded / total!) * 100).toFixed(2)}%`;
}).then((res) => {
if (res.data.code === 0) {
span!.innerHTML = "100%";
return;
}
return Promise.reject(res.data);
});
});
//等待所有处理结果
Promise.all(requests)
.then(() => {
alert("所有文件上传成功");
})
.catch((err) => {
alert(JSON.stringify(err));
})
.finally(() => {
disableHandle(false);
_file=[]
list.style.display = "none";
list.innerHTML = "";
input.files=null
});
});
function disableHandle(flag: boolean) {
if (!flag) {
select.classList.remove("disable");
upload?.classList.remove("loading");
} else {
select.classList.add("disable");
upload?.classList.add("loading");
}
}
#总结
该方法是每个文件单独一个请求,然后单独进行进度显示,区别就是通过promsie.all进行处理,让所有请求并行发送
#7.拖拽上传
#tips
发现一个新api,结构化克隆算法,structuredClone()可以进行深拷贝
structuredClone() - Web API 接口参考 | MDN (mozilla.org)(opens new window)
#1.客户端
import { uploadSingleBase64, uploadSingleFormData } from './../api';
import { getUploadDom, checkIsDisable,fileToBase64 } from "../utils";
/* 拖拽上传 */
const {input,uploadDrag,uploadMask,uploadSubmit,section}=getUploadDom('upload6')
let _file:File|null=null
/* 点击手动选择文件 */
uploadSubmit.addEventListener('click',(e)=>{
e.preventDefault()
input.click()
})
input.addEventListener('change',(ev)=>{
_file=input.files![0]
if(!_file)return
uploadFile(_file)
})
/* 拖拽选择文件 */
/* section?.addEventListener('dragenter',(ev)=>{
console.log('enter',ev)
})
section?.addEventListener('dragleave',(ev)=>{
console.log('leave',ev)
}) */
/* 要想文件真正拖拽到容器中,需要阻止dragover 和 drop的默认行为 */
section?.addEventListener('drop',(ev)=>{
ev.preventDefault()
_file=ev.dataTransfer?.files[0]!
uploadFile(_file)
})
section?.addEventListener('dragover',(ev)=>{
ev.preventDefault()
})
/* 上传文件 */
let isRun=false
async function uploadFile(file:File){
if(isRun)return;
isRun=true
uploadMask.style.display='block'
try {
const res=await uploadSingleFormData(file)
if(res.data.code===0){
return alert(`文件上传成功,地址:${res.data.servicePath}`);
}
throw res.data
} catch (error) {
alert(JSON.stringify(error))
}finally{
uploadMask.style.display='none'
isRun=false
}
}
#总结
拖拽上传的新知识点就是利用拖拽事件里的DragEvent.dataTransfer来获取要上传的文件,上传文件跟前面几乎没有区别,要注意阻止dragover和drop的默认行为。
#8.切片上传和断点续传(重点)
主要思想是先访问服务器是否存在部分切片,如果存在就不需要上传已经存在的切片,然后进行切片的上传,待到上传完成后,发送切片合并请求,最后大文件分片上传完成。
#1.前端
import { getAlready, uploadChunk, uploadMerge } from './../api/slice';
import { getUploadDom, checkIsDisable ,fileToArrayBuffer,arrayBufferToHash, getExtension} from "../utils";
/* 大文件切片上传 */
const { select, input, progress } = getUploadDom("upload7");
const value = progress.children[0] as HTMLDivElement;
//1.点击选择按钮触发input的文件选择行为
select?.addEventListener("click", () => {
//当图片在上传时,阻止选择图片
if (checkIsDisable(select)) return;
input.click();
});
//2.监听用户选择的文件
input.addEventListener("change", async () => {
const file = input.files![0];
//如果没文件就返回,下面代码不执行
if (!file) return;
disableHandle(true, select);
progress.style.display='block';
const arrayBuffer=await fileToArrayBuffer(file)
//获取文件hash
const hash=arrayBufferToHash(arrayBuffer as ArrayBuffer)
//获取文件后缀
const extension=getExtension(file.name)
/* 获取已经上传的切片信息 */
let alreadyFileList:string[]=[]
try {
const res=await getAlready(hash)
if(res.code===0){
alreadyFileList=res.fileList
}else{
throw res
}
} catch (error) {
alert(JSON.stringify(error))
}
/* 实现文件切片处理(重点)[固定数量 & 固定大小(设置最大切片限制)] */
let max=1024*100;//每个切片100kb
let count=Math.ceil(file.size/max);//切片数量
let index=0;//索引
const chunks:{
file:Blob,
filename:string
}[]=[];//保存切片的集合
if(count>100){//如果切片数量大于100了采用固定数量
max=file.size/100;
count=100
}
/* 根据大小和数量进行切片 */
while(index<count){
chunks.push({
file:file.slice(index*max,(index+1)*max),
filename:`${hash}_${index}${extension}`
})
index++;
}
let flag=0
/* 把每一片上传到服务器 */
chunks.forEach(item=>{
//已经上传的无需再上传了
if(alreadyFileList.length>0 && alreadyFileList.includes(item.filename)){
complete()
return;
}
uploadChunk(item).then(res=>{
if(res.code===0){
complete()
return
}
return Promise.reject(res)
}).catch((err)=>{
alert('当前切片上传失败')
clear()
})
})
/* 上传成功的处理 */
async function complete(){
//管控进度
flag++;
value.style.width=`${flag/count*100}%`;
// 当所有切片上传成功,发送合并切片的请求
if(flag<count)return
value.style.width=`100%`;
/* 进行合并请求 */
try {
const res=await uploadMerge(hash,count)
console.log(res)
if(res.code===0){
return alert(`文件上传成功,地址:${res.path}`);
}
throw res
} catch (error) {
alert(JSON.stringify(error))
}finally{
clear()
}
}
});
function disableHandle(flag: boolean, select: HTMLButtonElement) {
if (!flag) {
select.classList.remove("loading");
} else {
select.classList.add("loading");
}
}
//恢复默认状态
function clear(){
disableHandle(false, select);
progress.style.display='none';
value.style.width='0'
input.files=null
}
#2.前端请求接口
import { api } from "./instance";
/* 获取已经上传成功的切片 */
export const getAlready=async(hash:string)=>{
return (await api.get(`/upload_already?hash=${hash}`)).data
}
/* 上传切片 */
export const uploadChunk=async(obj:{file:Blob,filename:string})=>{
const formData=new FormData()
formData.append('file',obj.file)
formData.append('filename',obj.filename)
return (await api.post('/upload_chunk',formData)).data
}
/* 合并切片 */
export const uploadMerge=async (hash:string,count:number)=>{
//get请求通过params也能传递URLSearchParams参数
return (await api.get('/upload_merge',{
params:{
hash,
count
}
})).data
}
#3.后端控制层
import {
Body,
Controller,
Get,
HttpException,
HttpStatus,
ParseIntPipe,
Post,
Query,
UploadedFile,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
import { extname, resolve } from 'path';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
/* 获取切片上传中已经上传的切片 */
@Get('/upload_already')
async getAlready(@Query('hash') hash: string) {
try {
const res = await this.appService
.getAlreadyFiles(hash)
.then((res) => this.appService.softFile(res));
return {
code: 0,
fileList: res,
codeText: 'ok',
};
} catch (error) {
return {
code: 0,
fileList: [],
codeText: 'ok',
};
}
}
/* 对前端上传的切片进行接收 */
@Post('/upload_chunk')
@UseInterceptors(FileInterceptor('file', { storage: false }))
async chunk(
@UploadedFile() file: Express.Multer.File,
@Body('filename') filename: string,
) {
try {
const res = await this.appService.writeChunkFile(filename, file.buffer);
return {
code: 0,
filename: res,
codeText: 'ok',
};
} catch (error) {
throw new HttpException(
'write_chunk_failed',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
/* 对上传文件进行合并 */
@Get('/upload_merge')
async merge(
@Query('hash') hash: string,
@Query('count', ParseIntPipe) count: number,
) {
await this.appService.delay(2000);
const path = await this.appService.mergeChunks(hash, count);
return {
hash,
code: 0,
path,
};
}
}
#4.后端服务层
import { Injectable } from '@nestjs/common';
import {
createWriteStream,
access,
constants,
readdir,
statSync,
mkdirSync,
readdirSync,
unlinkSync,
appendFileSync,
readFileSync,
rmSync,
} from 'fs';
import { readFile, appendFile, rm, unlink } from 'fs/promises';
import path, { resolve, extname, join } from 'path';
import { nextTick } from 'process';
import { ArrayBuffer } from 'spark-md5';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
/* 文件保存路径 */
getFileSavePath(hashName: string, extension?: string) {
return resolve(
__dirname,
'../public',
`${hashName}${extension ? extension : ''}`,
);
}
/* 写入文件到指定路径 */
writeFileByBuffer(file: Buffer, path: string): Promise<string> {
return new Promise((res, reject) => {
const writeStream = createWriteStream(path);
writeStream.write(file, (err) => {
if (err) {
reject(err);
}
res(
path.replace(
resolve(__dirname, '../public') + '\',
'http://localhost:3000/',
),
);
});
});
}
/* 读取目录获得已经上传的切片 */
getAlreadyFiles(hash: string): Promise<string[]> {
/* 通过文件hash值获取目录 */
const path = this.getFileSavePath(hash);
return new Promise((resolve) => {
readdir(path, (err, files) => {
if (err) {
resolve([]);
} else {
resolve(files);
}
});
});
}
/* 对文件进行排序 */
softFile(files: string[]) {
const regExp = /_(\d+)/;
return files.sort((a, b) => {
return +regExp.exec(a)[1] - +regExp.exec(b)[1];
});
}
/* 根据切片文件名保存文件 */
async writeChunkFile(hashName: string, file: Buffer) {
const hash = hashName.split('_')[0];
const dir = this.getFileSavePath(hash);
/* 判断文件夹是否存在,不存在就创建 */
try {
statSync(dir);
} catch (error) {
mkdirSync(dir);
}
return await this.writeFileByBuffer(file, resolve(dir, hashName));
}
/* 进行合并操作 */
async mergeChunks(hash: string, count: number) {
//1.根据hash获取到存放的路径
const dirPath = resolve(__dirname, '../public', hash);
//2.验证文件夹是否存在
const state = statSync(dirPath, {
throwIfNoEntry: false,
});
if (!state) return 'hash is not exist';
//3.读取文件夹下所有文件
const files = readdirSync(dirPath);
if (files.length < count) {
return 'Incomplete slice';
}
//4.获取文件后缀
const extension = extname(files[0]);
//5.读取文件
const executionQueue = this.softFile(files).map((file) => {
return (): Promise<string> => {
const path = resolve(dirPath, file);
return new Promise((r) => {
readFile(path).then((res) => {
appendFile(this.getFileSavePath(hash, extension), res);
r(path);
});
});
};
});
executionQueue[Symbol.asyncIterator] = this.generateAsync;
//6.写入文件(异步迭代生成器)
try {
for await (const fileBuffer of executionQueue) {
console.log(`${fileBuffer}=====文件写入成功`);
}
} catch (error) {
console.log(error);
}
//为啥不能删除文件,因为我读取文件时,程序占用了
//7.返回最后的预览路径
return `http://localhost:3000/${hash}${extension}`;
}
/* 批量按顺序读取文件 */
async *generateAsync(this: (() => Promise<any>)[] | any) {
for (const i of this) {
yield i();
}
}
/* 删除文件夹 */
async removeDir(hash: string) {
const files = await this.getAlreadyFiles(hash);
const path = this.getFileSavePath(hash);
files.forEach((item) => {
console.log(join(path, item));
unlinkSync(join(path, item));
});
}
}
#5.遇到的问题
服务器端进行缓存文件夹删除的时候,删除不了,推测问题原因是,程序占用了文件,进行手动删除也删除不了,权限问题导致的。
#6.总结
后端在前端发起已经上传的切片请求时,需要把缓存文件中的文件名读取出来返回给前端,前端根据该返回结果判断那些切片需要上传,再进行上传,上传完成后,即flag的值大于切片数量时,发起合并切片请求,后端收到请求进行文件合并,读取缓存目录下的文件,排序后挨个写入到最终文件中,这里可以使用异步迭代生成器,控制批量处理异步任务的执行顺序。最后再删除缓存文件夹,返回最终文件夹预览地址