实现思路
分片上传的思路:
- 我们先拿到文件,在前端进行分片,将分片之后的小的文件传递给服务端。
- 当在客户端传送完成的时候,发送最后一个请求告诉服务端,文件已经传送完成了,然后服务端再将之前接收到文件进行合并成一个大的文件。最终再告诉客户端合并好的这个大文件。
断点续传,两种方案:
- 在上传之前先拉一下已经上传了那些切片在服务端了,然后客户端就可以跳过已经上传的切片了。
- 客户端不处理,在服务端进行处理,客户端上传所有的切片,然后服务端发现如果已经上传过了,则迅速返回成功,告诉客户端再继续传送下一个切片。
前端实现
1. axios 封装
js
import axios from "axios";
import Qs from "qs";
let instance = axios.create();
instance.defaults.baseURL = "http://127.0.0.1:8888";
instance.defaults.headers["Content-Type"] = "multipart/form-data";
instance.defaults.transformRequest = (data, headers) => {
const contentType = headers["Content-Type"];
if (contentType === "application/x-www-form-urlencoded")
return Qs.stringify(data);
return data;
};
instance.interceptors.response.use((response) => {
return response.data;
});
export default instance;
2. 分片上传逻辑
js
import { useRef, useState } from "react";
import SparkMD5 from "spark-md5";
import "./large-file-upload.less";
import instance from "../utils/instance";
function LargeFileUpload() {
const [loading, setLoading] = useState(false);
const inputFileRef = useRef();
const handleUploadClick = () => {
inputFileRef.current.click();
};
// 根据文件生成 一个hash 值
function changeBuffer(file) {
return new Promise((resolve) => {
let fileReader = new FileReader();
// 调用读取 file 内容的函数,当读取完成的时候, readyState 变成 DONE (已完成), 并触发 loadend 时间,同时 result 属性中包含一个
// arrayBuffer 对象表示所读取文件的数据。
fileReader.readAsArrayBuffer(file);
// 当读取操作完成的时候,触发 loaded 事件
fileReader.onload = (ev) => {
// result 表示所读取的文件数据
let buffer = ev.target.result;
let spark = new SparkMD5.ArrayBuffer();
let HASH, suffix;
spark.append(buffer);
// 根据文件内容生成一个 hash 值
HASH = spark.end();
// 在文件名里面匹配 . 后面的字母
// 第一个是匹配的 所有字符, 第二个是 匹配的第一个组
suffix = /\.([a-zA-z0-9]+)$/.exec(file.name)[1];
resolve({
buffer,
HASH,
suffix,
filename: `${HASH}.${suffix}`,
});
};
});
}
const onInputFileChange = async () => {
let file = inputFileRef.current.files[0];
// 获取文件的 hash 值
let already = [],
data = null;
// 根据文件内容生成 hash 值的时候,也是必要消耗时间的
setLoading(true);
// 根据文件内容生成 hash 值,和获取文件后缀
let { HASH, suffix } = await changeBuffer(file);
// 获取已经上传的切片信息
try {
data = await instance.get("/upload_already", {
params: {
HASH,
},
});
// 拿到已经上传好的 切片列表
if (+data.code === 0) {
already = data.fileList;
}
} catch (err) {
console.log(err);
}
// 实现文件的切片处理
// 有两种策略 【固定大小 或者 固定数量】
let max = 1024 * 100; // 每次传输的最大字节数
let count = Math.ceil(file.size / max); // 计算一共要分多少个切片
let index = 0;
let chunks = [];
// 如果计算的 切片个数大于 100 个则,就固定切片个数
if (count > 100) {
count = 100;
// 重新计算切片的大小
max = file.size / 100;
}
// 生成切片信息
while (index < count) {
chunks.push({
file: file.slice(index * max, (index + 1) * max),
filename: `${HASH}_${index + 1}.${suffix}`,
});
index++;
}
index = 0;
const complate = async () => {
index++;
// 当如果没有达到最大的个数
if (index < count) return;
try {
data = await instance.post(
"/upload_merge",
{
HASH,
count,
},
{
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
}
);
if (+data.code === 0) {
setLoading(false);
// 上传完成之后清除 form 的值
inputFileRef.current.value = "";
alert(
`恭喜你,文件上传成功,你可以访问 ${data.servicePath} 访问该文件~~`
);
}
} catch (err) {
alert("切片合并失败,请稍后再试");
}
};
// 遍历收集好的 切片信息
chunks.forEach((chunk) => {
// 看看是否有已经上传的切片信息
if (already.length > 0 && already.includes(chunk.filename)) {
// 这里的 return 表示跳过的意思
complate();
return;
}
let fm = new FormData();
fm.append("file", chunk.file);
fm.append("filename", chunk.filename);
instance.post("/upload_chunk", fm).then((data) => {
if (+data.code === 0) {
complate();
}
// 如果 code 不是 0
return Promise.reject(data.codeText);
});
});
};
return (
<div className="large-file-upload" onClick={handleUploadClick}>
{loading ? (
<div className="loading">loading...</div>
) : (
<span className="add">+</span>
)}
<input type="file" ref={inputFileRef} onChange={onInputFileChange} />
</div>
);
}
export default LargeFileUpload;
实现文件上传切片处理的方式
- 固定数量。
- 固定大小。(设置一个每次传输的最大字节数,如果根据最大字节数计算出来的切片数量超过最大切片数量的话,则按照最大的切片数量重新计算每次传输的最大字节数)
后端实现(nodejs)
1.引用包
body-parser
:bodyParser用于解析客户端请求的body中的内容,内部使用JSON编码处理,url编码,处理以及对于文件的上传处理。
express
: 创建 api
服务
multiparty
: 解析Content-Type multipart/form-data的HTTP请求,也被称为文件上传。
spark-md5
:
-
MD5计算将整个文件或者字符串,通过其不可逆的字符串变换计算,产生文件或字符串的MD5散列值。任意两个文件、字符串不会有相同的散列值(即"很大可能"是不一样的,理论上要创造出两个散列值相同的字符串是很困难的)。
-
因此MD5常用于校验字符串或者文件,以防止文件、字符串被"篡改"。因为如果文件、字符串的MD5散列值不一样,说明文件内容也是不一样的,即经过修改的,如果发现下载的文件和给的MD5值不一样,需要慎重使用。
2. 代码实现
js
// 使用 express 编写 api 程序
const express = require("express");
const fs = require("fs");
const bodyParser = require("body-parser");
const multipart = require("multiparty");
const sparkMd5 = require("spark-md5");
// 创建服务
const app = express();
const PORT = 8888;
const HOST = "http://127.0.0.1";
const HOSTNAME = `${HOST}:${PORT}`;
app.listen(PORT, () => {
console.log(`上传服务启动,请访问${HOSTNAME}`);
});
// 中间件
app.use((req, res, next) => {
res.header("Access-Control-allow-origin", "*");
// 如果是 options 请求则放行
req.method === "OPTIONS"
? res.send("current services support cross domain requests!")
: next();
});
app.use(
bodyParser.urlencoded({
extended: false,
limit: "1024mb",
})
);
// API
// 延时函数
function delay(interval) {
typeof interval !== "number" ? (interval = 1000) : null;
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, interval);
});
}
// 检测文件是否存在
const exists = function exists(path) {
return new Promise((resolve) => {
fs.access(path, fs.constants.F_OK, (err) => {
if (err) {
resolve(false);
return;
}
resolve(true);
});
});
};
// 大文件上传 & 合并切片
const merge = (HASH, count) => {
return new Promise(async (resolve, reject) => {
let path = `${uploadDir}/${HASH}`;
let fileList = [];
let suffix = "";
let isExists;
// 看当前路径是否存在
isExists = await exists(path);
// 根据 hash 值没有找到
if (!isExists) {
reject("HASH path is not found!");
return;
}
fileList = fs.readdirSync(path);
if (fileList.length < count) {
reject("ths slice has not been uploaded!");
return;
}
fileList
.sort((a, b) => {
let reg = /_(\d+)/;
return reg.exec(a)[1] - reg.exec(b)[1];
})
.forEach((item) => {
!suffix ? (suffix = /\.([0-9a-zA-Z]+)$/.exec(item)[1]) : null;
// 把切片合并成一个文件
fs.appendFileSync(
`${uploadDir}/${HASH}.${suffix}`,
fs.readFileSync(`${path}/${item}`)
);
// 删除切片
fs.unlinkSync(`${path}/${item}`);
});
// 移除临时空的文件夹
fs.rmdirSync(path);
resolve({
path: `${uploadDir}/${HASH}.${suffix}`,
filename: `${HASH}.${suffix}`,
});
});
};
// 创建文件 并写入到指定的目录 并且返回给客户端结果
const writeFile = (res, path, file, filename, stream) => {
return new Promise((resolve, reject) => {
if (stream) {
try {
// 创建可读,可写 流
let readStream = fs.createReadStream(file.path);
let writeStream = fs.createWriteStream(path);
// 将可写流交给可读流管道
// 上面三行代码的作用是,将文件从 file.path 复制到 path
readStream.pipe(writeStream);
readStream.on("end", () => {
resolve();
// 然后删除掉 file.path 下面的文件
fs.unlinkSync(file.path);
res.send({
code: 0,
codeText: "upload success",
originalFilename: filename,
servicePath: path.replace(__dirname, HOSTNAME),
});
});
} catch (err) {
reject(err);
res.send({
code: 1,
codeText: err,
});
}
return;
}
fs.writeFile(path, file, (err) => {
if (err) {
reject(err);
res.send({
code: 1,
codeText: err,
});
return;
}
resolve();
res.send({
code: 0,
codeText: "upload success",
originalFilename: filename,
servicePath: path.replace(__dirname, HOSTNAME),
});
});
});
};
// 基于 multiparty 插件实现文件上传处理 & form-data 解析
const uploadDir = `${__dirname}/upload`;
const multiparty_upload = (req, auto) => {
typeof auto !== "boolean" ? (auto = false) : null;
let config = {
maxFieldsSize: 200 * 1024 * 1024,
};
if (auto) config.uploadDir = uploadDir;
return new Promise(async (resolve, reject) => {
await delay();
new multipart.Form(config).parse(req, (err, fields, files) => {
if (err) {
reject(err);
return;
}
// 解析出文件,和文件名
resolve({
fields,
files,
});
});
});
};
// 合并文件
app.post("/upload_merge", async (req, res) => {
let { HASH, count } = req.body;
// 尝试合并文件
try {
let { filename, path } = await merge(HASH, count);
res.send({
code: 0,
codeText: "merge success",
originalFilename: filename,
servicePath: path.replace(__dirname, HOSTNAME),
});
} catch (err) {
res.send({
code: 1,
codeText: err,
});
}
});
// 请求已经上传好的分片
app.get("/upload_already", async (req, res) => {
let { HASH } = req.query;
let path = `${uploadDir}/${HASH}`;
let fileList = [];
try {
// 读取文件目录
fileList = fs.readdirSync(path);
// 对文件 进行一个排序
fileList = fileList.sort((a, b) => {
// 匹配数字
let reg = /_(\d)+/;
return reg.exec(a)[1] - reg.exec(b)[1];
});
// 发送给前端
res.send({
code: 0,
codeText: "",
fileList,
});
} catch (err) {
res.send({
code: 0,
codeText: "",
fileList: fileList,
});
}
});
// 上传分片的接口
app.post("/upload_chunk", async (req, res) => {
try {
let { fields, files } = await multiparty_upload(req);
let file = (files.file && files.file[0]) || {};
let filename = (fields.filename && fields.filename[0]) || "";
let path = "";
let isExists = false;
// 创建存放 切片的临时目录
let [, HASH] = /^([^_]+)_(\d+)/.exec(filename);
path = `${uploadDir}/${HASH}`;
!fs.existsSync(path) ? fs.mkdirSync(path) : null;
// 把切片存储发哦临时目录中
path = `${uploadDir}/${HASH}/${filename}`;
isExists = await exists(path);
if (isExists) {
res.send({
// 0 表示成功
code: 0,
codeText: "file is exists",
originalFilename: filename,
servicePath: path.replace(__dirname, HOSTNAME),
});
return;
}
writeFile(res, path, file, filename, true);
} catch (err) {
res.send({
code: 1,
codeText: err,
});
}
});
app.use(express.static("./"));
app.use((req, res) => {
res.status(404);
res.send("not found!");
});
shell
## 求职广告^_^
如有大佬招前端(西安)的可评论留言。
私信或者微信:xiuxiuyifanf,跪求一个前端开发坑位。