大文件的分片上傳、斷點(diǎn)續(xù)傳及其相關(guān)拓展實(shí)踐
大廠技術(shù)??高級(jí)前端??Node進(jìn)階
點(diǎn)擊上方?程序員成長(zhǎng)指北,關(guān)注公眾號(hào)
回復(fù)1,加入高級(jí)Node交流群
原文鏈接: https://juejin.cn/post/7071877982574346277
作者: 火車頭
大文件分片上傳核心方法
在JavaScript中,文件FIle對(duì)象是Blob對(duì)象的子類,Blob對(duì)象包含一個(gè)重要的方法slice通過(guò)這個(gè)方法,我們就可以對(duì)二進(jìn)制文件進(jìn)行拆分
使用 FormData 格式進(jìn)行上傳
服務(wù)端接口接受到數(shù)據(jù),通過(guò) multiparty 庫(kù)對(duì)數(shù)據(jù)進(jìn)行處理
區(qū)分 files 和 fields,通過(guò) fse.move 將上傳的文件移動(dòng)到目標(biāo)路徑下
客戶端使用 Promise.all 方法,當(dāng)監(jiān)聽到所有切片已上傳完,調(diào)用 merge 接口,通知服務(wù)端進(jìn)行切片的合并
使用 Stream 對(duì)切片邊讀邊寫,設(shè)置可寫流的 start
Promise.all判斷所有切片是否寫入完畢
進(jìn)度條
使用瀏覽器 XMLHttpRequest 的 onprogress 的方法對(duì)進(jìn)度進(jìn)行監(jiān)聽
// 作為request的入?yún)?/span>
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = onProgress;
// 回調(diào)方法
onProgress: this.createProgressHandler(this.data[index])
// 接受回調(diào),通過(guò) e.loaded 和 e.total 獲取進(jìn)度
createProgressHandler(item) {
return (e) => {
item.percentage = parseInt(String((e.loaded / e.total) * 100));
};
},
斷點(diǎn)續(xù)傳核心方法
1、通過(guò)xhr的 abort 方法,主動(dòng)放棄當(dāng)前請(qǐng)求
this.requestList.forEach((xhr) => xhr?.abort());
2、番外篇:斷點(diǎn)續(xù)傳服務(wù)端做法
當(dāng)用戶在聽一首歌的時(shí)候,如果聽到一半(網(wǎng)絡(luò)下載了一半),網(wǎng)絡(luò)斷掉了,用戶需要繼續(xù)聽的時(shí)候,文件服務(wù)器不支持?jǐn)帱c(diǎn)的話,則用戶需要重新下載這個(gè)文件。而Range支持的話,客戶端應(yīng)該記錄了之前已經(jīng)讀取的文件范圍,網(wǎng)絡(luò)恢復(fù)之后,則向服務(wù)器發(fā)送讀取剩余Range的請(qǐng)求,服務(wù)端只需要發(fā)送客戶端請(qǐng)求的那部分內(nèi)容,而不用整個(gè)文件發(fā)送回客戶端,以此節(jié)省網(wǎng)絡(luò)帶寬。
如果Server支持Range,首先就要告訴客戶端,咱支持Range,之后客戶端才可能發(fā)起帶Range的請(qǐng)求。這里套用唐僧的一句話,你不說(shuō)我怎么知道呢。response.setHeader('Accept-Ranges', 'bytes');
Server通過(guò)請(qǐng)求頭中的Range: bytes=0-xxx來(lái)判斷是否是做Range請(qǐng)求,如果這個(gè)值存在而且有效,則只發(fā)回請(qǐng)求的那部分文件內(nèi)容,響應(yīng)的狀態(tài)碼變成206,表示Partial Content,并設(shè)置Content-Range。如果無(wú)效,則返回416狀態(tài)碼,表明Request Range Not Satisfiable(www.w3.org/Protocols/r… )。如果不包含Range的請(qǐng)求頭,則繼續(xù)通過(guò)常規(guī)的方式響應(yīng)。
getStream(req, res, filepath, fileStat) {
res.setHeader('Accept-Range', 'bytes'); //告訴客戶端服務(wù)器支持Range
let range = req.headers['range'];
let start = 0;
let end = fileStat.size;
if (range) {
let reg = /bytes=(\d*)-(\d*)/;
let result = range.match(reg);
if (result) {
start = isNaN(result[1]) ? 0 : parseInt(result[1]);
end = isNaN(result[2]) ? 0 : parseInt(result[2]);
}
};
debug(`start=${start},end=${end}`);
return fs.createReadStream(filepath, {
start,
end
});
}
提高篇
時(shí)間切片計(jì)算文件hash:計(jì)算hash耗時(shí)的問(wèn)題,不僅可以通過(guò)web-workder,還可以參考React的Fiber架構(gòu),通過(guò)requestIdleCallback來(lái)利用瀏覽器的空閑時(shí)間計(jì)算,也不會(huì)卡死主線程抽樣hash:文件hash的計(jì)算,是為了判斷文件是否存在,進(jìn)而實(shí)現(xiàn)秒傳的功能,所以我們可以參考布隆過(guò)濾器的理念, 犧牲一點(diǎn)點(diǎn)的識(shí)別率來(lái)?yè)Q取時(shí)間,比如我們可以抽樣算hash根據(jù)文件名 + 文件修改時(shí)間 + size 生成hash網(wǎng)絡(luò)請(qǐng)求并發(fā)控制:大文件由于切片過(guò)多,過(guò)多的HTTP鏈接過(guò)去,也會(huì)把瀏覽器打掛, 我們可以通過(guò)控制異步請(qǐng)求的并發(fā)數(shù)來(lái)解決,這也是頭條的一個(gè)面試題慢啟動(dòng)策略:由于文件大小不一,我們每個(gè)切片的大小設(shè)置成固定的也有點(diǎn)略顯笨拙,我們可以參考TCP協(xié)議的慢啟動(dòng)策略, 設(shè)置一個(gè)初始大小,根據(jù)上傳任務(wù)完成的時(shí)候,來(lái)動(dòng)態(tài)調(diào)整下一個(gè)切片的大小, 確保文件切片的大小和當(dāng)前網(wǎng)速匹配并發(fā)重試+報(bào)錯(cuò):并發(fā)上傳中,報(bào)錯(cuò)如何重試,比如每個(gè)切片我們?cè)试S重試兩次,三次再終止文件碎片清理
1、時(shí)間切片計(jì)算文件hash
其實(shí)就是time-slice概念,React中Fiber架構(gòu)的核心理念,利用瀏覽器的空閑時(shí)間,計(jì)算大的diff過(guò)程,中途又任何的高優(yōu)先級(jí)任務(wù),比如動(dòng)畫和輸入,都會(huì)中斷diff任務(wù), 雖然整個(gè)計(jì)算量沒有減小,但是大大提高了用戶的交互體驗(yàn)
requestIdleCallback

requestIdelCallback(myNonEssentialWork);
function myNonEssentialWork (deadline) {
// deadline.timeRemaining()可以獲取到當(dāng)前幀剩余時(shí)間
// 當(dāng)前幀還有時(shí)間 并且任務(wù)隊(duì)列不為空
while (deadline.timeRemaining() > 0 && tasks.length > 0) {
doWorkIfNeeded();
}
if (tasks.length > 0){
requestIdleCallback(myNonEssentialWork);
}
}
2、抽樣hash
計(jì)算文件md5值的作用,無(wú)非就是為了判定文件是否存在,我們可以考慮設(shè)計(jì)一個(gè)抽樣的hash,犧牲一些命中率的同時(shí),提升效率,設(shè)計(jì)思路如下
文件切成大小為 XXX Mb的切片
第一個(gè)和最后一個(gè)切片全部?jī)?nèi)容,其他切片的取 首中尾三個(gè)地方各2個(gè)字節(jié)
合并后的內(nèi)容,計(jì)算md5,稱之為影分身Hash
這個(gè)hash的結(jié)果,就是文件存在,有小概率誤判,但是如果不存在,是100%準(zhǔn)的的 ,和
布隆過(guò)濾器的思路有些相似, 可以考慮兩個(gè)hash配合使用我在自己電腦上試了下1.5G的文件,全量大概要20秒,抽樣大概1秒還是很不錯(cuò)的, 可以先用來(lái)判斷文件是不是不存在

3、根據(jù)文件名 + 文件修改時(shí)間 + size 生成hash
可根據(jù)File的lastModified、name、size生成hash,避免通過(guò)spark-md5對(duì)大文件進(jìn)行hash計(jì)算,大大的節(jié)省時(shí)間
lastModified: 1633436262311
lastModifiedDate: Tue Oct 05 2021 20:17:42 GMT+0800 (中國(guó)標(biāo)準(zhǔn)時(shí)間) {}
name: "2021.docx"
size: 1696681
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
4、網(wǎng)絡(luò)請(qǐng)求并發(fā)控制
大文件hash計(jì)算后,一次發(fā)幾百個(gè)http請(qǐng)求,計(jì)算哈希沒卡,結(jié)果TCP建立的過(guò)程就把瀏覽器弄死了
思路其實(shí)也不難,就是我們把異步請(qǐng)求放在一個(gè)隊(duì)列里,比如并發(fā)數(shù)是3,就先同時(shí)發(fā)起3個(gè)請(qǐng)求,然后有請(qǐng)求結(jié)束了,再發(fā)起下一個(gè)請(qǐng)求即可
我們通過(guò)并發(fā)數(shù)max來(lái)管理并發(fā)數(shù),發(fā)起一個(gè)請(qǐng)求max--,結(jié)束一個(gè)請(qǐng)求max++即可
async sendRequest(forms, max=4) {
return new Promise(resolve => {
const len = forms.length;
let idx = 0;
let counter = 0;
const start = async ()=> {
// 有請(qǐng)求,有通道
while (idx < len && max > 0) {
max--; // 占用通道
console.log(idx, "start");
const form = forms[idx].form;
const index = forms[idx].index;
idx++
request({
url: '/upload',
data: form,
onProgress: this.createProgresshandler(this.chunks[index]),
requestList: this.requestList
}).then(() => {
max++; // 釋放通道
counter++;
if (counter === len) {
resolve();
} else {
start();
}
});
}
}
start();
});
}
5、慢啟動(dòng)策略實(shí)現(xiàn)
chunk中帶上size值,不過(guò)進(jìn)度條數(shù)量不確定了,修改createFileChunk, 請(qǐng)求加上時(shí)間統(tǒng)計(jì)
比如我們理想是30秒傳遞一個(gè)
初始大小定為1M,如果上傳花了10秒,那下一個(gè)區(qū)塊大小變成3M
如果上傳花了60秒,那下一個(gè)區(qū)塊大小變成500KB 以此類推
6、并發(fā)重試+報(bào)錯(cuò)
請(qǐng)求出錯(cuò).catch 把任務(wù)重新放在隊(duì)列中
出錯(cuò)后progress設(shè)置為-1 進(jìn)度條顯示紅色
數(shù)組存儲(chǔ)每個(gè)文件hash請(qǐng)求的重試次數(shù),做累加 比如[1,0,2],就是第0個(gè)文件切片報(bào)錯(cuò)1次,第2個(gè)報(bào)錯(cuò)2次
超過(guò)3的直接reject
7、服務(wù)器碎片文件清理
如果很多人傳了一半就離開了,這些切片存在就沒意義了,可以考慮定期清理
我們可以使用 node-schedule 來(lái)管理定時(shí)任務(wù) 比如我們每天掃一次存放文件目錄,如果文件的修改時(shí)間是一個(gè)月以前了,就直接刪除把
// 為了方便測(cè)試,我改成每5秒掃一次, 過(guò)期1鐘的刪除做演示
const fse = require('fs-extra')
const path = require('path')
const schedule = require('node-schedule')
// 空目錄刪除
function remove(file,stats){
const now = new Date().getTime()
const offset = now - stats.ctimeMs
if(offset>1000*60){
// 大于60秒的碎片
console.log(file,'過(guò)期了,浪費(fèi)空間的玩意,刪除')
fse.unlinkSync(file)
}
}
async function scan(dir,callback){
const files = fse.readdirSync(dir)
files.forEach(filename=>{
const fileDir = path.resolve(dir,filename)
const stats = fse.statSync(fileDir)
if(stats.isDirectory()){
return scan(fileDir,remove)
}
if(callback){
callback(fileDir,stats)
}
})
}
// * * * * * *
// ┬ ┬ ┬ ┬ ┬ ┬
// │ │ │ │ │ │
// │ │ │ │ │ └ day of week (0 - 7) (0 or 7 is Sun)
// │ │ │ │ └───── month (1 - 12)
// │ │ │ └────────── day of month (1 - 31)
// │ │ └─────────────── hour (0 - 23)
// │ └──────────────────── minute (0 - 59)
// └───────────────────────── second (0 - 59, OPTIONAL)
let start = function(UPLOAD_DIR){
// 每5秒
schedule.scheduleJob("*/5 * * * * *",function(){
console.log('開始掃描')
scan(UPLOAD_DIR)
})
}
exports.start = start
客戶端核心代碼
<template>
<section id="app">
<section>
<input
type="file"
:disabled="status !== Status.wait"
@change="handleFileChange"
/>
<el-button @click="handleUpload" :disabled="uploadDisabled"
>上傳 >
<el-button @click="handleResume" v-if="status === Status.pause"
>恢復(fù) >
<el-button
v-else
:disabled="status !== Status.uploading || !container.hash"
@click="handlePause"
>暫停 >
section>
<section>
<section>計(jì)算文件 hashsection>
<el-progress :percentage="hashPercentage">el-progress>
<section>總進(jìn)度section>
<el-progress :percentage="fakeUploadPercentage">el-progress>
section>
<el-table :data="data">
<el-table-column
prop="hash"
label="切片hash"
align="center"
>el-table-column>
<el-table-column label="大小(KB)" align="center" width="120">
<template v-slot="{ row }">
{{ row.size | transformByte }}
template>
el-table-column>
<el-table-column label="進(jìn)度" align="center">
<template v-slot="{ row }">
<el-progress
:percentage="row.percentage"
color="#909399"
>el-progress>
template>
el-table-column>
el-table>
section>
template>
<script>
const SIZE = 128 * 1024; // 切片大小
const Status = {
wait: "wait",
pause: "pause",
uploading: "uploading",
};
export default {
name: "app",
filters: {
transformByte(val) {
return Number((val / 1024).toFixed(0));
},
},
data: () => ({
Status,
container: {
file: null,
hash: "",
worker: null,
},
hashPercentage: 0,
data: [],
requestList: [],
status: Status.wait,
// 當(dāng)暫停時(shí)會(huì)取消 xhr 導(dǎo)致進(jìn)度條后退
// 為了避免這種情況,需要定義一個(gè)假的進(jìn)度條
fakeUploadPercentage: 0,
}),
computed: {
uploadDisabled() {
return (
!this.container.file ||
[Status.pause, Status.uploading].includes(this.status)
);
},
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;
}
},
},
methods: {
handlePause() {
this.status = Status.pause;
this.resetData();
},
resetData() {
this.requestList.forEach((xhr) => xhr?.abort());
this.requestList = [];
if (this.container.worker) {
this.container.worker.onmessage = null;
}
},
async handleResume() {
this.status = Status.uploading;
const { uploadedList } = await this.verifyUpload(
this.container.file.name,
this.container.hash
);
await this.uploadChunks(uploadedList);
},
// 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) => {
// 將請(qǐng)求成功的 xhr 從列表中刪除
if (requestList) {
const xhrIndex = requestList.findIndex((item) => item === xhr);
requestList.splice(xhrIndex, 1);
}
resolve({
data: e.target.response,
});
};
// 暴露當(dāng)前 xhr 給外部
requestList?.push(xhr);
});
},
// 生成文件切片
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;
},
// 生成文件 hash(web-worker)
calculateHash(fileChunkList) {
return new Promise((resolve) => {
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);
}
};
});
},
handleFileChange(e) {
const [file] = e.target.files;
if (!file) return;
console.log(file)
this.resetData();
Object.assign(this.$data, this.$options.data());
this.container.file = file;
},
async handleUpload() {
if (!this.container.file) return;
this.status = Status.uploading;
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("秒傳:上傳成功");
this.status = Status.wait;
return;
}
this.data = fileChunkList.map(({ file }, index) => ({
fileHash: this.container.hash,
index,
hash: this.container.hash + "-" + index,
chunk: file,
size: file.size,
percentage: uploadedList.includes(this.container.hash + "-" + index) ? 100 : 0,
}));
await this.uploadChunks(uploadedList);
},
// 上傳切片,同時(shí)過(guò)濾已上傳的切片
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);
// 之前上傳的切片數(shù)量 + 本次上傳的切片數(shù)量 = 所有切片數(shù)量時(shí)
// 合并切片
if (uploadedList.length + requestList.length === this.data.length) {
await this.mergeRequest();
}
},
// 通知服務(wù)端合并切片
async mergeRequest() {
await this.request({
url: "http://localhost:3000/merge",
headers: {
"content-type": "application/json",
},
data: JSON.stringify({
size: SIZE,
fileHash: this.container.hash,
filename: this.container.file.name,
}),
});
this.$message.success("上傳成功");
this.status = Status.wait;
},
// 根據(jù) hash 驗(yàn)證文件是否曾經(jīng)已經(jīng)被上傳過(guò)
// 沒有才進(jìn)行上傳
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);
},
// 用閉包保存每個(gè) chunk 的進(jìn)度數(shù)據(jù)
createProgressHandler(item) {
return (e) => {
console.log(item.hash, parseInt(String((e.loaded / e.total) * 100)));
item.percentage = parseInt(String((e.loaded / e.total) * 100));
};
},
},
};
script>
服務(wù)端核心代碼
index.js
const Controller = require("./controller");
const http = require("http");
const server = http.createServer();
const controller = new Controller();
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 === "/verify") {
await controller.handleVerifyUpload(req, res);
return;
}
if (req.url === "/merge") {
await controller.handleMerge(req, res);
return;
}
if (req.url === "/") {
await controller.handleFormData(req, res);
}
});
server.listen(3000, () => console.log("正在監(jiān)聽 3000 端口"));
controller.js
const multiparty = require("multiparty");
const path = require("path");
const fse = require("fs-extra");
const extractExt = (filename) =>
filename.slice(filename.lastIndexOf("."), filename.length); // 提取后綴名
const UPLOAD_DIR = path.resolve(__dirname, "..", "target"); // 大文件存儲(chǔ)目錄
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, fileHash, size) => {
const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
const chunkPaths = await fse.readdir(chunkDir);
// 根據(jù)切片下標(biāo)進(jìn)行排序
// 否則直接讀取目錄的獲得的順序可能會(huì)錯(cuò)亂
chunkPaths.sort((a, b) => a.split("-")[1] - b.split("-")[1]);
await Promise.all(
chunkPaths.map((chunkPath, index) =>
pipeStream(
path.resolve(chunkDir, chunkPath),
// 指定位置創(chuàng)建可寫流
fse.createWriteStream(filePath, {
start: index * size,
end: (index + 1) * size,
})
)
)
);
fse.rmdirSync(chunkDir); // 合并后刪除保存切片的目錄
};
const resolvePost = (req) =>
new Promise((resolve) => {
let chunk = "";
req.on("data", (data) => {
chunk += data;
});
req.on("end", () => {
resolve(JSON.parse(chunk));
});
});
// 返回已經(jīng)上傳切片名
const createUploadedList = async (fileHash) =>
fse.existsSync(path.resolve(UPLOAD_DIR, fileHash))
? await fse.readdir(path.resolve(UPLOAD_DIR, fileHash))
: [];
module.exports = class {
// 合并切片
async handleMerge(req, res) {
const data = await resolvePost(req);
const { fileHash, filename, size } = data;
const ext = extractExt(filename);
const filePath = path.resolve(UPLOAD_DIR, `${fileHash}${ext}`);
await mergeFileChunk(filePath, fileHash, size);
res.end(
JSON.stringify({
code: 0,
message: "file merged success",
})
);
}
// 處理切片
async handleFormData(req, res) {
const multipart = new multiparty.Form();
multipart.parse(req, async (err, fields, files) => {
if (err) {
console.error(err);
res.status = 500;
res.end("process file chunk failed");
return;
}
const [chunk] = files.chunk;
const [hash] = fields.hash;
const [fileHash] = fields.fileHash;
const [filename] = fields.filename;
const filePath = path.resolve(
UPLOAD_DIR,
`${fileHash}${extractExt(filename)}`
);
const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
// 文件存在直接返回
if (fse.existsSync(filePath)) {
res.end("file exist");
return;
}
// 切片目錄不存在,創(chuàng)建切片目錄
if (!fse.existsSync(chunkDir)) {
await fse.mkdirs(chunkDir);
}
// fs-extra 專用方法,類似 fs.rename 并且跨平臺(tái)
// fs-extra 的 rename 方法 windows 平臺(tái)會(huì)有權(quán)限問(wèn)題
// https://github.com/meteor/meteor/issues/7852#issuecomment-255767835
await fse.move(chunk.path, path.resolve(chunkDir, hash));
res.end("received file chunk");
});
}
// 驗(yàn)證是否已上傳/已上傳切片下標(biāo)
async handleVerifyUpload(req, res) {
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),
})
);
}
}
};
完整代碼
github.com/miracle90/b…
參考鏈接
字節(jié)跳動(dòng)面試官:請(qǐng)你實(shí)現(xiàn)一個(gè)大文件上傳和斷點(diǎn)續(xù)傳
字節(jié)跳動(dòng)面試官,我也實(shí)現(xiàn)了大文件上傳和斷點(diǎn)續(xù)傳
前端上傳大文件怎么處理
Node 社群
我組建了一個(gè)氛圍特別好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你對(duì)Node.js學(xué)習(xí)感興趣的話(后續(xù)有計(jì)劃也可以),我們可以一起進(jìn)行Node.js相關(guān)的交流、學(xué)習(xí)、共建。下方加 考拉 好友回復(fù)「Node」即可。
如果你覺得這篇內(nèi)容對(duì)你有幫助,我想請(qǐng)你幫我2個(gè)小忙:
1. 點(diǎn)個(gè)「在看」,讓更多人也能看到這篇文章 2. 訂閱官方博客?www.inode.club?讓我們一起成長(zhǎng) 點(diǎn)贊和在看就是最大的支持??
