├── README.md └── uploader(forElementUI2).mixin.js /README.md: -------------------------------------------------------------------------------- 1 | # § ElementUI 1.x - Upload 结合 OSS 的封装 2 | 3 | > ElementUI 2.x:请看 [uploader(forElementUI2).mixin.js](./uploader(forElementUI2).mixin.js)(大同小异) 4 | 5 | [ElUpload](http://element-cn.eleme.io/1.4/#/zh-CN/component/upload) 对于直传阿里云 OSS 或其他 CDN 服务的场景显得相当不便利(因为涉及到签名校验以及有效期等) 6 | 此时我们需要自行封装出符合业务需求的通用化组件 7 | 8 | 于我而言,我本身比较排斥组件嵌套较深的情况,且很多时候还要考虑到父子组件通信的问题 9 | 因此我有很多所谓的“通用化组件”实际上是以 mixin 的形式来实现的 10 | 11 | 首先讲讲需要注意的问题: 12 | * 需要从我们自己的后端获取签名(详见 OSS 文档 - [服务端签名后直传](https://help.aliyun.com/document_detail/31926.html)) 13 | * 需要考虑签名的有效期(我司一般是设置 2 ~ 3 min) 14 | * 在签名有效期内,不能重复请求后端获取签名(签名的跨组件共享,原理可参考[这里](https://github.com/kenberkeley/vue-state-management-alternative)) 15 | * 是逐个上传,而非并发上传(否则网速慢的时候得卡死) 16 | * 需要支持各类文件的不定项上传 17 | * 需要支持回显操作(编辑状态下肯定是必须的) 18 | * 需要支持样式各异的组件形式(单单这个需求就已经没办法实现一个所谓的通用化组件了,因为模板与样式各异) 19 | * ElUpload 截止到 1.4.7 时还是有很多坑,需要一一避免(e.g. 令人抓狂的 `fileList`) 20 | 21 | 下面是对应的 `@/mixins/uploader.js` 22 | 23 | ```js 24 | import moment from 'moment' 25 | import urljoin from 'url-join' 26 | import debounce from 'lodash/debounce' 27 | import browserMD5File from 'browser-md5-file' 28 | const isStr = s => typeof s === 'string' 29 | 30 | // 组件共享状态:OSS 上传签名(这只是我司后端返回的原始形式,真正 POST 到 OSS 的是 computed:access) 31 | const oss = { 32 | accessid: '', // 16 位字符串 33 | policy: '', // Base64 编码字符串 34 | signature: '', // 28 位字符串 35 | dir: '', // 上传路径(根据当前用户决定) 36 | expire: '', // 13 位毫秒数 37 | cdnUrl: '' // 阿里云 OSS 地址 38 | } 39 | 40 | export default { 41 | props: { 42 | // 【注意】该项请使用 .sync 修饰,形式可为 'url' 或 ['url1', 'url2', ...] 43 | files: { type: [String, Array], required: true } 44 | }, 45 | data: () => ({ 46 | oss, 47 | key: '', // 正在上传的文件的 key(computed:access 依赖项) 48 | percent: 0, // 当前任务上传进度 49 | taskQueue: [], // 上传队列(基于 Promise 实现) 50 | isUploading: false, 51 | fileList: [] // 用于 ElUpload 组件的 $props.fileList 52 | }), 53 | computed: { 54 | action () { // 用于 ElUpload 组件的 $props.action 55 | return oss.cdnUrl 56 | }, 57 | access () { // 用于 ElUpload 组件的 $props.data 58 | return { 59 | key: this.key, 60 | policy: oss.policy, 61 | signature: oss.signature, 62 | OSSAccessKeyId: oss.accessid, 63 | success_action_status: 200 64 | } 65 | } 66 | }, 67 | watch: { 68 | files: { 69 | handler (files) { 70 | if (isStr(files)) { 71 | files = [files] 72 | } 73 | // 遵循 ElUpload 的 $props.fileList 的 [{ name, url }] 格式 74 | this.fileList = files.map((url, idx) => ({ name: '' + idx, url })) 75 | }, 76 | immediate: true 77 | }, 78 | isUploading (isUploading) { 79 | // isUploading 从 true 变成 false 时,在 nextTick 中同步 ElUpload $data.uploadFiles 到 $props.files 80 | // 为什么要 nextTick?因为 onSuccess 中执行 this.nextFile() 之后还有 file.url = uploadFile 的操作 81 | isUploading || this.$nextTick(() => { 82 | this.syncUploadFiles() 83 | }) 84 | } 85 | }, 86 | methods: { 87 | /** 88 |     * 【注意:该方法须自行实现】新增上传任务,用于 ElUpload 组件的 before-upload 钩子函数,举例如下: 89 | * @param {File} 90 | * @return {Boolean/Promise} - 官方文档写道:若返回 false 或者 Promise 则停止上传 91 | beforeUpload (file) { 92 | // 此处进行检测 file 合法性等操作,之后就只需要调用如下函数即可 93 | return this.addFile(file) 94 | } 95 | */ 96 | syncUploadFiles () { 97 | // 这里最后意为排除掉 blob 开头的 URL(这算是一个坑),此时 files 有可能是空数组 98 | let files = this.$refs.upload.uploadFiles.map(({ url }) => url).filter(url => url.startsWith('http')) 99 | 100 | // 对于无论是否 multiple,ElUpload 的 $data.uploadFiles 始终都是数组类型 101 | // 因此若 $props.files 为字符串类型,则应取 files 的末位元素(注:空数组时取得 undefined) 102 | this.$emit('update:files', isStr(this.files) ? files.slice(-1)[0] || '' : files) 103 | }, 104 | // 用于 ElUpload 的 on-progress 105 | onProgress ({ percent }) { 106 | this.percent = ~~percent 107 | }, 108 | // 用于 ElUpload 的 on-success 109 | onSuccess (res, file, uploadFiles) { 110 | const uploadPath = this.nextFile() 111 | file.url = uploadPath // 把 blob 链接替换成 CDN 链接 112 | }, 113 | // 用于 ElUpload 的 on-remove 114 | onRemove: debounce(function () { 115 | // 手动点击删除显然会调用本函数,但如下场景也会触发调用: 116 | // 限制 5 张,已传 3 张,若在文件管理器中再选 10 张上传 117 | // 则溢出了 8 张,即本函数将会频繁调用 8 次(所以要 debounce 一下) 118 | 119 | // 若本函数仅仅就是单纯执行 syncUploadFiles,则必然报错: 120 | // Uncaught TypeError: Cannot set property 'status' of null 121 | // 122 | // 因为此时正在上传 2 张,ElUpload 内部的 handleProgress 一直在不断执行 123 | // 若直接就粗暴地调用 syncUploadFiles 则会触发 ElUpload $data.uploadFiles 的更新 124 | // 导致 handleProgress 中的 var file = this.getFile(rawFile) 为 null 125 | // 故随后 file.status = 'uploading' 就会立即报错 126 | // (详见源码 https://github.com/ElemeFE/element/blob/1.x/packages/upload/src/index.vue#L141-L146) 127 | this.isUploading 128 | ? setTimeout(() => this.onRemove, 1000) 129 | : this.syncUploadFiles() 130 | }, 250), 131 | // 用于 ElUpload 的 on-error(一般是 OSS access 过期了) 132 | onError () { 133 | this.isUploading = false // 重置上传状态很关键,否则之后就不能 auto run 了 134 | this.$message.warning('上传功能出了点问题,请重试') 135 | }, 136 | addFile (file) { 137 | return new Promise(resolve => { 138 | this.taskQueue.push({ file, start: resolve }) 139 | 140 | // auto run 141 | if (!this.isUploading) { 142 | this.isUploading = true 143 | this.nextFile(true) 144 | } 145 | }) 146 | }, 147 | nextFile (isAutorun) { 148 |      // 当 isUploading false => true 时(auto run): 149 |      // 1. 若之前没有上传过的,则 this.action 和 this.key 均为 '',故 join 出来是 '/' 150 | // 2. 若之前有上传过的,则结果为上一次的 uploadPath 151 | // 鉴于两者都没有意义,故由 auto run 触发的都无需执行 urljoin 152 | let uploadPath 153 | if (!isAutorun) { 154 | uploadPath = urljoin(this.action, this.key) 155 | } 156 | // 开发环境下打印出刚上传成功的文件链接以便调试 157 | // (为什么不写成 if(__DEV__ && !isAutorun)?因为有利于 UglifyJS 压缩时直接剔除整块代码 ) 158 | if (__DEV__) { 159 | if (!isAutorun) { 160 | console.info('上传成功:', uploadPath) 161 | } 162 | } 163 | 164 | const { taskQueue } = this 165 | if (taskQueue.length) { 166 | const ensureAccessValid = isAccessExpired() ? updateAccess : doNothing 167 | let nextTask 168 | ensureAccessValid().then(() => { 169 | nextTask = taskQueue.shift() 170 | return keygen(nextTask.file) 171 | }).then(key => { 172 | this.key = key // 更新 key 以更新 computed:access 173 | this.$nextTick(() => { 174 | nextTask.start() // 相当于 resolve 掉 before-upload 钩子中返回的 promise 175 | }) 176 | }).catch(e => console.warn(e)) 177 | } else { 178 | this.isUploading = false 179 | } 180 | 181 | return uploadPath 182 | } 183 | } 184 | } 185 | 186 | // 判断 access 是否过期(提前 10 秒过期) 187 | function isAccessExpired () { 188 | return +moment().add(10, 's').format('x') > +oss.expire 189 | } 190 | 191 | /** 192 | * 更新 OSS access 193 | * @return {Promise} 194 | */ 195 | function updateAccess() { 196 | return <获取 OSS 签名的 API>.then(re => { 197 | Object.assign(oss, re) 198 | }) 199 | } 200 | 201 | function doNothing () { 202 | return Promise.resolve() 203 | } 204 | 205 | /** 206 | * 生成上传 key(基于文件哈希) 207 | * @param {File} 208 | * @resolve {String} 形如 '<上传路径>/3d3e93a9745fd21240ef3c88045cc0d1.jpg' 209 | */ 210 | function keygen(file) { 211 | detectCompatibility() 212 | return new Promise((resolve, reject) => { 213 | browserMD5File(file, (err, md5) => { 214 | if (err) { 215 | reject(err) 216 | return 217 | } 218 | resolve( 219 | urljoin(oss.dir, `${md5}.${file.name.split('.').pop()}`) 220 | ) 221 | }) 222 | }) 223 | } 224 | 225 | function detectCompatibility() { 226 | window.File || window.FileReader || alert( 227 | '当前浏览器不支持 File / FileReader,上传功能受限。\n建议您使用特性更多,性能更好的现代浏览器。' 228 | ) 229 | } 230 | detectCompatibility() 231 | ``` 232 | 233 | 例如,我们有一个上传 icon 的组件(`IconUploader`),如下: 234 | 235 | ```html 236 | 256 | 288 | 309 | ``` 310 | 311 | 用的时候相当简单,就是: 312 | 313 | ```html 314 | 315 | ``` 316 | 317 | *** 318 | 319 | 同样地,上传 App 包体的组件(`AppUploader`)如下: 320 | 321 | ```html 322 | 345 | 358 | 364 | ``` 365 | 366 | 用法: 367 | 368 | ```html 369 | 370 | ``` 371 | 372 | *** 373 | 374 | 我们来总结一下,三步走: 375 | 1. 引入 `@/mixins/uploader` 376 | 2. 把 mixin 中的对应的参数以及方法传给 ElUpload,顺便实现自己的模板与样式   377 | 3. 实现 `beforeUpload` 方法(内部须调用 `addFile` 把文件添加到上传队列中) 378 | 379 | 本人经过多次尝试才总结出当前这种较为通用的 mixin 方式,希望可以抛砖引玉,得到您改进的建议与意见 380 | -------------------------------------------------------------------------------- /uploader(forElementUI2).mixin.js: -------------------------------------------------------------------------------- 1 | import urljoin from 'url-join' 2 | import debounce from 'lodash/debounce' 3 | import browserMD5File from 'browser-md5-file' 4 | import addSeconds from 'date-fns/add_seconds' 5 | const isStr = s => typeof s === 'string' 6 | 7 | // 组件共享状态:OSS 上传签名 8 | const oss = { 9 | dir: '', // 上传路径 10 | url: '', // 阿里云 OSS 地址 11 | expire: '', // 13 位毫秒数 12 | policy: '', // Base64 编码字符串 13 | signature: '', // 28 位字符串 14 | accessKeyId: '' // 16 位字符串 15 | } 16 | 17 | export default { 18 | props: { 19 | // 【注意】该项请使用 .sync 修饰,形式可为 'url' 或 ['url1', 'url2', ...] 20 | files: { type: [String, Array], required: true } 21 | }, 22 | data: () => ({ 23 | oss, 24 | key: '', // 正在上传的文件的 key(computed:access 依赖项) 25 | percent: 0, // 当前任务上传进度 26 | taskQueue: [], // 上传队列(基于 Promise 实现) 27 | isUploading: false, 28 | fileList: [] // 用于 ElUpload 组件的 $props.fileList 29 | }), 30 | computed: { 31 | action () { // 用于 ElUpload 组件的 $props.action 32 | return oss.url 33 | }, 34 | access () { // 用于 ElUpload 组件的 $props.data 35 | return { 36 | key: this.key, 37 | policy: oss.policy, 38 | signature: oss.signature, 39 | OSSAccessKeyId: oss.accessKeyId, 40 | success_action_status: 200 41 | } 42 | } 43 | }, 44 | watch: { 45 | files: { 46 | handler (files) { 47 | if (isStr(files)) { 48 | files = [files] 49 | } 50 | // 遵循 ElUpload 的 $props.fileList 的 [{ name, url }] 格式 51 | this.fileList = files.map((url, idx) => ({ name: '' + idx, url })) 52 | }, 53 | immediate: true 54 | }, 55 | isUploading (isUploading) { 56 | // isUploading 从 true 变成 false 时,在 nextTick 中同步 ElUpload $data.uploadFiles 到 $props.files 57 | // 为什么要 nextTick?因为 onSuccess 中执行 this.nextFile() 之后还有 file.url = uploadFile 的操作 58 | isUploading || this.$nextTick(() => { 59 | this.syncUploadFiles() 60 | }) 61 | } 62 | }, 63 | methods: { 64 | /** 65 | * 【注意:该方法须自行实现】新增上传任务,用于 ElUpload 组件的 before-upload 钩子函数,举例如下: 66 | * @param {File} 67 | * @return {Boolean/Promise} - 官方文档写道:若返回 false 或者 Promise 则停止上传 68 | beforeUpload (file) { 69 | // 此处进行检测 file 合法性等操作,之后就只需要调用如下函数即可 70 | return this.addFile(file) 71 | } 72 | */ 73 | syncUploadFiles () { 74 | // 这里最后意为排除掉 blob 开头的 URL(这算是一个坑),此时 files 有可能是空数组 75 | let files = this.$refs.upload.uploadFiles.map(({ url }) => url).filter(url => url.startsWith('http')) 76 | 77 | // 对于无论是否 multiple,ElUpload 的 $data.uploadFiles 始终都是数组类型 78 | // 因此若 $props.files 为字符串类型,则应取 files 的末位元素(注:空数组时取得 undefined) 79 | this.$emit('update:files', isStr(this.files) ? files.slice(-1)[0] || '' : files) 80 | }, 81 | // 用于 ElUpload 的 on-progress 82 | onProgress ({ percent }) { 83 | this.percent = ~~percent 84 | }, 85 | // 用于 ElUpload 的 on-success 86 | onSuccess (res, file, uploadFiles) { 87 | const uploadPath = this.nextFile() 88 | file.url = uploadPath // 把 blob 链接替换成 CDN 链接 89 | }, 90 | // 用于 ElUpload 的 on-remove 91 | onRemove: debounce(function () { 92 | // 手动点击删除显然会调用本函数,但如下场景也会触发调用: 93 | // 限制 5 张,已传 3 张,若在文件管理器中再选 10 张上传 94 | // 则溢出了 8 张,即本函数将会频繁调用 8 次(所以要 debounce 一下) 95 | 96 | // 若本函数仅仅就是单纯执行 syncUploadFiles,则必然报错: 97 | // Uncaught TypeError: Cannot set property 'status' of null 98 | // 99 | // 因为此时正在上传 2 张,ElUpload 内部的 handleProgress 一直在不断执行 100 | // 若直接就粗暴地调用 syncUploadFiles 则会触发 ElUpload $data.uploadFiles 的更新 101 | // 导致 handleProgress 中的 var file = this.getFile(rawFile) 为 null 102 | // 故随后 file.status = 'uploading' 就会立即报错 103 | // (详见源码 https://github.com/ElemeFE/element/blob/1.x/packages/upload/src/index.vue#L141-L146) 104 | this.isUploading 105 | ? setTimeout(() => this.onRemove, 1000) 106 | : this.syncUploadFiles() 107 | }, 250), 108 | // 用于 ElUpload 的 on-error(一般是 OSS access 过期了) 109 | onError () { 110 | this.isUploading = false // 重置上传状态很关键,否则之后就不能 auto run 了 111 | this.$message.warning('上传功能出了点问题,请重试') 112 | }, 113 | addFile (file) { 114 | return new Promise(resolve => { 115 | this.taskQueue.push({ file, start: resolve }) 116 | 117 | // auto run 118 | if (!this.isUploading) { 119 | this.isUploading = true 120 | this.nextFile(true) 121 | } 122 | }) 123 | }, 124 | nextFile (isAutorun) { 125 | // 当 isUploading false => true 时(auto run): 126 | // 1. 若之前没有上传过的,则 this.action 和 this.key 均为 '',故 join 出来是 '/' 127 | // 2. 若之前有上传过的,则结果为上一次的 uploadPath 128 | // 鉴于两者都没有意义,故由 auto run 触发的都无需执行 urljoin 129 | let uploadPath 130 | if (!isAutorun) { 131 | uploadPath = urljoin(this.action, this.key) 132 | } 133 | // 开发环境下打印出刚上传成功的文件链接以便调试 134 | // (为什么不写成 if(__DEV__ && !isAutorun)?因为有利于 UglifyJS 压缩时直接剔除整块代码 ) 135 | if (__DEV__) { 136 | if (!isAutorun) { 137 | console.info('上传成功:', uploadPath) 138 | } 139 | } 140 | 141 | const { taskQueue } = this 142 | if (taskQueue.length) { 143 | const ensureAccessValid = isAccessExpired() ? updateAccess : doNothing 144 | let nextTask 145 | ensureAccessValid().then(() => { 146 | nextTask = taskQueue.shift() 147 | return keygen(nextTask.file) 148 | }).then(key => { 149 | this.key = key // 更新 key 以更新 computed:access 150 | this.$nextTick(() => { 151 | nextTask.start() // 相当于 resolve 掉 before-upload 钩子中返回的 promise 152 | }) 153 | }).catch(e => console.warn(e)) 154 | } else { 155 | this.isUploading = false 156 | } 157 | 158 | return uploadPath 159 | } 160 | } 161 | } 162 | 163 | // 判断 access 是否过期(提前 10 秒过期) 164 | function isAccessExpired () { 165 | return +addSeconds(new Date(), 10) > +oss.expire 166 | } 167 | 168 | /** 169 | * 更新 OSS access 170 | * @return {Promise} 171 | */ 172 | function updateAccess() { 173 | return .then(re => { 174 | Object.assign(oss, re) 175 | }) 176 | } 177 | 178 | function doNothing () { 179 | return Promise.resolve() 180 | } 181 | 182 | /** 183 | * 生成上传 key(基于文件哈希) 184 | * @param {File} 185 | * @resolve {String} 形如 '<上传路径>/3d3e93a9745fd21240ef3c88045cc0d1.jpg' 186 | */ 187 | function keygen(file) { 188 | return new Promise((resolve, reject) => { 189 | browserMD5File(file, (err, md5) => { 190 | if (err) { 191 | reject(err) 192 | return 193 | } 194 | resolve( 195 | urljoin(oss.dir, `${md5}.${file.name.split('.').pop()}`) 196 | ) 197 | }) 198 | }) 199 | } 200 | --------------------------------------------------------------------------------