文件切片上传(切片)
Client:
-
html 基本结构搭建
-
<style> body>div { margin: 8px; } </style> <body> <h4>deng-cl</h4> <div> <input type="file" id="file"> </div> <div> <button class="upload">上传</button> </div> <script type="module" src="./src/index.js"></script> </body>
-
-
JS 常量定义
-
import axios from "axios"; // -- 依赖库 // -- 操作提示 const UPLOAD_INFO = { "NOT_SELETED_FLIE": "请先选择文件", "NOT_SUPPORTED_TYPE": "不支持该文件类型", "FETCH_SUCCESS": "上传成功", "FETCH_FAILED": "上传失败" }; // -- 文件类型限制 / 切片大小定义 const ALLOWEN_TYPE = ["image"]; const CHUNK_SIZE = 1024 * 1024; // -- 请求 const BASE_URL = "http://localhost:3000"; const FETCH_API = { "UPLOAD": "/upload", "MERGE": "/merge", };
-
-
获取文件上传的 input 与 upload 按钮 DOM 元素
-
const fileInputEl = document.querySelector("#file") // -- input dom const uploadButtonEl = document.querySelector(".upload") // -- button
-
-
监听 upload 按钮点击执行文件上传操作
-
uploadButtonEl.addEventListener("click", handleFileUpload, false)
-
对应处理函数代码如下 → 🔺里面使用其它的函数统一放在 "辅助函数栏" 中(使用 "▲ util" 进行标记)
-
async function handleFileUpload() { // -- 1. 点击上传按钮,触发该函数 const files = fileInputEl.files const file = files[0] // -- 2. 获取 input 元素中所选择的文件 if (!file) { // -- 3. 校验是否已经选择了文件(file 是否有值...) alert(UPLOAD_INFO["NOT_SELETED_FLIE"]) return } if (!ALLOWEN_TYPE.includes(file.type.split("/")[0])) { // -- 4. 校验选择的文件,是否符合对应的类型(ALLOWEN_TYPE 常量) alert(UPLOAD_INFO['NOT_SUPPORTED_TYPE']) return } // -- ▲ util: 5. 根据 File 内容,生成对应 hashHex 文件名(filename) → 用于上传文件时,对应的文件名尽可能是唯一,方便后台对文件的读写等 const filename = await createFileName(file) const fileExtension = file.name.split(".").pop() // -- 6. 获取文件扩展名 → 用于后台在写入文件时,创建对应后缀的文件进行写入 // -- ▲ util: 7. 对 file 对象进行切片 → 切片上传 const chunks = await createChunk(file, file.size, filename) try { // -- ▲ util: 8. 将文件切片信息上传至服务器中 → 文件切片数据上传 const response = await uploadFileChunks(chunks) // -- ▲ util: 9. 所有切片文件上传后,给服务发送文件合并请求 → 切片合并 const mergeRes = await mergeFileChunks(filename, fileExtension) alert(UPLOAD_INFO["FETCH_SUCCESS"]) } catch (err) { console.log(err); alert(UPLOAD_INFO["FETCH_FAILED"] + ": " + err.message || "") } }
-
-
辅助函数:
(handleFileUpload ↑)
-
▲ util: 5
→ createFileName
-
async function createFileName(file) { // -- 🔺此处: 需要理解前面的二进制家族中的一些基本操作,才能更好的理解 // -- 1. 获取 file 中的 arrayBuffer 对象(数据缓存区) const fileBuffer = await file.arrayBuffer() // -- 2. 根据对应的 arrayBuffer 生成一个包含对应摘要值的 arrayBuffer 对象 const hashBuffer = await crypto.subtle.digest("SHA-256", fileBuffer) // -- 3. 通过 TypedArray 视图对 hashBuffer 进行读写操作 → 获取里面的数据(无符号8位数组) const hashArray = Array.from(new Uint8Array(hashBuffer)) // -- 4. 映射 hashArray 中的每一项,将里面的每一项转换为 16 进制,并对每一项进行拼接操作 → 生成对应的 hash 值 const hashHex = hashArray.map(b => b.toString(16).padStart(2, 0)).join("") return hashHex // -- 5. 返回对应根据 file 中的数据生成的 hashHex 文件名 }
-
-
▲ util: 7
→ createChunk
-
async function createChunk(file, size, filename) { // -- 创建切片 // -- 1. 定义切片容器于当前切片大小 const chunks = [] let chunkSize = 0 // -- 2. 循环对文件进行切片 while (chunkSize < size) { // -- 3. 对文件进行切片 → 返回对应切片的 blob 对象 const chunkFile = file.slice(chunkSize, chunkSize + CHUNK_SIZE, file.type) // -- 4. 通过 chunkFile 创建对应切片的 File 对象(也可以不创建直接上传,不过 File 对象可以定义对应的 filename → 方便后端处理) const newFile = new File([chunkFile], filename + "-" + chunkSize, { type: file.type }) // -- 5. 创建 FormData 对象,将 newFile 文件切片数据存放在 formData 对象中 → 进行对应 "multipart/form-data" 数据的上传 const formData = new FormData() formData.append("file", newFile) // -- 6. 将对应切片(可直接上传)的对象添加至 chunks 切片容器中 chunks.push(formData) chunkSize += CHUNK_SIZE // -- 7. 递加切片 } return chunks // -- 8. 返回所有切片的存储容器 }
-
-
▲ util: 8
→ uploadFileChunks
-
async function uploadFileChunks(chunks) { // -- 分片上传 const fetchPromises = chunks.map(chunk => { // -- 1. 遍历 chunks 切片容器中的所有切片数据 → 上传切片数据 return axios.post( // -- 2. 发送请求 → 上传切片 BASE_URL + FETCH_API["UPLOAD"], chunk, { "Content-Type": "multipart/form-data" } ) }) return Promise.all(fetchPromises) // -- 通过 Promise.all 统一监听所有切片上传的结果... }
-
-
▲ util: 9
→ mergeFileChunks
-
async function mergeFileChunks(filename, fileExtension) { // -- 合并文件切片 return axios.post(BASE_URL + FETCH_API["MERGE"], { filename, fileExtension }) // -- 1. 发送对应文件的切片文件合并请求 }
-
-
-
依赖库:
-
axios : 发送请求...
-
Server:
-
依赖库的引入
-
const Koa = require('koa') // -- koa const Router = require('koa-router') // -- koa-router const static = require('koa-static') // -- koa-static 静态资源处理 const { resolve, join } = require('path') // -- path 内置库(用来处理路径拼接等) const cors = require('koa-cors') // -- koa-cors 处理跨域 const fse = require('fs-extra') // -- fs-extra fs 内置库的扩展库(处理文件的读写等) const multer = require('koa-multer') // -- koa-multer 处理文件上传 const bodyparser = require('koa-bodyparser') // -- koa-bodyparser 处理 body 的解析(json)
-
-
中间件的使用于服务的启动
-
const app = new Koa(); // -- 创建 Koa 实例 app 对象 const router = new Router(); // -- 创建 router 对象 app.use(static(join(__dirname, "static"))); // -- 定义静态目录 app.use(cors()) // -- 跨域处理 app.use(bodyparser()) // -- 处理 body 解析(json) // -- 🔺🔺🔺 router 接口处理部分,在下方展示 app.use(router.routes()) // -- 使用对应 router 中的 routes 路由表中间件(挂载至 app 中) app.listen(3000, () => { // -- 服务启动(监听) console.log("server is running on 3000 port"); })
-
-
router 接口处理部分
-
const storage = multer.diskStorage({ // -- 1. 自定义存储引擎(文件存储路径等) destination: function (req, file, cb) { cb(null, resolve(__dirname, './static/temp/')) // -- 保存的路径 }, filename: function (req, file, cb) { cb(null, file.originalname); // -- 使用 file 中原来的名字 → 方便合并操作等 } }); const upload = multer({ storage: storage }); // 初始化 upload 对象 // -- 2. 分片文件上传接口 → 使用上面 upload 对象中的中间件,自动根据上面存储引擎中的存储路径将对应上传的 FormData 文件数据进行生成对应文件进行存储(临时文件,后续合并后会删除) router.post("/upload", upload.single('file'), async (ctx) => { ctx.body = { message: "uploaded success~" } }) // -- 3. merge 合并文件分片数据接口 const TEMP_DIR = resolve(__dirname, "./static/temp") // -- 定义临时文件路径 → 方便后续使用 const UPLOAD_DIR = resolve(__dirname, "./static/uploads") // -- 定义合并后文件路径 → 方便后续使用 router.post("/merge", async (ctx) => { // -- 接口实现 // -- 获取请求中的文件名于对应后缀名 → 根据该文件名查找对应分片文件 : 根据后缀名创建对应类型的合并文件 const { filename, fileExtension } = ctx.request.body // -- 在 UPLOAD_DIR 中创建对应的合并文件,该路径用于方便后续写入切片数据时,获取写入文件路径 const NEW_FILE_PATH = UPLOAD_DIR + `/${filename}.${fileExtension}` fse.createFileSync(NEW_FILE_PATH) // -- 在 uploads 中创建对应的数据写入文件 // -- 查看 temp 目录下的所有临时文件 → 用于里面查找对应切片文件,并进行数据的写入(↑) fse.readdir(TEMP_DIR, (err, files) => { // -- 过滤出需要合并的临时文件的文件名 const mergeFilename = files.filter(item => item.includes(filename)) // -- 遍历所有分片文件 → 将每一部分切片文件的数据写入至对应的文件中 mergeFilename.sort().forEach(filename => { // -- 读取对应分片文件中的数据 const buffer = fse.readFileSync(TEMP_DIR + "/" + filename) try { // -- 写入切片文件数据至 uploads 中对应的合并文件中 fse.appendFileSync(NEW_FILE_PATH, buffer) // -- 数据写入后,删除对应历史文件 fse.unlinkSync(TEMP_DIR + "/" + filename) } catch (error) { ctx.body = { // -- 写入失败响应 message: "Error", error } console.log("err:", error); } }) }) ctx.body = { // -- 写入成功响应 message: "OK", // -- 返回对应文件访问路径 → 注意访问静态资源不需要加上配置的静态目录(即路径上不需要加上 static) url: "http://localhost:3000/uploads" + `/${filename}.${fileExtension}` } })
-