文件上传是前端业务中一个比较重要的场景,有各种不同的实现方式,比如,单个文件上传,多个文件上传,文件夹上传,裁剪上传,大文件上传,拖拽上传等等。但是文件上传的核心逻辑很好理解,就是客户端将文件数据上传到服务端。而客户端这边需要做的就是样式,交互逻辑,以及消息格式,传输方式。
不管有多少种上传方式,最核心的就是,发送一个请求,把文件数据传递过去。接下来通过原生js方式实现几种文件上传,以便理解文件上传的本质
点击上传单个文件
静态页面
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<style>
.wrapper {
width: 200px;
}
.container {
position: relative;
margin-bottom: 10px;
height: 200px;
border-radius: 4px;
border: 2px solid #a0a0a0;
cursor: pointer;
}
.container .upload {
opacity: 0;
}
.container img {
position: absolute;
width: 30%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.container img.preview {
opacity: 0.5;
width: 90%;
}
.container label {
margin-left: 8px;
width: 90%;
align-items: center;
position: absolute;
bottom: 5px;
}
.container label progress {
margin-right: 10px;
}
.container.select .select {
display: block;
}
.container.select .preview {
display: none;
}
.container.preview .preview {
display: flex;
}
.container.preview .select {
display: none;
}
.container.result img.preview {
display: flex;
}
.container.result img.preview {
opacity: 1;
}
.container.result .select,
label {
display: none;
}
.operate {
display: flex;
justify-content: space-evenly;
}
</style>
<body>
<div class="wrapper">
<div class="container select">
<input type="file" class="upload" />
<img src="./upload.png" alt="select" class="select" />
<img src="" alt="preview" class="preview" />
<label for="file" class="preview">
<progress id="file" max="100" value="0"></progress>
<span>0</span>%
</label>
</div>
<div class="operate">
<button class="cancel">取消上传</button>
</div>
</div>
</body>
<script src="./upload.js"></script>
</html>
前端逻辑
使用 <input type='file' />
,可以选择选择一个或多个文件通过表单方式 上传;
在上传过程中,可以使用 FileReader.readAsDataURL() 读取出data:
URL 格式的字符串(base64 编码)表示的文件内容,显示预览图片;
在上传过程中,通过事件xhr.upload.onprogress 来获取上传进度;
通过 FormData设置编码类型被设为 "multipart/form-data"
的消息格式
js
const containerEl = document.querySelector(".container");
const inputEl = document.querySelector(".upload");
const cancelEl = document.querySelector(".cancel");
const imgEl = document.querySelector("img.preview");
const progressEl = document.querySelector("label.preview progress");
const progressTextEl = document.querySelector("label.preview span");
let cancelUpload = null;
// 点击容器弹出文件选择框
containerEl.onclick = function () {
inputEl.click();
};
inputEl.onchange = function (e) {
if (!this.files.length) {
return;
}
const file = this.files[0];
if (!validateFile(file)) {
return;
}
// 读取文件数据预览
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = function (e) {
showArea("preview");
// e.target.result -> data:URL 格式的字符串(base64 编码)
imgEl.src = e.target.result;
};
//调接口上传
cancelUpload = upload(
file,
(res) => {
showArea("result");
},
(value) => {
setProgress(value);
}
);
};
//取消上传
cancelEl.onclick = () => {
cancelUpload?.();
showArea("select");
setProgress(0);
inputEl.value = null;
};
// 校验文件大小和类型
function validateFile(file, size, exts) {
const sizeLimit = size || 2 * 1024 * 1024;
const legalExts = exts || [".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"];
const fileName = file.name.toLowerCase();
if (!legalExts.some((ext) => fileName.endsWith(ext))) {
alert("文件类型不正确");
return false;
}
if (file.size > sizeLimit) {
alert("文件尺寸超过限制");
return false;
}
return true;
}
function showArea(selector) {
containerEl.className = `container ${selector}`;
}
function setProgress(value) {
progressEl.value = value;
progressTextEl.innerText = value;
if (value === 100) {
cancelEl.innerText = "返回";
} else {
cancelEl.innerText = "取消上传";
}
}
//上传文件
function upload(file, onFinish, onProgress) {
const xhr = new XMLHttpRequest();
xhr.open("POST", "http://localhost:2024/upload/single");
xhr.onload = () => {
const resp = JSON.parse(xhr.responseText);
onFinish(resp);
};
xhr.upload.onprogress = (e) => {
const value = Math.floor((e.loaded / e.total) * 100);
onProgress(value);
};
const form = new FormData();
form.append("avatar", file);
xhr.send(form);
return () => xhr.abort();
}
服务端逻辑
使用 koa 搭建一个简单的文件上传服务器
js
const path = require("path");
const Koa = require("koa");
const KoaRouter = require("@koa/router");
const multer = require("@koa/multer");
const cors = require("@koa/cors");
const FileDir = path.resolve(__dirname, "./images");
const upload = multer({
storage: multer.diskStorage({
// 文件存储的文件夹
destination(req, file, cb) {
cb(null, FileDir);
},
// 文件名
filename(req, file, cb) {
cb(null, file.originalname);
},
}),
});
const router = new KoaRouter({ prefix: "/upload" });
// upload.single 处理单个上传文件
router.post("/single", upload.single("avatar"), (ctx, next) => {
console.log("ctx:", ctx);
ctx.body = {
code: 200,
msg: "文件上传成功",
};
});
const app = new Koa();
app.use(cors()); //解决跨域
app.use(router.routes());
app.use(router.allowedMethods());
app.listen(2024, () => console.log("Koa文件服务器启动"));
拖拽上传单个文件
<input type='file' />
本身就可以支持文件的拖拽上传,但是为了兼容性,最好是使外面的容器div变成一个拖动目标,在监听这个容器拖动事件上要禁止默认行为
静态页面
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<style>
.wrapper {
width: 200px;
}
.container {
position: relative;
margin-bottom: 10px;
height: 200px;
border-radius: 4px;
border: 2px solid #a0a0a0;
cursor: pointer;
}
.container .upload {
display: none;
}
.container img {
position: absolute;
width: 30%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.container img.preview {
opacity: 0.5;
width: 90%;
}
.container label {
margin-left: 8px;
width: 90%;
align-items: center;
position: absolute;
bottom: 5px;
}
.container label progress {
margin-right: 10px;
}
.container.select .select {
display: block;
}
.container.select .preview {
display: none;
}
.container.preview .preview {
display: flex;
}
.container.preview .select {
display: none;
}
.container.result img.preview {
display: flex;
}
.container.result img.preview {
opacity: 1;
}
.container.result .select,
label {
display: none;
}
.operate {
display: flex;
justify-content: space-evenly;
}
</style>
<body>
<div class="wrapper">
<div class="container select">
<input type="file" class="upload" />
<img src="./upload.png" alt="select" class="select" />
<img src="" alt="preview" class="preview" />
<label for="file" class="preview">
<progress id="file" max="100" value="0"></progress>
<span>0</span>%
</label>
</div>
<div class="operate">
<button class="cancel">取消上传</button>
</div>
</div>
</body>
<script src="./拖拽上传文件.js"></script>
</html>
前端逻辑
js
const containerEl = document.querySelector(".container");
const inputEl = document.querySelector(".upload");
const cancelEl = document.querySelector(".cancel");
const imgEl = document.querySelector("img.preview");
const progressEl = document.querySelector("label.preview progress");
const progressTextEl = document.querySelector("label.preview span");
let cancelUpload = null;
// 点击容器弹出文件选择框
containerEl.onclick = function () {
inputEl.click();
};
// 支持点击上传
inputEl.onchange = filesChangeHandler;
// e.preventDefault() 阻止默认行为
containerEl.ondragenter = function (e) {
e.preventDefault();
};
containerEl.ondragover = function (e) {
e.preventDefault();
};
containerEl.ondragleave = function (e) {
e.preventDefault();
};
// 支持拖拽上传
containerEl.ondrop = function (e) {
e.preventDefault();
inputEl.files = e.dataTransfer.files;
filesChangeHandler();
};
function filesChangeHandler() {
if (!inputEl.files.length) {
return;
}
const file = inputEl.files[0];
if (!validateFile(file)) {
return;
}
// 读取文件数据预览
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = function (e) {
showArea("preview");
// e.target.result -> data:URL 格式的字符串(base64 编码)
imgEl.src = e.target.result;
};
//调接口上传
cancelUpload = upload(
file,
(res) => {
showArea("result");
},
(value) => {
setProgress(value);
}
);
}
//取消上传
cancelEl.onclick = () => {
cancelUpload?.();
showArea("select");
setProgress(0);
inputEl.value = null;
};
// 校验文件大小和类型
function validateFile(file, size, exts) {
const sizeLimit = size || 2 * 1024 * 1024;
const legalExts = exts || [".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"];
const fileName = file.name.toLowerCase();
if (!legalExts.some((ext) => fileName.endsWith(ext))) {
alert("文件类型不正确");
return false;
}
if (file.size > sizeLimit) {
alert("文件尺寸超过限制");
return false;
}
return true;
}
function showArea(selector) {
containerEl.className = `container ${selector}`;
}
function setProgress(value) {
progressEl.value = value;
progressTextEl.innerText = value;
if (value === 100) {
cancelEl.innerText = "返回";
} else {
cancelEl.innerText = "取消上传";
}
}
//上传文件
function upload(file, onFinish, onProgress) {
const xhr = new XMLHttpRequest();
xhr.open("POST", "http://localhost:2024/upload/single");
xhr.onload = () => {
const resp = JSON.parse(xhr.responseText);
onFinish(resp);
};
xhr.upload.onprogress = (e) => {
const value = Math.floor((e.loaded / e.total) * 100);
onProgress(value);
};
const form = new FormData();
form.append("avatar", file);
xhr.send(form);
return () => xhr.abort();
}
服务端逻辑
同上
上传base64格式
静态页面
同上,可点击,可拖拽
前端逻辑
js
const containerEl = document.querySelector(".container");
const inputEl = document.querySelector(".upload");
const cancelEl = document.querySelector(".cancel");
const imgEl = document.querySelector("img.preview");
const progressEl = document.querySelector("label.preview progress");
const progressTextEl = document.querySelector("label.preview span");
let cancelUpload = null;
// 点击容器弹出文件选择框
containerEl.onclick = function () {
inputEl.click();
};
inputEl.onchange = filesChangeHandler;
// e.preventDefault() 阻止默认行为
containerEl.ondragenter = function (e) {
e.preventDefault();
};
containerEl.ondragover = function (e) {
e.preventDefault();
};
containerEl.ondragleave = function (e) {
e.preventDefault();
};
containerEl.ondrop = function (e) {
e.preventDefault();
inputEl.files = e.dataTransfer.files;
filesChangeHandler();
};
function filesChangeHandler() {
if (!inputEl.files.length) {
return;
}
const file = inputEl.files[0];
if (!validateFile(file)) {
return;
}
// 读取文件数据预览
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = function (e) {
showArea("preview");
// e.target.result -> data:URL 格式的字符串(base64 编码)
imgEl.src = e.target.result;
//调接口上传
cancelUpload = uploadBase64(
{ name: file.name, base64: e.target.result.split(",")[1] },
(res) => {
showArea("result");
},
(value) => {
setProgress(value);
}
);
};
}
//取消上传
cancelEl.onclick = () => {
cancelUpload?.();
showArea("select");
setProgress(0);
inputEl.value = null;
};
// 校验文件大小和类型
function validateFile(file, size, exts) {
const sizeLimit = size || 2 * 1024 * 1024;
const legalExts = exts || [".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"];
const fileName = file.name.toLowerCase();
if (!legalExts.some((ext) => fileName.endsWith(ext))) {
alert("文件类型不正确");
return false;
}
if (file.size > sizeLimit) {
alert("文件尺寸超过限制");
return false;
}
return true;
}
function showArea(selector) {
containerEl.className = `container ${selector}`;
}
function setProgress(value) {
progressEl.value = value;
progressTextEl.innerText = value;
if (value === 100) {
cancelEl.innerText = "返回";
} else {
cancelEl.innerText = "取消上传";
}
}
//上传文件
function uploadBase64(file, onFinish, onProgress) {
const xhr = new XMLHttpRequest();
xhr.open("POST", "http://localhost:2024/upload/base64");
xhr.onload = () => {
const resp = JSON.parse(xhr.responseText);
onFinish(resp);
};
xhr.upload.onprogress = (e) => {
const value = Math.floor((e.loaded / e.total) * 100);
onProgress(value);
};
xhr.setRequestHeader("content-type", "application/json");
xhr.send(JSON.stringify(file));
return () => xhr.abort();
}
服务端逻辑
js
const path = require("path");
const fs = require("fs");
const Koa = require("koa");
const KoaRouter = require("@koa/router");
const cors = require("@koa/cors");
const bodyParser = require("koa-bodyparser");
const static = require("koa-static");
const fileDir = path.resolve(__dirname, "./upload");
const router = new KoaRouter({ prefix: "/upload" });
router.post("/base64", async (ctx) => {
const { name, base64 } = ctx.request.body;
const buffer = Buffer.from(base64, "base64");
await fs.writeFile(`${fileDir}/${name}`, buffer, null, () => {});
ctx.body = {
code: 200,
msg: "文件上传成功",
url: `http://localhost:2024/${name}`,
};
});
const app = new Koa();
app.use(cors()); //解决跨域
app.use(static(fileDir)); //访问静态资源
app.use(bodyParser());
app.use(router.routes()).use(router.allowedMethods());
app.listen(2024, () => console.log("Koa文件服务器启动"));
上传二进制文件
静态页面
同上,可点击,可拖拽
前端逻辑
js
const containerEl = document.querySelector(".container");
const inputEl = document.querySelector(".upload");
const cancelEl = document.querySelector(".cancel");
const imgEl = document.querySelector("img.preview");
const progressEl = document.querySelector("label.preview progress");
const progressTextEl = document.querySelector("label.preview span");
let cancelUpload = null;
// 点击容器弹出文件选择框
containerEl.onclick = function () {
inputEl.click();
};
inputEl.onchange = filesChangeHandler;
// e.preventDefault() 阻止默认行为
containerEl.ondragenter = function (e) {
e.preventDefault();
};
containerEl.ondragover = function (e) {
e.preventDefault();
};
containerEl.ondragleave = function (e) {
e.preventDefault();
};
containerEl.ondrop = function (e) {
e.preventDefault();
inputEl.files = e.dataTransfer.files;
filesChangeHandler();
};
function filesChangeHandler() {
if (!inputEl.files.length) {
return;
}
const file = inputEl.files[0];
if (!validateFile(file)) {
return;
}
// 读取文件数据预览
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = function (e) {
showArea("preview");
// e.target.result -> data:URL 格式的字符串(base64 编码)
imgEl.src = e.target.result;
};
//调接口上传
cancelUpload = uploadBinary(
file,
(res) => {
showArea("result");
},
(value) => {
setProgress(value);
}
);
}
//取消上传
cancelEl.onclick = () => {
cancelUpload?.();
showArea("select");
setProgress(0);
inputEl.value = null;
};
// 校验文件大小和类型
function validateFile(file, size, exts) {
const sizeLimit = size || 2 * 1024 * 1024;
const legalExts = exts || [".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"];
const fileName = file.name.toLowerCase();
if (!legalExts.some((ext) => fileName.endsWith(ext))) {
alert("文件类型不正确");
return false;
}
if (file.size > sizeLimit) {
alert("文件尺寸超过限制");
return false;
}
return true;
}
function showArea(selector) {
containerEl.className = `container ${selector}`;
}
function setProgress(value) {
progressEl.value = value;
progressTextEl.innerText = value;
if (value === 100) {
cancelEl.innerText = "返回";
} else {
cancelEl.innerText = "取消上传";
}
}
//上传文件
function uploadBinary(file, onFinish, onProgress) {
const xhr = new XMLHttpRequest();
xhr.open("POST", "http://localhost:2024/upload/binary");
xhr.onload = () => {
const resp = JSON.parse(xhr.responseText);
onFinish(resp);
};
xhr.upload.onprogress = (e) => {
const value = Math.floor((e.loaded / e.total) * 100);
onProgress(value);
};
const form = new FormData();
form.append("files", file);
xhr.send(form);
return () => xhr.abort();
}
服务端逻辑
js
const path = require("path");
const fs = require("fs");
const Koa = require("koa");
const KoaRouter = require("@koa/router");
const cors = require("@koa/cors");
const static = require("koa-static");
const { koaBody } = require("koa-body");
const fileDir = path.resolve(__dirname, "./upload");
const router = new KoaRouter({ prefix: "/upload" });
router.post(
"/binary",
koaBody({
multipart: true,
formidable: {
keepExtensions: true,
maxFieldsSize: 20 * 1024 * 1024, //文件大小
uploadDir: fileDir, //文件夹
},
}),
async (ctx) => {
ctx.body = {
code: 200,
msg: "文件上传成功",
url: `http://localhost:2024/${ctx.request.files.files.newFilename}`,
};
}
);
const app = new Koa();
app.use(cors()); //解决跨域
app.use(static(fileDir)); //访问静态资源
app.use(router.routes()).use(router.allowedMethods());
app.listen(2024, () => console.log("Koa文件服务器启动"));
上传多个文件
静态页面
同上,给input标签加上 multiple
js
<input type="file" multiple />
前端逻辑
同样是可以点击,可以拖拽,拿到input的change事件里面的e.target.files,一个伪数组
js
const containerEl = document.querySelector(".container");
const inputEl = document.querySelector(".upload");
const cancelEl = document.querySelector(".cancel");
const imgEl = document.querySelector("img.preview");
const progressEl = document.querySelector("label.preview progress");
const progressTextEl = document.querySelector("label.preview span");
let cancelUpload = null;
// 点击容器弹出文件选择框
containerEl.onclick = function () {
inputEl.click();
};
inputEl.onchange = filesChangeHandler;
//拖拽 e.preventDefault() 阻止默认行为
containerEl.ondragenter = function (e) {
e.preventDefault();
};
containerEl.ondragover = function (e) {
e.preventDefault();
};
containerEl.ondragleave = function (e) {
e.preventDefault();
};
containerEl.ondrop = function (e) {
e.preventDefault();
// 拿到拖拽的文件
inputEl.files = e.dataTransfer.files;
filesChangeHandler();
};
function filesChangeHandler() {
if (!inputEl.files.length) {
return;
}
// 暂且只预览第一张
const file = inputEl.files[0];
// 读取文件数据预览
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = function (e) {
showArea("preview");
imgEl.src = e.target.result;
};
//调接口上传
cancelUpload = uploadMultiple(
inputEl.files,
(res) => {
showArea("result");
},
(value) => {
setProgress(value);
}
);
}
//取消上传
cancelEl.onclick = () => {
cancelUpload?.();
showArea("select");
setProgress(0);
inputEl.value = null;
};
function showArea(selector) {
containerEl.className = `container ${selector}`;
}
function setProgress(value) {
progressEl.value = value;
progressTextEl.innerText = value;
if (value === 100) {
cancelEl.innerText = "返回";
} else {
cancelEl.innerText = "取消上传";
}
}
//上传文件
function uploadMultiple(files, onFinish, onProgress) {
const xhr = new XMLHttpRequest();
xhr.open("POST", "http://localhost:2024/upload/multiple");
xhr.onload = () => {
const resp = JSON.parse(xhr.responseText);
onFinish(resp);
};
xhr.upload.onprogress = (e) => {
const value = Math.floor((e.loaded / e.total) * 100);
onProgress(value);
};
const form = new FormData();
// 用fromData一起打包传送
Array.from(files).forEach((file) => form.append("files", file));
xhr.send(form);
return () => xhr.abort();
}
服务端逻辑
js
const path = require("path");
const Koa = require("koa");
const KoaRouter = require("@koa/router");
const multer = require("@koa/multer");
const cors = require("@koa/cors");
const fileDir = path.resolve(__dirname, "./upload");
const upload = multer({
storage: multer.diskStorage({
destination(req, file, cb) {
cb(null, fileDir);
},
filename(req, file, cb) {
cb(null, file.originalname);
},
}),
});
const router = new KoaRouter({ prefix: "/upload" });
// upload.array 接收多个文件
router.post("/multiple", upload.array("files"), (ctx) => {
ctx.body = {
code: 200,
msg: "文件上传成功",
};
});
const app = new Koa();
app.use(cors()); //解决跨域
app.use(router.routes());
app.use(router.allowedMethods());
app.listen(2024, () => console.log("Koa文件服务器启动"));
上传文件夹
静态页面
同上,给input标签加上 webkitdirectory
js
<input type="file" multiple webkitdirectory/>
前端逻辑
文件夹可以点击,在input的change事件中拿到files上传
js
// 拖拽文件夹
containerEl.ondrop = function (e) {
e.preventDefault();
for (const item of e.dataTransfer.items) {
// 判断是文件还是文件夹
const entry = item.webkitGetAsEntry();
if (entry.isDirectory) {
// 目录
const reader = entry.createReader();
reader.readEntries(
(en) => {
console.log("entry:", en);
// en 还是一个entry对象,递归遍历
},
(e) => console.log(e)
);
} else {
// 文件
entry.file((file) => {
console.log("file:", file);
// 收集file,放到数组中,最后还是调用 uploadMultiple
});
}
}
服务端逻辑
同上 ,多文件上传
文件裁剪上传
要解决两个问题,预览图片和发送部分图片。 预览图片依然通过 FileReader
读出文件的base64编码。 接下来就是获取裁剪部分的文件数据:
js
// 获取裁剪的文件数据
function getCroppedFile(cutInfo) {
// 准备裁剪的数据
const cutData = cutInfo || {
//原图中的裁剪的坐标
x: 100,
y: 100,
//原图中要裁剪的宽高
cutWidth: 300,
cutHeight: 300,
// 裁剪后要缩放的尺寸
width: 100,
height: 200,
};
const imgEl = document.querySelector("img.preview");
const canvas = document.createElement("canvas");
canvas.width = cutData.width;
canvas.height = cutData.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(
//要绘制到上下文的元素
imgEl,
//需要绘制到目标上下文中的,image 的矩形(裁剪)选择框的左上角 X 轴坐标
cutData.x,
cutData.y,
//需要绘制到目标上下文中的,image 的矩形(裁剪)选择框的宽度
cutData.cutWidth,
cutData.cutHeight,
//image 的左上角在目标画布上 X 轴坐标
0,
0,
//image 在目标画布上绘制的宽度
cutData.width,
cutData.height
);
canvas.toBlob((blob) => {
// 获取file对象
const file = new File([blob], "avatar.png", { type: "image/png" });
// 调用 ajax 上传
}, "image/jpg");
}