Client:
html 基本结构搭建
<style>
body>div { margin: 8px; }
style>
<body>
<h4>deng-clh4>
<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}`
}
})