文件上传与下载的场景梳理

文件上传

单文件与多文件上传

利用 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 请求首部的前提条件下,大文件并发下载的完整流程如下:

 

总结

文件上传与下载的场景比较场景, 其实在处理文件的过程中,可以使用 gzipdeflate 或 br 等压缩算法对文件进行压缩,提高上传与下载的传输效率。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

薛定谔的猫96

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值