likes
comments
collection
share

从业务开发角度看UI库中upload组件的实现

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

这里以element-plus中的upload组件为例, 其他UI库的实现也差不太多. github地址

在实际场景的使用

上传前需要获取凭证怎么办

一般我们的上传服务是个公共服务, 所以需要获取凭证(有些只需要个token校验, 有些则麻烦些). 我们公司的上传服务呢, 由于历史债务问题, 目前不支持批量上传,所以当我们批量上传时, 只能多次调用.

第一种图省事

把获取凭证的相关流程都写好在一个js里, 直接在onchange中调用 只用upload组件里的onChange事件一把梭😍 第一版只需要单个上传

import { uploadImage } from '@/api/common';
async function change({ raw: file }) {
  const imageUrl = await uploadImage(file);
  emits('update:modelValue', imageUrl[0]);
})

然后自己补相关交互, 一般是图片的预览和上传load状态的显示.

一旦你采取了这种方式, 你就开始走偏了.

过了一阵子, 产品说加个上传视频的功能, 以及上传进度的显示. 你就想着去uploadImage补这个监听逻辑, 一层层传进去监听onUploadProgress事件, 谁怕谁啊🤔

再过了一阵子, 产品说为什么只能一张张图片上传, 我要批量上传. 这时候问题就来了👻👻👻

  1. 每上传完一个资源, 就在下面列表显示返回的cdn地址. 这时候,你感觉到有点不对劲了.但是还是有办法处理, 就是把onUploadProgress的最大值设置为99%来做个障眼法👀, 到最后返回的时候再统一改成100%.
  2. 随着批量上传的文件数量越来越多,问题又来了, 上传报错的概率开始出现了. 对的. 大雷出现了😵

所有上传逻辑封装在了一起, 强耦合了所有逻辑, 导致你的及时反馈能力不及时, 要处理的错误逻辑过多(遇到上传失败是继续执行完其他文件的上传, 还是马上停止, 如果选择马上停止返回, 那后续的UI交互...😌, 如果你再想着在退出页面时abort未上传完的请求). 然后, 我就想upload组件不会这么华而不实吧, 带着疑问去看了源码.

第二种, 正确的使用方式

由于我们接手别人的代码时, 思路被局限在了旧的逻辑里面, 想着以最小改动的原则去兼容旧业务, 导致要处理的逻辑越来越多, 最终不堪重负, 选择重构.

现在来看看upload组件为我们做了什么吧😍, 相关逻辑也很简洁, 可读性很强, 不要看到一大串的代码就慌了. 点明表扬ts, 为这方面做的贡献

事件委托
// 外层div的click事件触发<input type="file">的click事件
  <div
    @click="handleClick"
    @keydown.self.enter.space="handleKeydown"
  >
    <input
      ref="inputRef"
      :class="ns.e('input')"
      :name="name"
      :multiple="multiple"
      :accept="accept"
      type="file"
      @change="handleChange"
      @click.stop
    />
  </div>
  
  const handleClick = () => {
      if (!disabled.value) {
        inputRef.value!.value = ''
        inputRef.value!.click()
      }
    }

    const handleKeydown = () => {
      handleClick()
    }

上传流程的生命周期的设计
// 首当其冲, 选择文件后的change
const handleChange = (e: Event) => {
  const files = (e.target as HTMLInputElement).files
  if (!files) return
  uploadFiles(Array.from(files))
}

let fileId = 1
export const genFileId = () => Date.now() + fileId++

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)
  }
}
// 上面的 onStart 就是👇
  const handleStart: UploadContentProps['onStart'] = (file) => {
    if (isNil(file.uid)) file.uid = genFileId()
    const uploadFile: UploadFile = {
      name: file.name,
      percentage: 0, // 复杂的加载状态得靠这个
      status: 'ready', // 简单的加载状态靠这个参数就够了, 当只是uploading就转圈圈
      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.fileList
    props.onChange(uploadFile, uploadFiles.value) // 外部传入的onChange
  }
  
  const upload = async (rawFile: UploadRawFile) => {
      inputRef.value!.value = ''

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

      let hookResult: Exclude<ReturnType<UploadHooks['beforeUpload']>, Promise<any>>
      try {
        hookResult = await props.beforeUpload(rawFile)
      } catch {
        hookResult = false
      }
        
    // 这个逻辑可以让我们在生命周期中加入限制勾子, 如果不符就返回false
      if (hookResult === false) { 
        props.onRemove(rawFile)
        return
      }

      let file: File = rawFile
      if (hookResult instanceof Blob) {
       // 同理, 想在客户端做相关压缩也可以返回个压缩后的file. 这是文档没说明的.
        if (hookResult instanceof File) {
          file = hookResult
        } else {
          file = new File([hookResult], rawFile.name, {
            type: rawFile.type,
          })
        }
      }


      doUpload(
        Object.assign(file, {
          uid: rawFile.uid,
        })
      )
}

从业务开发角度看UI库中upload组件的实现

const doUpload = (rawFile: UploadRawFile) => {
  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, // 加入凭证信息
    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) // 在里面加了监听和把options.data和file转为formData的形式上传
  requests.value[uid] = request
  if (request instanceof Promise) {
    request.then(options.onSuccess, options.onError)
  }
}
// 上面的 onProgress 就是👇
  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) // 进度条显示数值
  }
// 上面的 onSuccess 就是👇
  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)
  }

总结: 可以看到在handleChange时, 遍历每个文件, 使其每个都当成独立的个体去走完这个生命周期.所有的状态都在fileList可以拿到.

现在我们来解决第一种方案的问题

  1. 获取凭证在beforeUpload做, await auth()凭证信息 赋值给 props.headers/props.data
  2. 相关UI交互还是要做, 毕竟可能以后有其他类型的文件需要显示, 但是文件的状态组件的够用了
  3. 每上传完一个资源, 就在下面列表显示返回的cdn地址. 监听fileList数组, 过滤出response就行,因为相关状态已经解耦, 所以状态能及时反馈.
  4. 批量上传的报错会触发onError, 你可以在那处理报提示. 这里有点需要注意的是, 我其实担心极端情况下会影响上面的第一点, 就是把凭证信息赋值给 props.headers/props.data这里.会存在同时的情况, 但是经过我实践的10张文件大小相同, 名字不同的图片后发现, 会存在时间差的流程, 即使很小.
    • for循环每个实例, 不是同时进入相同的生命周期勾子
    • 网络状况, 服务器状况
    • 组成formData就算成功, 即使没发出请求
 const formData = new FormData()
  if (option.data) {
    for (const [key, value] of Object.entries(option.data)) {
      if (Array.isArray(value)) formData.append(key, ...value)
      else formData.append(key, value)
    }
  }
  formData.append(option.filename, option.file, option.file.name)

  xhr.addEventListener('error', () => {
    option.onError(getError(action, option, xhr))
  })

  xhr.addEventListener('load', () => {
    if (xhr.status < 200 || xhr.status >= 300) {
      return option.onError(getError(action, option, xhr))
    }
    option.onSuccess(getBody(xhr))
  })

  xhr.open(option.method, action, true)

如果有更合理的解释这一现象, 请大佬们在评论区说明🙇🏻‍♂️

总结

没有苦是白吃的, 只有经过第一种方案的洗礼, 你才会注重很多忽视的技术实现细节, 而不是模棱两可。才会明白别人为什么这么设计, 解决了什么问题. 技术圈还是比较浮躁的, 许多都想着弯道超车, 走路都没学会就想跑了, 到后面慢慢的就开始出现焦虑. 还是得有所沉淀, 巩固好自己的知识体系, 而不是当个api调用工程师.

下一步想做什么分享?

因为之前都是用的react, 新公司用的vue3, 所以想把阿里的基于react实现的hook中的useRequest用vue3来做一个简易版, 具有报错重试、轮询、防抖节流的功能.

转载自:https://juejin.cn/post/7204057769493512247
评论
请登录