├── 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 |
237 |
238 |
249 |
250 |
{{ percent }} %
251 |
![]()
252 |
(推荐分辨率为 100 × 100)
253 |
254 |
255 |
256 |
288 |
309 | ```
310 |
311 | 用的时候相当简单,就是:
312 |
313 | ```html
314 |
315 | ```
316 |
317 | ***
318 |
319 | 同样地,上传 App 包体的组件(`AppUploader`)如下:
320 |
321 | ```html
322 |
323 |
324 |
334 |
335 |
336 | {{ percent }}%
337 |
338 |
339 |
340 | 上传应用
341 |
342 |
343 |
344 |
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 |
--------------------------------------------------------------------------------