【架构师(第三十二篇)】 通用上传组件开发及测试用例

2022-12-10 13:44:59 浏览数 (1)


主要内容

  • 使用 TDD 的开发方式,一步步开发一个上传组件
  • 分析 Element Plus 中的 uploader 组件的源码
  • 将上传组件应用到编辑器中
  • 对于知识点的发散和总结
    • Vue3 中实例的类型
    • Vue3 中组件通讯方法
    • 预览本地图片的两种方法
    • HtmlImgElement 家族的一系列关系
    • JSDOM 是什么? Jest 是怎么使用它来模拟浏览器环境的

上传组件需求分析

  • 基本上传流程
    • 点击按钮选择文件,完成上传
  • 支持查看上传文件列表
    • 文件名称
    • 上传状态
    • 上传进度
    • 删除按钮
    • 其它更丰富的显示
  • 自定义模板
    • 初始容器自定义
    • 上传完毕自定义
  • 支持一系列的生命周期钩子函数,上传事件
    • beforeUpload
    • onSuccess
    • onError
    • onChange
    • onProgress
      • 使用 aixos 内置 Api
      • 设置事件的参数
  • 支持拖拽上传
    • dargoverdargLeave 添加或者删除对应的 class
    • drop 事件拿到正在拖拽的文件,删除 class 并且触发上传
    • 事件是可选的,只有在属性 dargtrue 的时候才会生效
  • 支持手动上传
  • 等等
    • 支持自定义 headers
    • 自定义 file 的表单名称
    • 更多需要发送的数据
    • input 原生属性 multiple
    • input 原生属性 accept
    • with-credentials 发送时是否支持发送 cookie

上传文件的原理

enctype

  • 表单默认: application/x-www-form-urlencoded
  • 二进制数据: multipart/form-data

传统模式

通过 input type="file", 然后触发 formsubmit 上传。

代码语言:javascript复制
    <from method="post"
          action="http://api/upload"
          enctype="multipart/form-data">
      <input type="file">
      <button type="submit">Submit </button>
    </from>

使用 js 模拟

代码语言:javascript复制
    <input type="file"
           name="file"
           @change="handleFileChange">

Input 获取 Files

  • e.target.filesFileList 对象,它是一个类数组,并不是真正的数组。
  • 可以通过 files[index] 拿到对应的文件,它是 File 对象。
  • FormData 是针对 XHR2 设计的数据结构,可以完美模拟 HTMLform 标签。
代码语言:javascript复制
import axios from 'axios';
const handleFileChange = (e: Event) => {
  // 获取文件列表
  const target = e.target as HTMLInputElement
  const files = target.files
  if (files) {
    // 获取文件
    const uploadedFile = files[0]
    // 创建 FormData 数据结构
    const formData = new FormData()
    // 往 FormData 中 添加数据
    formData.append(uploadedFile.name, uploadedFile)
    // 发送请求
    axios.post('/api/upload', formData, {
      headers: {
        // 需要在请求头中设置类型
        'Content-Type': "multipart/form-data"
      }
    }).then((resp) => {
      console.log(resp.data);
    })
  }
}

编写测试用例

基础结构

代码语言:javascript复制
import type { VueWrapper } from '@vue/test-utils';
import { shallowMount } from '@vue/test-utils';
import Uploader from '@/components/Uploader.vue';
import axios from 'axios';
import flushPromises from 'flush-promises';

jest.mock('axios');
//将 mock 对象断言为特定类型 使用 jest.Mocked<T>
const mockAxios = axios as jest.Mocked<typeof axios>;
// 定义 wrapper
let wrapper: VueWrapper<any>;
// 定义测试文件
const testFile = new File(['xyz'], 'test.png', { type: 'image/png' });
// 测试 UserProfile.vue
describe('UserProfile.vue', () => {
  beforeAll(() => {
    // 获取组件
    wrapper = shallowMount(Uploader, {
      // 传入到组件内部的属性
      props: { action: 'https://jsonplaceholder.typicode.com/posts/' },
    });
  });
  afterEach(() => {
    // 重置 post 请求
    mockAxios.post.mockReset();
  });
});

测试初始界面渲染

代码语言:javascript复制
  it('basic layout before uploading', async () => {
    // 存在上传按钮
    expect(wrapper.find('button').exists()).toBeTruthy();
    // 按钮文字是点击上传
    expect(wrapper.get('button').text()).toBe('点击上传');
    // input 是隐藏的
    expect(wrapper.get('input').isVisible()).toBeFalsy();
  });

测试上传成功

代码语言:javascript复制
  it('upload process should works fine', async () => {
    // mock 成功的请求
    mockAxios.post.mockResolvedValueOnce({ status: 'success' });
    // 模拟 input 的 e.target.files
    const fileInput = wrapper.get('input').element as HTMLInputElement;
    const files = [testFile] as any;
    Object.defineProperty(fileInput, 'files', {
      value: files,
      writable: false,
    });
    // 触发 change 事件
    await wrapper.get('input').trigger('change');
    // post 请求被调用一次
    expect(mockAxios.post).toHaveBeenCalledTimes(1);
    // 按钮文字为 正在上传
    expect(wrapper.get('button').text()).toBe('正在上传');
    // 按钮状态为禁用
    expect(wrapper.get('button').attributes()).toHaveProperty('disabled');
    // 列表长度修改, 并且有正确的 class
    expect(wrapper.findAll('li').length).toBe(1);
    // 获取列表第一个元素
    const firstItem = wrapper.get('li:first-child');
    // 元素的类名包含 uploading
    expect(firstItem.classes()).toContain('upload-loading');
    // 清除 promise
    await flushPromises();
    // 按钮文字为点击上传
    expect(wrapper.get('button').text()).toBe('点击上传');
    // 元素的类名包含 upload-success
    expect(firstItem.classes()).toContain('upload-success');
    // 元素的内容正确
    expect(firstItem.get('.filename').text()).toBe(testFile.name);
  });

测试上传失败

代码语言:javascript复制
  it('should return error text when post is rejected', async () => {
    // mock 失败的请求
    mockAxios.post.mockRejectedValueOnce({ error: 'error' });
    // 触发 change 事件
    await wrapper.get('input').trigger('change');
    // post 请求被调用2次
    expect(mockAxios.post).toHaveBeenCalledTimes(2);
    // 按钮文字为正在上传
    expect(wrapper.get('button').text()).toBe('正在上传');
    // 清除 promise
    await flushPromises();
    // 按钮文字为正在上传
    expect(wrapper.get('button').text()).toBe('点击上传');
    // 列表长度增加 列表的最后一项有正确的class名
    expect(wrapper.findAll('li').length).toBe(2);
    // 获取最后一个元素
    const lastItem = wrapper.get('li:last-child');
    // 元素的类名包含 upload-error
    expect(lastItem.classes()).toContain('upload-error');
    // 点击删除图标,可以删除这一项
    await lastItem.get('.delete-icon').trigger('click');
    // 列表长度减少1
    expect(wrapper.findAll('li').length).toBe(2);
  });

测试自定义插槽

代码语言:javascript复制
  it('should show current custom slot', async () => {
    // 成功的请求
    mockAxios.post.mockResolvedValueOnce({ data: { url: 'aa.url' } });
    // 获取 wrapper
    const wrapper = shallowMount(Uploader, {
      props: {
        action: 'https://jsonplaceholder.typicode.com/posts/',
      },
      slots: {
        default: '<button>Custom Button</button>',
        loading: "<div class='loading'>Custom Loading</div>",
        uploaded: `<template #uploaded="{ uploadedData }">
          <div class='custom-loaded'>{{uploadedData.url}}</div>
        </template>`,
      },
    });
    // 自定义上传按钮
    expect(wrapper.get('button').text()).toBe('Custom Button');
    // 模拟 input 的 e.target.files
    const fileInput = wrapper.get('input').element as HTMLInputElement;
    const files = [testFile] as any;
    Object.defineProperty(fileInput, 'files', {
      value: files,
      writable: false,
    });
    // 触发 change 事件
    await wrapper.get('input').trigger('change');
    // 自定义loading
    expect(wrapper.get('.loading').text()).toBe('Custom Loading');
    // 清除 promise
    await flushPromises();
    // 自定义文件名称
    expect(wrapper.get('.custom-loaded').text()).toBe('aa.url');
  });

测试上传前检查

代码语言:javascript复制
  it('before upload check', async () => {
    // 模拟一个回调函数
    const callback = jest.fn();
    // 模拟post请求
    mockAxios.post.mockResolvedValueOnce({ data: { url: 'aa.url' } });
    // 模拟上传前的check
    const checkFileSize = (file: File) => {
      if (file.size > 2) {
        callback();
        return false;
      }
      return true;
    };
    const wrapper = shallowMount(Uploader, {
      props: {
        action: 'https://jsonplaceholder.typicode.com/posts/',
        beforeUpload: checkFileSize,
      },
    });
    // 模拟 input 的 e.target.files
    const fileInput = wrapper.get('input').element as HTMLInputElement;
    const files = [testFile] as any;
    Object.defineProperty(fileInput, 'files', {
      value: files,
      writable: false,
    });
    // 触发 input 的 change 事件
    await wrapper.get('input').trigger('change');
    // post 请求没有被触发
    expect(mockAxios.post).not.toHaveBeenCalled();
    // 页面中没有生成 li
    expect(wrapper.findAll('li').length).toBe(0);
    // 回调函数被触发
    expect(callback).toHaveBeenCalled();
  });

测试上传前检查 使用失败的 promise

代码语言:javascript复制
  it('before upload check using Promise file', async () => {
    // 模拟 post 请求
    mockAxios.post.mockRejectedValueOnce({ data: { url: 'aa.url' } });
    // 失败的情况
    const failedPromise = (file: File) => {
      return Promise.reject('wrong type');
    };
    const wrapper = shallowMount(Uploader, {
      props: {
        action: 'https://jsonplaceholder.typicode.com/posts/',
        beforeUpload: failedPromise,
      },
    });
    // 模拟 input 的 e.target.files
    const fileInput = wrapper.get('input').element as HTMLInputElement;
    const files = [testFile] as any;
    Object.defineProperty(fileInput, 'files', {
      value: files,
      writable: false,
    });
    // 触发 input 的 change 事件
    await wrapper.get('input').trigger('change');
    // 清除 promise
    await flushPromises();
    // post 请求没有被触发
    expect(mockAxios.post).not.toHaveBeenCalled();
    // 页面中没有生成 li
    expect(wrapper.findAll('li').length).toBe(0);
  });

测试上传前检查 使用成功的 promise

代码语言:javascript复制
  it('before upload check using Promise success', async () => {
    // 模拟 post 请求
    mockAxios.post.mockRejectedValueOnce({ data: { url: 'aa.url' } });
    // 成功的情况
    const successPromise = (file: File) => {
      const newFile = new File([file], 'new_name.docx', { type: file.type });
      return Promise.reject(newFile);
    };
    const wrapper = shallowMount(Uploader, {
      props: {
        action: 'https://jsonplaceholder.typicode.com/posts/',
        beforeUpload: successPromise,
      },
    });
    // 模拟 input 的 e.target.files
    const fileInput = wrapper.get('input').element as HTMLInputElement;
    const files = [testFile] as any;
    Object.defineProperty(fileInput, 'files', {
      value: files,
      writable: false,
    });
    // 触发 input 的 change 事件
    await wrapper.get('input').trigger('change');
    // 清除 promise
    await flushPromises();
    // post 请求被触发
    expect(mockAxios.post).toHaveBeenCalled();
    // 页面中生成了一个 li
    expect(wrapper.findAll('li').length).toBe(1);
    // 获取列表第一个元素
    const firstItem = wrapper.get('li:first-child');
    // 元素的类名包含 upload-success
    expect(firstItem.classes()).toContain('upload-success');
    // 元素的内容正确
    expect(firstItem.get('.filename').text()).toBe('new_name.docx');

    // 成功的情况 返回了错误类型
    const successPromiseWrongType = (file: File) => {
      const newFile = new File([file], 'new_name.docx', { type: file.type });
      return Promise.reject(newFile);
    };
    // 设置 props
    await wrapper.setProps({ beforeUpload: successPromiseWrongType });
    // 触发 input 的 change 事件
    await wrapper.get('input').trigger('change');
    // 清除 promise
    await flushPromises();
    // post 请求没有被触发
    expect(mockAxios.post).not.toHaveBeenCalled();
    // 页面中没有生成 li
    expect(wrapper.findAll('li').length).toBe(0);
  });

测试拖拽功能

代码语言:javascript复制
 it('test drag and drop function', async () => {
    // 模拟 post 请求
    mockAxios.post.mockResolvedValueOnce({ data: { url: 'aa.url' } });
    const wrapper = shallowMount(Uploader, {
      props: {
        action: 'https://jsonplaceholder.typicode.com/posts/',
        drag: true,
      },
    });
    // 获取上传区域
    const uploadArea = wrapper.get('.upload-area');
    // 触发 dragover 事件
    await uploadArea.trigger('dragover');
    // 存在类名
    expect(uploadArea.classes()).toContain('is-dragover');
    // 触发 dragleave 事件
    await uploadArea.trigger('dragleave');
    // 不存在类名
    expect(uploadArea.classes()).not.toContain('is-dragover');
    // 触发 drop 事件
    await uploadArea.trigger('drop', { dataTransfer: { files: [testFile] } });
    // post 请求被触发
    expect(mockAxios.post).toHaveBeenCalled();
    // 页面中生成了一个 li
    expect(wrapper.findAll('li').length).toBe(1);
  });

测试手动上传

代码语言:javascript复制
  it('test manual upload process', async () => {
    // 模拟 post 请求
    mockAxios.post.mockResolvedValueOnce({ data: { url: 'aa.url' } });
    const wrapper = shallowMount(Uploader, {
      props: {
        action: 'https://jsonplaceholder.typicode.com/posts/',
        drag: true,
        autoUpload: false,
      },
    });
    // 模拟 input 的 e.target.files
    const fileInput = wrapper.get('input').element as HTMLInputElement;
    const files = [testFile] as any;
    Object.defineProperty(fileInput, 'files', {
      value: files,
      writable: false,
    });
    // 触发 input 的 change 事件
    await wrapper.get('input').trigger('change');
    // 获取列表第一个元素
    const firstItem = wrapper.get('li:first-child');
    // 元素的类名包含 upload-ready
    expect(firstItem.classes()).toContain('upload-ready');
    // 获取组件实例 触发 uploadFiles 方法
    wrapper.vm.uploadFiles();
    // post 请求被触发
    expect(mockAxios.post).toHaveBeenCalled();
    // 清空 promise
    await flushPromises();
    // 元素的类名包含 upload-success
    expect(firstItem.classes()).toContain('upload-success');
  });

测试文件列表展示

代码语言:javascript复制
it('pictureList mode should works fine', async () => {
    // 模拟 post 请求
    mockAxios.post.mockResolvedValueOnce({ data: { url: 'aa.url' } });
    // 模拟 URL.createObjectURL 方法
    window.URL.createObjectURL = jest.fn(() => {
      return 'test.url';
    });
    const wrapper = shallowMount(Uploader, {
      props: {
        action: 'https://jsonplaceholder.typicode.com/posts/',
        listType: 'picture',
      },
    });
    // 模拟 input 的 e.target.files
    const fileInput = wrapper.get('input').element as HTMLInputElement;
    const files = [testFile] as any;
    Object.defineProperty(fileInput, 'files', {
      value: files,
      writable: false,
    });
    // 元素的类名包含 upload-list-picture
    expect(wrapper.get('ul').classes()).toContain('upload-list-picture');
    // 触发 input 的 change 事件
    await wrapper.get('input').trigger('change');
    // 清空 promise
    await flushPromises();
    // 页面中生成了一个 li
    expect(wrapper.findAll('li').length).toBe(1);
    // 检测 图片是否正确渲染
    expect(wrapper.find('li:first-child img').exists()).toBeTruthy();
    // 图片src是否正确
    const firstImg = wrapper.get('li:first-child img');
    expect(firstImg.attributes('src')).toEqual('test.url');
  });

编写实际代码

代码语言:javascript复制
<template>
  <div class="file-upload">
    <!-- 使用 button 模拟 input 上传-->
    <div v-on="events"
         class="upload-area"
         :class="{ 'is-dragover': drag &amp;&amp; isDragOver }"
         :disabled="isUploading">
      <slot v-if="isUploading"
            name="loading">
        <button disabled>正在上传</button>
      </slot>
      <slot name="uploaded"
            v-else-if="lastFileData &amp;&amp; lastFileData.loaded">
        <button>点击上传</button>
      </slot>
      <slot v-else
            name="default">
        <button>点击上传</button>
      </slot>
    </div>
    <!-- 隐藏 input 控件 -->
    <input type="file"
           ref="fileInput"
           @change="handleFileChange"
           :style="{ display: 'none' }">
    <!-- 上传文件列表 -->
    <ul class="uploaded-file">
      <li v-for="file in filesList"
          :class="`uploaded-file upload-${file.status}`"
          :key="file.uid">
        <img :src="file.url"
             v-if="file.url &amp;&amp; listType === 'picture'"
             :alt="file.name">
        <span class="filename">{{ file.name }}</span>
        <button class="delete-icon"
                @click="removeFile(file.uid)">del</button>
      </li>
    </ul>
  </div>
</template>
代码语言:javascript复制
import axios from 'axios';
import { ref, defineProps, reactive, computed, PropType } from 'vue';
import { v4 as uuidv4 } from 'uuid'
import { last } from 'lodash-es'
export type CheckUpload = (file: File) => boolean | Promise<File>
export type UploadStatus = 'ready' | 'success' | "error" | 'loading'
export type FileListType = 'picture' | 'text'
export interface UploadFile {
  uid: string;
  size: number;
  name: string;
  status: UploadStatus;
  raw: File;
  resp?: any;
  url?: string;
}
const props = defineProps({
  action: {
    type: String,
    required: true,
  },
  beforeUpload: {
    type: Function as PropType<CheckUpload>
  },
  drag: {
    type: Boolean,
    default: false
  },
  autoUpload: {
    type: Boolean,
    default: true
  },
  listType: {
    type: String as PropType<FileListType>,
    default: 'text'
  }
})
// 上传文件列表
const filesList = ref<UploadFile[]>([])

const isDragOver = ref(false)

// 最后一个文件的数据
const lastFileData = computed(() => {
  const lastFile = last(filesList.value)
  if (lastFile) {
    return {
      loaded: lastFile.status === 'success',
      data: lastFile.resp
    }
  }
  return false
})

// 是否正在上传
const isUploading = computed(() => {
  return filesList.value.some((file => file.status === 'loading'))
})

// 删除文件
const removeFile = (id: string) => {
  filesList.value = filesList.value.filter((file) => file.uid === id)
}

// input ref
const fileInput = ref<null | HTMLInputElement>(null)
// 点击 button 触发选择文件弹窗
const triggerUpload = () => {
  fileInput?.value?.click()
}

const postFile = (readyFile: UploadFile) => {
  // 创建 FormData 数据结构
  const formData = new FormData()
  // 往 FormData 中 添加数据
  formData.append(readyFile.name, readyFile.raw)
  readyFile.status = 'loading'
  // 发送请求
  axios.post(props.action, formData, {
    headers: {
      // 需要在请求头中设置类型
      'Content-Type': "multipart/form-data"
    }
  }).then((resp) => {
    console.log(resp.data);
    readyFile.status = 'success'
    readyFile.resp = resp.data
  }).catch(() => {
    readyFile.status = 'error'
  }).finally(() => {
    if (fileInput.value) {
      fileInput.value.value = ''
    }
  })
}

// 添加到文件列表
const addFileToList = (uploadedFile: File) => {
  const fileObj = reactive<UploadFile>({
    uid: uuidv4(),
    size: uploadedFile.size,
    name: uploadedFile.name,
    status: 'ready',
    raw: uploadedFile,
  })
  if (props.listType === 'picture') {
    // try {
    //   fileObj.url = URL.createObjectURL(uploadedFile)
    // } catch (error) {
    //   console.log('upload transform error', error)
    // }
    const fileReader = new FileReader()
    fileReader.readAsDataURL(uploadedFile)
    fileReader.addEventListener('load', () => {
      fileObj.url = fileReader.result as string
    })
    fileReader.addEventListener('error', () => {

      //   console.log('upload transform error', error)
    })
  }

  filesList.value.push(fileObj)
  if (props.autoUpload) {
    postFile(fileObj)
  }
}


// 上传文件
const upLoadFiles = (files: FileList | null) => {
  if (files) {
    // 获取文件
    const uploadedFile = files[0]
    // beforeUpload 钩子
    if (props.beforeUpload) {
      const result = props.beforeUpload(uploadedFile)
      if (result &amp;&amp; result instanceof Promise) {
        result.then((processedFile) => {
          // 判断是否是 file 类型
          if (processedFile instanceof File) {
            addFileToList(processedFile)
          } else {
            throw new Error("beforeUpload Promise should return a file")
          }
        }).catch((e) => console.log(e))
      } else if (result === true) {
        addFileToList(uploadedFile)
      }
    } else {
      addFileToList(uploadedFile)
    }
  }
}

// 上传文件到服务器
const handleFileChange = (e: Event) => {
  // 获取文件列表
  const target = e.target as HTMLInputElement
  const files = target.files
  upLoadFiles(files)
}

/**
 * @description: 上传文件列表
 */
const uploadFiles = () => {
  filesList.value.filter(file => file.status === 'ready').forEach((readFile) => { postFile(readFile) })
}

const handleDrag = (e: DragEvent, over: boolean) => {
  // 取消默认行为
  e.preventDefault()
  isDragOver.value = over
}

const handleDrop = (e: DragEvent) => {
  e.preventDefault()
  isDragOver.value = false
  if (e.dataTransfer) {
    upLoadFiles(e.dataTransfer.files)
  }
}

// 事件列表
let events: { [key: string]: (e: any) => void } = {
  'click': triggerUpload,
}
if (props.drag) {
  events = {
    ...events,
    'dragover': (e: DragEvent) => { handleDrag(e, true) },
    'dragleave': (e: DragEvent) => { handleDrag(e, false) },
    'drop': handleDrop
  }
}

0 人点赞