公司千萬級用戶應用繼續上新功能了,這次新功能主要迭代是小程式,讓小程式支持發佈商品功能,這是封裝weui上傳組件的原因,又是因為工作才去做的事情,我真是個不主動學習的人,先自責一下;這次記錄主要是考慮到其中的實現有很多小程式開發的細節,實現方案比較low,但是還是記一下這個上傳圖片+視頻的組件,其它 ...
公司千萬級用戶應用繼續上新功能了,這次新功能主要迭代是小程式,讓小程式支持發佈商品功能,這是封裝weui上傳組件的原因,又是因為工作才去做的事情,我真是個不主動學習的人,先自責一下;這次記錄主要是考慮到其中的實現有很多小程式開發的細節,實現方案比較low,但是還是記一下這個上傳圖片+視頻的組件,其它文件上傳也是同理。
先來看看weui的上傳組件
從UI的角度來看,明顯是用flex佈局,view元素來實現整體佈局。
我們的需求是:
- 長按後可拖拽換文件位置
- 如果是圖片,需要先壓縮尺寸,再壓縮質量,直到壓縮尺寸小於500kb,視頻尺寸小於5mb
- 圖片視頻上傳到雲伺服器,成功後渲染
- 圖片,視頻數量限制 n + m
總結組件的主要功能需要做到:
- 文件大小限制
- 壓縮圖片
- 上傳圖片視頻
- 可拖拽
具體實現
1. wxml文件,直接拷貝weui,然後寫一些自定義樣式
/*! * WeUI v2.3.0 (https://github.com/weui/weui-wxss) * Copyright 2020 Tencent, Inc. * Licensed under the MIT license */ [data-weui-theme=light], page { --weui-BG-0: #ededed; --weui-BG-1: #f7f7f7; --weui-BG-2: #fff; --weui-BG-3: #f7f7f7; --weui-BG-4: #4c4c4c; --weui-BG-5: #fff; --weui-FG-0: rgba(0, 0, 0, .9); --weui-FG-HALF: rgba(0, 0, 0, .9); --weui-FG-1: rgba(0, 0, 0, .5); --weui-FG-2: rgba(0, 0, 0, .3); --weui-FG-3: rgba(0, 0, 0, .1); --weui-RED: #fa5151; --weui-ORANGE: #fa9d3b; --weui-YELLOW: #ffc300; --weui-GREEN: #91d300; --weui-LIGHTGREEN: #95ec69; --weui-BRAND: #07c160; --weui-BLUE: #10aeff; --weui-INDIGO: #1485ee; --weui-PURPLE: #6467f0; --weui-WHITE: #fff; --weui-LINK: #576b95; --weui-TEXTGREEN: #06ae56; --weui-FG: #000; --weui-BG: #fff; --weui-TAG-TEXT-ORANGE: #fa9d3b; --weui-TAG-BACKGROUND-ORANGE: rgba(250, 157, 59, .1); --weui-TAG-TEXT-GREEN: #06ae56; --weui-TAG-BACKGROUND-GREEN: rgba(6, 174, 86, .1); --weui-TAG-TEXT-BLUE: #10aeff; --weui-TAG-BACKGROUND-BLUE: rgba(16, 174, 255, .1); --weui-TAG-TEXT-BLACK: rgba(0, 0, 0, .5); --weui-TAG-BACKGROUND-BLACK: rgba(0, 0, 0, .05) } [data-weui-theme=dark] { --weui-BG-0: #191919; --weui-BG-1: #1f1f1f; --weui-BG-2: #232323; --weui-BG-3: #2f2f2f; --weui-BG-4: #606060; --weui-BG-5: #2c2c2c; --weui-FG-0: hsla(0, 0%, 100%, .8); --weui-FG-HALF: hsla(0, 0%, 100%, .6); --weui-FG-1: hsla(0, 0%, 100%, .5); --weui-FG-2: hsla(0, 0%, 100%, .3); --weui-FG-3: hsla(0, 0%, 100%, .05); --weui-RED: #fa5151; --weui-ORANGE: #c87d2f; --weui-YELLOW: #cc9c00; --weui-GREEN: #74a800; --weui-LIGHTGREEN: #28b561; --weui-BRAND: #07c160; --weui-BLUE: #10aeff; --weui-INDIGO: #1196ff; --weui-PURPLE: #8183ff; --weui-WHITE: hsla(0, 0%, 100%, .8); --weui-LINK: #7d90a9; --weui-TEXTGREEN: #259c5c; --weui-FG: #fff; --weui-BG: #000; --weui-TAG-TEXT-ORANGE: rgba(250, 157, 59, .6); --weui-TAG-BACKGROUND-ORANGE: rgba(250, 157, 59, .1); --weui-TAG-TEXT-GREEN: rgba(6, 174, 86, .6); --weui-TAG-BACKGROUND-GREEN: rgba(6, 174, 86, .1); --weui-TAG-TEXT-BLUE: rgba(16, 174, 255, .6); --weui-TAG-BACKGROUND-BLUE: rgba(16, 174, 255, .1); --weui-TAG-TEXT-BLACK: hsla(0, 0%, 100%, .5); --weui-TAG-BACKGROUND-BLACK: hsla(0, 0%, 100%, .05) } [data-weui-theme=light], page { --weui-BG-COLOR-ACTIVE: #ececec } [data-weui-theme=dark] { --weui-BG-COLOR-ACTIVE: #373737 } [data-weui-theme=light], page { --weui-BTN-DISABLED-FONT-COLOR: rgba(0, 0, 0, .2) } [data-weui-theme=dark] { --weui-BTN-DISABLED-FONT-COLOR: hsla(0, 0%, 100%, .2) } [data-weui-theme=light], page { --weui-BTN-DEFAULT-BG: #f2f2f2 } [data-weui-theme=dark] { --weui-BTN-DEFAULT-BG: hsla(0, 0%, 100%, .08) } [data-weui-theme=light], page { --weui-BTN-DEFAULT-COLOR: #06ae56 } [data-weui-theme=dark] { --weui-BTN-DEFAULT-COLOR: hsla(0, 0%, 100%, .8) } [data-weui-theme=light], page { --weui-BTN-DEFAULT-ACTIVE-BG: #e6e6e6 } [data-weui-theme=dark] { --weui-BTN-DEFAULT-ACTIVE-BG: hsla(0, 0%, 100%, .126) } [data-weui-theme=light], page { --weui-DIALOG-LINE-COLOR: rgba(0, 0, 0, .1) } [data-weui-theme=dark] { --weui-DIALOG-LINE-COLOR: hsla(0, 0%, 100%, .1) } .weui-uploader { -webkit-box-flex: 1; -webkit-flex: 1; flex: 1 } .weui-uploader__hd { display: -webkit-box; display: -webkit-flex; display: flex; padding-bottom: 16px; -webkit-box-align: center; -webkit-align-items: center; align-items: center } .weui-uploader__title { -webkit-box-flex: 1; -webkit-flex: 1; flex: 1 } .weui-uploader__info { color: var(--weui-FG-2) } .weui-uploader__bd { margin-bottom: -8px; margin-right: -8px; overflow: hidden } .weui-uploader__files { list-style: none } .weui-uploader__file { float: left; background: no-repeat 50%; background-size: cover; position: relative; } .weui-uploader__file_status { position: relative } .weui-uploader__file_status:before { content: " "; position: absolute; top: 0; right: 0; bottom: 0; left: 0; background-color: rgba(0, 0, 0, .5) } .weui-uploader__file_status .weui-uploader__file-content { display: block } .weui-uploader__file-content { display: none; position: absolute; top: 50%; left: 50%; -webkit-transform: translate(-50%, -50%); transform: translate(-50%, -50%); color: var(--weui-WHITE) } .weui-uploader__file-content .weui-icon-warn { display: inline-block } .weui-uploader__input-box { float: left; position: relative; margin-right: 8px; margin-bottom: 8px; box-sizing: border-box; background-color: #ededed } [data-weui-theme=dark] .weui-uploader__input-box { background-color: #2e2e2e } .weui-uploader__input-box:after, .weui-uploader__input-box:before { content: " "; position: absolute; top: 50%; left: 50%; -webkit-transform: translate(-50%, -50%); transform: translate(-50%, -50%); background-color: #a3a3a3 } [data-weui-theme=dark] .weui-uploader__input-box:after, [data-weui-theme=dark] .weui-uploader__input-box:before { background-color: #6d6d6d } .weui-uploader__input-box:before { width: 2px; height: 32px } .weui-uploader__input-box:after { width: 32px; height: 2px } .weui-uploader__input-box:active:after, .weui-uploader__input-box:active:before { opacity: .7 } .weui-uploader__input { position: absolute; z-index: 1; top: 0; left: 0; width: 100%; height: 100%; opacity: 0; -webkit-tap-highlight-color: rgba(0, 0, 0, 0) } .weui-uploader__img { display: block; width: 100%; height: 100%; } .uploader-img-delete { position: absolute; right: 0px; top: 0px; width: 18px; height: 18px; } .play-icon { position: absolute; top: 0px; left: 0px; width: 100%; height: 100%; }View Code
2.WXS文件,主要計算坐標
function getRowsNum(files,maxNumPerRow) { if (!files) return 1; return parseInt(files.length/maxNumPerRow) + 1; } function getAddedImgPos(index, maxNumPerRow, imageWidth) { var x = 0, y = 0; var quotient = parseInt((index)/maxNumPerRow); var remainder = (index + 1)%maxNumPerRow; if (remainder === 0) { x = (maxNumPerRow - 1) * imageWidth; } else { x = (remainder - 1) * imageWidth; } y = quotient * imageWidth; var pos = { x: x, y: y, }; return pos; }View Code
3.js文件
export default class UploadUtil { constructor() { this.getTokenUrl = ''; this.uploadToken = ''; this.uploadUrl = ''; this.uploadedImgUrl = ''; this.getToken(); } getToken = () => { wx.request({ url: this.getTokenUrl, success: (res) => { const { uptoken } = res.data; if (uptoken && uptoken.length > 0) { this.uploadToken = uptoken } else { console.error('----- 0xg -----') } }, fail: function (error) { console.error('---- 0xg -----: ' + error) } }) } getRandomName = (filePath = '') => { const a = parseInt(Math.random() * 10); const b = parseInt(Math.random() * 10); const c = parseInt(Math.random() * 10); const d = parseInt(Math.random() * 10); const ext = filePath.substring(filePath.lastIndexOf('.')); return `0xg_${Date.now()}_${a}${b}${c}${d}${ext}`; } wxUpload = (filePath, resolve, reject, chooseIndex, cb) => { const randomName = this.getRandomName(filePath); wx.uploadFile({ url: this.uploadUrl, filePath, name: 'file', formData: { 'token': this.uploadToken, 'key': randomName, 'name': randomName, }, success: res => { const dataString = res.data; try { const dataObject = JSON.parse(dataString); const fileName = this.uploadedImgUrl + dataObject.key; resolve(fileName); typeof cb === 'function' && cb(fileName, chooseIndex); } catch(e) { reject(res); console.log('parse JSON failed, origin String is: ' + dataString) } }, fail: (error) => { reject(error); } }) } _uploadFile = (filePath, resolve, reject, chooseIndex, cb) => { if (typeof resolve === 'function') { this.wxUpload(filePath, resolve, reject, chooseIndex, cb); } else { return new Promise((resolve, reject) => { this.wxUpload(filePath, resolve, reject, chooseIndex, cb); }) } } /** * 壓縮圖片 * @param {*} imgPath 圖片臨時文件 * @param {*} chooseIndex 選擇圖片的順序 */ compressAndUpload = (imgPath, chooseIndex, cb, componentCtx) => { return new Promise((resolve, reject) => { wx.getImageInfo({ src: imgPath, success: res => { const { height, width, path } = res; const longerSide = height >= width ? height : width; if (longerSide > 1280) { // 壓縮尺寸 const compressedW = longerSide === width ? 1280 : parseInt((1280 / height) * width); const compressedH = longerSide === height ? 1280 : parseInt((1280 / width) * height); componentCtx.setData({ compressedW, compressedH, }, () => { const ctx = wx.createCanvasContext('compress_canvas'); ctx.drawImage(path, 0, 0, width, height, 0, 0, compressedW, compressedH); setTimeout(() => { ctx.draw(false, () => { wx.canvasToTempFilePath({ x: 0, y: 0, width: compressedW, height: compressedH, destWidth: compressedW, destHeight: compressedH, canvasId: 'compress_canvas', fileType: 'jpg', success: res => { const compressedSizeImagePath = res.tempFilePath; this.compressImageQuality(compressedSizeImagePath, resolve, reject, chooseIndex, cb); }, fail: res => { console.log('canvasToTempFilePath fail: ', res); reject(res); }, }) }); }, 1000) }); } else { // 直接壓縮質量 this.compressImageQuality(path, resolve, reject, chooseIndex, cb); } } }) }); } /** * 按照80%比例壓縮質量 */ compressImageQuality = (compressedSizeImagePath, resolve, reject, chooseIndex, cb) => { wx.getFileInfo({ filePath: compressedSizeImagePath, success: res => { if (res.size > maxImageSize) { wx.compressImage({ src: compressedSizeImagePath, quality: 80, success: res => { this.compressImageQuality(res.tempFilePath, resolve, reject, chooseIndex, cb); }, fail: res => { console.log('compressImage fail: ', res) reject(res); } }) } else { this._uploadFile(compressedSizeImagePath, resolve, reject, chooseIndex, cb); } }, fail: res => { reject(res); }, }) } }View Code
/** * files: [{ name: '', index: 0, status: 'uploaded | uploading', isVideo: true | false }] */ import UploadUtil from '@utils/upload_utils'; const uploadUtil = new UploadUtil(); const movableAreaPaddingW = 15; const maxImageNum = 9; const maxVideoNum = 1; const maxVideoSize = 5 * 1024 * 1024; const maxImageSize = 500 * 1024; function getFilesNum(files) { let imageNum = 0; let videoNum = 0; for (let i = 0; i < files.length; i++) { const { isVideo } = files[i]; if (isVideo) { ++videoNum; } else { ++imageNum; } } return { imageNum, videoNum, } } function getFileTypeFromExt(fileName) { if (typeof fileName !== 'string') return; if ( fileName.indexOf('.jpg') > -1 || fileName.indexOf('.jpeg') > -1 || fileName.indexOf('.png') > -1 || fileName.indexOf('.bmp') > -1 || fileName.indexOf('.webp') > -1 ) { return 'image'; } else { return 'video'; } } Component({ properties: { defaultsFiles: { type: Array, value: [], } }, data: { movableAreaW: 0, maxNumPerRow: 0, compressedW: 0, compressedH: 0, imageWidth: 0, files: [], movingIndex: 0, movable: false }, ready() { const { windowWidth } = wx.getSystemInfoSync(); const movableAreaW = windowWidth - movableAreaPaddingW * 2; const maxNumPerRow = parseInt(movableAreaW/imageWidth); const { defaultsFiles } = this.data; this.setData({ movableAreaW, maxNumPerRow, movingIndex: 0, movable: false, files: defaultsFiles.map((item, index) => ({ ...item, index })) }); }, methods: { chooseFile: () => { const { files } = this.data; const { imageNum, videoNum } = getFilesNum(files); const canUploadImageNum = maxImageNum - imageNum; const canUploadVideoNum = maxVideoNum - videoNum; if (canUploadImageNum > 0 && canUploadVideoNum > 0) { wx.showActionSheet({ itemList: ['選擇照片', '選擇視頻'], success: res => { const { tapIndex } = res; const count = tapIndex === 0 ? canUploadImageNum : canUploadVideoNum; this.chooseLocalFile(tapIndex, count); } }) } else if (canUploadImageNum > 0 && canUploadVideoNum === 0) { this.chooseLocalFile(0, canUploadImageNum); } else if (canUploadImageNum === 0 && canUploadVideoNum > 0) { this.chooseLocalFile(1, canUploadImageNum); } }, chooseLocalFile(type, maxNum) { if (type === 0) { wx.chooseImage({ count: maxNum, success: res => { // compress && upload console.log('res: ', res); const { tempFiles } = res; // 如果想要更好的體驗,可以使用臨時文件先渲染已添加列表, // 在編輯其它信息的過程中,讓用戶無感知地進行上傳,實現起來也很簡單,這裡就不實現這種方式了 // const { files } = this.data; // const newFiles = [...files, tempFiles.map((item, index) => ({name: item.path, index: files.length + index}))]; // this.setData({ // files: newFiles // }); this.createUploadImageTask(tempFiles); } }); } else if (type === 1) { wx.chooseVideo({ count: 1, maxDuration: 30, success: res => { this.createUploadVideoTask(res); } }) } }, imgPosRealTime(x, y) { const { maxNumPerRow, imageWidth, files } = this.data; const lastRowNum = files.length%maxNumPerRow; const row = parseInt(y/(imageWidth + 10)); const col = parseInt(x/(imageWidth + 10)); const endIndex = row * maxNumPerRow + col; return { lastRowNum, row, col, endIndex }; }, handleChangePos: e => { const { dataset } = e.currentTarget; const { index } = dataset; const { x, y, source } = e.detail; if (source === 'touch') { const imgPos = this.imgPosRealTime(x + imageWidth/3, y + imageWidth/3); this.moveEndInfo = {...imgPos, index, x, y}; } }, handleChangePosStart: e => { const { dataset } = e.currentTarget; const { index } = dataset; this.setData({ movingIndex: index }); }, /** * 拖拽結束才可以setData,小程式開發的整個過程都要考慮setData的次數,越少越好 */ handleChangePosEnd: () => { if (!this.moveEndInfo) return; const { files } = this.data; const { index, endIndex } = this.moveEndInfo; const movingImg = files[index]; if (endIndex === files.length) { files[index] = movingImg; } else { if (endIndex > index) { for (let i = 0; i < files.length; i++) { if (i >= index && i <= endIndex) { files[i] = files[i + 1]; } } } else if (endIndex < index) { for (let i = files.length - 1; i >= 0; i--) { if (i <= index && i >= endIndex) { files[i] = files[i - 1]; } } } files[endIndex] = movingImg; } this.setData({ files, movable: false, }); this.moveEndInfo = null; }, /** * 單擊預覽 */ handleTapItem: e => { const { dataset } = e.currentTarget; const { index } = dataset; const { files, themeType } = this.data; if ((themeType === 4 || themeType === 1) && index === 0) { this.videoContext = wx.createVideoContext('preview-video', this); this.videoContext.requestFullScreen({ direction: 0 }); this.videoContext.play(); } else { wx.previewImage({ urls: [files[index].name] }); } }, videoFullScreenChange: e => { const { fullScreen } = e.detail; if (!fullScreen) { this.videoContext.pause(); } }, /** * 長按激活拖拽 */ handleLongPressMovableItem: () => { wx.vibrateShort(); component.setData({ movable: true, }) }, deleteFile: (e) => { const { files } = this.data; const { dataset } = e.currentTarget; const { index } = dataset; const newFiles = [ ...files.slice(0, index), ...files.slice(index + 1), ]; this.setData({ files: newFiles.map(((item, index) => ({...item, index}))), // 重置索引 }); }, createUploadImageTask: async tempFiles => { wx.showLoading({ title: '正在上傳...' }); const { files } = this.component.getComponentData(); const compressAndUploadQueue = []; const uploadQueue = []; for (let i = 0; i < tempFiles.length; i++) { const chooseIndex = files.length + i; const { size, path } = tempFiles[i]; if (size > maxImageSize) { const compressAndUploadItem = uploadUtil.compressAndUpload(path, chooseIndex, this.fileAdded, this); compressAndUploadQueue.push(compressAndUploadItem); } else { const uploadItem = this.uploadFile(path, '', '', chooseIndex, this.fileAdded); uploadQueue.push(uploadItem); } } await Promise.all([...compressAndUploadQueue, ...uploadQueue]); wx.hideLoading(); console.log('---- all files uploaded ----'); }, createUploadVideoTask: async file => { wx.showLoading({ title: '正在上傳...' }); const { size, tempFilePath } = file; if (size > maxVideoSize) { wx.hideLoading(); wx.showModal({ title: '提示', content: '視頻大小不能超過5M,上傳大於5M視頻請前往app', showCancel: false, confirmText: '知道了' }) } else { await this.uploadFile(tempFilePath); wx.hideLoading(); } }, }, fileAdded(fileName, chooseIndex) { const fileType = getFileTypeFromExt(fileName); let videoImg = ''; if (fileType === 'video') { videoImg = fileName + '?first_frame/0'; // 首幀 } // 處理files const { files } = this.data; const newFile = { name: videoImg || fileName, status: 'uploaded' }; let newFiles = []; if (fileType === 'video') { newFiles = [ { ...newFile, index: 0, source: fileName }, ...files.map(item => ({...item, index: item.index + 1})) ]; } else { let index = 0; for (let i = 0; i < files.length; i++) { const pre = files[i]; const next = files[i + 1]; if (!pre) { index = 0; break; } else if (!next) { const { index: indexPre } = pre; if (indexPre > chooseIndex) { index = i; } else { index = i + 1; } break; } const { index: indexPre } = pre; const { index: indexNext } = next; if (indexPre < chooseIndex && indexNext > chooseIndex) { index = i + 1; break; } } const insertItem = {...newFile, index: chooseIndex}; newFiles = [ ...files.slice(0, index), insertItem, ...files.slice(index), ]; } this.setData({ files: newFiles }) }, })View Code
4.wxml文件,使用movable-area和movable-view實現拖拽功能
<!-- 已上傳文件 --> <view class="page__bd"> <view class="weui-cells" style="margin-top: 0px;"> <view class="weui-cell"> <view class="weui-cell__bd"> <view class="weui-uploader"> <view class="weui-uploader__files" id="uploaderFiles" style="height: {{(imageW + paddingW * 2) * WXS.getRowsNum(files, maxNumPerRow)}}px;" > <movable-area style="width: 100%; height: 100%;"> <block wx:for="{{files}}" wx:key="unique"> <movable-view animation="{{false}}" direction="all" disabled="{{!movable}}" style="padding: 5px; width: {{imageW}}px; height: {{imageW}}px; z-index: {{movingIndex === index ? 1000 : 0}}" x="{{WXS.getAddedImgPos(index, maxNumPerRow, imageW + paddingW * 2).x}}" y="{{WXS.getAddedImgPos(index, maxNumPerRow, imageW + paddingW * 2).y}}" data-index="{{index}}" bindchange="{{handleChangePos}}" bindtouchstart="{{handleChangePosStart}}" bindtouchend="{{handleChangePosEnd}}" bindtap="{{handleTapItem}}" bindlongtap="{{handleLongPressMovableItem}}" > <view style="width: 100%; height: 100%;position: relative;"> <image class="weui-uploader__img" src="{{item.name}}" mode="aspectFill" /> <view wx:if="{{item.status === 'uploading'}}" class="weui-uploader__file-content"> <view class="weui-loading"></view> </view> <view wx:if="{{item.isVideo}}" class="f-flex f-vc f-hc play-icon" > <image src="/assets/images/video_icon.png" style="width: {{imageW/maxNumPerRow}}px; height: {{imageW/maxNumPerRow}}px;" /> </view> <image data-index="{{index}}" class="uploader-img-delete" src="/assets/images/delete-img.png" catchtap="{{deleteFile}}" /> </view> </movable-view> </block> <movable-view wx:if="{{files.length < 10}}" animation="{{false}}" direction="all" disabled style="width: {{imageW}}px; height: {{imageW}}px; padding: 5px;" x="{{WXS.getAddedImgPos(files.length, maxNumPerRow, imageW + paddingW * 2).x}}" y="{{WXS.getAddedImgPos(files.length, maxNumPerRow, imageW + paddingW * 2).y}}" data-index="{{-1}}" &g