写在前面
前段时间在工作中接触到了文件上传的内容,但业务中实现的功能比较简单,于是我想着能不能使用纯原生的方式实现一个大文件的上传demo,从而在本质上学习大文件上传的思路。本教程使用纯原生的html+node.js实现,能快速上手一个简单的大文件上传,深入理解其内部的原理,也能方便在后续的工作中对demo进行快速扩展,非常适合想入门学习大文件上传的同学。
效果展示
首先来看看最后的效果。
实现思路
上图是大文件上传的整体流程图,显示了客户端和服务端的交互逻辑,方便大家从宏观上理解大文件上传的过程,但如果按照上面的流程讲解大文件上传入门,很容易被劝退。
下面我们将按照功能点逐步迭代的方式讲解大文件上传,每个功能点都很简单,每实现一个功能点都会极大的增涨我们的信心。大文件上传一共分为分片上传、分片合并、文件秒传、断点续传、上传进度这五个功能点,后面的功能都是在前面的功能基础上迭代完成。如果能实现一个分片上传功能就算是入门了大文件上传了,后面都是在此基础上增加功能而已。
具体实现
分片上传
首先我们来实现一个最简单也最核心的分片上传,这个功能点分为客户端的文件分片、计算hash值、上传分片文件和服务端的创建分片目录并存储分片。客户端和服务端源代码分别存放在bigfileupload.html
和server.js
文件中。
客户端
为了方便后面能够处理取消上传和上传进度,我们首先对fetch
请求做一个简单的封装。
/** * @description: 封装fetch * @param {object} fetchconfig fetch config * @return {promise} fetch result */ const requestapi = ({ url, method = "get", ...fetchprops }) => { return new promise(async (resolve, reject) => { const res = await fetch(url, { method, ...fetchprops, }); resolve(res.json()); }); };
下面是分片功能需要的标签元素。
<input type="file" name="file" id="file" multiple /> <button id="upload" onclick="handleupload()">上传</button> <p id="hash-progress"></p> <p id="total-slice"></p>
首先,我们需要使用slice()
方法对大文件进行分片,并把分片的内容、大小等信息都放入到分片列表中,最后在页面上显示一下分片数量。
// 文件分片 const createfilechunk = (file) => { const chunklist = []; //计算文件切片总数 const slicesize = 5 * 1024 * 1024; // 每个文件切片大小定为5mb const totalslice = math.ceil(filesize / slicesize); for (let i = 1; i <= totalslice; i++) { let chunk; if (i == totalslice) { // 最后一片 chunk = file.slice((i - 1) * slicesize, filesize - 1); //切割文件 } else { chunk = file.slice((i - 1) * slicesize, i * slicesize); } chunklist.push({ file: chunk, filesize, size: math.min(slicesize, file.size), }); } const slicetext = `一共分片:${totalslice}`; document.getelementbyid("total-slice").innerhtml = slicetext; console.log(slicetext); return chunklist; };
然后, 使用spark-md5 分别计算每个分片的hash值,最后得到整个文件hash值。计算hash值需要比较长的时间,可以在页面上输出计算hash值的进度。
// 根据分片生成hash const calculatehash = (filechunklist) => { return new promise((resolve) => { const spark = new sparkmd5.arraybuffer(); let count = 0; // 计算出hash const loadnext = (index) => { const reader = new filereader(); // 文件阅读对象 reader.readasarraybuffer(filechunklist[index].file); reader.onload = (e) => { count++; spark.append(e.target.result); if (count === filechunklist.length) { resolve(spark.end()); } else { // 还没读完 const percentage = parseint( ((count + 1) / filechunklist.length) * 100 ); const progresstext = `计算hash值:${percentage}%`; document.getelementbyid("hash-progress").innerhtml = progresstext; console.log(progresstext); loadnext(count); } }; }; loadnext(0); }); };
紧接着,需要将分片数据全部上传到服务器,这里需要注意是的分片的hash值是 ${filehash}-${index}
, 服务端会根据这个hash值创建分片文件。
let filename = "", filehash = "", filesize = 0, filechunklistdata = []; const host = "http://localhost:3000"; // ... const handleupload = async () => { const file = document.getelementbyid("file").files[0]; if (!file) return alert("请选择文件!"); filename = file.name; // 文件名 filesize = file.size; // 文件大小 const filechunklist = createfilechunk(file); filehash = await calculatehash(filechunklist); // 文件hash filechunklistdata = filechunklist.map(({ file, size }, index) => { const hash = `${filehash}-${index}`; return { file, size, filename, filehash, hash, }; }); await uploadchunks(); }; //上传分片 const uploadchunks = async () => { const requestlist = filechunklistdata .map(({ file, filehash, filename, hash }, index) => { const formdata = new formdata(); formdata.append("file", file); formdata.append("filehash", filehash); formdata.append("name", filename); formdata.append("hash", hash); return { formdata }; }) .map(async ({ formdata }) => { return requestapi({ url: `${host}`, method: "post", body: formdata, }); }); await promise.all(requestlist); };
服务端
首先,我们使用原生node.js
启动一个后端服务。
import * as http from "http"; //es 6 import path from "path"; const server = http.createserver(); server.on("request", async (req, res) => { res.setheader("access-control-allow-origin", "*"); res.setheader("access-control-allow-headers", "*"); if (req.method === "options") { res.status = 200; res.end(); return; } }); server.listen(3000, () => console.log("正在监听 3000 端口"));
接下来,我们就可以在里面添加上传分片的接口。使用multiparty读取到客户端提交的表单数据后,判断切片目录是否存在,不存在就使用 filehash
值创建一个临时的分片目录,并使用fs-extra 的move
方法存储文件分片到对应的分片目录下。
import * as http from "http"; //es 6 import path from "path"; import fse from "fs-extra"; import multiparty from "multiparty"; const server = http.createserver(); const upload_dir = path.resolve("/users/sxg/downloads/", "target"); // 大文件存储目录 server.on("request", async (req, res) => { res.setheader("access-control-allow-origin", "*"); res.setheader("access-control-allow-headers", "*"); if (req.method === "options") { res.status = 200; res.end(); return; } if (req.url === "/") { const multipart = new multiparty.form(); multipart.parse(req, async (err, fields, files) => { if (err) { console.error(err); res.status = 500; res.end( json.stringify({ messaage: "process file chunk failed", }) ); return; } const [chunk] = files.file; const [hash] = fields.hash; const [filename] = fields.name; const [filehash] = fields.filehash; const chunkdir = `${upload_dir}/${filehash}`; const filepath = path.resolve( upload_dir, `${filehash}${extractext(filename)}` ); // 文件存在直接返回 if (fse.existssync(filepath)) { res.end( json.stringify({ messaage: "file exist", }) ); return; } // 切片目录不存在,创建切片目录 if (!fse.existssync(chunkdir)) { await fse.mkdirs(chunkdir); } // fs-extra 专用方法,类似 fs.rename 并且跨平台 // fs-extra 的 rename 方法 windows 平台会有权限问题 // https://github.com/meteor/meteor/issues/7852#issuecomment-255767835 await fse.move(chunk.path, `${chunkdir}/${hash}`); res.status = 200; res.end( json.stringify({ messaage: "received file chunk", }) ); }); } }); server.listen(3000, () => console.log("正在监听 3000 端口"));
到这里为止,我们就已经实现了文件上传最基本的功能,后续只是在此基础上进行迭代。
合并分片
客户端
在上传完文件分片之后,我们就可以对所有文件分片进行合并,这里需要请求一个合并分片的接口,需要传递文件的filehash
和 filename
。
//上传分片 const uploadchunks = async () => { //... await mergerequest(filename, filehash); }; // 合并分片 const mergerequest = async (filename, filehash) => { await requestapi({ url: `${host}/merge`, method: "post", headers: { "content-type": "application/json;charset=utf-8", }, body: json.stringify({ filename: filename, filehash, }), }); };
服务端
合并切片功能最核心的功能就是根据filehash
读取对应分片目录下的分片文件列表,并按照分片下标进行排序,避免后面合并时顺序错乱。然后,使用 writefile
方法创建一个空文件,再使用appendfilesync
依次向文件中添加分片数据,最后删除临时的分片目录。
// 合并切片 const mergefilechunk = async (filepath, filehash) => { const chunkdir = `${upload_dir}/${filehash}`; const chunkpaths = await fse.readdir(chunkdir); // 根据切片下标进行排序,否则直接读取目录的获得的顺序可能会错乱 chunkpaths.sort((a, b) => a.split("-")[1] - b.split("-")[1]); await fse.writefile(filepath, ""); chunkpaths.foreach((chunkpath) => { fse.appendfilesync(filepath, fse.readfilesync(`${chunkdir}/${chunkpath}`)); fse.unlinksync(`${chunkdir}/${chunkpath}`); }); fse.rmdirsync(chunkdir); // 合并后删除保存切片的目录 };
这里实现一下合并分片的接口,首先需要读取请求中的数据,然后拼接出合并后的文件名称 ${upload_dir}/${filehash}${ext}
,最后调用合并分片方法。
import * as http from "http"; //es 6 import path from "path"; import fse from "fs-extra"; import multiparty from "multiparty"; const server = http.createserver(); const extractext = (filename) => filename.slice(filename.lastindexof("."), filename.length); // 提取后缀名 //... const resolvepost = (req) => new promise((resolve) => { let chunk = ""; req.on("data", (data) => { chunk += data; }); req.on("end", () => { resolve(json.parse(chunk)); }); }); server.on("request", async (req, res) => { //... if (req.url === "/merge") { const data = await resolvepost(req); const { filename, filehash } = data; const ext = extractext(filename); const filepath = `${upload_dir}/${filehash}${ext}`; await mergefilechunk(filepath, filehash); res.status = 200; res.end(json.stringify("file merged success")); } }); server.listen(3000, () => console.log("正在监听 3000 端口"));
秒传
客户端
实现秒传只需要在文件上传之前请求接口验证一下文件是否存在。
const handleupload = async () => { //... const { shouldupload } = await verifyupload( filename, filehash ); if (!shouldupload) { alert("秒传:上传成功"); return; } //... }; //文件秒传 const verifyupload = async (filename, filehash) => { const data = await requestapi({ url: `${host}/verify`, method: "post", headers: { "content-type": "application/json;charset=utf-8", }, body: json.stringify({ filename, filehash, }), }); return data; };
服务端
如果文件存在shouldupload
就返回 false
,否则就返回 true
。
import * as http from "http"; //es 6 import path from "path"; import fse from "fs-extra"; import multiparty from "multiparty"; const server = http.createserver(); //... server.on("request", async (req, res) => { //... if (req.url === "/verify") { const data = await resolvepost(req); const { filehash, filename } = data; const ext = extractext(filename); const filepath = `${upload_dir}/${filehash}${ext}`; if (fse.existssync(filepath)) { res.end( json.stringify({ shouldupload: false, }) ); } else { res.end( json.stringify({ shouldupload: true, }) ); } } }); server.listen(3000, () => console.log("正在监听 3000 端口"));
断点续传
客户端
断点续传新增了两个按钮,来控制文件上传进度。
/* ... */ <button id="pause" onclick="handlepause()" style="display: none"> 暂停 </button> <button id="resume" onclick="handleresume()" style="display: none"> 恢复 </button> /* ... */
这里需要对requestapi
进行一些改造,添加 abortcontrollerlist
用于存储需要被取消的请求,如果接口请求成功,则将fetch
从 abortcontrollerlist
中移除。
/** * @description: 封装fetch * @param {object} fetchconfig fetch config * @return {promise} fetch result */ const requestapi = ({ url, method = "get", onprogress, ...fetchprops }) => { const controller = new abortcontroller(); abortcontrollerlist.push(controller); return new promise(async (resolve, reject) => { const res = await fetch(url, { method, ...fetchprops, signal: controller.signal, }); // 将请求成功的 fetch 从列表中删除 const acindex = abortcontrollerlist.findindex( (c) => c.signal === controller.signal ); abortcontrollerlist.splice(acindex, 1); //... }); };
在分片上传也需要做一些改造,将接口中获取到的uploadedlist
,从所有分片列表中过滤出去,当已上传的uploadedlist
数量加 requestlist
的数量等于分片列表filechunklistdata
的数量时才进行分片合并。
let filename = "", filehash = "", filesize = 0, filechunklistdata = [], abortcontrollerlist = []; const host = "http://localhost:3000"; //... const handleupload = async () => { //... const { shouldupload, uploadedlist } = await verifyupload( filename, filehash ); if (!shouldupload) { alert("秒传:上传成功"); return; } //... await uploadchunks(uploadedlist); }; //上传分片 const uploadchunks = async (uploadedlist) => { const requestlist = filechunklistdata .filter(({ hash }) => !uploadedlist.includes(hash)) .map(({ file, filehash, filename, hash }, index) => { //... }) .map(async ({ formdata, hash }) => { . //... }); //... // 之前上传的切片数量 + 本次上传的切片数量 = 所有切片数量时 //合并分片 if ( uploadedlist.length + requestlist.length === filechunklistdata.length ) { await mergerequest(filename, filehash); } };
然后,实现一下暂停和恢复的事件处理,暂停是通过调用 abortcontroller 的 abort()
方法实现。恢复则是重新获取uploadedlist
后再进行分片上传实现。
//暂停 const handlepause = () => { abortcontrollerlist.foreach((controller) => controller?.abort()); abortcontrollerlist = []; }; // 恢复 const handleresume = async () => { const { uploadedlist } = await verifyupload(filename, filehash); await uploadchunks(uploadedlist); };
服务端
断点续传是在秒传接口的基础上实现的,只是需要新增已上传分片列表uploadedlist
。
import * as http from "http"; //es 6 import path from "path"; import fse from "fs-extra"; import multiparty from "multiparty"; const server = http.createserver(); //... // 返回已经上传切片名列表 const createuploadedlist = async (filehash) => fse.existssync(`${upload_dir}/${filehash}`) ? await fse.readdir(`${upload_dir}/${filehash}`) : []; server.on("request", async (req, res) => { //... if (req.url === "/verify") { const data = await resolvepost(req); const { filehash, filename } = data; const ext = extractext(filename); const filepath = `${upload_dir}/${filehash}${ext}`; if (fse.existssync(filepath)) { res.end( json.stringify({ shouldupload: false, }) ); } else { res.end( json.stringify({ shouldupload: true, uploadedlist: await createuploadedlist(filehash), }) ); } } }); server.listen(3000, () => console.log("正在监听 3000 端口"));
上传进度
上传进度只需要改造客户端,首先,新增显示进度的标签。
<p id="progress"></p>
上传进度需要对fetch
请求再做一点改造,这里需要使用getreader()
手动读取数据流,获取到当前上传进度,并添加onprogress
回调。
/** * @description: 封装fetch * @param {object} fetchconfig fetch config * @return {promise} fetch result */ const requestapi = ({ url, method = "get", onprogress, ...fetchprops }) => { //... return new promise(async (resolve, reject) => { const res = await fetch(url, { method, ...fetchprops, }); const total = res.headers.get("content-length"); const reader = res.body.getreader(); //创建可读流 const decoder = new textdecoder(); let loaded = 0; let data = ""; while (true) { const { done, value } = await reader.read(); loaded += value?.length || 0; data += decoder.decode(value); onprogress && onprogress({ loaded, total }); if (done) { break; } } //... resolve(json.parse(data)); }); };
然后,在上传的时候将已上传进度设置成100,并添加onprogress
回调处理,累计每个分片的进度,得到整体的上传进度。
let filename = "", filehash = "", filesize = 0, filechunklistdata = [], abortcontrollerlist = []; const host = "http://localhost:3000"; //... const handleupload = async () => { //... filechunklistdata = filechunklist.map(({ file, size }, index) => { //... return { percentage: uploadedlist.includes(hash) ? 100 : 0, }; }); //... }; //上传分片 const uploadchunks = async (uploadedlist) => { const requestlist = filechunklistdata .filter(({ hash }) => !uploadedlist.includes(hash)) .map(({ file, filehash, filename, hash }, index) => { //... }) .map(async ({ formdata, hash }) => { return requestapi({ url: `${host}`, method: "post", body: formdata, onprogress: ({ loaded, total }) => { const percentage = parseint((loaded / total) * 100); // console.log("分片上传百分比:", percentage); const curindex = filechunklistdata.findindex( ({ hash: h }) => h === hash ); filechunklistdata[curindex].percentage = percentage; const totalloaded = filechunklistdata .map((item) => item.size * item.percentage) .reduce((acc, cur) => acc + cur); const totalpercentage = parseint( (totalloaded / filesize).tofixed(2) ); const progresstext = `上传进度:${totalpercentage}%`; document.getelementbyid("progress").innerhtml = progresstext; console.log(progresstext); }, }); }); //... };
总结
大文件上传其实很多时候不需要我们自己去实现,因为已经有很多成熟的解决方案。
但深入理解大文件上传背后的原理,更加有利于我们对已有的大文件上传方案进行个性化改造。
在线实现大文件上传的过程中使用到了三个插件,multiparty
、fs-extra
、spark-md5
,如果大家不太理解,需要自己去补充一下相关知识。
以上就是使用纯原生js实现大文件分片上传的详细内容,更多关于js大文件分片上传的资料请关注代码网其它相关文章!
发表评论