簡單的文件上傳
一、准備文件上傳的條件:
1、安裝nodejs環境
2、安裝vue環境
3、驗證環境是否安裝成功
二、實現上傳步驟
1、前端部分使用 vue-cli 腳手架,搭建一個 demo 版本,能夠實現簡單交互:
<template> <div id="app"> <input type="file" @change="uploadFile"></button> </div> </template>
2、安裝 axios 實現與后端交互:
import Axios from 'axios' const Server = Axios.create({ baseURL: '/api' }) export default Server
3、后端使用 node-koa 框架:
// index.js const Koa = require('koa'); const router = require('koa-router')() // koa路由組件 const fs = require('fs') // 文件組件 const path = require('path') // 路徑組件 const koaBody = require('koa-body') //解析上傳文件的插件 const static = require('koa-static') // 訪問服務器靜態資源組件 const uploadPath = path.join(__dirname, 'public/uploads') // 文件上傳目錄 const app = new Koa(); // 實例化 koa // 定義靜態資源訪問規則 app.use(static('public', { maxAge: 30 * 24 * 3600 * 1000 // 靜態資源緩存時間 ms })) app.use(koaBody({ multipart: true, formidable: {
uploadDir: uploadPath, maxFileSize: 10000 * 1024 * 1024 // 設置上傳文件大小最大限制,默認20M } })) // 對於任何請求,app將調用該異步函數處理請求: app.use(async (ctx, next) => { console.log(`Process ${ctx.request.method} ${ctx.request.url}...`); ctx.set('Access-Control-Allow-Origin', '*');//*表示可以跨域任何域名都行 也可以填域名表示只接受某個域名 ctx.set('Access-Control-Allow-Headers', 'X-Requested-With,Content-Type,token');//可以支持的消息首部列表 ctx.set('Access-Control-Allow-Methods', 'PUT,POST,GET,DELETE,OPTIONS');//可以支持的提交方式 ctx.set('Content-Type', 'application/json;charset=utf-8');//請求頭中定義的類型 if (ctx.request.method === 'OPTIONS') { ctx.response.status = 200 } try { await next(); } catch (err) { console.log(err, 'errmessage') ctx.response.status = err.statusCode || err.status || 500 ctx.response.body = { errcode: 500, msg: err.message } ctx.app.emit('error', err, ctx); } })
4、前端實現上傳請求:
// vue export default { name: 'App', methods: { uploadFile(e) { const file = e.target.files[0] this.sendFile(file) }, sendFile(file) { let formdata = new FormData() formdata.append("file", file) this.$http({ url: "/upload/file", method: "post", data: formdata, headers: { "Content-Type": "multipart/form-data" } }).then(({ data }) => { console.log(data, 'upload/file') }) } } }
5、node 接收文件接口:
// 上傳文件處理 function uploadFn(ctx, destPath) { return new Promise((resolve, reject) => { const { name, path: _path } = ctx.request.files.file const filePath = destPath || path.join(uploadPath, name) fs.rename(_path, filePath, (err) => { if (err) { return reject(err) } resolve(name) }) }) } // 接收文件接口 router.post('/api/upload/file', async function uploadFile(ctx) { await uploadFn(ctx).then((name) => { ctx.body = { code: 0, url: path.join('http://localhost:3000/uploads', name), msg: '文件上傳成功' } }).catch(err => { console.log(err) ctx.body = { code: -1, msg: '文件上傳失敗' } }) })
以上全部過程就實現了一個簡單的文件上傳功能。
這種實現方式上傳功能對於小文件來說沒什么問題,但當需求中碰到大文件的時候,能解決上傳中遇到的各種問題,比如網速不好時、上傳速度慢、斷網情況、暫停上傳、重復上傳等問題。想要解決以上問題則需要優化前面的邏輯。
分片上傳
1、分片邏輯如下:
- 由於前端已有 Blob Api 能操作文件二進制,因此最核心邏輯就是前端運用 Blob Api 對大文件進行文件分片切割,將一個大文件切成一個個小文件,然后將這些分片文件一個個上傳。
- 現在的 http 請求基本是 1.1 版本,瀏覽器能夠同時進行多個請求,這將用到一個叫 js 異步並發控制的處理邏輯。
- 當前端將所有分片上傳完成之后,前端再通知后端進行分片合並成文件。
2、在進行文件分片處理之前,先介紹下 js 異步並發控制:
function sendRequest(arr, max = 6, callback) { let i = 0 // 數組下標 let fetchArr = [] // 正在執行的請求 let toFetch = () => { // 如果異步任務都已開始執行,剩最后一組,則結束並發控制 if (i === arr.length) { return Promise.resolve() } // 執行異步任務 let it = fetch(arr[i++]) // 添加異步事件的完成處理 it.then(() => { fetchArr.splice(fetchArr.indexOf(it), 1) }) fetchArr.push(it) let p = Promise.resolve() // 如果並發數達到最大數,則等其中一個異步任務完成再添加 if (fetchArr.length >= max) { p = Promise.race(fetchArr) } // 執行遞歸 return p.then(() => toFetch()) } toFetch().then(() => // 最后一組全部執行完再執行回調函數 Promise.all(fetchArr).then(() => { callback() }) ) }
js 異步並發控制的邏輯是:運用 Promise 功能,定義一個數組 fetchArr,每執行一個異步處理往 fetchArr 添加一個異步任務,當異步操作完成之后,則將當前異步任務從 fetchArr 刪除,則當異步 fetchArr 數量沒有達到最大數的時候,就一直往 fetchArr 添加,如果達到最大數量的時候,運用 Promise.race Api,每完成一個異步任務就再添加一個,當所有最后一個異步任務放進了 fetchArr 的時候,則執行 Promise.all,等全部完成之后則執行回調函數。
上面這邏輯剛好適合大文件分片上傳場景,將所有分片上傳完成之后,執行回調請求后端合並分片。
前端改造:
1、定義一些全局參數:
export default { name: 'App', data() { return { remainChunks: [], // 剩余切片 isStop: false, // 暫停上傳控制 precent: 0, // 上傳百分比 uploadedChunkSize: 0, // 已完成上傳的切片數 chunkSize: 2 * 1024 * 1024 // 切片大小 } } }
2、文件分割方法:
cutBlob(file) { const chunkArr = [] // 所有切片緩存數組 const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice // 切割Api不同瀏覽器分割處理 const spark = new SparkMD5.ArrayBuffer() // 文件hash處理 const chunkNums = Math.ceil(file.size / this.chunkSize) // 切片總數 return new Promise((resolve, reject) => { const reader = new FileReader() reader.readAsArrayBuffer(file) reader.addEventListener('loadend', () => { const content = reader.result // 生成文件hash spark.append(content) const hash = spark.end() let startIndex = '' let endIndex = '' let contentItem = '' // 文件切割 for(let i = 0; i < chunkNums; i++) { startIndex = i * this.chunkSize endIndex = startIndex + this.chunkSize endIndex > file.size && (endIndex = file.size) contentItem = blobSlice.call(file, startIndex, endIndex) chunkArr.push({ index: i, hash, total: chunkNums, name: file.name, size: file.size, chunk: contentItem }) } resolve({ chunkArr, fileInfo: { hash,
total: chunkNums, name: file.name, size: file.size } }) }) reader.addEventListener('error', function _error(err) { reject(err) }) }) }
以上方式的處理邏輯:定義一個切片緩存數組,當文件進行分片之后,將緩存所有的分片信息、根據最大分片大小計算分片數量、計算整個文件的 hash (spark-md5) 值,這將意味着,只要文件內容不變,這 hash 值也將不變,這涉及到后面的秒傳功能、然后進行文件分片。
3、改造上傳方法:
async uploadFile(e) { const file = e.target.files[0] this.precent = 0 this.uploadedChunkSize = 0 // 如果文件大於分片大小5倍,則進行分片上傳 if (file.size < this.chunkSize * 5) { this.sendFile(file) } else { const chunkInfo = await this.cutBlob(file) this.remainChunks = chunkInfo.chunkArr this.fileInfo = chunkInfo.fileInfo this.mergeRequest() } }
注意:以上代碼中設置當文件大小大於分片大小的5倍進行分片上傳。
4、定義分片上傳請求(sendRequest)和合並請求(chunkMerge):
mergeRequest() { const chunks = this.remainChunks const fileInfo = this.fileInfo this.sendRequest(chunks, 6, () => { // 請求合並 this.chunkMerge(fileInfo) }) }
5、分片請求將結合上面提到的 JS 異步並發控制:
sendRequest(arr, max = 6, callback) { let fetchArr = [] let toFetch = () => { if (this.isStop) { return Promise.reject('暫停上傳') } if (!arr.length) { return Promise.resolve() } const chunkItem = arr.shift() const it = this.sendChunk(chunkItem) it.then(() => { fetchArr.splice(fetchArr.indexOf(it), 1) }, err => { this.isStop = true arr.unshift(chunkItem) Promise.reject(err) }) fetchArr.push(it) let p = Promise.resolve() if (fetchArr.length >= max) { p = Promise.race(fetchArr) } return p.then(() => toFetch()) } toFetch().then(() => { Promise.all(fetchArr).then(() => { callback() }) }, err => { console.log(err) }) }
6、切片上傳請求:
sendChunk(item) { let formdata = new FormData() formdata.append("file", item.chunk) formdata.append("hash", item.hash) formdata.append("index", item.index) formdata.append("name", item.name) return this.$http({ url: "/upload/snippet", method: "post", data: formdata, headers: { "Content-Type": "multipart/form-data" }, onUploadProgress: (e) => { const { loaded, total } = e this.uploadedChunkSize += loaded < total ? 0 : +loaded this.uploadedChunkSize > item.size && (this.uploadedChunkSize = item.size) this.precent = (this.uploadedChunkSize / item.size).toFixed(2) * 1000 / 10 } }) }
7、切片合並請求:
chunkMerge(data) { this.$http({ url: "/upload/merge", method: "post", data, }).then(res => { console.log(res.data) }) }
前端處理文件分片邏輯代碼已完成
后端處理
后端部分就只新增兩個接口:分片上傳請求和分片合並請求:
1、分片上傳請求:
// 分片文件上傳 router.post('/api/upload/snippet', async function snippet(ctx) { let files = ctx.request.files const { index, hash } = ctx.request.body // 切片上傳目錄 const chunksPath = path.join(uploadPath, hash, '/') if(!fs.existsSync(chunksPath)) { fs.mkdirSync(chunksPath) } // 切片文件 const chunksFileName = chunksPath + hash + '-' + index // 秒傳,如果切片已上傳,則立即返回 if (fs.existsSync(chunksFileName)) { ctx.response.body = { code: 0, msg: '切片上傳完成' } return } await uploadFn(ctx, chunksFileName).then(name => { ctx.response.body = { code: 0, msg: '切片上傳完成' } }).catch(err => { console.log(err) ctx.response.body = { code: 0, msg: '切片上傳失敗' } }) })
2、分片合並請求:
/** * 文件異步合並 * @param {String} dirPath 分片文件夾 * @param {String} filePath 目標文件 * @param {String} hash 文件hash * @param {Number} total 分片文件總數 * @returns {Promise} */ function mergeFile(dirPath, filePath, hash, total) { return new Promise((resolve, reject) => { fs.readdir(dirPath, (err, files) => { if (err) { return reject(err) } if(files.length !== total || !files.length) { return reject('上傳失敗,切片數量不符') } const fileWriteStream = fs.createWriteStream(filePath) function merge(i) { return new Promise((res, rej) => { // 合並完成 if (i === files.length) { fs.rmdir(dirPath, (err) => { console.log(err, 'rmdir') }) return res() } let chunkpath = dirPath + hasn + '_' + i fs.readFile(chunkpath, (err, data) => { if (err) return rej(err) // 將切片追加到存儲文件 fs.appendFile(filePath, data, () => { // 刪除切片文件 fs.unlink(chunkpath, () => { // 遞歸合並 res(merge(i + 1)) }) }) }) }) } merge(0).then(() => { // 默認情況下不需要手動關閉,但是在某些文件的合並並不會自動關閉可寫流,比如壓縮文件,所以這里在合並完成之后,統一關閉下 resolve(fileWriteStream.close()) }) }) }) } /** * 1、判斷是否有切片hash文件夾 * 2、判斷文件夾內的文件數量是否等於total * 4、然后合並切片 * 5、刪除切片文件信息 */ router.post('/api/upload/merge', async function uploadFile(ctx) { const { total, hash, name } = ctx.request.body const dirPath = path.join(uploadPath, hash, '/') const filePath = path.join(uploadPath, name) // 合並文件 // 已存在文件,則表示已上傳成功 if (fs.existsSync(filePath)) { ctx.response.body = { code: 0, url: path.join('http://localhost:3000/uploads', name), msg: '文件上傳成功' } // 如果沒有切片hash文件夾則表明上傳失敗 } else if (!fs.existsSync(dirPath)) { ctx.response.body = { code: -1, msg: '文件上傳失敗' } } else { await mergeFile(dirPath, filePath, hash, total).then(() => { ctx.body = { code: 0, url: path.join('http://localhost:3000/uploads', name), msg: '文件上傳成功' } }).catch(err => { ctx.body = { code: -1, msg: err } }) } })
切片上傳成功與文件合並截圖:
其它
1、前端暫停,續傳功能:
<template> <div id="app"> <input type="file" @change="uploadFile">{{ precent }}% <button type="button" v-if="!isStop" @click="stopUpload">暫停</button> <button type="button" v-else @click="reupload">繼續上傳</button> </div> </template>
2、js 新增主動暫停和續傳方法,比較簡單,這里沒有做停止正在執行的請求:
stopUpload() { this.isStop = true }, reupload() { this.isStop = false this.mergeRequest() }
前端大文件的分片上傳就差不多了。還可以優化的一點,在進行文件 hash 求值的時候,大文件的 hash 計算會比較慢,這里可以加上 html5 的新特性,用 Web Worker 新開一個線程進行 hash 計算。
GitHub:https://github.com/554246839/file-upload