предисловие
В этот период времени интервьюеры были очень заняты и часто появлялись в заголовках статей в блогах.Хоть я и не особо хотел горячиться, но никак не мог придумать хороший заголовок-. -, тереть и тереть :)
На самом деле, мне задали этот вопрос во время интервью, и это был вопрос онлайн-кодирования, программирования Хотя идея была правильной в то время, к сожалению, она не была полностью правильной в конце.
После окончания потребовалось некоторое время, чтобы разобраться с идеями, так как реализовать загрузку большого файла, и как реализовать функцию возобновления загрузки в выгрузке?
В этой статье мы создадим внешний интерфейс и серверную часть с нуля, а также продемонстрируем загрузку больших файлов и возобновление загрузки с точки останова.
внешний интерфейс:vue
element-ui
Сервер:nodejs
文章有误解的地方,欢迎指出,将在第一时间改正,有更好的实现方式希望留下你的评论
Загрузка большого файла
вся идея
внешний интерфейс
В большинстве статей о фронтенде загрузки больших файлов онлайн есть решения.利用 Blob.prototype.slice
метод, аналогичный методу среза массива, вызываемый метод среза может возвращать原文件的某个切片
Так что мы можем подать максимальное количество хорошего среза, разрезанного на ломтик, то помощь может быть HTTP-параллелизм на основе предварительно установленного, загрузка нескольких ломтиков, поэтому из исходного передачи большого файла в同时
Загрузка нескольких небольших ломтиков файлов может значительно уменьшить время загрузки
Кроме того, из-за параллелизма может измениться порядок передачи на сервер, поэтому нам также необходимо записывать порядок для каждого слайса
Сервер
Сервер должен нести ответственность за прием этих слайсов, и после получения всех слайсов合并
ломтик
Вот еще два вопроса
- Когда объединять слайсы, т. е. когда слайсы переносятся
- Как объединить фрагменты
Первая проблема требует взаимодействия с интерфейсом. Интерфейс несет информацию о максимальном количестве слайсов в каждом слайсе. Когда сервер получит это количество слайсов, он автоматически объединится или может отправить дополнительный запрос активно уведомлять сервер о слиянии фрагментов.
Второй вопрос, как слить слайсы? Здесь вы можете использовать поток чтения/записи nodejs (readStream/writeStream) для передачи потока всех слайсов в поток конечного файла
talk is cheap,show me the code
, а затем мы используем код для реализации вышеуказанной идеи
внешний интерфейс
Фронтенд использует в качестве фреймворка разработки Vue, который не предъявляет особых требований к интерфейсу, может быть нативным, а в качестве UI фреймворка, учитывая красоту, используется element-ui.
контроль загрузки
Сначала создайте элемент управления для выбора файла, прослушайте событие изменения и кнопку загрузки.
<template>
<div>
<input type="file" @change="handleFileChange" />
<el-button @click="handleUpload">上传</el-button>
</div>
</template>
<script>
export default {
data: () => ({
container: {
file: null
}
}),
methods: {
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
});
};
});
}
Загрузить фрагмент
Затем, чтобы реализовать более важную функцию загрузки, загрузка должна делать две вещи.
- нарезать файл
- Передать слайс на сервер
<template>
<div>
<input type="file" @change="handleFileChange" />
<el-button @click="handleUpload">上传</el-button>
</div>
</template>
<script>
+ const SIZE = 10 * 1024 * 1024; // 切片大小
export default {
data: () => ({
container: {
file: null
},
+ data: []
}),
methods: {
request() {},
handleFileChange() {},
+ // 生成文件切片
+ createFileChunk(file, size = SIZE) {
+ const fileChunkList = [];
+ let cur = 0;
+ while (cur < file.size) {
+ fileChunkList.push({ file: file.slice(cur, cur + size) });
+ cur += size;
+ }
+ return fileChunkList;
+ },
+ // 上传切片
+ async uploadChunks() {
+ const requestList = this.data
+ .map(({ chunk,hash }) => {
+ 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 МБ, что означает, что файл размером 100 МБ будет разделен на 10 фрагментов.
Используйте цикл while и метод slice внутри createFileChunk, чтобы поместить слайсы вfileChunkList
возврат в массиве
При генерации файловых слайсов нужно дать каждому слайсу идентификатор в виде хэша, который здесь временно используется文件名 + 下标
, чтобы серверная часть могла знать, какой слайс является текущим слайсом, который используется для последующих объединенных слайсов.
Тогда позвониuploadChunks
Загрузите все ломтики файлов, поместите файлы, нарезовые хэши и имена файлов в FormData, а затем вызовите предыдущий шагrequest
Функция возвращает обещание и, наконец, вызывает Promise.all для одновременной загрузки всех фрагментов.
Отправить запрос на слияние
Здесь используется второй упомянутый в общей идее метод слияния слайсов, то есть фронтенд активно информирует сервер о слиянии, поэтому фронтенду нужно отправлять дополнительные запросы, а сервер активно сливает слайсы при получении этот запрос.
<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() {},
handleFileChange() {},
createFileChunk() {},
// 上传切片,同时过滤已上传的切片
async uploadChunks() {
const requestList = this.data
.map(({ chunk,hash }) => {
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 端口"));
принять кусок
использоватьmultiparty
Пакет обрабатывает FormData из внешнего интерфейса.
В обратном вызове multiparty.parse параметр files сохраняет файлы в FormData, а параметр fields сохраняет нефайловые поля в 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 = path.resolve(UPLOAD_DIR, filename);
+ // 切片目录不存在,创建切片目录
+ 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.end("received file chunk");
+ });
});
server.listen(3000, () => console.log("正在监听 3000 端口"));
Просмотрите объект чанка, обработанный многопартийным, путь — это путь для хранения временного файла, размер — размер временного файла, в многопартийной документации упоминается, что можно использовать fs.rename (поскольку я использую fs-extra, его метод переименования имеет проблему с правами доступа к платформе Windows, поэтому я заменил его на fse.move) для перемещения временных файлов, то есть для перемещения фрагментов файлов.
При приеме файловых слайсов сначала необходимо создать папку для хранения слайсов, так как фронтенд дополнительно несет уникальный хэш значения при отправке каждого слайса, хэш используется как имя файла, а слайс перемещается из временного пути в папку фрагмента. Результат выглядит следующим образом
Объединить фрагменты
После получения запроса на слияние, отправленного внешним интерфейсом, сервер объединяет все фрагменты в папке.
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 pipeStream = (path, writeStream) =>
+ new Promise(resolve => {
+ const readStream = fse.createReadStream(path);
+ readStream.on("end", () => {
+ fse.unlinkSync(path);
+ resolve();
+ });
+ readStream.pipe(writeStream);
+ });
// 合并切片
+ const mergeFileChunk = async (filePath, filename, size) => {
+ const chunkDir = path.resolve(UPLOAD_DIR, filename);
+ const chunkPaths = await fse.readdir(chunkDir);
+ // 根据切片下标进行排序
+ // 否则直接读取目录的获得的顺序可能会错乱
+ chunkPaths.sort((a, b) => a.split("-")[1] - b.split("-")[1]);
+ await Promise.all(
+ chunkPaths.map((chunkPath, index) =>
+ pipeStream(
+ path.resolve(chunkDir, chunkPath),
+ // 指定位置创建可写流
+ fse.createWriteStream(filePath, {
+ start: index * size,
+ end: (index + 1) * size
+ })
+ )
+ )
+ );
+ 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,size } = data;
+ const filePath = path.resolve(UPLOAD_DIR, `${filename}`);
+ await mergeFileChunk(filePath, filename);
+ res.end(
+ JSON.stringify({
+ code: 0,
+ message: "file merged success"
+ })
+ );
+ }
});
server.listen(3000, () => console.log("正在监听 3000 端口"));
Поскольку при отправке запроса на слияние внешний интерфейс будет нести имя файла, сервер может найти папку фрагмента, созданную на предыдущем шаге, в соответствии с именем файла.
Затем используйте fs.createWriteStream для создания потока с возможностью записи, имя файла потока с возможностью записиИмя папки фрагмента + имя суффиксакомбинированный
Затем просмотрите всю папку фрагмента, создайте читаемый поток из фрагмента с помощью fs.createReadStream и объедините передачу с целевым файлом.
Стоит отметить, что каждый поток для чтения будет передан в указанную позицию потока для записи, которая управляется вторым параметром start/end createWriteStream, цель состоит в том, чтобы иметь возможность одновременно объединять несколько потоков для чтения в поток для записи, Таким образом, даже если порядок потока отличается, он может быть передан в правильную позицию, поэтому также необходимо, чтобы фронтенд предоставлял дополнительный параметр размера при запросе.
async mergeRequest() {
await this.request({
url: "http://localhost:3000/merge",
headers: {
"content-type": "application/json"
},
data: JSON.stringify({
+ size: SIZE,
filename: this.container.file.name
})
});
},
На самом деле, вы также можете дождаться слияния предыдущего слайса перед слиянием следующего слайса, так что вам не нужно указывать местоположение, но скорость передачи будет снижена, поэтому используется метод параллельного слияния, а затем вам нужно только убедиться, что слайс удаляется после каждого завершения слияния и т. д. После того, как все слайсы будут объединены, окончательно удалите папку слайса
На этом простая загрузка большого файла завершена, а дальше на этой основе мы расширим некоторые дополнительные функции.
Показать индикатор загрузки
Существует два процесса загрузки: один — это процесс загрузки каждого фрагмента, а другой — процесс загрузки всего файла, а ход загрузки всего файла рассчитывается на основе хода загрузки каждого фрагмента, поэтому мы сначала понимаем ход загрузки фрагмента
Индикатор выполнения нарезки
XMLHttpRequest изначально поддерживает мониторинг хода загрузки, вам нужно только отслеживать upload.onprogress, мы передаем параметр 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,hash,index }) => {
const formData = new FormData();
formData.append("chunk", chunk);
formData.append("hash", hash);
formData.append("filename", this.container.file.name);
+ return { formData,index };
})
+ .map(async ({ formData,index }) =>
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));
+ };
+ }
Когда каждый фрагмент загружается, процентное свойство соответствующего элемента массива данных будет обновляться с помощью функции прослушивателя, а затем массив данных будет отображаться в представлении.
индикатор выполнения файла
Ход загрузки текущего файла можно получить путем суммирования загруженных частей каждого среза и деления на размер всего файла, поэтому здесь мы используем 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));
}
}
Окончательный вид выглядит следующим образом
http
Принцип возобновления передачи с точки останова заключается в том, что передний конец/сервер должен记住
Слайс был загружен, поэтому при следующей загрузке вы можете пропустить ранее загруженные части, есть два варианта достижения памяти.
- Внешний интерфейс использует записи LocalStorage, загруженные Hash.
- Сервер сохраняет хэш загруженного фрагмента, а передний конец получает загруженный фрагмент с сервера перед каждой загрузкой.
Первое решение front-end, второе решение server, а у front-end решения есть недостаток, при смене браузера эффект памяти пропадет, поэтому здесь выбран последний.
генерировать хэш
Будь то внешний интерфейс или сервер, должны быть сгенерированы хэши файлов и фрагментов.之前我们使用文件名 + 切片下标作为切片 hash
, как только имя файла будет изменено, оно потеряет свою силу.На самом деле, пока содержимое файла остается неизменным, хэш не должен меняться, поэтому правильный способ根据文件内容生成 hash
, так что давайте изменим правила генерации хеша
Здесь используется другая библиотекаspark-md5
, он может вычислить хеш-значение файла в соответствии с содержимым файла, и учитывая, что если загружается большой файл, чтение содержимого файла для вычисления хеш-функции занимает очень много времени, и он引起 UI 的阻塞
, что приводит к приостановленному состоянию страницы, поэтому мы используем web-worker для вычисления хэша в рабочем потоке, чтобы пользователь мог нормально взаимодействовать с основным интерфейсом.
При создании веб-воркера параметром является путь к файлу js и он не может быть междоменным, поэтому мы создаем отдельный файл hash.js и помещаем его в публичный каталог, кроме того, доступ к dom в работник, но обеспечиваетimportScripts
Функция используется для импорта внешних скриптов, через которые импортируется spark-md5
// /public/hash.js
self.importScripts("/spark-md5.min.js"); // 导入脚本
// 生成文件 hash
self.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);
};
В рабочем потоке принять файловый слайс fileChunkList, использовать FileReader для чтения ArrayBuffer каждого слайса и непрерывно передавать его в spark-md5, после расчета каждого слайса в основной поток через postMessage отправляется событие progress, а финальный хэш завершен после отправки в основной поток
spark-md5 需要根据所有切片才能算出一个 hash 值,不能直接将整个文件放入计算,否则即使不同文件也会有相同的 hash,具体可以看官方文档
Затем напишите логику для связи между основным потоком и рабочим потоком.
+ // 生成文件 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
Передайте все фрагменты fileChunkList в рабочий поток и прослушайте событие postMessage, отправленное рабочим потоком, чтобы получить хэш файла.
Плюс индикатор выполнения, показывающий рассчитанный хеш, выглядит так
На этом этапе клиентскому интерфейсу необходимо переписать предыдущее использование имени файла в качестве хэша на хэш, возвращенный рабочим процессом.
Сервер использует хеш в качестве имени папки слайса, хэш + нижний индекс в качестве имени слайса и хэш + расширение в качестве имени файла.Новой логики нет.
Передача файлов за секунды
Прежде чем реализовать возобновление точки останова, давайте кратко представим вторую передачу файла.
Так называемая передача файлов за секунды означает, что загруженные ресурсы уже существуют на стороне сервера, поэтому, когда пользователь再次上传
Когда загрузка будет успешной, он напрямую предложит
Вторая передача файла должна опираться на хэш, сгенерированный на предыдущем шаге, то есть в上传前
, сначала вычислите хэш файла и отправьте хеш на сервер для проверки. Из-за уникальности хэша, как только сервер сможет найти файл с таким же хэшем, он напрямую вернет информацию об успешной загрузке.
+ 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();
}
Miaochuan на самом деле просто уловка для пользователей, чтобы увидеть, по сути, загрузки нет вообще.
:)
Логика сервера очень проста: добавьте интерфейс проверки, чтобы проверить, существует ли файл.
+ 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 = path.resolve(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 端口"));
Приостановить загрузку
Поговорив о генерации хэша и передаче файлов за считанные секунды, вернитесь к точке останова, чтобы возобновить передачу.
Возобновление точки останова, как следует из названия, это точка останова + возобновление, поэтому наш первый шаг — реализовать «точку останова», то есть приостановить загрузку.
Принцип заключается в использовании XMLHttpRequestabort
метод, можно отменить отправку 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 в качестве параметра, и метод запроса сохранит все xhr в массиве.
Всякий раз, когда фрагмент успешно загружен, удалите соответствующий xhr из requestList, чтобы сохранить только в requestList.正在上传切片的 xhr
Затем создайте новую кнопку паузы, при нажатии на кнопку вызовите метод прерывания, сохраненный в xhr в requestList, то есть отмените и очистите все фрагменты загрузки.
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(path.resolve(UPLOAD_DIR, fileHash))
+ ? await fse.readdir(path.resolve(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 = path.resolve(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, то есть список имен слайсов, возвращаемый сервером на рисунке выше. условия срабатывания предыдущего интерфейса слияния объединены. внесены некоторые изменения
На этом функция возобновления точки останова в основном завершена.
улучшения индикатора выполнения
Несмотря на то, что реализовано возобновление точки останова, необходимо изменить правила отображения индикатора выполнения, иначе индикатор выполнения будет отклоняться, когда загрузка будет приостановлена/получен загруженный фрагмент.
Индикатор выполнения нарезки
Поскольку интерфейс проверки будет вызываться для возврата загруженного фрагмента при нажатии кнопки загрузки/возобновления загрузки, необходимо изменить ход загруженного фрагмента на 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, чтобы нарезать файл, одновременно загрузить несколько фрагментов и, наконец, отправить запрос на слияние, чтобы уведомить сервер о слиянии фрагментов.
- После того, как сервер получает и сохраняет фрагмент, с помощью запроса на слияние получен файл для потоковой передачи файла в окончательные разделы слияния.
- upload.onprogress собственного XMLHttpRequest отслеживает ход загрузки фрагментов.
- Используйте вычисляемые свойства Vue для расчета хода загрузки всего файла на основе хода выполнения каждого фрагмента.
http
- Используйте spark-md5 для вычисления хэша файла на основе содержимого файла.
- С помощью хэша вы можете определить, загрузил ли сервер файл, чтобы напрямую сообщить пользователю, что загрузка прошла успешно (вторая загрузка).
- Приостановить загрузку фрагментов с помощью метода отмены XMLHttpRequest.
- Перед загрузкой сервер возвращает загруженные имена слайсов, а фронтенд пропускает загрузку этих слайсов.
Проблема обратной связи
В связи с неудобством тестирования некоторых функций, вот несколько вопросов, собранных в комментариях Заинтересованные друзья могут выдвинуть свои идеи/написать демо для дальнейшего общения
- Нет обработки неудачных загрузок фрагментов
- Отправлять информацию о ходе выполнения с сервера с помощью веб-сокета
- При открытии страницы слайс загрузки не получается автоматически, но перед его отображением его необходимо загрузить повторно.
исходный код
В исходный код добавлены некоторые состояния кнопок, взаимодействие стало более дружественным, а выражение статьи более неясным, вы можете перейти к исходному коду для просмотра.
Спасибо за просмотр :)
Команда внешнего интерфейса ByteDance EA (Enterprise Application) набирает ~
Координаты Шанхай/Пекин.Есть все программы рекрутинга и социального рекрутинга.Верхнего предела по hc нет.Если вы заинтересованы, пожалуйста, присылайте свое резюме на 1996yeyan@gmail.com.Школьный рекрутинговый код Q7QUGMV