记一次Vue附件上传组件踩坑记

上个礼拜刚刚结束一个Vue项目,这个项目分为移动端和桌面端,由于UI差异较大,分别建立了各自的Git仓库,公共代码部分通过Git Subtree共享。其中比较折腾的就是附件上传组件,这个组件有什么特点呢?

  1. 业务有大量附件要上传,包括图片和非图片,以及文件多选,图片类需要有预览
  2. 这个项目包括移动端和桌面端,希望能够复用非UI部分的代码
  3. 对于移动端,图片上传需要支持拍照上传,这对于iOS问题不大,可是安卓就坑了。
  4. 尤其移动端上的图片,一般都是手机拍照图片,像素可谓不小,需要进行压缩再上传,以加快上传速度、减少带宽占用

其中:PC端依赖了饿了么组件库element-ui, 移动端依赖了饿了么移动端组件库mint-ui。附件上传的服务端接口已经通过Nodejs实现,基于Koabusboy。虽然element-ui已经提供了附件上传功能,但是在文件个数方面的检查没有暴露出足够的钩子来处理,最终决定还是自己写,其实并没有很复杂。

对于第1点,对于上传本身来说并没有什么区别,只是需要对上传结果进行分别处理, 另外需要提供文件个数限制的prop。

对于第2点,由于两个端的UI差异,移动端和桌面端需要作为两个独立组件存在,组件模板以及样式分别处理,于是我们可以把组件的props,data,computed,methods等选项都抽出来作为mixin 放置在Git Subtree,两个组件都引入这个mixin

对于第3点,国内各大安卓手机厂商似乎对浏览器的附件方面支持不统一,附件选择原生样式方面就不说了,连功能也不统一。单文件上传还好,多文件(即<input type="file" multiple>)有一些手机就不支持拍照上传了,尤其是今年来很火热的华为手机最让人费解,这么优秀的厂商,对浏览器的附件上传拍照功能支持居然不友好。google了一番后,发现结合capture这个属性可以解决一部分问题,而且只是基于微信的webview,很多安卓手机自带浏览器仍然有问题。如果希望比较完美的话,估计还得是放弃支持安卓自带浏览器,然后调用微信jssdk接口,最终通过原生的方式调用摄像头拍照上传。

对于第4点,同事之前写了一个图片压缩的库,很赞!但有一个问题,传给压缩库的是File对象,返回的却是Blob对象,如果在append到formData时,未指定文件名,那么服务端拿到的文件名将是blob,参考MDN文档,解决办法是从原始File对象读取文件名,传给第三个参数。

以下为最终组件源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
// common-sdk/components/file-uploader/mixin.js common-sdk可通过webpack设置别名
// 我司出品的图片压缩库
import ImageCompressor from '@xkeshi/image-compressor'
const FILE_TYPES = [
'image/jpg',
'image/jpeg',
'image/png',
// gif被压缩后,就不会动了。。。不压缩gif
]
export default {
props: {
value: {
type: [Array, String],
},
// 文件个数限制
fileQuantityLimit: {
type: Number,
default: 5,
},
// 文件大小限制
fileSizeLimit: {
type: Number,
default: 10,
},
// 是否为图片上传,如果是,将提供图片预览
isPicture: {
type: Boolean,
default: true,
},
},
computed: {
/**
* 是否显示文件选择器
* @return {boolean} *
*/
isShowSelector() {
// 上传中不显示
if (this.isUploading) {
return false
}
if (this.fileQuantityLimit === 1) {
return !this.value
}
return this.fileQuantityLimit - (this.value || []).length > 0
},
},
data() {
return {
// 对于图片上传,理论上最好把具体的图片类型列举出来,但是移动端的图片上传只能写成image/*
accept: this.isPicture ? 'image/*' : '',
isUploading: false,
}
},
methods: {
/**
* 单图片上传时的删除事件
*/
removeSinglePicture() {
this.$emit('input', null)
},
/**
* 多图上传时的删除事件
* @param {number} removeIndex - 待删除的图片下标
*/
removeMultipleImage(removeIndex) {
this.$emit('input', this.value.filter((file, index) => {
return removeIndex !== index
}))
},
/**
* 多文件上传时的删除操作
* @param {number} removeIndex - 待删除的图片下标
*/
removeAttachment(removeIndex) {
this.$emit('input', this.value.filter((file, index) => {
return removeIndex !== index
}))
},
/**
* 文件触发器的点击事件
*/
onClickTrigger() {
this.$refs.file.click()
},
/**
* 文件改变事件
* @param {Event} e - 文件事件对象
*/
onFileChange(e) {
const fileList = [].slice.apply(e.target.files)
if (this.fileQuantityLimit > 1 &&
fileList.length - (this.fileQuantityLimit - this.value.length) > 0) {
// showError方法由各自实现,因为UI不一致
this.showError(`最多可上传${this.fileQuantityLimit}个文件`)
return
}
if (this.isPicture) {
const isFilesTypeValid = fileList.every((file) => {
const isValid = /\.(jpe?g|png|gif)$/i.test(file.name)
if (!isValid) {
this.showError(`请选择jpg、jpeg、png、gif格式的图片, '${file.name}' 不符合要求`)
}
return isValid
})
if (!isFilesTypeValid) {
return
}
}
const isFilesSizeValid = fileList.every((file) => {
const isValid = file.size <= (this.fileSizeLimit * 1024 * 1024)
if (!isValid) {
this.showError(`文件尺寸不得超过${this.fileSizeLimit}M, '${file.name}' 不符合要求`)
}
return isValid
})
if (!isFilesSizeValid) {
return
}
this.isUploading = true
// 清空文件, 否则上传与上次一样的文件,不会触发change事件
this.$refs.file.value = null
Promise.all(fileList.map((file) => {
return this.upload(file)
}))
.then(() => {
this.isUploading = false
})
.catch((error) => {
this.isUploading = false
this.showError('上传出错')
this.error(error)
})
},
/**
* 上传操作
* @param {File} file - 文件对象
* @return {Promise} *
*/
upload(file) {
this.log('File before compression:', file)
return this.compress(file)
.then((compressedFile) => {
const formData = new FormData()
// 第三个参数是必要的,根据MDN文档,如果第二个参数是Blob类型,那么传递给服务器的文件名将是"blob"
// 第三个参数用于设置传递给服务器的文件名
formData.append('upfile', compressedFile, compressedFile.name)
return this.http.post('/upload', formData)
})
.then((res) => {
const originalName = file.name
const {
url,
} = res.result
if (this.isPicture && this.fileQuantityLimit === 1) {
this.$emit('input', url)
} else {
this.value.push({
name: originalName,
url,
})
}
})
},
/**
* 压缩图片
* @param {File} file - 待压缩的file对象
* @return {Promise} *
*/
compress(file) {
if (FILE_TYPES.indexOf(file.type) === -1) {
this.log('File type is not supported to be compressed, skipped.')
return Promise.resolve(file)
}
return new Promise((resolve, reject) => {
this.log(`File type is '${file.type}', start compressing...`)
new ImageCompressor(file, {
quality: 0.2,
maxWidth: 2560,
maxHeight: 2560,
// eslint-disable-next-line
success: (result) => {
this.log('Compressed file:', result)
const originalSize = Math.round(file.size / 1024)
const compressedSize = Math.round(result.size / 1024)
const rate = ((1 - (compressedSize / originalSize)) * 100).toFixed(2)
this.log(`File compression for '${result.name}': ${originalSize}K => ${compressedSize}K(${rate}% off)`)
resolve(result)
},
// eslint-disable-next-line
error: (e) => {
reject(e)
},
})
})
},
},
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
<!-- 移动端上传组件,已省略样式部分 -->
<template>
<div class="file-uploader-wrapper">
<div v-if="value && isPicture && fileQuantityLimit === 1" class="closable-wrapper">
<fa-icon name="times-circle" @click.native="removeSinglePicture"></fa-icon>
<image-preview :src="value"></image-preview>
</div>
<template v-if="value && isPicture && fileQuantityLimit > 1">
<div v-for="(img, index) in value" :key="index" class="closable-wrapper">
<fa-icon name="times-circle" @click.native="removeMultipleImage(index)"></fa-icon>
<image-preview :src="img.url"></image-preview>
</div>
</template>
<ul v-if="!isPicture" class="attachments-wrapper">
<li v-for="(item, index) in value"
class="closable-wrapper attachments-file-wrapper"
:key="index">
<fa-icon name="times-circle" @click.native="removeAttachment(index)"></fa-icon>
{{item.name | middleEllipsisText}}
</li>
</ul>
<input type="file"
:accept="accept"
:multiple="fileQuantityLimit > 1"
ref="file"
@change="onFileChange">
<div class="file-uploader__cover" v-show="isShowSelector" @click="onClickTrigger">
<span class="file-uploader__icon" :class="{ 'upload-file': !isPicture }"></span>
</div>
<span class="loading" v-show="isUploading">
<mt-spinner type="snake"></mt-spinner>
</span>
</div>
</template>
<script type="text/babel">
import fileUploadMixin from 'common-sdk/components/file-uploader/mixin'
const isIOS = /iphone|ipad|ipod/i.test(global.navigator.userAgent)
export default {
mixins: [fileUploadMixin],
props: {
tip: {
type: String,
default: '最多上传5个,单个文件大小在10M以内',
},
},
mounted() {
// 根据是否多图设置captrue属性,否则有些手机无法拍照上传,比如华为
const $file = this.$refs.file
if (this.fileQuantityLimit === 1 || isIOS) {
return
}
$file.setAttribute('capture', 'camera')
},
methods: {
/**
* 显示错误消息
* @param {string} message - 错误信息
*/
showError(message) {
this.$toast(message)
},
},
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
<!-- 桌面端上传组件,已忽略样式部分 -->
<template>
<div class="file-uploader">
<input type="file"
:multiple="fileQuantityLimit > 1"
:accept="accept"
ref="file"
@change="onFileChange">
<button :disabled="!isShowSelector" @click="onClickTrigger"><i class="el-icon-plus"></i>
上传{{ isPicture ? '图片' : ''}}</button>
<span class="loading" v-show="isUploading">正在上传...</span>
<div class="file-uploader__tip">{{tip}}</div>
<div v-if="value && isPicture && fileQuantityLimit === 1" class="closable-wrapper">
<i class="el-icon-circle-cross" @click="removeSinglePicture"></i>
<image-view-lg v-model="value"></image-view-lg>
</div>
<ul v-if="value && isPicture && fileQuantityLimit > 1" class="img-wrapper">
<li v-for="(img, index) in value" :key="index" class="closable-wrapper">
<i class="el-icon-circle-cross" @click="removeMultipleImage(index)"></i>
<image-view-lg v-model="img.url" class="avatar"></image-view-lg>
</li>
</ul>
<ul v-if="!isPicture" class="attachments-wrapper">
<li v-for="(item, index) in value"
class="closable-wrapper attachments-file-wrapper"
:key="index">
<i class="el-icon-circle-cross" @click="removeAttachment(index)"></i>
<a :href="item.url" target="_blank">
{{item.name}}
</a>
</li>
</ul>
</div>
</template>
<script>
import fileUploadMixin from 'common-sdk/components/file-uploader/mixin'
export default {
mixins: [fileUploadMixin],
props: {
tip: {
type: String,
default: '支持JPG、PNG、GIF、JPEG格式',
},
},
data() {
return {
// PC端用image/*会很慢
accept: this.isPicture ? 'image/jpg,image/jpeg,image/png,image/gif' : '',
}
},
methods: {
/**
* 显示错误消息
* @param {string} message - 错误信息
*/
showError(message) {
this.$alert(message)
},
},
}
</script>

欢迎交流。