文件上传
单文件与多文件上传
利用 input
元素的 accept
属性限制上传文件的类型、比如使用 image/*
限制只能选择图片文件;
同时,为了防止修改文件后缀绕过限制,需要利用 JS 读取文件中的二进制数据来识别正确的文件类型。
然后把读取的 File 对象封装成 FromData 对象,然后利用 Axios 实例的 post
方法实现文件上传的功能。然后服务端使用 Koa 实现单文件上传的功能;多文件上传利用 input
元素的 multiple
属性。
/** 客户端 */
<input id="uploadFile" type="file" accept="image/*" />
<input id="uploadFile" type="file" accept="image/*" multiple/>
const uploadFileEle = document.querySelector("#uploadFile");
const request = axios.create({
baseURL: "http://localhost:3000/upload",
timeout: 60000,
});
async function uploadFile() {
if (!uploadFileEle.files.length) return;
// const files = Array.from(uploadFileEle.files); 多个文件上传
const file = uploadFileEle.files[0]; // 获取单个文件
// 省略文件的校验过程,比如文件类型、大小校验
upload({
url: "/single", // multiple
file, // files
});
}
function upload({ url, file, fieldName = "file" }) {
let formData = new FormData();
formData.set(fieldName, file);
request.post(url, formData, {
// 监听上传进度
onUploadProgress: function (progressEvent) {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
console.log(percentCompleted);
},
});
}
function uploadMult({ url, files, fieldName = "file" }) {
let formData = new FormData();
files.forEach((file) => {
formData.append(fieldName, file);
});
request.post(url, formData, {
// 监听上传进度
onUploadProgress: function (progressEvent) {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
console.log(percentCompleted);
},
});
}
/** 服务端 */
const path = require("path");
const Koa = require("koa");
const serve = require("koa-static");
const cors = require("@koa/cors");
const multer = require("@koa/multer");
const Router = require("@koa/router");
const app = new Koa();
const router = new Router();
const PORT = 3000;
// 上传后资源的URL地址
const RESOURCE_URL = `http://localhost:${PORT}`;
// 存储上传文件的目录
const UPLOAD_DIR = path.join(__dirname, "/public/upload");
const storage = multer.diskStorage({
destination: async function (req, file, cb) {
// 设置文件的存储目录
cb(null, UPLOAD_DIR);
},
filename: function (req, file, cb) {
// 设置文件名
cb(null, `${file.originalname}`);
},
});
const multerUpload = multer({ storage });
router.get("/", async (ctx) => {
ctx.body = "欢迎使用文件服务";
});
router.post(
"/upload/single",
async (ctx, next) => {
try {
await next();
ctx.body = {
code: 1,
msg: "文件上传成功",
url: `${RESOURCE_URL}/${ctx.file.originalname}`,
};
} catch (error) {
ctx.body = {
code: 0,
msg: "文件上传失败"
};
}
},
multerUpload.single("file")
);
router.post(
"/upload/multiple",
async (ctx, next) => {
try {
await next();
urls = ctx.files.file.map(file => `${RESOURCE_URL}/${file.originalname}`);
ctx.body = {
code: 1,
msg: "文件上传成功",
urls
};
} catch (error) {
ctx.body = {
code: 0,
msg: "文件上传失败",
};
}
},
multerUpload.fields([
{
name: "file", // 与FormData表单项的fieldName想对应
},
])
);
// 注册中间件
app.use(cors());
app.use(serve(UPLOAD_DIR));
app.use(router.routes()).use(router.allowedMethods());
app.listen(PORT, () => {
console.log(`app starting at port ${PORT}`);
});
拖拽上传
拖拽事件有:
dragenter
:当拖拽元素或选中的文本到一个可释放目标时触发;dragover
:当元素或选中的文本被拖到一个可释放目标上时触发(每100毫秒触发一次);dragleave
:当拖拽元素或选中的文本离开一个可释放目标时触发;drop
:当元素或选中的文本在可释放目标上被释放时触发。
<div id="dropArea">
<p>拖拽上传文件</p>
<div id="imagePreview"></div>
</div>
const dropAreaEle = document.querySelector("#dropArea");
const imgPreviewEle = document.querySelector("#imagePreview");
const IMAGE_MIME_REGEX = /^image\/(jpe?g|gif|png)$/i;
["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => {
dropAreaEle.addEventListener(eventName, preventDefaults, false);
document.body.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
/** 切换目标区域的高亮状态 */
["dragenter", "dragover"].forEach((eventName) => {
dropAreaEle.addEventListener(eventName, highlight, false);
});
["dragleave", "drop"].forEach((eventName) => {
dropAreaEle.addEventListener(eventName, unhighlight, false);
});
// 添加高亮样式
function highlight(e) {
dropAreaEle.classList.add("highlighted");
}
// 移除高亮样式
function unhighlight(e) {
dropAreaEle.classList.remove("highlighted");
}
/** 处理图片预览 */
dropAreaEle.addEventListener("drop", handleDrop, false);
function previewImage(file, container) {
if (IMAGE_MIME_REGEX.test(file.type)) {
const reader = new FileReader();
reader.onload = function (e) {
let img = document.createElement("img");
img.src = e.target.result;
container.append(img);
};
reader.readAsDataURL(file);
}
}
/** 文件上传 */
function handleDrop(e) {
const dt = e.dataTransfer;
const files = [...dt.files];
files.forEach((file) => {
previewImage(file, imgPreviewEle);
});
// 省略图片预览代码
files.forEach((file) => {
upload({
url: "/single",
file,
});
});
}
const request = axios.create({
baseURL: "http://localhost:3000/upload",
timeout: 60000,
});
function upload({ url, file, fieldName = "file" }) {
let formData = new FormData();
formData.set(fieldName, file);
request.post(url, formData, {
// 监听上传进度
onUploadProgress: function (progressEvent) {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
console.log(percentCompleted);
},
});
}
剪贴板复制上传
利用 Clipboard API 进行系统剪贴板的读写访问,可用于实现剪切、复制和粘贴功能。前端只需要通过 navigator.clipboard
来获取 Clipboard
对象,剪贴板为空或者不包含文本时,navigator.clipboard.readText() 方法会返回一个空字符串。
具体实现逻辑:
- 监听容器的粘贴事件;
- 读取并解析剪贴板中的内容;
- 动态构建
FormData
对象并上传。
<div id="uploadArea">
<p>请先复制图片后再执行粘贴操作</p>
</div>
const IMAGE_MIME_REGEX = /^image\/(jpe?g|gif|png)$/i;
const uploadAreaEle = document.querySelector("#uploadArea");
uploadAreaEle.addEventListener("paste", async (e) => {
e.preventDefault();
const files = [];
if (navigator.clipboard) {
let clipboardItems = await navigator.clipboard.read();
for (const clipboardItem of clipboardItems) {
for (const type of clipboardItem.types) {
if (IMAGE_MIME_REGEX.test(type)) {
const blob = await clipboardItem.getType(type);
insertImage(blob, uploadAreaEle);
files.push(blob);
}
}
}
} else {
const items = e.clipboardData.items;
for (let i = 0; i < items.length; i++) {
if (IMAGE_MIME_REGEX.test(items[i].type)) {
let file = items[i].getAsFile();
insertImage(file, uploadAreaEle);
files.push(file);
}
}
}
if (files.length > 0) {
confirm("剪贴板检测到图片文件,是否执行上传操作?")
&& upload({
url: "/multiple",
files,
});
}
});
function previewImage(file, container) {
const reader = new FileReader();
reader.onload = function (e) {
let img = document.createElement("img");
img.src = e.target.result;
container.append(img);
};
reader.readAsDataURL(file);
}
function upload({ url, files, fieldName = "file" }) {
let formData = new FormData();
files.forEach((file) => {
let fileName = +new Date() + "." + IMAGE_MIME_REGEX.exec(file.type)[1];
formData.append(fieldName, file, fileName);
});
request.post(url, formData);
}
比较特殊的是大文件分块上传: 利用 Blob.slice 方法对大文件按照指定的大小进行切割,然后通过多线程进行分块上传,等所有分块都成功上传后,再通知服务端进行分块合并。
大文件并发上传的完整流程如下:
服务器上传
是指文件从一台服务器上传到另外一台服务器,可以借助form-data 这个库实现。具体是通过 fs.createReadStream
API 创建可读流,然后调用 FormData
对象的 append
方法添加表单项,最后再调用 submit
方法执行提交操作
const fs = require("fs");
const path = require("path");
const FormData = require("form-data");
/** 单文件上传 */
const form1 = new FormData();
form1.append("file", fs.createReadStream(path.join(__dirname, "images/image-1.jpeg")));
form1.submit("http://localhost:3000/upload/single", (error, response) => {
if(error) {
console.log("单图上传失败");
return;
}
console.log("单图上传成功");
})
/** 多文件上传 */
const form2 = new FormData();
form2.append("file", fs.createReadStream(path.join(__dirname, "images/image-2.jpeg")));
form2.append("file", fs.createReadStream(path.join(__dirname, "images/image-3.jpeg")));
form2.submit("http://localhost:3000/upload/multiple", (error, response) => {
if(error) {
console.log("多图上传失败");
return;
}
console.log("多图上传成功");
});
文件下载
在 JavaScript 中 Blob 类型的对象表示一个不可变、原始数据的类文件对象。 它的数据可以按文本或二进制的格式进行读取,也可以转换成 ReadableStream 用于数据操作。
/**
* blobParts:它是一个由 ArrayBuffer,ArrayBufferView,Blob,DOMString 等对象构成的数组。其中,DOMStrings 会被编码为 UTF-8。
* options:type —— 代表了将会被放入到 blob 中的数组内容的 MIME 类型,默认值为 ""。
* endings —— 用于指定包含行结束符 \n 的字符串如何被写入。 默认值为 "transparent",代表会保持 blob 中保存的结束符不变。"native",代表行结束符会被更改为适合宿主操作系统文件系统的换行符。
*/
new Blob(blobParts, options);
a 标签下载
图片下载的功能是借助 dataUrlToBlob
和 saveFile
这两个函数来实现。它们分别用于实现 Data URLs => Blob 的转换和文件的保存。
function dataUrlToBlob(base64, mimeType) {
let bytes = window.atob(base64.split(",")[1]);
let ab = new ArrayBuffer(bytes.length);
let ia = new Uint8Array(ab);
for (let i = 0; i < bytes.length; i++) {
ia[i] = bytes.charCodeAt(i);
}
return new Blob([ab], { type: mimeType });
}
// 保存文件
function saveFile(blob, filename) {
const a = document.createElement("a");
// HTMLAnchorElement.download 属性的作用是表明链接的资源将被下载,而不是显示在浏览器中。
a.download = filename;
// 创建 Object URL,并把返回的 URL 赋值给 a 元素的 href 属性
a.href = URL.createObjectURL(blob);
// 调用 a 元素的 click 方法来触发文件的下载操作,
a.click();
// 调用 URL.revokeObjectURL 方法从内部映射中删除引用,从而允许删除 Blob 来释放内存
URL.revokeObjectURL(a.href)
}
showSaveFilePicker API 下载
Window.showSaveFilePicker(options) 该方法会显示允许用户选择保存路径的文件选择器,该方法会返回一个 FileSystemFileHandle 对象,FileSystemFileHandle.createWritable 方法返回FileSystemWritableFileStream对象支持将数据(blob)写入文件中。
async function saveFile(blob, filename) {
try {
const handle = await window.showSaveFilePicker({
suggestedName: filename,
types: [
{
description: "PNG file",
accept: {
"image/png": [".png"],
},
},
{
description: "Jpeg file",
accept: {
"image/jpeg": [".jpeg"],
},
},
],
});
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
return handle;
} catch (err) {
console.error(err.name, err.message);
}
}
FileSaver 下载
借助 FileSaver.js 提供的 saveAs
方法来保存文件。saveAs 方法支持 3 个参数,第 1 个参数表示它支持 Blob/File/Url
三种类型,第 2 个参数表示文件名(可选),而第 3 个参数表示配置对象(可选)。
saveAs(imgBlob, "face.png");
Zip 下载
借助 JSZip 可以实现压缩多文件并下载的功能。
const images = ["1.png", "2.png", "3.png"];
const imageUrls = images.map((name) => "../images/" + name);
// 从指定的url上下载文件内容
function getFileContent(fileUrl) {
return new JSZip.external.Promise(function (resolve, reject) {
// 调用jszip-utils库提供的getBinaryContent方法获取文件内容
JSZipUtils.getBinaryContent(fileUrl, function (err, data) {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});
}
async function download() {
let zip = new JSZip();
Promise.all(imageUrls.map(getFileContent)).then((contents) => {
contents.forEach((content, i) => {
zip.file(images[i], content);
});
zip.generateAsync({ type: "blob" }).then(function (blob) {
saveAs(blob, "material.zip");
});
});
}
附件形式下载
服务端场景,通过设置 Content-Disposition
响应头来指示响应的内容以何种形式展示,是以内联(inline)的形式,还是以附件(attachment)的形式下载并保存到本地。
// attachment/file-server.js
const fs = require("fs");
const path = require("path");
const Koa = require("koa");
const Router = require("@koa/router");
const app = new Koa();
const router = new Router();
const PORT = 3000;
const STATIC_PATH = path.join(__dirname, "./static/");
// http://localhost:3000/file?filename=mouth.png
router.get("/file", async (ctx, next) => {
const { filename } = ctx.query;
const filePath = STATIC_PATH + filename;
const fStats = fs.statSync(filePath);
ctx.set({
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename=${filename}`,
"Content-Length": fStats.size,
});
ctx.body = fs.createReadStream(filePath);
});
// 注册中间件
app.use(async (ctx, next) => {
try {
await next();
} catch (error) {
// ENOENT(无此文件或目录):通常是由文件操作引起的,这表明在给定的路径上无法找到任何文件或目录
ctx.status = error.code === "ENOENT" ? 404 : 500;
ctx.body = error.code === "ENOENT" ? "文件不存在" : "服务器开小差";
}
});
app.use(router.routes()).use(router.allowedMethods());
app.listen(PORT, () => {
console.log(`应用已经启动:http://localhost:${PORT}/`);
});
base64 格式(Data URLs)下载
利用 axios 实例的 get
方法发起 HTTP 请求来获取指定的 base64 格式图片。然后先将 base64 字符串转换成 blob 对象,再调用 FileSaver 提供的 saveAs
方法下载保存文件到客户端:
const picSelectEle = document.querySelector("#picSelect");
const imgPreviewEle = document.querySelector("#imgPreview");
picSelectEle.addEventListener("change", (event) => {
imgPreviewEle.src = "./static/" + picSelectEle.value + ".png";
});
const request = axios.create({
baseURL: "http://localhost:3000",
timeout: 60000,
});
async function download() {
const response = await request.get("/file", {
params: {
filename: picSelectEle.value + ".png",
},
});
if (response && response.data && response.data.code === 1) {
const fileData = response.data.data;
const { name, type, content } = fileData;
// 将 base64 字符串(data urls)转换成 blob 对象
const imgBlob = base64ToBlob(content, type);
saveAs(imgBlob, name);
}
}
对图片进行 Base64 编码的操作是定义在 /file
路由对应的路由处理器中,调用 Buffer 对象的 toString
方法对文件内容进行 Base64 编码,最终所下载的图片将以 Base64 格式返回到客户端:
// base64/file-server.js
const fs = require("fs");
const path = require("path");
const mime = require("mime");
const Koa = require("koa");
const cors = require("@koa/cors");
const Router = require("@koa/router");
const app = new Koa();
const router = new Router();
const PORT = 3000;
const STATIC_PATH = path.join(__dirname, "./static/");
router.get("/file", async (ctx, next) => {
const { filename } = ctx.query;
const filePath = STATIC_PATH + filename;
const fileBuffer = fs.readFileSync(filePath);
ctx.body = {
code: 1,
data: {
name: filename,
type: mime.getType(filename),
content: fileBuffer.toString("base64"),
},
};
});
// 注册中间件
app.use(async (ctx, next) => {
try {
await next();
} catch (error) {
ctx.body = {
code: 0,
msg: "服务器开小差",
};
}
});
app.use(cors());
app.use(router.routes()).use(router.allowedMethods());
app.listen(PORT, () => {
console.log(`应用已经启动:http://localhost:${PORT}/`);
});
chunked 分块下载
适用于要传输大量的数据,但是在请求在没有被处理完之前响应的长度是无法获得的场景。要使用分块传输编码,则需要在响应头配置 Transfer-Encoding
字段,并设置它的值为 chunked
或 gzip, chunked
:
/** 带 chunked 表示数据以一系列分块的形式进行发送 */
Transfer-Encoding: chunked
或
Transfer-Encoding: gzip, chunked
而且响应报文中不能出现与之互斥的字段 Content-Length
。
具体客户端实现逻辑——浏览器端通过 Fetch API 获取,以流的形式进行接收数据,用 ReadableStream.getReader()
创建一个读取器,最后调用 reader.read
方法来读取已返回的分块数据,如果收到的分块非 终止块,result.done
的值是 false
,则会继续调用 readChunk
方法来读取分块数据。而当接收到 终止块 之后,表示分块数据已传输完成。此时,result.done
属性就会返回 true
。从而会自动调用 onChunkedResponseComplete
函数,在该函数内部,我们以解码后的文本作为参数来创建 Blob 对象。之后,继续使用 FileSaver 库提供的 saveAs
方法实现文件下载:
const chunkedUrl = "http://localhost:3000/file?filename=file.txt";
function download() {
return fetch(chunkedUrl)
.then(processChunkedResponse)
.then(onChunkedResponseComplete)
.catch(onChunkedResponseError);
}
function processChunkedResponse(response) {
let text = "";
let reader = response.body.getReader();
let decoder = new TextDecoder();
return readChunk();
function readChunk() {
return reader.read().then(appendChunks);
}
function appendChunks(result) {
let chunk = decoder.decode(result.value || new Uint8Array(), {
stream: !result.done,
});
console.log("已接收到的数据:", chunk);
console.log("本次已成功接收", chunk.length, "bytes");
text += chunk;
console.log("目前为止共接收", text.length, "bytes\n");
if (result.done) {
return text;
} else {
return readChunk();
}
}
}
function onChunkedResponseComplete(result) {
let blob = new Blob([result], {
type: "text/plain;charset=utf-8",
});
saveAs(blob, "hello.txt");
}
function onChunkedResponseError(err) {
console.error(err);
}
服务器端利用 fs.createReadStream(filePath) 创建数据的可读流,返回给客户端。
范围下载
在服务端支持 Range
请求首部的前提条件下,在一个HTTP Range 首部中,可以一次性请求多个部分,服务器会以 multipart 文件的形式将其返回。如果服务器返回的是范围响应,需要使用 206 Partial Content 状态码。假如所请求的范围不合法,那么服务器会返回 416 Range Not Satisfiable 状态码,表示客户端错误。服务器允许忽略 Range 首部,从而返回整个文件,状态码用 200 。
Range 的语法:
Range: <unit>=<range-start>-
Range: <unit>=<range-start>-<range-end>
Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>
Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>, <range-start>-<range-end>
unit:范围请求所采用的单位,通常是字节(bytes)。
<range-start>:一个整数,表示在特定单位下,范围的起始值。
<range-end>:一个整数,表示在特定单位下,范围的结束值。这个值是可选的,如果不存在,表示此范围一直延伸到文档结束。
借助 xhr 对象设置HTTP 请求头的 range,实现范围下载:
function getBinaryContent(url, start, end, responseType = "arraybuffer") {
return new Promise((resolve, reject) => {
try {
let xhr = new XMLHttpRequest();
xhr.open("GET", url, true);
xhr.setRequestHeader("range", `bytes=${start}-${end}`);
xhr.responseType = responseType;
xhr.onload = function () {
resolve(xhr.response);
};
xhr.send();
} catch (err) {
reject(new Error(err));
}
});
}
服务端则可以直接借助 koa-range 中间件来实现范围请求的响应。
大文件分块下载
在服务端支持 Range
请求首部的前提条件下,大文件并发下载的完整流程如下:
总结
文件上传与下载的场景比较场景, 其实在处理文件的过程中,可以使用 gzip
、deflate
或 br
等压缩算法对文件进行压缩,提高上传与下载的传输效率。