本文需要vue基础,以及对ElementUI有一定的了解
不知道大家在使用vue的时候,是否也像小编一样遇到过下面的场景:
第三方组件封装了具备了核心功能,但是有那么一点不满足业务的需求,或者因为组件太过灵活用起来有点麻烦?
是不是我得把人家源码复制一份到我项目内部再重新写一份呢?如果这个组件引入了一些子组件,岂不是要复制一堆代码?这时候让extends来拯救你吧。
extends在扩展继承第三方组件上功劳很大,不仅保留了原组件的所有功能,还能按照你所需覆盖原有功能,和mixins的区别是:mixins不会覆盖方法和生命周期钩子,但是extends会。
使用ElementUI的小伙伴可能多少被Upload组件困扰。它仅仅提供了以下功能:
- 文件选择
- 文件上传状态管理
- 文件展示
- 文件上传
它不支持v-model双向绑定,文件上传组件通常是作为表单的一部分使用,其它组件都支持v-model,凭什么Upload要独树一帜,一句话说的好“约定优于配置”,那么我们就加个v-model约束吧。
准备工作
首先,你肯定已经install好ElementUI了吧。接下来,新建my-upload.vue文件:
<!-- my-upload.vue -->
import { Upload } from 'element-ui'
// 错误处理函数(非必须)
import { handleError } from '../../../utils/error'
export default {
extends: Upload // 引入被即将被继承的组件
}
分析改动点
实现v-model需要完成以下功能:
- 声明value属性,接收外部v-model绑定传入的值。
- 监听组件内部文件变化,向父组件发送input事件。
- 监听外部value传入值的变化,并更新内部文件列表。
1. 声明value
props: {
value: { // 属性声明value
type: Array
}
}
2. 监听组件内部文件变化
内部文件由uploadFiles管理,它的值变化直接影响到value值。整理uploadFiles的变化节点有以下:
- 调用
clearFiles方法清空文件列表,uploadFiles置为空数组 - 组件
listType值变化时更新,值变为picture-card或者picture时为每个文件生成url属性 fileList变化,uploadFiles重新初始化。- 选择完文件后,上传前push到
uploadFiles结尾 - 用户手动删除文件
变化的节点分析完了,我们需要注意的是:
listType只是展示形式的差别,它的变化无需触发input事件fileList的功能和v-model有重合,应该要弃用- 选择完文件后,不能立即触发input事件,要等文件上传之后根据上传结果决定,也就是说,如果上传失败,还需要将
uploadFiles中失败的文件删除,且保证不能触发input事件 - 鉴于上传接口的差异,从文件上传结果获取文件地址应该由外部自己定义
将改动点和uploadFiles的变化节点结合,得出大致的组件代码如下:
import { Upload } from 'element-ui'
import { handleError } from '../../../utils/error'
export default {
extends: Upload, // 引入被即将被继承的组件
props: {
// ...
getUrlMethod: {
type: Function,
required: true,
default: noop
}
},
watch: {
fileList: { // 弃用fileList
immediate: true,
handler: noop
}
},
methods: {
/** 更新value */
updateValue() {
const newVal = this.uploadFiles.map(file => file.remoteUrl)
this.$emit('input', newVal)
},
/**
* 重写。上传成功回调增加input事件触发逻辑
*/
handleSuccess(res, rawFile) {
// ...此处粘贴原方法的逻辑
this.updateValue()
},
handleError(err, rawFile) {
// ...此处粘贴原方法的逻辑
this.updateValue()
},
handleRemove(file, raw) {
// ...此处粘贴原方法的逻辑
const doRemove = () => {
// ...此处粘贴原方法的逻辑
this.updateValue()
}
// ...此处粘贴原方法的逻辑
},
clearFiles() {
// ...此处粘贴原方法的逻辑
this.updateValue()
}
}
}
3. 监听外部value传入值的变化,并更新内部文件列表
value: {
immediate: true,
handler(val, oldVal) {
if (!val || !val.length) {
this.uploadFiles = []
}
// 拦截上传结束/删除文件导致的变化
if (this.isInnerChange()) return
this.uploadFiles = val.map(item => {
return {
url: item,
uid: (Date.now() + this.tempIndex++),
remoteUrl: item,
status: 'success'
}
})
}
}
你可能觉得,直接watch uploadFiles不是更简单吗?
首先,watch会监听到uploadFiles的所有属性变更,其中一些是你必须要忽略的,比如说“选择文件后,文件上传前”这个时机。
其次,有些需要监听的节点watch无法捕捉,比如,文件上传成功时,uploadFiles的值是没有变化的。
所以,你无法确保在watch handler中很好的处理好这些工作。另外一方面,watch value的handler中也可能会更新uploadFiles,这样更容易出现意想不到的更新死循环。
4. 完善细节
检查此前我们提出几个需要注意的点,还有以下问题需要解决:
listType只是展示形式的差别,它的变化无需触发value变化。value和uploadFiles存在互相触发更新的情况,可能会触发更新死循环。我们需要保证触发valuewatcher的时候,仅仅处理外部条件引发的变更(如:编辑页面数据初始化),忽略uploadFiles导致的变更
以上2个问题其实就是在解决如何区分value变化是外部导致的,我的改动有2个点:
1)添加isInnerChange方法
判断是否是内部变化的方法是:将value和uploadFiles的url值提取出来排序,如果相等,则说明是内部变化。
isInnerChange() {
let innerValue = this.uploadFiles.map(file => file.remoteUrl)
innerValue = innerValue.sort().join()
let innerValue = this.value.map(file => file)
outerValue = outerValue.sort().join()
return innerValue === outerValue
}
2)valuewatcher handler调用isInnerChange
value: {
immediate: true,
handler(val, oldVal) {
// ...
// 拦截上传结束/删除文件导致的变化
if (this.isInnerChange()) return
// this.uploadFiles = ...
}
}
5. 支持复杂类型
以上的实现只考虑了value为Array[String]的情况,针对组件扩展为支持Array[Object],其中Object必须包含url属性。
完整代码
import { Upload } from 'element-ui'
import { isString, isNumber, isEmpty, isObject, isArray, toArray, noop, isFunction, deepCopy } from '../../../utils'
import { handleError } from '../../../utils/error'
export default {
extends: Upload,
props: {
value: {
required: true
},
getUrlMethod: {
type: Function,
required: true,
default: noop
}
},
watch: {
value: {
immediate: true,
handler(val) {
if (!val || !val.length) {
this.uploadFiles = []
}
// 拦截上传结束/删除文件导致的变化
if (this.isInnerChange()) { return }
this.uploadFiles = val.map(item => {
let fileItem
if (isString(item)) { // 纯字符串
fileItem = {
url: item,
uid: (Date.now() + this.tempIndex++),
remoteUrl: item,
status: 'success'
}
} else if (isObject(item)) {
// item.url字段必须存在
if (!isString(item.url)) {
handleError('upload', 'object类型的文件值"url"字段不能为空')
}
fileItem = {
...item,
remoteUrl: deepCopy(item),
status: item.status || 'success',
uid: item.uid || (Date.now() + this.tempIndex++)
}
} else {
// value格式错误提示
handleError('upload', '值类型不合法')
}
return fileItem
})
}
},
fileList: {
immediate: true,
handler: noop
}
},
methods: {
isInnerChange() {
let innerValue = this.uploadFiles.map(file => {
let url = isObject(file.remoteUrl)
? file.remoteUrl.url
: isString(file.remoteUrl)
? file.remoteUrl
: ''
if (isEmpty(url)) url = ''
return url
})
innerValue = innerValue.sort().join()
let outerValue = (this.value || []).map(valItem => {
let url = isObject(valItem)
? valItem.url
: isString(valItem)
? valItem
: ''
if (isEmpty(url)) url = ''
return url
})
outerValue = outerValue.sort().join()
return innerValue === outerValue
},
updateValue() {
const newVal = this.uploadFiles.map(file => file.remoteUrl)
this.$emit('input', newVal)
},
/**
* 重写handleSuccess
*/
handleSuccess(res, rawFile) {
const file = this.getFile(rawFile)
if (file) {
file.status = 'success'
file.response = res
if (isFunction(this.getUrlMethod)) {
const url = this.getUrlMethod(res)
if (url) { // 成功拿到文件链接
this.onSuccess(res, file, this.uploadFiles)
this.onChange(file, this.uploadFiles)
// url如果是object,则必须有url字段
if (isObject(url) && isEmpty(url.url)) {
handleError('upload', 'getUrlMethod函数返回值未找到文件url')
}
// 便于删除查找value的值
file.remoteUrl = url
this.updateValue()
} else { // 文件链接获取失败
this.handleError(new Error('文件上传失败,或文件链接获取失败'), rawFile)
}
} else {
handleError('upload', '无法获取文件服务器返回的文件链接,请为组件配置getUrlMethod属性')
}
}
},
handleError(err, rawFile) {
const file = this.getFile(rawFile)
const fileList = this.uploadFiles
file.status = 'fail'
fileList.splice(fileList.indexOf(file), 1)
this.updateValue()
this.onError(err, file, this.uploadFiles)
this.onChange(file, this.uploadFiles)
},
handleRemove(file, raw) {
if (raw) {
file = this.getFile(raw)
}
const doRemove = () => {
this.abort(file)
const fileList = this.uploadFiles
fileList.splice(fileList.indexOf(file), 1)
this.updateValue()
this.onRemove(file, fileList)
}
if (!this.beforeRemove) {
doRemove()
} else if (typeof this.beforeRemove === 'function') {
const before = this.beforeRemove(file, this.uploadFiles)
if (before && before.then) {
before.then(() => {
doRemove()
}, noop)
} else if (before !== false) {
doRemove()
}
}
}
}
}
知识补充
在看ElementUI的Upload实现时,发现了一段用心的代码:
beforeDestroy() {
this.uploadFiles.forEach(file => {
if (file.url && file.url.indexOf('blob:') === 0) {
URL.revokeObjectURL(file.url);
}
});
}
大家看到beforeDestroy这个钩子大概就能猜到是关于内存释放的,有兴趣的同学可以去研究下URL对象。
本文使用 mdnice 排版