整体流程
1、选择文件,切片,计算文件hash,查询文件是否已经上传部分切片
2、如果是,则返回已上传的切片列表,对比所有切片,得到未上传的切片列表,并上传。否则上传所有切片。
4、服务器端接收到切片后,建立文件夹,以文件hash命名。切片文件存入文件夹中,以切片序号命名。
3、当所有切片成功上传,发送合并切片的请求,在服务器端合并切片,并删除hash文件夹。
文件目录:
- routes:路由
- static:页面和静态资源文件,上传和下载的文件放在files中
- app.js:项目入口文件
文件切片上传
<input type="file">
选择文件后会返回一个File对象数组,File类继承自Blob。所以它也有slice方法,file.slice(start,end)返回新的Blob对象,它包含有源 Blob 对象中指定范围内的数据。我们用slice方法将大文件切片,然后上传。同一个站点可以并发发送6个http请求,也就是可以同时发送6个切片,理论上应该会比直接传一个大文件快(后面发现只快一点点,但是在中途断掉后再续传就会优秀很多)。
- 1、生成切片
function createFileChunk(file, size=CHUNK_SIZE) {
var fileChunkList = []
var cur = 0,i=0
while (cur < file.size) {
fileChunkList.push({
file: file.slice(cur, cur + size),
idx: i
})
cur += size
i++
}
return fileChunkList
}
- 2、计算文件hash hash是文件的唯一标识,可以利用文件的hash查询文件的上传状态。生成hash用的是spark-md5库,大文件的hash计算需要分片读取文件,因为如果一次性读取文件太大,会导致页面很卡,甚至崩掉。
将读取的文件内容添加到spark-md5的hash计算中,直到文件读取完毕,最后返回最终的hash码。
function genHash(){
return new Promise(function(resolve,reject){
var chunkList = createFileChunk(file),
chunks = chunkList.length,
currentChunk = 0,
spark = new SparkMD5.ArrayBuffer(),
fileReader = new FileReader()
function loadNext() {
fileReader.readAsArrayBuffer(chunkList[currentChunk].file);
}
fileReader.onload = function(e){
spark.append(e.target.result)
currentChunk++
if (currentChunk < chunks) {
loadNext()
}else {
var md5 = spark.end()
resolve(md5)
}
}
fileReader.onerror = function (err) {
console.warn('oops, something went wrong.')
reject(err)
}
loadNext()
})
}
- 3、查询文件状态 得到文件hash后,从后台查询是否存在名称为hash的文件夹,如果存在,列出文件夹下所有文件,得到已上传的切片列表,如果不存在,则已上传的切片列表为空。
Ajax('POST','http://localhost:9000/checkFile',{
name: FileMd5
}).then(function(res){
var chunks = res.data.chunks
...
})
- 4、发送切片 将已经上传的切片和所有切片作对比,得到需要上传的切片。
var chunks = res.data.chunks //服务器端已经存在的切片,以chunkList[i].idx命名
var list = chunkList //由createFileChunk(file)得到的所有切片
if(chunks.length > 0){
var newList = []
chunks.sort(function(a,b){return a.idx-b.idx})
for(var i=0,j=0; i<chunkList.length; i++){
if(chunks[j] == chunkList[i].idx){
j++
}else{
newList.push(chunkList[i])
}
}
list = newList
}
将切片上传,会用到Promise.all方法,如果所有切片都上传成功,执行成功回调函数,只要有切片上传失败,则执行失败回调函数。
function addRequest(chunkList,hash){
return chunkList.map(function(chunk){
var formData = new FormData()
formData.append('data', chunk.file)
formData.append('index', chunk.idx)
formData.append('hash', hash)
return uploadAjax('POST','http://localhost:9000/uploadBigFile',formData,{
progressCallback: function(loaded,total){
chunk.loaded = loaded //用于计算文件上传进度
}
})
})
}
Promise.all(addRequest(list,FileMd5)).then(function(res){
mergeRequest(FileMd5,fileName)
},function(err){})
- 5、所有切片上传成功后,发送合并文件请求。在服务器端,将切片合并成文件后,删除切片文件。
function mergeRequest(hash,name){
Ajax('POST','http://localhost:9000/mergeFile',{
hash: hash,
name: name
}).then(function(res){//...})
}
服务器端
服务器端用的是koa2、koa-router。
app.js
const Koa = require('koa');
const path = require('path');
const koaBody = require('koa-body');
const static = require('koa-static');
const home = require('./routes/home.js')
const app = new Koa();
app.use(async (ctx, next) => {
fileFilter(ctx)
await next()
})
app.use(koaBody({ //ctx.request.body 用于获取post的参数;//ctx.query 是用于获取get请求的参数;ctx.request.files获取文件
multipart: true, // 支持文件上传
formidable: {
maxFileSize: 2 * 1024 * 1024 * 1024, // 单次上传的文件最大为2G
multipart: true
}
}));
app.use(static(path.join(__dirname,'./static')));
app.use(home.routes(), home.allowedMethods())
app.on("error",(err,ctx)=>{//捕获异常记录错误日志
console.log(new Date(),":",err);
});
app.listen(9000, () => {
console.log('server is listen in 9000');
});
function fileFilter(ctx){//文件下载设置
const url = ctx.request.url
const p = /^\/files\//
if(p.test(url)){
ctx.set('Accept-Ranges', 'bytes')
ctx.set('Content-Disposition', 'attachment')
}
}
上传的文件切片放在files/temp文件夹,合并完后生成的文件放在files文件夹。
- 查询文件上传状态。files/temp文件夹中是否有文件hash命名的文件夹,如果有,则返回其中已上传的切片。
router.post('/checkFile', async (ctx) => {
var { name } = ctx.request.body //以hash命名
var chunks = []
var files = fs.readdirSync(resolveFilePathTemp(''))
files.forEach( file => {
if(file == name){
var filePath = resolveFilePathTemp(file);
var stat = fs.statSync(filePath);
if(stat.isDirectory()){
chunks = fs.readdirSync(resolveFilePathTemp(file))
}
}
})
ctx.body = {
code: 400001,
data: {
chunks: chunks
}
}
});
- 接收切片。以文件hash查询文件夹,如果不存在则创建hash文件夹,然后往该文件夹中存入切片。
router.post('/uploadBigFile', async (ctx) => {
var { index, hash } = ctx.request.body
var file = ctx.request.files.data
await saveFragmentFile(file,hash,index)
ctx.body = {
code: 400001,
message: '上传成功'
}
});
//处理分片上传文件
async function saveFragmentFile(file,hash,index){
return new Promise((resolve,reject) =>{
var exist = fs.existsSync(resolveFilePathTemp(hash))
if(!exist){
fs.mkdirSync(resolveFilePathTemp(hash))
}
var writeFilePath = resolveFilePathTemp(`${hash}/${index}`)
readStream = fs.createReadStream(file.path);
writeStream = fs.createWriteStream(writeFilePath);
readStream.pipe(writeStream);
readStream.on("end",() => {
resolve()
})
readStream.on("error",() => {
reject()
})
})
};
- 合并切片。由hash找到需要合并的文件夹,其中文件可能不是按照切片序号先后到达的,所以先按文件名排序,然后依次写入目标文件。合并完后将hash文件夹删除。
router.post('/mergeFile', async (ctx) => {
var { hash,name } = ctx.request.body
var files = fs.readdirSync(resolveFilePathTemp(hash)).sort(function(a,b){return a-b})
var dirs = files.map((item) => {
return resolveFilePathTemp(`${hash}/${item}`)
})
await mergeFile(dirs, resolveFilePath(name))
deleteDir(resolveFilePathTemp(hash))
ctx.body = {
code: 400001,
message: '上传成功'
}
});
//合并文件
async function mergeFile(dirs, writePath){
const fileWriteStream = fs.createWriteStream(writePath);
return new Promise((resolve) => {
mergeFileRecursive(dirs, fileWriteStream,resolve)
})
}
function mergeFileRecursive(dirs, fileWriteStream,resolve){
if (!dirs.length) {
fileWriteStream.end()
resolve()
return
}
const currentFile = dirs.shift()
const currentReadStream = fs.createReadStream(currentFile)
currentReadStream.pipe(fileWriteStream, { end: false });
currentReadStream.on('end', function() {
mergeFileRecursive(dirs, fileWriteStream,resolve);
});
}
上传进度
因为多个切片并发上传,所以监听每一个切片的xhr.upload.onprogress不能直接得到整个文件的上传进度。这里,我们监听每一个切片的xhr.upload.onprogress,将每个切片已上传的部分,为每个切片所属的对象添加loaded属性。然后每隔一秒读取每个切片对象这个属性,求和,再除以整个文件的大小,得到上传进度。
var interval
function setProgress(){
interval = setInterval(function(){
var loadedSum = 0
chunkList.forEach(item => {
if(item.loaded){
loadedSum += item.loaded
}
});
var percent = loadedSum/fileSize >= 1 ? 1: loadedSum/fileSize
progressBar.innerText = (percent * 100).toFixed(2) + '%'
},1000)
}
function clearProgress(){
clearInterval(interval)
}
webworker生成hash
生成文件hash是比较耗时的一个操作,会引起js线程阻塞。如果是分片生成hash,那么分片文件size越大,阻塞越明显。所以我们尝试用webworker另启线程来处理。 大文件计算hash耗时比较久,所以我们在file输入框onchange事件即开始计算hash,而不是点击上传按钮再开始计算。所以如果file输入框的文件变了,就需要中止当前hash计算,重新开始。
importScripts("./spark-md5.min.js")
var fileReader = new FileReader(),
chunkList,chunks,currentChunk,spark
function loadNext() {
fileReader.readAsArrayBuffer(chunkList[currentChunk].file);
}
fileReader.onload = function(e){
console.log('load')
spark.append(e.target.result)
currentChunk++
if (currentChunk < chunks) {
loadNext()
}else {
var md5 = spark.end()
self.postMessage(md5)
}
}
fileReader.onerror = function (err) {
console.warn('oops, something went wrong.')
self.postMessage(null);
}
fileReader.onabort = function (err) {
console.log('abort')
}
self.addEventListener('message', function (e) {
console.log(e.data)
if(e.data.changeChunk){
fileReader.abort()
}
chunkList = e.data.chunkList
chunks = chunkList.length,
currentChunk = 0,
spark = new SparkMD5.ArrayBuffer()
loadNext()
}, false);
下载
- 方式一 一个url指向的资源,如果浏览器可以解析就会渲染,解析不了就直接下载,所以如果是.html/.jpg/.mp4等就会在开一个tab渲染出来,如果是.rar/.zip等文件就会直接下载。
如果需要所有资源都直接下载,不管什么媒体类型,就需要在响应头加上Content-Disposition:attachment。
app.use(async (ctx, next) => {
fileFilter(ctx)
await next()
})
function fileFilter(ctx){
const url = ctx.request.url
const p = /^\/files\//
if(p.test(url)){
ctx.set('Accept-Ranges', 'bytes')
ctx.set('Content-Disposition', 'attachment')
}
}
点击a标签就可以直接下载文件。
- 方式二 如果用户要监听下载进度,并且在进度中做一些定制,用上面这种方法就不行了,要用ajax,然后监听xhr.onprogress事件。
var xhr = new XMLHttpRequest()
xhr.onreadystatechange = function () {
if(xhr.readyState == 4 && xhr.status == 200){
//xhr.response为二进制流
var blob = new Blob([xhr.response],{type:downloadOption.fileType});
var href = window.URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = href;
link.download = downloadOption.fileName;
link.click();
if(downloadOption.successCallback){
downloadOption.successCallback()
}
}
}
xhr.onprogress = function (ev) {
if (ev.lengthComputable && downloadOption.progressCallback) {
downloadOption.progressCallback(ev.loaded,ev.total)
}
}
如上所示,在后一种方式中,在下载过程中,如果不小心关了页面,下载就会停止。所以在大文件系统中,我们选择第一种方式。
项目地址: github.com/alasolala/f…