1、前言
很早面试的时候就被问到过,当时问我大视频如何传输,但是记得回答了流,管道,具体细节和如何实现不清楚, 然后后面工作中又陆续遇到了类似的问题, 比如图片的上传, 上传错误重试、大图比如gif的上传渲染, 大量图片如何实现秒传、以及视频的上传等等。 所以大文件上传涉及到了前后端诸多的技术点, 很有必要花时间精力去掌握, 另外对于工作中如果能运用到,绝对也是面试中的亮点
2、实现图片视频的上传与预览
html
<input type="file" />
<img id="showImg" alt="" />
<video id="showVideo" controls style="display: none"></video>
页面上放一个input用于获取文件,一个img和一个video放预览效果, 可以通过css先将标签隐藏
js
const showImg = document.getElementById("showImg");
const showVideo = document.getElementById("showVideo");
function render(event) {
const file = event.target.files[0];
const url = URL.createObjectURL(file);
if (file.type.includes("video")) {
showVideo.setAttribute("src", url);
showVideo.style.display = "block";
} else {
showImg.setAttribute("src", url);
}
}
document.querySelector("input").onchange = function (event) {
render(event);
};
js中检测input的onChange事件,通过event.target.files 拿到文件列表, 此时可以看到信息中包含了文件的type和文件的大小size,以及文件File类型
简单的实现文件上传与预览就实现了
3、实现文件的切割
实现一个小文件与上传其实比较简单,直接将获取的文件类型,通过流的方式传给后端,或者上传oss即可, 但是对于很大的文件来说,上传意味着会严重影响网络带宽以及网络波动造成传输一半的文件丢失,用户从头再开始重传, 对用户来说体验非常不好, 那么就需要将文件进行切割再分批次上传, 那么分批次的话,就要确定分多大,分多少片, 太大了,太小了都不好, 那么这个可以通过获取用户网络状态,进行动态调整,那个不在我们的讨论中, 我们先简单实现一版。
3.1 实现切割
js
async function cutFile(file) {
console.time("111");
const result1 = await chunkFileNotWork(file, chunkCount);
console.log(result1, "result1...");
console.timeEnd("111");
}
在渲染的时候,调用cutFile函数,将文件传入, 调用 chunkFileNotWork 函数, 然后接收返回的结果,并计算时长,为什么叫chunkFileNotWork, 因为后面我们还要再写一个利用web worker 实现切割文件函数进行性能对比,那么先实现 chunkFileNotWork 函数
js
const CHUNK_SIZE = 20 * 1024 * 1024; // 20M
async function chunkFileNotWork(file, chunkCount) {
let result = [];
for (let i = 0; i < chunkCount; i++) {
result.push(createChunk(file, i, CHUNK_SIZE));
}
return Promise.all(result);
}
在这个方法中我们我们定义了,要切割的片大小为 20M, 后面我们要上传一个大概六七百兆的视频, 进行切片后, 我们会循环进行切片, 调用 createChunk 函数, 将文件, 起始位置, 文件切割的尺寸传入
js
// 切片
export function createChunk(file, index, chunkSize) {
return new Promise((resolve) => {
const start = index * chunkSize;
const end = start + chunkSize;
const fileReader = new FileReader();
fileReader.onload = async (e) => {
resolve({
start,
end,
index,
hash: await calculateHash(e.target.result),
});
};
fileReader.readAsArrayBuffer(file.slice(index, end));
});
}
async function calculateHash(content) {
const hashBuffer = await crypto.subtle.digest("SHA-256", content);
return bufferToHex(hashBuffer);
}
function bufferToHex(buffer) {
return Array.from(new Uint8Array(buffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
切片函数,再切片的时候, 我们是通过FileReader读取文件每一段的内容, 然后利用 cryoto 根据内容计算出一个每片的 hash 值, 为什么要这么做的, 是因为每一段通过内容可以计算出固定的hash, 这样在上传服务器时,就可以告诉后端,哪片上传了, 哪片没有上传,可以实现后面的断点续传与秒传, 另外也可以告诉服务端,文件上传是不是完整,是不是需要合并文件了
因为计算hash需要时间, 我们是通过异步的方式, 返回一个 promise, 在 chunkFileNotWork 函数中接收所有的promise, 然后使用 promise.all 并行实现
3.2 性能分析
到此,其实切片上传就基本完成了, 但是如果页面有元素在进行动画,就会发现页面其实发生了卡顿, 我们通过写一个动画的小球看一下效果
很明显可以看出来,小球发生了卡顿, 就是因为大量的计算, 阻塞了主渲染的进程
4、 启用web worker 优化切片
先看一下优化后的效果
优化后,效果很好,小球没有发生卡顿
js
async function cutFile(file) {
// 文件切割
const chunkCount = Math.ceil(file.size / CHUNK_SIZE);
console.log(chunkCount); // 20
console.log("start...");
console.time("222");
const result2 = await chunkFileWithWork(file, chunkCount);
console.log(result2, "result2");
console.timeEnd("222");
console.log("end...");
}
我们在 cutFile中 实现一个 chunkFileWithWork 替换掉之前 chunkFileNotWork 函数, 然后实现一下 chunkFileWithWork
js
let THREAD_COUNT = navigator.hardwareConcurrency || 4; // 开启多少线程
async function chunkFileWithWork(file, chunkCount) {
return new Promise((resolve) => {
const workerChunkCount = Math.ceil(chunkCount / THREAD_COUNT);
console.log(chunkCount, THREAD_COUNT, workerChunkCount);
let result = [];
let finishCount = 0;
// 多个线程一起跑
console.log(THREAD_COUNT, "启用线程数量");
for (let i = 0; i < THREAD_COUNT; i++) {
// 创建一个新的 Worker 线程
const worker = new Worker("worker.js", {
type: "module",
});
// 计算每个线程的开始索引和结束索引
const startIndex = i * workerChunkCount;
let endIndex = startIndex + workerChunkCount;
if (endIndex > chunkCount) {
endIndex = chunkCount;
}
console.log(startIndex, endIndex, "切分位置");
if (startIndex <= chunkCount) {
// 发送去计算
worker.postMessage({
file,
CHUNK_SIZE,
startIndex,
endIndex,
});
finishCount++;
// 接收计算结果
worker.onmessage = (e) => {
for (let i = startIndex; i < endIndex; i++) {
result[i] = e.data[i - startIndex];
}
worker.terminate();
finishCount--;
if (finishCount === 0) {
resolve(result);
}
};
}
}
});
}
该方法的核心就是创建 new Worker , 根据 navigator.hardwareConcurrency 看一下支持多少核, 然后循环建立多个 worker线程,让worker 线程 帮助我们计算这些耗时的计算,计算好后再交回给主线程, 实现主线程和worker线程通讯过程也很简单, 通过worker.possMessage 进行发送, 然后通过 worker.onmessage 进行接收, 然后我们通过 finishCount进行计数, 发送一个 +1, 收到一个 -1, 然后当全部接收到后, 通过promise resove 到最后的结果, 传递给上层函数。
work.js
js
self.onmessage = async (e) => {
const proms = [];
const { file, CHUNK_SIZE, startIndex, endIndex } = e.data;
console.log("开始任务", startIndex, endIndex);
for (let i = startIndex; i < endIndex; i++) {
proms.push(createChunk(file, i, CHUNK_SIZE));
}
try {
const chunks = await Promise.all(proms);
console.log(chunks, "chunks");
self.postMessage(chunks);
} catch (error) {
console.log(error, "error");
}
};
function createChunk(file, index, chunkSize) {
return new Promise((resolve) => {
const start = index * chunkSize;
const end = start + chunkSize;
const fileReader = new FileReader();
fileReader.onload = async (e) => {
resolve({
start,
end,
index,
hash: await calculateHash(e.target.result),
});
};
fileReader.readAsArrayBuffer(file.slice(index, end));
});
}
async function calculateHash(content) {
const hashBuffer = await crypto.subtle.digest("SHA-256", content);
return bufferToHex(hashBuffer);
}
function bufferToHex(buffer) {
return Array.from(new Uint8Array(buffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
work.js
该文件中,依旧是进行分片,计算文件的 hash
4、 总结
到此,文件上传的核心思想切片与计算hash就完成了,计算hash等长耗时的操作使用 web worker进行优化, 接下来的就是拿这些信息给后端,进行通讯,实现具体业务了, 就不在我们的讨论范围了, 有兴趣的可以再看一下相关文章