用現代化的方式開發一個圖片上傳工具
寫於 2017.04.18
對於圖片上傳,大家一定不陌生。最近工作中遇到了關於圖片上傳的內容,藉此機會認真研究了一番,遂一發不可收拾,最後琢磨了一個東西出來。在開發的過程中有不少的體會,於是打算寫一篇文章分享一下心得體會。 本文將會以這個名為Dolu
的專案為例子,一步步介紹我是如何進行環境搭建、程式碼設計以及實際開發的。內容較多,還請耐心讀完。
一、環境搭建
本專案使用目前最新的webpack 2
和es7
進行開發,所以環境的搭建必不可少。但是由於這個專案比較簡單,所以環境的搭建也是非常簡單的,只有一個webpack.config.js
var path = require('path')
var webpack = require('webpack')
module.exports = {
entry: './src/main.js', // 開發模式用
// entry: './src/dolu.js', // 生產模式用
output: {
path: path.resolve(__dirname, './dist'),
publicPath: '/dist/',
filename: 'build.js', // 開發模式用
// filename: 'index.js', // 生產模式用
libraryTarget: 'umd'
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules|dist/,
use: [
'babel-loader',
'eslint-loader'
]
}
]
},
devServer: {
historyApiFallback: true,
noInfo: true,
host: '0.0.0.0'
},
performance: {
hints: false
},
devtool: '#eval-source-map'
}
if (process.env.NODE_ENV === 'production') {
module.exports.devtool = '#source-map'
module.exports.plugins = (module.exports.plugins || []).concat([
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: '"production"'
}
}),
new webpack.optimize.UglifyJsPlugin({
sourceMap: true,
compress: {
warnings: false
}
}),
new webpack.LoaderOptionsPlugin({
minimize: true
})
])
}
複製程式碼
考慮到“生產模式”使用的次數不多,所以並沒有區分dev
和prod
模式,而是手動註釋對應的內容進行切換。
定義好入口檔案和輸出路徑後,我使用了babel-loader
和eslint-loader
。這兩個loader的作用就不多作介紹了,值得注意的是養成使用eslint
的習慣是極好的,能夠有效減少程式碼的錯誤,並且能夠改掉很多壞習慣。同時在編輯器裡(我用VSCODE)中也能夠實時進行程式碼檢查,非常方便。
為了使用最新的es7
,我們也需要在根目錄下配置一份.babelrc
檔案:
{
"presets": [
["latest", {
"es2015": { "modules": false }
}]
],
"plugins": [
["transform-runtime"]
]
}
複製程式碼
配置好了webpack.config.js
和.babelrc
以後,我們開啟package.json
,來看看需要安裝的依賴都有哪些:
"devDependencies": {
"babel-core": "^6.24.0",
"babel-loader": "^6.4.1",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-polyfill": "^6.23.0",
"babel-preset-latest": "^6.24.0",
"cors": "^2.8.3",
"cross-env": "^3.2.4",
"eslint": "^3.19.0",
"eslint-config-standard": "^10.2.1",
"eslint-loader": "^1.7.1",
"eslint-plugin-import": "^2.2.0",
"eslint-plugin-node": "^4.2.2",
"eslint-plugin-promise": "^3.5.0",
"eslint-plugin-standard": "^3.0.1",
"multer": "^1.3.0",
"webpack": "^2.3.1",
"webpack-dev-server": "^2.4.2"
}
複製程式碼
當中的cors
模組和multer
模組為我們之後搭建node伺服器需要用的,其他都是執行所需。
然後在"scripts"裡面寫上我們要用到的幾條命令:
"scripts": {
"dev": "cross-env NODE_ENV=development webpack-dev-server --hot",
"build": "cross-env NODE_ENV=production webpack --progress --hide-modules",
"server": "node ./server/index.js"
},
複製程式碼
分別對應開發模式
,生產模式
,啟動本地後臺伺服器
。
然後我們在根目錄下新建一個src
目錄,一個index.html
,一個/src/main.js
。這時候整個專案的目錄結構如下:
├── index.html
├── package.json
├── src
│ └── main.js
├── webpack.config.js
└── .babelrc
複製程式碼
至此,我們的開發環境已經搭建完畢。
二、功能設計
基本的流程及功能如上圖所示,其中的每一步我們都將以模組的方式進行開發。
當然,我們不能滿足於這麼一點點的功能,我們需要考慮更多的情況更多的可能,擴充套件一下,也許我們可以這麼做:
比如我們在獲取圖片之後先不進行上傳,也許我們還要對轉出來的base64
進行處理或使用,也許我們能夠直接上傳一堆由第三方提供的base64
甚至formdata
。另外我們還需要對上傳的方法進行自定義,又或者可以選擇多張圖片什麼的……除此之外,可能還有許許多多的場景,為了開發一個通用的元件,我們需要思考的地方實在有很多很多。
當然,這一次我們的任務比較簡單,上面這麼多功能已經夠我們玩的了,下面我們進入實際的開發。
三、開始coding!
在/src
目錄下新建一個dolu.js
檔案,這將會是我們整個專案的核心。
首先定義一個類:
class Dolulu {
constructor (config = {}) {}
}
複製程式碼
然後我們按照上一節腦圖的思路,先完成“圖片選取”相關的功能。
在這個類裡面我們定義一個名為_pickFile()
的私有方法,這個方法我們不希望被外部呼叫,只是作為Dolu
內建的方法。
_pickFile () {
const picker = document.querySelector(this.config.picker)
picker.addEventListener('change', () => {
if (!picker.files.length) {
return
}
const files = [...picker.files]
if (files.length > this.config.quantity) {
throw new Error('Out of file quantity limit!')
}
/*
* 這時候我們已經拿到了檔案陣列files,可以馬上進行轉碼
* _transformer()函式是另一個私有方法,用於格式轉碼
*/
this._transformer(files)
/*
* 加入這一行以實現重複選中同一張圖片
*/
picker.value = null
})
}
複製程式碼
然後寫一個初始化的方法,讓Dolu
例項能夠自動開啟檔案選取功能:
_init () {
if (this.config.picker) {
return this._pickFile()
}
}
複製程式碼
只要在constructor
裡面呼叫這個方法就可以了。
選擇完圖片,我們就要對它進行轉碼了。為了更好地組織我們的程式碼,我們把這個“圖片轉成base64”的函式封裝成一個模組。在/src
目錄下新建fileToBase64.js
:
const fileToBase64 = (file) => {
const reader = new FileReader()
reader.readAsDataURL(file)
return new Promise((resolve) => {
reader.addEventListener('load', () => {
const result = reader.result
resolve(result)
})
})
}
export default fileToBase64
複製程式碼
程式碼內容只有15行,其輸入為一個圖片檔案,輸出為一串base64編碼。返回一個Promise方便接下來我們使用async/await
語法。
同樣的道理,我們新建一個base64ToBlob.js
檔案,以實現輸入為base64,輸出為formdata的功能:
const base64ToBlob = (base64) => {
const byteString = atob(base64.split(',')[1])
const mimeString = base64.split(',')[0].split(':')[1].split(';')[0]
const ab = new ArrayBuffer(byteString.length)
const ia = new Uint8Array(ab)
for (let i = 0, len = byteString.length; i < len; i += 1) {
ia[i] = byteString.charCodeAt(i)
}
let Builder = window.WebKitBlobBuilder || window.MozBlobBuilder
let blobUrl
if (Builder) {
const builder = new Builder()
builder.append(ab)
blobUrl = builder.getBlob(mimeString)
} else {
blobUrl = new window.Blob([ab], { type: mimeString })
}
const fd = new FormData()
fd.append('file', blobUrl)
return fd
}
export default base64ToBlob
複製程式碼
接下來我們利用這兩個模組,構建我們的_transformer()
方法:
_transformer (files, manually = false) {
files.forEach(async (file, index) => {
if (isObject(file)) {
if (!/\/(?:jpeg|png|gif)/i.test(file.type)) {
return
}
const dataUrl = await fileToBase64(file)
const formData = await base64ToBlob(dataUrl)
if (this.config.autoSend || manually) {
this._uploader(formData, index)
}
}
})
複製程式碼
可以看到,這個方法會遍歷整個files陣列,通過篩選保證其檔案型別為圖片,然後連續轉碼生成formdata格式資料,作為引數傳入_uploader()
方法中。另外為了方便擴充套件和使用,同時傳入了圖片的下標。圖片的下標能夠方便在上傳函式中讓使用者知道“現在是第幾張圖片被處理”。
_upload()
函式將會直接呼叫Dolu
例項中所定義的上傳方法,這個稍後再述。
到這裡,我們已經完成了上一節第一張圖片的幾個“基本功能”了,和外面一撈一大把的教程相差無幾。別急,我們馬上進入對擴充套件功能的開發。
四、實現向外輸出完整的base64字串陣列
我們重新把目光投向上一節的_transformer()
函式。這個函式接受一個陣列,在內部使用.forEach()
方法遍歷每一個檔案,對它進行轉碼處理。為了向外輸出完整的轉碼後的陣列,關鍵的步驟在於如何確定轉碼已經完成了。從最簡單的想法開始,在forEach
迴圈體的外部直接把陣列丟擲去行不行?比如這樣:
_transformer (files, manually = false) {
files.forEach(async (file, index) => {
if (isObject(file)) {
if (!/\/(?:jpeg|png|gif)/i.test(file.type)) {
return
}
const dataUrl = await fileToBase64(file)
const formData = await base64ToBlob(dataUrl)
this.dataUrlArr.push(dataUrl)
if (this.config.autoSend || manually) {
this._uploader(formData, index)
}
}
})
this.config.getDataUrls(this.dataUrlArr)
return this
}
複製程式碼
看起來沒有問題,但是在實際的測試中,傳入this.config.getDataUrls
中的dataUrlArr
首先會是一個空陣列,過一會兒才會有資料。為了驗證這個結論,我們在/src
名錄下新建一個檔案main.js
,寫入如下內容:
import Dolu from './dolu'
const dolu = new Dolu({
picker: '#picker',
getDataUrls (arr) {
console.info(arr)
arr.forEach((dataUrl) => {
console.log(dataUrl)
})
}
})
複製程式碼
執行一下,發現輸出結果如下:
只有一個空陣列,而且forEach()
迴圈並沒有打印出任何東西。這個例子不直觀,我們現在把開發者工具關掉,然後重新開啟,看看會發生什麼:
僅僅是重新開啟開發者工具,就發現剛才的空陣列變成了一個有內容的陣列,特別奇怪。
其實原因也很簡單,因為_transformer()
內部的forEach()
迴圈,並不能保證圖片已經轉碼完畢,這涉及到瀏覽器任務佇列的知識(此處理解可能有誤,歡迎指出),在這裡就不展開討論了。
那麼我們只能等待圖片轉碼完畢,才呼叫this.config.getDataUrls()
方法。要實現這個目的,我們有許多種方法,最簡單粗暴的就是利用setInterval()
進行輪詢,當dataUrlArr.length === files.length
,則立即呼叫,但是這種做法一點兒也不優雅。我們能不能讓函式傳送一個通知,當.push()
方法執行併成功的時候就判斷dataUrlArr.length =?= files.length
,若條件符合則進行相應的處理。
這時候我們可以考慮使用es6新增語法Proxy
來解決。關於Proxy
的使用可以查閱我的另外一篇文章 《使用ES6的新特性Proxy來實現一個數據繫結例項》,然後我們一起來步入正題吧!
五、使用Proxy
實現資料繫結
在/src
目錄下的utils.js
裡,我們加入一個新的工具方法:
function proxier (props, callback) {
const waitProxy = new Proxy(props, {
set (target, property, value) {
target[property] = value
callback(target, property, value)
return true
}
})
return waitProxy
}
複製程式碼
回到dolu.js
檔案,改寫一下_transformer()
方法:
_transformer (files, manually = false) {
const dataUrlArrProxy = proxier(this.dataUrlArr, (target, property, value) => {
if (property === 'length') {
if (target.length === files.length) {
this.config.getDataUrls(this.dataUrlArr)
}
}
})
files.forEach(async (file, index) => {
if (isObject(file)) {
if (!/\/(?:jpeg|png|gif)/i.test(file.type)) {
return
}
const dataUrl = await fileToBase64(file)
const formData = await base64ToBlob(dataUrl)
dataUrlArrProxy.push(dataUrl)
if (this.config.autoSend || manually) {
this._uploader(formData, index)
}
}
})
return this
}
複製程式碼
這樣,我們每一次轉碼過後,都會呼叫代理陣列dataUrlArrProxy
中的.push()
方法,這時候代理陣列就會自動判斷target.length =?= files.length
然後呼叫相應的方法。
嘗試執行一下,發現結果符合預期。同樣的方式,我們可以為formDataArr
也設定一個代理陣列,以實現向外丟擲formdata
陣列的目的。
六、伺服器搭建
把前端這邊的圖片選取、圖片轉碼都已經做完了,那麼我們是時候搭建一個後臺伺服器,去測試以formdata
格式上傳圖片是否有效了。
進入根目錄下的/server
資料夾,我們新建一個/imgs
目錄以及一個index.js
檔案,內容如下:
const express = require('express')
const multer = require('multer')
const cors = require('cors')
const app = express()
app.use(express.static('./public'))
app.use(cors())
app.listen(process.env.PORT || 8888)
console.log('Node.js Ajax Upload File running at: http://0.0.0.0:8888')
app.post('/upload', (req, res) => {
const store = multer.diskStorage({
destination: './server/imgs'
})
const upload = multer({
storage: store
}).any()
upload(req, res, function (err) {
if (err) {
console.log(err)
return res.end('Error')
} else {
console.log(req.body)
req.files.forEach(function (item) {
console.log(item)
})
res.end('File uploaded')
}
})
})
複製程式碼
該伺服器將會運行於本地8888
埠,通過post
方法傳送到localhost:8888/upload
,然後圖片會儲存到server/imgs
目錄下。
回到dolu.js
,我們寫一個_uploader()
方法,該方法會呼叫config
裡面的自定義設定,呼叫設定中具體的上傳方法:
_uploader (formData, index) {
this.config.uploader(formData, index)
}
複製程式碼
在main.js
中,我們使用axios
作為上傳的工具:
const dolu = new Dolu({
picker: '#picker',
autoSend: true,
uploader (data, index) {
axios({
method: 'post',
url: 'http://0.0.0.0:8888/upload',
data: data,
onUploadProgress: (e) => {
const percent = Math.round((e.loaded * 100) / e.total)
console.log(percent, index)
}
}).then((res) => {
console.log(res)
}).catch((err) => {
console.log(err)
})
}
})
複製程式碼
激動人心的時刻來了,我們來測試一下吧!
七、實際執行測試
開啟開發者工具當中的Network
,隨便選幾張圖片進行上傳,看看效果如何:
點選去看看傳送的是什麼東西:
如上圖所示,是一個formdata資料。開啟./server/imgs
目錄,我們應該就能看到三個檔案了:
上傳成功!而且符合我們以“formdata上傳的二進位制格式”的需求。
八、後續工作
至此已經基本完成了我們整個圖片上傳元件,還有幾個細節需要注意,比如所傳送圖片的命名、對圖片通過canvas進行壓縮等等,這些坑以後有空再填。比較完善的程式碼可以直接檢視我的倉庫。
感謝您的閱讀,歡迎對文章內容提出批評指導建議!
參考資料: