はじめに
今回インタビュアーはかなり忙しいです、頻繁に私は特に熱をこするしたくないが、ブログ記事のタイトルに表示されますが、良いタイトルを考えることはできません- 。- Cengceng上Cengceng :)
私が行ったとき、実際にはインタビューはこの質問をしますが、プログラミングの問題をコーディング一行、考えは正しかったものの、結局も完全に正解はないました
次の思考を終え過ごしたいくつかの時間の後、我々は大規模なファイルのアップロードを実現すべきか、そしてどのようにHTTPのアップロード、それの機能を実装するために?
このゼロからの記事や壊れた大容量ファイルのアップロードのデモと再開を達成するために、フロントエンドサーバーを構築
記事の誤解の場所は、それが最初の時間を修正します歓迎、あなたがコメントを残しておきたいことを達成するためのより良い方法があります
大容量ファイルのアップロードは、
の全体的なアイデア
のフロントエンド
大容量ファイルのアップロードほとんどのオンライン記事のフロントエンドソリューションを与えてきた、コアがBlob.prototype.slice方法の使用である、と同様のスライス方法の配列、スライス法は、呼び出し元のファイルを戻すことができますスライス
だから我々は、スライスに優れスライスカットの最大数を提出することができていること、そして援助は、複数の同時送信にオリジナルの転送からそれほど、大きなファイルを複数のスライスをアップロードし、あらかじめ設定に基づいてHTTP同時実行をすることができファイルの小さなスライス、大幅アップロード時間を短縮することができます
また、それはオーダーのサービス側への同時であるとして、変更される可能性があり、我々はまた、各スライスの順序を記録する必要があるので、
サーバーの
サーバーこれらのセクションを受け入れる責任、および合併スライスがスライスした後、すべての受信
ここで再び2つの帰結質問
転送が完了したときである合併スライス、スライスする際
のスライスをマージする方法
フロントエンドと一緒に最初の質問のニーズは、各区間の前端は、情報のスライスの最大数を運ぶ、自動的にマージするときのスライスの数によってサービス終了、あなたはまた、スライスされた追加の事前通知サーバーの統合のための要求を送信することができます
2番目の質問は、どのように特定のスライスにそれをマージするには?ここでは、最初に、このドキュメントのすべてのセクションをマージする徐々に、最終的な文書を作成し、そして、すべてのセクションによると、サービス終了、つまり、それは同期指定したファイルにデータを追加することができ、fs.appendFileSyncはnodejs APIを使用することができますで
話は安いです、私のコードを表示し、その後、我々は、上記のアイデアを実現するためにコードを使用します
先端部
ヴューが開発フレームワークとして使用される遠位端は、インターフェイスの多くの需要がありません、天然であってもよいし、素子のUIとして視覚UIフレームワークを考慮して使用されます
アップロードコントロール
まず、変更イベント、およびアップロードボタンを監視し、制御ファイルの選択を作成
<template> <div> <input type="file" @change="handleFileChange" /> <el-button @click="handleUpload">上传</el-button> </div></template><script>export default { data: () => ({ container: { file: null } }), methods: { async handleFileChange(e) { const [file] = e.target.files; if (!file) return; Object.assign(this.$data, this.$options.data()); this.container.file = file; }, async handleUpload() {} }};</script>
リクエストロジック
汎用性の考慮事項は、単純なネイティブのXMLHttpRequest封入層の要求を行うことによって、サードパーティのライブラリによって要求がありません
request({ url, method = "post", data, headers = {}, requestList }) { return new Promise(resolve => { const xhr = new XMLHttpRequest(); xhr.open(method, url); Object.keys(headers).forEach(key => xhr.setRequestHeader(key, headers[key]) ); xhr.send(data); xhr.onload = e => { resolve({ data: e.target.response }); }; }); }
アップロードスライスは
、2つのことを行うために、より重要なのアップロード、アップロードの必要性を実現します
スライスファイルが
サーバーに送信されるスライス
<template> <div> <input type="file" @change="handleFileChange" /> <el-button @click="handleUpload">上传</el-button> </div></template><script>+ const LENGTH = 10; // 切片数量export default { data: () => ({ container: { file: null,+ data: [] } }), methods: { request() {}, async handleFileChange() {},+ // 生成文件切片+ createFileChunk(file, length = LENGTH) {+ const fileChunkList = [];+ const chunkSize = Math.ceil(file.size / length);+ let cur = 0;+ while (cur < file.size) {+ fileChunkList.push({ file: file.slice(cur, cur + chunkSize) });+ cur += chunkSize;+ }+ return fileChunkList;+ },+ // 上传切片+ async uploadChunks() {+ const requestList = this.data+ .map(({ chunk }) => {+ const formData = new FormData();+ formData.append("chunk", chunk);+ formData.append("hash", hash);+ formData.append("filename", this.container.file.name);+ return { formData };+ })+ .map(async ({ formData }) =>+ this.request({+ url: "http://localhost:3000",+ data: formData+ })+ );+ await Promise.all(requestList); // 并发切片+ },+ async handleUpload() {+ if (!this.container.file) return;+ const fileChunkList = this.createFileChunk(this.container.file);+ this.data = fileChunkList.map(({ file },index) => ({+ chunk: file,+ hash: this.container.file.name + "-" + index // 文件名 + 数组下标+ }));+ await this.uploadChunks();+ } }};</script>
あなたは、アップロードボタンでファイルのスライスをクリックすると、一定の長さの制御を通じて呼び出しcreateFileChunkの数をスライスし、ここでのファイルアップロードするのに約10個のスライスに分割、10に設定
whileループとスライス方法CreateFileChunkはfileChunkListにスライスのアレイを返します
一時ファイル名+添字本明細書中で、識別子としてハッシュを与えるために、各スライスの必要性ファイルのセクションを生成する場合、そう後端現在のスライスを知ることができることは、その後の合併のためのスライス、いくつかのスライスの最初のものです
すべての後続の呼び出しuploadChunksアップロードファイルスライス、いるFormDataにファイル、スライスハッシュ、およびファイル名をスライスして、要求機能の最後のステップを呼び出すことは、最後の呼び出しPromise.all同時アップロードすべてのスライスをproimiseを返します。
マージ要求を送信
即ち、アクティブ先端が通知サービスをマージサービス分割により能動終端要求を組み合わせた場合に、フロントエンドはまた、追加の送信要求を必要とし、全体的なアイデアは、実施例を挙げ第2の合成スライスを使用してここに
<template> <div> <input type="file" @change="handleFileChange" /> <el-button @click="handleUpload">上传</el-button> </div></template><script>export default { data: () => ({ container: { file: null }, data: [] }), methods: { request() {}, async handleFileChange() {}, createFileChunk() {}, // 上传切片,同时过滤已上传的切片 async uploadChunks() { const requestList = this.data .map(({ chunk }) => { const formData = new FormData(); formData.append("chunk", chunk); formData.append("hash", hash); formData.append("filename", this.container.file.name); return { formData }; }) .map(async ({ formData }) => this.request({ url: "http://localhost:3000", data: formData }) ); await Promise.all(requestList);+ // 合并切片+ await this.mergeRequest(); },+ async mergeRequest() {+ await this.request({+ url: "http://localhost:3000/merge",+ headers: {+ "content-type": "application/json"+ },+ data: JSON.stringify({+ filename: this.container.file.name+ })+ });+ }, async handleUpload() {} }};</script>
サーバ部分
のhttpサーバを使用して構築されたシンプルなモジュール
const http = require("http");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 端口"));
受信部は、
フロントエンドの処理、マルチパーティ送信されたパケットを使用しFORMDATA
コールバックmultiparty.parseでは、ファイルは、ファイルのフィールドいるFormDataアフリカを保存するフィールドパラメータをいるFormData・パラメータ・ファイルを保存します
const http = require("http");const path = require("path");const fse = require("fs-extra");const multiparty = require("multiparty");const server = http.createServer();+ const UPLOAD_DIR = path.resolve(__dirname, "..", "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; }+ const multipart = new multiparty.Form();+ multipart.parse(req, async (err, fields, files) => {+ if (err) {+ return;+ }+ const [chunk] = files.chunk;+ const [hash] = fields.hash;+ const [filename] = fields.filename;+ const chunkDir = `${UPLOAD_DIR}/${filename}`;+ // 切片目录不存在,创建切片目录+ if (!fse.existsSync(chunkDir)) {+ await fse.mkdirs(chunkDir);+ }+ // 重命名文件+ await fse.rename(chunk.path, `${chunkDir}/${hash}`);+ res.end("received file chunk");+ });});server.listen(3000, () => console.log("正在监听 3000 端口"));
チャンクオブジェクトは、マルチパーティのプロセスを表示した後、パスは言及がマルチパーティの文書に一時ファイルの名前を変更するために移動するために使用fs.renameの方法かもしれ、サイズは、一時ファイルのサイズは、一時ファイルを格納するパスで、ドキュメントをスライスします
スライスされたファイルとのインタビューで、追加の各スライスを送信するので、ファイル名とハッシュに、一意のハッシュ値を運ぶフロントエンドいるので、最終的に一時的なフォルダのパスからスライススライスを移動し、店のスライスにフォルダを作成します結果は以下の通りであります
合わせたセクション
のすべてのセクションでは、送信のフロントエンドを受信する要求をマージし、マージされたフォルダサーバー
const http = require("http");const path = require("path");const fse = require("fs-extra");const server = http.createServer();const UPLOAD_DIR = path.resolve(__dirname, "..", "target"); // 大文件存储目录+ const resolvePost = req =>+ new Promise(resolve => {+ let chunk = "";+ req.on("data", data => {+ chunk += data;+ });+ req.on("end", () => {+ resolve(JSON.parse(chunk));+ });+ });+ // 合并切片+ const mergeFileChunk = async (filePath, filename) => {+ const chunkDir = `${UPLOAD_DIR}/${filename}`;+ const chunkPaths = await fse.readdir(chunkDir);+ await fse.writeFile(filePath, "");+ chunkPaths.forEach(chunkPath => {+ fse.appendFileSync(filePath, fse.readFileSync(`${chunkDir}/${chunkPath}`));+ fse.unlinkSync(`${chunkDir}/${chunkPath}`);+ });+ fse.rmdirSync(chunkDir); // 合并后删除保存切片的目录+ };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 === "/merge") {+ const data = await resolvePost(req);+ const { filename } = data;+ const filePath = `${UPLOAD_DIR}/${filename}`;+ await mergeFileChunk(filePath, filename);+ res.end(+ JSON.stringify({+ code: 0,+ message: "file merged success"+ })+ );+ }});server.listen(3000, () => console.log("正在监听 3000 端口"));
フロントエンドのリクエストを送信する際に合併は、ファイルの名前を運びますので、ファイル名に基づいて、サーバは、フォルダを作成し、前のステップのスライスファイルに記載されています
その後fs.writeFileSyncは空のファイルを作成して使用し、ファイルのファイル名が、その後、fs.appendFileSyncによって空のファイルフォルダにスライスからスライスをマージしていき、空のスライスフォルダ名+サフィックスの組み合わせで、毎回合併の完了は、このスライスを削除した後、すべてのスライスが完成し、最後のスライスの削除フォルダたらマージされます
このように、単純な大容量ファイルのアップロードが完了し、その後、我々はいくつかの追加に基づいて、この機能を拡張します
ショーのアップロード進捗バーの
2つの方法でアップロードの進捗は、1は、各スライスのアップロードの進捗状況であり、我々が最初にスライスされた実現ので、他は全体のファイルアップロードの進行状況ですが、各スライスのアップロードファイルのアップロードの進行状況の進捗を基礎から計算されますアップロードの進捗状況
切片进度条
XMLHttpRequest 原生支持上传进度的监听,只需要监听 upload.onprogress 即可,我们在原来的 request 基础上传入 onProgress 参数,给 XMLHttpRequest 注册监听事件
// xhr request({ url, method = "post", data, headers = {},+ onProgress = e => e, requestList }) { return new Promise(resolve => { const xhr = new XMLHttpRequest();+ xhr.upload.onprogress = onProgress; xhr.open(method, url); Object.keys(headers).forEach(key => xhr.setRequestHeader(key, headers[key]) ); xhr.send(data); xhr.onload = e => { resolve({ data: e.target.response }); }; }); }
由于每个切片都需要触发独立的监听事件,所以还需要一个工厂函数,根据传入的切片返回不同的监听函数
在原先的前端上传逻辑中新增监听函数部分
// 上传切片,同时过滤已上传的切片 async uploadChunks(uploadedList = []) { const requestList = this.data .map(({ chunk }) => { const formData = new FormData(); formData.append("chunk", chunk); formData.append("filename", this.container.file.name); return { formData }; }) .map(async ({ formData }) => this.request({ url: "http://localhost:3000", data: formData,+ onProgress: this.createProgressHandler(this.data[index]), }) ); await Promise.all(requestList); // 合并切片 await this.mergeRequest(); }, async handleUpload() { if (!this.container.file) return; const fileChunkList = this.createFileChunk(this.container.file); this.data = fileChunkList.map(({ file },index) => ({ chunk: file,+ index, hash: this.container.file.name + "-" + index+ percentage:0 })); await this.uploadChunks(); } + createProgressHandler(item) {+ return e => {+ item.percentage = parseInt(String((e.loaded / e.total) * 100));+ };+ }
每个切片在上传时都会通过监听函数更新 data 数组对应元素的 percentage 属性,之后把将 data 数组放到视图中展示即可
文件进度条
将每个切片已上传的部分累加,除以整个文件的大小,就能得出当前文件的上传进度,所以这里使用 Vue 计算属性
computed: { uploadPercentage() { if (!this.container.file || !this.data.length) return 0; const loaded = this.data .map(item => item.size * item.percentage) .reduce((acc, cur) => acc + cur); return parseInt((loaded / this.container.file.size).toFixed(2)); } }
最终视图如下
断点续传
断点续传的原理在于前端/服务端需要记住已上传的切片,这样下次上传就可以跳过之前已上传的部分,有两种方案实现记忆的功能
前端使用 localStorage 记录已上传的切片 hash
服务端保存已上传的切片 hash,前端每次上传前向服务端获取已上传的切片
第一种是前端的解决方案,第二种是服务端,而前端方案有一个缺陷,如果换了个浏览器就失去了记忆的效果,所以这里选取后者
生成 hash
无论是前端还是服务端,都必须要生成文件和切片的 hash,之前我们使用文件名 + 切片下标作为切片 hash,这样做文件名一旦修改就失去了效果,而事实上只要文件内容不变,hash 就不应该变化,所以正确的做法是根据文件内容生成 hash,所以我们修改一下 hash 的生成规则
这里用到另一个库 spark-md5,它可以根据文件内容计算出文件的 hash 值,另外考虑到如果上传一个超大文件,读取文件内容计算 hash 是非常耗费时间的,并且会引起 UI 的阻塞,导致页面假死状态,所以我们使用 web-worker 在 worker 线程计算 hash,这样用户仍可以在主界面正常的交互
由于实例化 web-worker 时,参数是一个 js 文件路径且不能跨域,所以我们单独创建一个 hash.js 文件放在 public 目录下,另外在 worker 中也是不允许访问 dom 的,但它提供了importScripts 函数用于导入外部脚本,通过它导入 spark-md5
// /public/hash.jsself.importScripts("/spark-md5.min.js"); // 导入脚本// 生成文件 hashself.onmessage = e => { const { fileChunkList } = e.data; const spark = new self.SparkMD5.ArrayBuffer(); let percentage = 0; let count = 0; 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) { self.postMessage({ percentage: 100, hash: spark.end() }); self.close(); } else { percentage += 100 / fileChunkList.length; self.postMessage({ percentage }); // 递归计算下一个切片 loadNext(count); } }; }; loadNext(0);};
在 worker 线程中,接受文件切片 fileChunkList,利用 FileReader 读取每个切片的 ArrayBuffer 并不断传入 spark-md5 中,每计算完一个切片通过 postMessage 向主线程发送一个进度事件,全部完成后将最终的 hash 发送给主线程
spark-md5 需要根据所有切片才能算出一个 hash 值,不能直接将整个文件放入计算,否则即使不同文件也会有相同的 hash,具体可以看官方文档
spark-md5
接着编写主线程与 worker 线程通讯的逻辑
+ // 生成文件 hash(web-worker)+ calculateHash(fileChunkList) {+ return new Promise(resolve => {+ // 添加 worker 属性+ this.container.worker = new Worker("/hash.js");+ this.container.worker.postMessage({ fileChunkList });+ this.container.worker.onmessage = e => {+ const { percentage, hash } = e.data;+ this.hashPercentage = percentage;+ if (hash) {+ resolve(hash);+ }+ };+ }); }, async handleUpload() { if (!this.container.file) return; const fileChunkList = this.createFileChunk(this.container.file);+ this.container.hash = await this.calculateHash(fileChunkList); this.data = fileChunkList.map(({ file },index) => ({+ fileHash: this.container.hash, chunk: file, hash: this.container.file.name + "-" + index, // 文件名 + 数组下标 percentage:0 })); await this.uploadChunks(); }
主线程使用 postMessage 给 worker 线程传入所有切片 fileChunkList,并监听 worker 线程发出的 postMessage 事件拿到文件 hash
加上显示计算 hash 的进度条,看起来像这样
至此前端需要将之前用文件名作为 hash 的地方改写为 workder 返回的这个 hash
服务端则使用 hash 作为切片文件夹名,hash + 下标作为切片名,hash + 扩展名作为文件名,没有新增的逻辑
文件秒传
在实现断点续传前先简单介绍一下文件秒传
所谓的文件秒传,即在服务端已经存在了上传的资源,所以当用户再次上传时会直接提示上传成功
文件秒传需要依赖上一步生成的 hash,即在上传前,先计算出文件 hash,并把 hash 发送给服务端进行验证,由于 hash 的唯一性,所以一旦服务端能找到 hash 相同的文件,则直接返回上传成功的信息即可
- async verifyUpload(filename, fileHash) {+ const { data } = await this.request({+ url: "http://localhost:3000/verify",+ headers: {+ "content-type": "application/json"+ },+ data: JSON.stringify({+ filename,+ fileHash+ })+ });+ return JSON.parse(data);+ }, async handleUpload() { if (!this.container.file) return; const fileChunkList = this.createFileChunk(this.container.file); this.container.hash = await this.calculateHash(fileChunkList);+ const { shouldUpload } = await this.verifyUpload(+ this.container.file.name,+ this.container.hash+ );+ if (!shouldUpload) {+ this.$message.success("秒传:上传成功");+ return;+ } this.data = fileChunkList.map(({ file }, index) => ({ fileHash: this.container.hash, index, hash: this.container.hash + "-" + index, chunk: file, percentage: 0 })); await this.uploadChunks(); }
秒传其实就是给用户看的障眼法,实质上根本没有上传
:)
服务端的逻辑非常简单,新增一个验证接口,验证文件是否存在即可
+ const extractExt = filename =>+ filename.slice(filename.lastIndexOf("."), filename.length); // 提取后缀名const UPLOAD_DIR = path.resolve(__dirname, "..", "target"); // 大文件存储目录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 === "/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 端口"));
暂停上传
讲完了生成 hash 和文件秒传,回到断点续传
断点续传顾名思义即断点 + 续传,所以我们第一步先实现“断点”,也就是暂停上传
原理是使用 XMLHttpRequest 的 abort 方法,可以取消一个 xhr 请求的发送,为此我们需要将上传每个切片的 xhr 对象保存起来,我们再改造一下 request 方法
request({ url, method = "post", data, headers = {}, onProgress = e => e,+ requestList }) { return new Promise(resolve => { const xhr = new XMLHttpRequest(); xhr.upload.onprogress = onProgress; xhr.open(method, url); Object.keys(headers).forEach(key => xhr.setRequestHeader(key, headers[key]) ); xhr.send(data); xhr.onload = e => {+ // 将请求成功的 xhr 从列表中删除+ if (requestList) {+ const xhrIndex = requestList.findIndex(item => item === xhr);+ requestList.splice(xhrIndex, 1);+ } resolve({ data: e.target.response }); };+ // 暴露当前 xhr 给外部+ requestList?.push(xhr); }); },
这样在上传切片时传入 requestList 数组作为参数,request 方法就会将所有的 xhr 保存在数组中了
每当一个切片上传成功时,将对应的 xhr 从 requestList 中删除,所以 requestList 中只保存正在上传切片的 xhr
之后新建一个暂停按钮,当点击按钮时,调用保存在 requestList 中 xhr 的 abort 方法,即取消并清空所有正在上传的切片
handlePause() { this.requestList.forEach(xhr => xhr?.abort()); this.requestList = [];}
点击暂停按钮可以看到 xhr 都被取消了
恢复上传
之前在介绍断点续传的时提到使用第二种服务端存储的方式实现续传
由于当文件切片上传后,服务端会建立一个文件夹存储所有上传的切片,所以每次前端上传前可以调用一个接口,服务端将已上传的切片的切片名返回,前端再跳过这些已经上传切片,这样就实现了“续传”的效果
而这个接口可以和之前秒传的验证接口合并,前端每次上传前发送一个验证的请求,返回两种结果
服务端已存在该文件,不需要再次上传
服务端不存在该文件或者已上传部分文件切片,通知前端进行上传,并把已上传的文件切片返回给前端
所以我们改造一下之前文件秒传的服务端验证接口
const extractExt = filename => filename.slice(filename.lastIndexOf("."), filename.length); // 提取后缀名const UPLOAD_DIR = path.resolve(__dirname, "..", "target"); // 大文件存储目录const resolvePost = req => new Promise(resolve => { let chunk = ""; req.on("data", data => { chunk += data; }); req.on("end", () => { resolve(JSON.parse(chunk)); }); }); + // 返回已经上传切片名列表+ 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 端口"));
接着回到前端,前端有两个地方需要调用验证的接口
点击上传时,检查是否需要上传和已上传的切片
点击暂停后的恢复上传,返回已上传的切片
新增恢复按钮并改造原来上传切片的逻辑
<template> <div id="app"> <input type="file" @change="handleFileChange" /> <el-button @click="handleUpload">上传</el-button> <el-button @click="handlePause" v-if="isPaused">暂停</el-button>+ <el-button @click="handleResume" v-else>恢复</el-button> //... </div></template>+ async handleResume() {+ const { uploadedList } = await this.verifyUpload(+ this.container.file.name,+ this.container.hash+ );+ await this.uploadChunks(uploadedList); }, async handleUpload() { if (!this.container.file) return; const fileChunkList = this.createFileChunk(this.container.file); this.container.hash = await this.calculateHash(fileChunkList);+ const { shouldUpload, uploadedList } = await this.verifyUpload( this.container.file.name, this.container.hash ); if (!shouldUpload) { this.$message.success("秒传:上传成功"); return; } this.data = fileChunkList.map(({ file }, index) => ({ fileHash: this.container.hash, index, hash: this.container.hash + "-" + index, chunk: file, percentage: 0 }));+ await this.uploadChunks(uploadedList); }, // 上传切片,同时过滤已上传的切片+ async uploadChunks(uploadedList = []) { const requestList = this.data+ .filter(({ hash }) => !uploadedList.includes(hash)) .map(({ chunk, hash, index }) => { const formData = new FormData(); formData.append("chunk", chunk); formData.append("hash", hash); formData.append("filename", this.container.file.name); formData.append("fileHash", this.container.hash); return { formData, index }; }) .map(async ({ formData, index }) => this.request({ url: "http://localhost:3000", data: formData, onProgress: this.createProgressHandler(this.data[index]), requestList: this.requestList }) ); await Promise.all(requestList); // 之前上传的切片数量 + 本次上传的切片数量 = 所有切片数量时 // 合并切片+ if (uploadedList.length + requestList.length === this.data.length) { await this.mergeRequest();+ } }
这里给原来上传切片的函数新增 uploadedList 参数,即上图中服务端返回的切片名列表,通过 filter 过滤掉已上传的切片,并且由于新增了已上传的部分,所以之前合并接口的触发条件做了一些改动
到这里断点续传的功能基本完成了
进度条改进
虽然实现了断点续传,但还需要修改一下进度条的显示规则,否则在暂停上传/接收到已上传切片时的进度条会出现偏差
切片进度条
由于在点击上传/恢复上传时,会调用验证接口返回已上传的切片,所以需要将已上传切片的进度变成 100%
async handleUpload() { if (!this.container.file) return; const fileChunkList = this.createFileChunk(this.container.file); this.container.hash = await this.calculateHash(fileChunkList); const { shouldUpload, uploadedList } = await this.verifyUpload( this.container.file.name, this.container.hash ); if (!shouldUpload) { this.$message.success("秒传:上传成功"); return; } this.data = fileChunkList.map(({ file }, index) => ({ fileHash: this.container.hash, index, hash: this.container.hash + "-" + index, chunk: file,+ percentage: uploadedList.includes(index) ? 100 : 0 })); await this.uploadChunks(uploadedList); },
uploadedList 会返回已上传的切片,在遍历所有切片时判断当前切片是否在已上传列表里即可
文件进度条
之前说到文件进度条是一个计算属性,根据所有切片的上传进度计算而来,这就遇到了一个问题
点击暂停会取消并清空切片的 xhr 请求,此时如果已经上传了一部分,就会发现文件进度条有倒退的现象
当点击恢复时,由于重新创建了 xhr 导致切片进度清零,所以总进度条就会倒退
解决方案是创建一个“假”的进度条,这个假进度条基于文件进度条,但只会停止和增加,然后给用户展示这个假的进度条
这里我们使用 Vue 的监听属性
data: () => ({+ fakeUploadPercentage: 0 }), computed: { uploadPercentage() { if (!this.container.file || !this.data.length) return 0; const loaded = this.data .map(item => item.size * item.percentage) .reduce((acc, cur) => acc + cur); return parseInt((loaded / this.container.file.size).toFixed(2)); } }, watch: {+ uploadPercentage(now) {+ if (now > this.fakeUploadPercentage) {+ this.fakeUploadPercentage = now;+ } } },
当 uploadPercentage 即真的文件进度条增加时,fakeUploadPercentage 也增加,一旦文件进度条后退,假的进度条只需停止即可
至此一个大文件上传 + 断点续传的解决方案就完成了
总结
大文件上传
Blob.prototype.sliceスライス、複数のスライスの同時アップロードを大きなファイルを、ファイルのフロントエンドをアップロード使用して、最後にマージ要求通知サーバのマージセクションを送信すると
、サーバーは、スライスを受信して記憶した後、マージ要求が多使用fs.appendFileSyncために受信されますスライスはマージ
アップロードスライスの進行状況を監視するupload.onprogressネイティブのXMLHttpRequest
Vueの計算プロパティを使用しては、各スライスの全体ファイルアップロードの進行の進行に応じて算出される
HTTP
使用SPART-MD5ファイルの内容が計算されるファイルハッシュが
サーバーは、ハッシュを介してファイルをアップロードしたか否かを判断することができる、アップロードするために、それによって直接プロンプトユーザが成功(第2パス)であった
のXMLHttpRequestのアボート方法を介してアップロードされたスライスを中断
スライスがアップロードされたサーバ・リターンをアップロードする前に名前、フロントセクションをスキップするためにそれらをアップロード