likes
comments
collection
share

【源码学习】第26期 | element-plus的upload组件是如何实现的?

作者站长头像
站长
· 阅读数 18

前言

    相信大家在实际项目中都遇到过需要上传图片、文件的需求,应该也有不少同学借助过element-plus的upload组件来实现,那么element-plus的upload组件是如何实现上传的,原理是什么?今天借助源码来一起探究一下~

组件介绍

  • 功能     通过点击或者拖拽上传文件。
  • 属性及方法 鉴于文章篇幅不宜过长,此处仅截图外部方法及插槽,详细属性可参照官网

【源码学习】第26期 | element-plus的upload组件是如何实现的?

收获清单

  • el-upload的实现
  • useSlots插槽的应用
  • 如何使用拖拽来选择文件等

源码下载

    学习源码前可以先看看README.md或者CONTRIBUTING.md,上面一般会介绍基础用法及运行环境要求~

    git clone https://github.com/element-plus/element-plus.git
    cd element-plus
    pnpm install
    pnpm docs:dev
  • 出现这样的界面就说明运行成功了: 【源码学习】第26期 | element-plus的upload组件是如何实现的?
  • 借助vue-devtools打开源码位置 【源码学习】第26期 | element-plus的upload组件是如何实现的? 下面开始逐一分析源码

源码分析

upload主文件

1.1 模板元素

<div>
  <upload-list v-if="isPictureCard && showFileList" :disabled="disabled" :list-type="listType" :files="uploadFiles"
    :handle-preview="onPreview" @remove="handleRemove">
    <template v-if="$slots.file" #default="{ file }">
      <slot name="file" :file="file" />
    </template>
    <template #append>
      <upload-content ref="uploadRef" v-bind="uploadContentProps">
        <slot v-if="slots.trigger" name="trigger" />
        <slot v-if="!slots.trigger && slots.default" />
      </upload-content>
    </template>
  </upload-list>
  
  <upload-content v-if="!isPictureCard || (isPictureCard && !showFileList)" ref="uploadRef" v-bind="uploadContentProps">
    <slot v-if="slots.trigger" name="trigger" />
    <slot v-if="!slots.trigger && slots.default" />
  </upload-content>

  <slot v-if="$slots.trigger" />
  <slot name="tip" />
  <upload-list v-if="!isPictureCard && showFileList" :disabled="disabled" :list-type="listType" :files="uploadFiles"
    :handle-preview="onPreview" @remove="handleRemove">
    <template v-if="$slots.file" #default="{ file }">
      <slot name="file" :file="file" />
    </template>
  </upload-list>
</div>
  • upload-content组件包含触发文件选择框的内容
  • upload-list组件主要展示已上传文件列表
  • $slots一个表示父组件所传入插槽的对象。通常用于手写渲染函数,但也可用于检测是否存在插槽。

主要分以下三种情况渲染

  1. 文件列表的类型是照片墙并且显示已上传文件列表
  2. 文件列表的类型不是照片墙或者文件列表的类型是照片墙但不展示已上传文件列表
  3. 文件列表的类型不是照片墙并且展示已上传文件列表

1.2 ts部分

<script lang="ts" setup>
  // 引入依赖
import {
  computed,
  onBeforeUnmount,
  provide,
  shallowRef,
  toRef,
  useSlots,
} from 'vue'
import { useFormDisabled } from '@element-plus/components/form'
import { uploadContextKey } from './constants'
import UploadList from './upload-list.vue'
import UploadContent from './upload-content.vue'
import { useHandlers } from './use-handlers'
import { uploadProps } from './upload'

import type {
  UploadContentInstance,
  UploadContentProps,
} from './upload-content'
// 定义组件名
defineOptions({
  name: 'ElUpload',
})
// props
const props = defineProps(uploadProps)
// 插槽
const slots = useSlots()
const disabled = useFormDisabled()

const uploadRef = shallowRef<UploadContentInstance>()
// 上传文件函数
const {
  abort,
  submit,
  clearFiles,
  uploadFiles,
  handleStart,
  handleError,
  handleRemove,
  handleSuccess,
  handleProgress,
} = useHandlers(props, uploadRef)
// 判断文件列表样式是否是照片墙
const isPictureCard = computed(() => props.listType === 'picture-card')

const uploadContentProps = computed<UploadContentProps>(() => ({
  ...props,
  fileList: uploadFiles.value,
  onStart: handleStart,
  onProgress: handleProgress,
  onSuccess: handleSuccess,
  onError: handleError,
  onRemove: handleRemove,
}))

onBeforeUnmount(() => {
  uploadFiles.value.forEach(({ url }) => {
    // 释放对象 URL,当图片加载完成之后对象 URL 就不再需要了
    if (url?.startsWith('blob:')) URL.revokeObjectURL(url)
  })
})
// 提供accept值依赖
provide(uploadContextKey, {
  accept: toRef(props, 'accept'),
})
// 给父组件暴露方法
defineExpose({
  /** @description cancel upload request */
  abort,
  /** @description upload the file list manually */
  submit,
  /** @description clear the file list  */
  clearFiles,
  /** @description select the file manually */
  handleStart,
  /** @description remove the file manually */
  handleRemove,
})
</script>

1.2.1 useSlots

    具体的用法vue官网有介绍,主要作用是在在 <script setup> 使用 slots,其实不太理解为何明明可以在模板中用$slots来获取插槽,并且这里的插槽都是在模板中渲染,却还是引入了useSlots~

【源码学习】第26期 | element-plus的upload组件是如何实现的?

1.2.2 useFormDisabled

export const useFormDisabled = (fallback?: MaybeRef<boolean | undefined>) => {

  const disabled = useProp<boolean>('disabled')

  const form = inject(formContextKey, undefined)

  return computed(

    () => disabled.value || unref(fallback) || form?.disabled || false

  )

}

    注入由form表单提供key为formContextKey的值,如果设置为 true, 它将覆盖内部组件的 disabled 属性

1.2.3 useHandlers.ts

    这个文件主要是处理文件的一些hooks

// 代码有删减
const SCOPE = 'ElUpload'
// 释放对象URL
const revokeObjectURL = (file: UploadFile) => {
  if (file.url?.startsWith('blob:')) {
    URL.revokeObjectURL(file.url)
  }
}

export const useHandlers = (
  props: UploadProps,
  uploadRef: ShallowRef<UploadContentInstance | undefined>
) => {
  // 上传的文件列表
  const uploadFiles = useVModel(
    props as Omit<UploadProps, 'fileList'> & { fileList: UploadFiles },
    'fileList',
    undefined,
    { passive: true }
  )
  // 根据uid获取文件
  const getFile = (rawFile: UploadRawFile) =>
    uploadFiles.value.find((file) => file.uid === rawFile.uid)
  // 放弃上传
  function abort(file: UploadFile) {
    uploadRef.value?.abort(file)
  }
  // 根据状态清除文件
  function clearFiles(
    /** @default ['ready', 'uploading', 'success', 'fail'] */
    states: UploadStatus[] = ['ready', 'uploading', 'success', 'fail']
  ) {
    uploadFiles.value = uploadFiles.value.filter(
      (row) => !states.includes(row.status)
    )
  }
  /* 上传文件错误处理
     ①将文件的状态置为fail
     ②在文件列表中清除该文件
     ③调用文件上传失败时的钩子
     ④调用文件状态改变时的钩子
  **/
  const handleError: UploadContentProps['onError'] = (err, rawFile) => {
    const file = getFile(rawFile)
    if (!file) return

    console.error(err)
    file.status = 'fail'
    uploadFiles.value.splice(uploadFiles.value.indexOf(file), 1)
    props.onError(err, file, uploadFiles.value)
    props.onChange(file, uploadFiles.value)
  }

  const handleProgress: UploadContentProps['onProgress'] = (evt, rawFile) => {
    const file = getFile(rawFile)
    if (!file) return

    props.onProgress(evt, file, uploadFiles.value)
    file.status = 'uploading'
    file.percentage = Math.round(evt.percent)
  }
  /**
   * 上传成功处理
   * ①没有文件中断执行
   * ②将文件状态改为success
   * ③调用文件上传成功时钩子
   * ④调用文件状态改变时的钩子
   */
  const handleSuccess: UploadContentProps['onSuccess'] = (
    response,
    rawFile
  ) => {
    const file = getFile(rawFile)
    if (!file) return

    file.status = 'success'
    file.response = response
    props.onSuccess(response, file, uploadFiles.value)
    props.onChange(file, uploadFiles.value)
  }
  /**
   * 文件开始上传处理
   * ①检查文件的uid 是否是 null 或者 undefined,若是则按照时间动态赋值uid
   * ②定义类型为UploadFile的文件变量
   * ③若文件列表类型为照片墙或照片,则使用URL.createObjectURL() 静态方法创建一个 DOMString,其中包含一个表示参数中给出的对象的 URL。这个新的 URL 对象表示指定的 File 对象或 Blob 对象。若创建有误则打印警告并调用文件上传失败时的钩子。
   * ④将该文件变量添加到文件列表
   * ⑤调用文件状态改变时的钩子
   * 
   */
  const handleStart: UploadContentProps['onStart'] = (file) => {
    if (isNil(file.uid)) file.uid = genFileId()
    const uploadFile: UploadFile = {
      name: file.name,
      percentage: 0,
      status: 'ready',
      size: file.size,
      raw: file,
      uid: file.uid,
    }
    if (props.listType === 'picture-card' || props.listType === 'picture') {
      try {
        uploadFile.url = URL.createObjectURL(file)
      } catch (err: unknown) {
        debugWarn(SCOPE, (err as Error).message)
        props.onError(err as Error, uploadFile, uploadFiles.value)
      }
    }
    uploadFiles.value = [...uploadFiles.value, uploadFile]
    props.onChange(uploadFile, uploadFiles.value)
  }

  /**
   * 移除指定文件处理
   * doRemove函数的作用:
   * ①取消上传指定文件
   * ②将指定文件从文件列表中移除
   * ③调用文件列表移除文件时的钩子
   * ④释放对象URL
   * 若删除文件前的钩子返回 false 或者返回 Promise 且被 reject,则停止删除。
   */
  const handleRemove: UploadContentProps['onRemove'] = async (
    file
  ): Promise<void> => {
    const uploadFile = file instanceof File ? getFile(file) : file
    if (!uploadFile) throwError(SCOPE, 'file to be removed not found')

    const doRemove = (file: UploadFile) => {
      abort(file)
      const fileList = uploadFiles.value
      fileList.splice(fileList.indexOf(file), 1)
      props.onRemove(file, fileList)
      revokeObjectURL(file)
    }

    if (props.beforeRemove) {
      const before = await props.beforeRemove(uploadFile, uploadFiles.value)
      if (before !== false) doRemove(uploadFile)
    } else {
      doRemove(uploadFile)
    }
  }
  /**
   * 将文件列表中状态为ready的文件提交上传
   */
  function submit() {
    uploadFiles.value
      .filter(({ status }) => status === 'ready')
      .forEach(({ raw }) => raw && uploadRef.value?.upload(raw))
  }
  /**
   * 监听文件列表类型
   * 若文件列表类型为照片墙或照片,则使用URL.createObjectURL() 静态方法创建一个 DOMString,其中包含一个表示参数中给出的对象的 URL。这个新的 URL 对象表示指定的 File 对象或 Blob 对象。
   */
  watch(
    () => props.listType,
    (val) => {
      if (val !== 'picture-card' && val !== 'picture') {
        return
      }

      uploadFiles.value = uploadFiles.value.map((file) => {
        const { raw, url } = file
        if (!url && raw) {
          try {
            file.url = URL.createObjectURL(raw)
          } catch (err: unknown) {
            props.onError(err as Error, file, uploadFiles.value)
          }
        }
        return file
      })
    }
  )

  watch(
    uploadFiles,
    (files) => {
      for (const file of files) {
        // 等价于 file.uid || file.uid=genFileId()
        file.uid ||= genFileId()
        file.status ||= 'success'
      }
    },
    { immediate: true, deep: true }
  )

 // 删减代码
}

</details>

upload-content 文件

<template>
  <div
    :class="[ns.b(), ns.m(listType), ns.is('drag', drag)]"
    tabindex="0"
    @click="handleClick"
    @keydown.self.enter.space="handleKeydown"
  >
  <!-- 启用拖拽上传显示区 -->
    <template v-if="drag">
      <upload-dragger :disabled="disabled" @file="uploadFiles">
        <slot />
      </upload-dragger>
    </template>
   <!-- 点击上传 -->
    <template v-else>
      <slot />
    </template>
    <!-- 选择文件 -->
    <input
      ref="inputRef"
      :class="ns.e('input')"
      :name="name"
      :multiple="multiple"
      :accept="accept"
      type="file"
      @change="handleChange"
      @click.stop
    />
  </div>
</template>

<script lang="ts" setup>
// 删减代码
defineOptions({
  name: 'ElUploadContent',
  inheritAttrs: false,
})

const props = defineProps(uploadContentProps)
// bem类名
const ns = useNamespace('upload')
// 是否禁用上传
const disabled = useFormDisabled()
// 请求对象
const requests = shallowRef<Record<string, XMLHttpRequest | Promise<unknown>>>(
  {}
)
// input元素
const inputRef = shallowRef<HTMLInputElement>()
/*
*上传文件列表
*①若上传文件数超过限制,终止函数
 ②若不支持多选文件,一次只选择一个文件
 ③遍历files列表,给文件添加uid属性,若自动上传则开始上传文件
*/
const uploadFiles = (files: File[]) => {
  if (files.length === 0) return

  const { autoUpload, limit, fileList, multiple, onStart, onExceed } = props

  if (limit && fileList.length + files.length > limit) {
    onExceed(files, fileList)
    return
  }

  if (!multiple) {
    files = files.slice(0, 1)
  }

  for (const file of files) {
    const rawFile = file as UploadRawFile
    rawFile.uid = genFileId()
    onStart(rawFile)
    if (autoUpload) upload(rawFile)
  }
}
// 上传函数
const upload = async (rawFile: UploadRawFile) => {
  inputRef.value!.value = ''

  if (!props.beforeUpload) {
    return doUpload(rawFile)
  }

  let hookResult: Exclude<ReturnType<UploadHooks['beforeUpload']>, Promise<any>>
  let beforeData: UploadContentProps['data'] = {}

  try {
    // origin data: Handle data changes after synchronization tasks are executed
    const originData = props.data
    const beforeUploadPromise = props.beforeUpload(rawFile)
    beforeData = isObject(props.data) ? cloneDeep(props.data) : props.data
    hookResult = await beforeUploadPromise
    if (isObject(props.data) && isEqual(originData, beforeData)) {
      beforeData = cloneDeep(props.data)
    }
  } catch {
    hookResult = false
  }

  if (hookResult === false) {
    props.onRemove(rawFile)
    return
  }

  let file: File = rawFile
  if (hookResult instanceof Blob) {
    if (hookResult instanceof File) {
      file = hookResult
    } else {
      file = new File([hookResult], rawFile.name, {
        type: rawFile.type,
      })
    }
  }

  doUpload(
    Object.assign(file, {
      uid: rawFile.uid,
    }),
    beforeData
  )
}
// 将文件上传到服务器
const doUpload = (
  rawFile: UploadRawFile,
  beforeData?: UploadContentProps['data']
) => {
  const {
    headers,
    data,
    method,
    withCredentials,
    name: filename,
    action,
    onProgress,
    onSuccess,
    onError,
    httpRequest,
  } = props

  const { uid } = rawFile
  const options: UploadRequestOptions = {
    headers: headers || {},
    withCredentials,
    file: rawFile,
    data: beforeData ?? data,
    method,
    filename,
    action,
    onProgress: (evt) => {
      onProgress(evt, rawFile)
    },
    onSuccess: (res) => {
      onSuccess(res, rawFile)
      delete requests.value[uid]
    },
    onError: (err) => {
      onError(err, rawFile)
      delete requests.value[uid]
    },
  }
  const request = httpRequest(options)
  requests.value[uid] = request
  if (request instanceof Promise) {
    request.then(options.onSuccess, options.onError)
  }
}
// 更新文件列表
const handleChange = (e: Event) => {
  const files = (e.target as HTMLInputElement).files
  if (!files) return
  uploadFiles(Array.from(files))
}
// 点击选择上传文件
const handleClick = () => {
  if (!disabled.value) {
    inputRef.value!.value = ''
    inputRef.value!.click()
  }
}
// 键盘事件触发点击上传
const handleKeydown = () => {
  handleClick()
}
// 取消上传文件
const abort = (file?: UploadFile) => {
  const _reqs = entriesOf(requests.value).filter(
    file ? ([uid]) => String(file.uid) === uid : () => true
  )
  _reqs.forEach(([uid, req]) => {
    if (req instanceof XMLHttpRequest) req.abort()
    delete requests.value[uid]
  })
}
// 将终止上传跟上传函数暴露出去
defineExpose({
  abort,
  upload,
})
</script>

upload-dragger.vue

    主要是借助拖拽事件DataTransfer对象来获取文件

<template>
  <div
    :class="[ns.b('dragger'), ns.is('dragover', dragover)]"
    @drop.prevent="onDrop"
    @dragover.prevent="onDragover"
    @dragleave.prevent="dragover = false"
  >
    <slot />
  </div>
</template>
<script lang="ts" setup>
import { inject, ref } from 'vue'
import { useNamespace } from '@element-plus/hooks'
import { useFormDisabled } from '@element-plus/components/form'
import { throwError } from '@element-plus/utils/error'
import { uploadContextKey } from './constants'
import { uploadDraggerEmits, uploadDraggerProps } from './upload-dragger'

const COMPONENT_NAME = 'ElUploadDrag'

defineOptions({
  name: COMPONENT_NAME,
})

defineProps(uploadDraggerProps)
const emit = defineEmits(uploadDraggerEmits)

const uploaderContext = inject(uploadContextKey)
if (!uploaderContext) {
  throwError(
    COMPONENT_NAME,
    'usage: <el-upload><el-upload-dragger /></el-upload>'
  )
}
// css bem类名
const ns = useNamespace('upload')
// 当元素或选中的文本是否被拖到一个可释放目标上
const dragover = ref(false)
// 是否禁用上传
const disabled = useFormDisabled()
// 当元素或选中的文本在可释放目标上被释放时触发
const onDrop = (e: DragEvent) => {
  if (disabled.value) return
  dragover.value = false

  e.stopPropagation()
  /* DataTransfer 对象用于保存拖动并放下(drag and drop)过程中的数据。它可以保存一项或多项数据,这些数据项可以是一种或者多种数据类型。
     e.dataTransfer!.files 包含数据传输中可用的所有本地文件的列表。如果拖动操作不涉及拖动文件,则此属性为空列表。
  **/
  const files = Array.from(e.dataTransfer!.files)
  const accept = uploaderContext.accept.value
  if (!accept) {
    emit('file', files)
    return
  }

  const filesFiltered = files.filter((file) => {
    const { type, name } = file
    const extension = name.includes('.') ? `.${name.split('.').pop()}` : ''
    const baseType = type.replace(/\/.*$/, '')
    return accept
      .split(',')
      .map((type) => type.trim())
      .filter((type) => type)
      .some((acceptedType) => {
        if (acceptedType.startsWith('.')) {
          return extension === acceptedType
        }
        if (/\/\*$/.test(acceptedType)) {
          return baseType === acceptedType.replace(/\/\*$/, '')
        }
        if (/^[^/]+\/[^/]+$/.test(acceptedType)) {
          return type === acceptedType
        }
        return false
      })
  })

  emit('file', filesFiltered)
}
// 当元素或选中的文本被拖到一个可释放目标上时触发(每 100 毫秒触发一次)。
const onDragover = () => {
  if (!disabled.value) dragover.value = true
}
</script>

upload-list 文件

    需要展示已上传文件列表的组件

总结

    今天学习分析了el-upload组件的实现,即主要通过input元素或拖拽事件的DataTransfer 对象来获取上传文件,并通过文件API处理选中的文件,加深了对插槽、依赖注入、拖拽文件的应用理解,最后,用一句话形容学源码:放弃很易,但坚持一定很酷!