文件上传
功能在web开发中很常见,到目前为止,常见且使用较多的上传方式有 form表单
、FormData
、Base64
;比较新的上传方案还有 WebSocket
、WebRTC
。
我们先主要讲解 FormData
、Base64
的上传方式,当然,这2种需要使用 Ajax
来进行提交,同时,我们将采用原生写法。
为什么用原生写法呢?只要你掌握原生写法,那么不管你用的什么第三方框架,都能写的出来。
后台我们使用 express
进行搭建。
初始化项目 新建一个文件夹 upload-file
, 然后执行以下命令 或
在 upload-file
下创建以下文件和目录 pages - index .html upload main .js package .json
执行以下命令安装依赖包 npm i express multer body-parser -S
或
yarn add express multer body-parser
各个依赖包的作用:
express
:搭建服务器body-parser
:处理请求参数multer
:处理文件上传到此,我们的项目就初始化完成了
基本配置 HTML配置 打开 pages/index.html
,在 body 内写入以下内容
<button id ="btn" > 上传</button > <script > let btn = document .getElementById('btn' ); </script >
我们在 body 标签内写了一个按钮,后续的上传操作,我们都通过点击这个按钮来完成
入口文件配置 打开我们都 main.js
,写入以下内容
const express = require ('express' )const bodyParser = require ('body-parser' )const path = require ('path' )const fs = require ('fs' )const multer = require ('multer' )const app = express()app.use(express.static(path.resolve(__dirname, 'pages' ))) app.use(bodyParser.json()) app.use(bodyParser.urlencoded({ extended : false })) app.listen(8080 , () => { console .log('http://127.0.0.1:8080' ); })
通过上面的配置,我们的服务器就搭建起来来,我们在 upload-file
这个文件夹下,按下键盘都 shift
+ 鼠标右键
, 选择 在此处打开 powershell
。
然后,我们去浏览器打开 http://127.0.0.1:8080
,就能看到我们的HTML文件了。
上传方式 通过ajax提交 formdata
数据至后台,这种方案其实就跟我们直接用 form 表单提交文件是差不多的形式
1. 绑定点击事件
打开 index.html ,为我们的按钮绑定点击事件,事件函数内编写我们的 Ajax 代码
btn.addEventListener('click' , () => { let fileInput = document .createElement('input' ); fileInput.type = 'file' ; fileInput.click(); fileInput.addEventListener('change' , function ( ) { if (!this .files.length) return ; let file = this .files[0 ]; sendFormData(file); }) }) function sendFormData ( ) { let xhr = new XMLHttpRequest(); let formdata = new FormData(); formdata.append('file' , data); xhr.open('post' , '/upload1' ); xhr.send(formdata); xhr.onload = function ( ) { let res = JSON .parse(this .response); if (res.state === true ) { alert('上传成功' ); } else { alert('上传失败' ); } } }
通过Ajax方式进行文件上传的话,必须要用 FormData 进行上传
注意: IE10以下的浏览器,不支持 FormData
2. 添加后台接口
打开 main.js ,添加一个 POST
类型的接口 upload1
,用于处理用户的上传请求,逻辑代码如下
app.post('/upload1' , multer().single('file' ), (req, res ) => { if (!req.file) return res.send({ state : false , msg : '未上传文件' }); let { originalname, buffer } = req.file; let saveFilePath = `./upload/${originalname} ` ; if (fs.existsSync(saveFilePath)) { return res.send({ state : true , msg : '上传成功' }); }; fs.writeFile(saveFilePath, buffer, err => { if (err) { console .log(err); res.send({ state : false , msg : '上传失败' }); } res.send({ state : true , msg : '上传成功' }); }); })
可以看到我们使用了 multer 这个中间件,single的参数是我们前台配置的key,也就是 formdata.append(‘file’, data) 这里的 ‘file’。
它们两的名字必须要一样
重启node服务器,然后刷新浏览器,就可以通过点击上传按钮,选择任意一个文件进行上传了。
Base64 除了使用 formdata 方式提交文件外,我们还可以使用 base64
数据格式进行上传。
base64
方式 只适合小文件
传输,不建议上传大文件时使用,因为 base64 数据的体积比较大,而且编码也比较久。
大文件编码可能会导致内存不足,进而浏览器崩溃。
1. 绑定点击事件
打开 index.html ,为我们的按钮绑定点击事件,事件函数内编写我们的 Ajax 代码
btn.addEventListener('click' , () => { let fileInput = document .createElement('input' ); fileInput.type = 'file' ; fileInput.click(); fileInput.addEventListener('change' , function ( ) { if (!this .files.length) return ; let file = this .files[0 ]; sendBase64(file); }) }) function sendBase64 (file ) { let fileReader = new FileReader(); fileReader.readAsDataURL(file); fileReader.onload = e => { let xhr = new XMLHttpRequest(); xhr.open('post' , '/upload2' ); xhr.setRequestHeader('Content-type' , 'application/x-www-form-urlencoded' ); let data = dataStringify({ file: encodeURIComponent (e.target.result), filename: file.name }); xhr.send(data); xhr.onload = function ( ) { let res = JSON .parse(this .response); if (res.state === true ) { alert('上传成功' ); } else { alert('上传失败' ); } } } } function dataStringify (obj ) { let str = []; for (let c in obj) { str.push(encodeURIComponent (c) + "=" + encodeURIComponent (obj[c])); } return str.join("&" ); }
注意点:
必须设置请求头的 Content-Type
必须采用 encodeURIComponent 进行编码 必须对请求体进行序列化 2. 添加后台接口
打开 main.js ,添加一个 POST
类型的接口 upload2
,用于处理用户的上传请求,逻辑代码如下
app.post('/upload2' , (req, res ) => { let { file, filename } = req.body; let saveFilePath = `./upload/${filename} ` ; if (fs.existsSync(saveFilePath)) { return res.send({ state : true , msg : '切片接收完成' }); }; file = decodeURIComponent (file); file = file.replace(/^data:.+;base64,/i , '' ); file = Buffer.from(file, 'base64' ); fs.writeFile(saveFilePath, file, err => { if (err) { console .log(err); return res.send({ state : false , msg : '上传失败' }); } res.send({ state : true , msg : '上传成功' }); }); })
重启node服务器,然后刷新浏览器,就可以通过点击上传按钮,选择任意一个文件进行上传了。
并行上传 并行上传,其实就是将大文件按字节进行切割,切割成一个个小片段,然后在同一时间内,一次性发送多个请求给后台,每个请求中均携带着一个小片段,而这也叫做切片上传。
切片非常适用于 大文件的上传
,以这种方式上传大体积的文件时,效率会很高,同时它的上传速度也比较快。
在开始写我们代码之前,有必要了解下并发上传的特点,所谓 并行 ,也可以称为 并发 ,它们的特点是在同一时间点内,同时执行N个相同的操作,例如在1s内同时发送50个请求。
并发是不保证顺序的,就好比田径场赛跑,多个选手同时起跑,谁先到达终点,谁最后到达,是没有一个固定顺序的,而场上的选手,就相当于我们的每一个请求,同时发送给后台,但谁先到达,谁先被后台处理,是不一定的。
理解完上面的概念之后,我们就可以开始编写我们的代码了。
文件切片 计算机内的任何文件其实都是以二进制数据的形式存在的,对一个文件进行切割,其实也就是对这二进制数据进行分割,将一个很长的二进制,分割成N个较短的二进制,在前端中,我们可以借助 FileReader
来帮我们完成这个需求
假如你对 FileReader
很陌生,那么建议你自行去了解一下它的介绍和用法,这里不会对它将的太多。
前端步骤:
绑定点击事件 将得到的file对象进行切片,将一个个切片装进一个请求队列中 使用 Promise.all 并发请求 在 then 内再发送merge请求合并切片(能进入then代表后台已成功接收所有切片) 判断 merge 的后台响应参数是成功还是失败 1. 绑定点击事件
打开 index.html ,为我们的按钮绑定点击事件,事件函数内编写我们的 Ajax 代码
btn.addEventListener('click' , () => { let fileInput = document .createElement('input' ); fileInput.type = 'file' ; fileInput.click(); fileInput.addEventListener('change' , function ( ) { if (!this .files.length) return ; let file = this .files[0 ]; let requestList = createRequestList(file, 100 ); sendSlice(requestList, file); }) })
然后,编写我们的 createRequestList
函数,这个函数的作用就是并行上传我们的文件
function createRequestList (file, sliceLength ) { if (!sliceLength || sliceLength <=1 ) { sliceLength = 100 ; } let size = file.size / sliceLength; let requestList = []; let currentSize = 0 ; let [, chunkFileName, suffix] = file.name.match(/(.+)(\.\w+)$/ ); for (let i = 0 ; i < sliceLength; i++) { let requestFn = new Promise ((resolve, reject ) => { let conf = { chunk: file.slice(currentSize, currentSize + size), filename: `${chunkFileName} _${i} ${suffix} ` } let formdata = new FormData(); formdata.append('chunk' , conf.chunk); formdata.append('filename' , conf.filename); let xhr = new XMLHttpRequest(); xhr.open('post' , '/upload3' ); xhr.send(formdata); xhr.onload = () => { let res = JSON .parse(xhr.response); if (res.state === true ) { resolve(res); } else { reject(res); } }; }) requestList.push(requestFn); currentSize += size; } return requestList; }
再然后,编写我们的 sendSlice
函数,这个函数的作用就是并行上传我们的文件
function sendSlice (list, file ) { Promise .all(list).then(() => { merge(file.name); }).catch(err => { console .error(err); alert('上传失败' ); }); }
最后,编写我们的 merge
函数,这个函数的作用是告诉后台我们的切片已经全部上传完毕,请求后台将那些切片进行合并
function merge ( ) { let xhr = new XMLHttpRequest(); xhr.open('get' , `/merge?filename=${filename} ` ); xhr.send(); xhr.onload = function ( ) { let res = JSON .parse(this .response); if (res.state === true ) { alert('上传成功' ); } else { alert('上传失败' ); } } }
接收合并 前端将一个个的切片进行上传后,我们的后台也要去接收它
后端步骤:
接收前端发送过来的切片,并保存至某个文件夹内 当接收到merge请求,则将这些切片进行合并成一个完整的文件 删除已保存的切片以及它们所在的文件夹 打开入口文件,新增 upload3
接口
app.post('/upload3' , multer().single('chunk' ), (req, res ) => { let { filename } = req.body; let chunkName; let originFileName; try { let matchFileName = filename.match(/^(.+)_\d+(\.\w+)/ ); chunkName = matchFileName[1 ]; originFileName = matchFileName[1 ] + matchFileName[2 ]; } catch (error) { return res.send({ state : false , msg : '文件名错误' }); } if (fs.existsSync(`./upload/${originFileName} ` )) { return res.send({ state : true , msg : '切片接收完成' }); } let chunkPath = `./upload/${chunkName} ` ; if (!fs.existsSync(chunkPath)) { fs.mkdirSync(chunkPath); }; let chunkFullPath = path.resolve(__dirname, chunkPath, filename); if (fs.existsSync(chunkFullPath)) { return res.send({ state : true , msg : '切片接收完成' }); }; fs.writeFile(chunkFullPath, req.file.buffer, (err ) => { if (err) throw err; res.send({ state : true , msg : '切片接收完成' }); }); })
仍然是在main.js内,再新增一个 merge
接口,用于合并切片
app.get('/merge' , (req, res ) => { let { filename } = req.query; let chunkPath = `./upload/${filename.match(/^(.+)(\.\w+)/ )[1 ]} ` ; let saveFilePath = `./upload/${filename} ` ; if (fs.existsSync(saveFilePath)) return res.send({ state : true , msg : '上传成功' }); if (!fs.existsSync(chunkPath)) return res.send({ state : false , msg : '不存在该文件' }); let dirs = fs.readdirSync(chunkPath); dirs.sort((a, b ) => { let fileIndexRegExp = /_(\d+)/ ; return fileIndexRegExp.exec(a)[1 ] - fileIndexRegExp.exec(b)[1 ] }); try { for (let i = 0 ; i < dirs.length; i++) { let file = dirs[i]; let chunkFullPath = path.resolve(__dirname, chunkPath, file); fs.appendFileSync(saveFilePath, fs.readFileSync(chunkFullPath)); fs.unlinkSync(chunkFullPath); } fs.rmdirSync(chunkPath); res.send({ state : true , msg : '上传成功' }); } catch (error) { console .log(error); return res.send({ state : false , msg : '合并失败' }); } })
到此,我们的大文件并发上传就完成了
完整代码 前端 <!DOCTYPE html > <html > <head > <meta charset ="UTF-8" > <meta http-equiv ="X-UA-Compatible" content ="IE=edge" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > Document</title > </head > <body > <button id ="btn" > 上传</button > <script > let btn = document .getElementById('btn' ); btn.addEventListener('click' , () => { let fileInput = document .createElement('input' ); fileInput.type = 'file' ; fileInput.click(); fileInput.addEventListener('change' , function ( ) { if (!this .files.length) return ; let file = this .files[0 ]; /* let requestList = createRequestList(file, 100); sendSlice(requestList, file); */ }) }) function sendFormData (data ) { let xhr = new XMLHttpRequest() let formdata = new FormData() formdata.append('file' , data) xhr.open('post' , '/upload1' ) console .time(); xhr.send(formdata) xhr.onload = function ( ) { console .timeEnd(); let res = JSON .parse(this .response); if (res.state === true ) { alert('上传成功' ) } else { alert('上传失败' ) } } } function sendBase64 (file ) { let fileReader = new FileReader() fileReader.readAsDataURL(file) fileReader.onload = e => { let xhr = new XMLHttpRequest() xhr.open('post' , '/upload2' ) xhr.setRequestHeader('Content-type' , 'application/x-www-form-urlencoded' ) let data = dataStringify({ file: encodeURIComponent (e.target.result), filename: file.name }) xhr.send(data) xhr.onload = function ( ) { console .log(this ); } } } function dataStringify (obj ) { let str = []; for (let c in obj) { str.push(encodeURIComponent (c) + "=" + encodeURIComponent (obj[c])); } return str.join("&" ); } function sendSlice (list, file ) { Promise .all(list).then(() => { merge(file.name); }).catch(err => { console .error(err); alert('上传失败' ); }); } function createRequestList (file, sliceLength ) { if (!sliceLength || sliceLength <= 1 ) { sliceLength = 100; } let size = file.size / sliceLength; let requestList = []; let currentSize = 0 ; let [, chunkFileName, suffix] = file.name.match(/(.+)(\.\w+)$/ ); for (let i = 0 ; i < sliceLength; i++) { requestList.push(new Promise ((resolve, reject ) => { let conf = { chunk: file.slice(currentSize, currentSize + size), filename: `${chunkFileName} _${i} ${suffix} ` } let form = new FormData(); form.append('chunk' , conf.chunk); form.append('filename' , conf.filename); let xhr = new XMLHttpRequest() xhr.open('post' , '/upload3' ) xhr.send(form); xhr.onload = () => { let res = JSON .parse(xhr.response); if (res.state === true ) { resolve(res); } else { reject(res); } }; })); currentSize += size; } return requestList; } function merge (filename ) { let xhr = new XMLHttpRequest(); xhr.open('get' , `/merge?filename=${filename} ` ); xhr.send(); xhr.onload = function ( ) { let res = JSON .parse(this .response); if (res.state === true ) { alert('上传成功' ) } else { alert('上传失败' ) } } } </script > </body > </html >
后台 const express = require ('express' )const bodyParser = require ('body-parser' )const path = require ('path' )const fs = require ('fs' )const multer = require ('multer' )const app = express()app.use(express.static(path.resolve(__dirname, 'pages' ))) app.use(bodyParser.json()) app.use(bodyParser.urlencoded({ extended : false , limit : '5000mb' , parameterLimit : 500000 })) app.post('/upload1' , multer().single('file' ), (req, res ) => { if (!req.file) return res.send({ state : false , msg : '未上传文件' }); let { originalname, buffer } = req.file; let saveFilePath = `./upload/${originalname} ` ; if (fs.existsSync(saveFilePath)) { return res.send({ state : true , msg : '上传成功' }); }; fs.writeFile(saveFilePath, buffer, err => { if (err) { console .log(err); res.send({ state : false , msg : '上传失败' }); } res.send({ state : true , msg : '上传成功' }); }); }) app.post('/upload2' , (req, res ) => { let { file, filename } = req.body; let saveFilePath = `./upload/${filename} ` ; if (fs.existsSync(saveFilePath)) { return res.send({ state : true , msg : '切片接收完成' }); }; file = decodeURIComponent (file); file = file.replace(/^data:.+;base64,/i , '' ); file = Buffer.from(file, 'base64' ); fs.writeFile(saveFilePath, file, err => { if (err) { console .log(err); res.send({ state : false , msg : '上传失败' }); } res.send({ state : true , msg : '上传成功' }); }); }) app.post('/upload3' , multer().single('chunk' ), (req, res ) => { let { filename } = req.body; let chunkName; let originFileName; try { let matchFileName = filename.match(/^(.+)_\d+(\.\w+)/ ); chunkName = matchFileName[1 ]; originFileName = matchFileName[1 ] + matchFileName[2 ]; } catch (error) { return res.send({ state : false , msg : '文件名错误' }); } if (fs.existsSync(`./upload/${originFileName} ` )) { return res.send({ state : true , msg : '切片接收完成' }); } let chunkPath = `./upload/${chunkName} ` ; if (!fs.existsSync(chunkPath)) { fs.mkdirSync(chunkPath); }; let chunkFullPath = path.resolve(__dirname, chunkPath, filename); if (fs.existsSync(chunkFullPath)) { return res.send({ state : true , msg : '切片接收完成' }); }; fs.writeFile(chunkFullPath, req.file.buffer, (err ) => { if (err) throw err; res.send({ state : true , msg : '切片接收完成' }); }); }) app.get('/merge' , (req, res ) => { let { filename } = req.query; let chunkPath = `./upload/${filename.match(/^(.+)(\.\w+)/ )[1 ]} ` ; let saveFilePath = `./upload/${filename} ` ; if (fs.existsSync(saveFilePath)) return res.send({ state : true , msg : '上传成功' }); if (!fs.existsSync(chunkPath)) return res.send({ state : false , msg : '不存在该文件' }); let dirs = fs.readdirSync(chunkPath); dirs.sort((a, b ) => { let fileIndexRegExp = /_(\d+)/ ; return fileIndexRegExp.exec(a)[1 ] - fileIndexRegExp.exec(b)[1 ] }); try { for (let i = 0 ; i < dirs.length; i++) { let file = dirs[i]; let chunkFullPath = path.resolve(__dirname, chunkPath, file); fs.appendFileSync(saveFilePath, fs.readFileSync(chunkFullPath)); fs.unlinkSync(chunkFullPath); } fs.rmdirSync(chunkPath); res.send({ state : true , msg : '上传成功' }); } catch (error) { console .log(error); return res.send({ state : false , msg : '合并失败' }); } }) app.listen(8080 , () => { console .log('http://127.0.0.1:8080' ); })