Vue extends组件继承

11,624 阅读4分钟

本文需要vue基础,以及对ElementUI有一定的了解

不知道大家在使用vue的时候,是否也像小编一样遇到过下面的场景:

第三方组件封装了具备了核心功能,但是有那么一点不满足业务的需求,或者因为组件太过灵活用起来有点麻烦?

是不是我得把人家源码复制一份到我项目内部再重新写一份呢?如果这个组件引入了一些子组件,岂不是要复制一堆代码?这时候让extends来拯救你吧。

extends在扩展继承第三方组件上功劳很大,不仅保留了原组件的所有功能,还能按照你所需覆盖原有功能,和mixins的区别是:mixins不会覆盖方法和生命周期钩子,但是extends会。

使用ElementUI的小伙伴可能多少被Upload组件困扰。它仅仅提供了以下功能:

  1. 文件选择
  2. 文件上传状态管理
  3. 文件展示
  4. 文件上传

它不支持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需要完成以下功能:

  1. 声明value属性,接收外部v-model绑定传入的值。
  2. 监听组件内部文件变化,向父组件发送input事件。
  3. 监听外部value传入值的变化,并更新内部文件列表。

1. 声明value

props: {
  value: { // 属性声明value
    type: Array
  }
}

2. 监听组件内部文件变化

内部文件由uploadFiles管理,它的值变化直接影响到value值。整理uploadFiles的变化节点有以下:

  1. 调用clearFiles方法清空文件列表,uploadFiles置为空数组
  2. 组件 listType值变化时更新,值变为picture-card或者picture时为每个文件生成url属性
  3. fileList变化,uploadFiles重新初始化。
  4. 选择完文件后,上传前push到uploadFiles结尾
  5. 用户手动删除文件

变化的节点分析完了,我们需要注意的是:

  • 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. 完善细节

检查此前我们提出几个需要注意的点,还有以下问题需要解决:

  1. listType只是展示形式的差别,它的变化无需触发value变化。
  2. valueuploadFiles存在互相触发更新的情况,可能会触发更新死循环。我们需要保证触发valuewatcher的时候,仅仅处理外部条件引发的变更(如:编辑页面数据初始化),忽略uploadFiles导致的变更

以上2个问题其实就是在解决如何区分value变化是外部导致的,我的改动有2个点:

1)添加isInnerChange方法

判断是否是内部变化的方法是:将valueuploadFilesurl值提取出来排序,如果相等,则说明是内部变化。

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 排版