0
点赞
收藏
分享

微信扫一扫

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

主要内容

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

上传组件需求分析

  • 基本上传流程
  • 点击按钮选择文件,完成上传
  • 支持查看上传文件列表
  • 文件名称
  • 上传状态
  • 上传进度
  • 删除按钮
  • 其它更丰富的显示
  • 自定义模板
  • 初始容器自定义
  • 上传完毕自定义
  • 支持一系列的生命周期钩子函数,上传事件
  • ​beforeUpload​
  • ​onSuccess​
  • ​onError​
  • ​onChange​
  • ​onProgress​
  • 使用​​aixos​​​ 内置​​Api​
  • 设置事件的参数

  • 支持拖拽上传
  • ​dargover​​​ 和​​dargLeave​​​ 添加或者删除对应的​​class​
  • ​drop​​​ 事件拿到正在拖拽的文件,删除​​class​​ 并且触发上传
  • 事件是可选的,只有在属性​​darg​​​ 为​​true​​ 的时候才会生效
  • 支持手动上传
  • 等等
  • 支持自定义​​headers​
  • 自定义​​file​​ 的表单名称
  • 更多需要发送的数据
  • ​input​​​ 原生属性​​multiple​
  • ​input​​​ 原生属性​​accept​
  • ​with-credentials​​​ 发送时是否支持发送​​cookie​

上传文件的原理

enctype

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

传统模式

通过 ​​input type="file"​​​, 然后触发 ​​form​​​ 的 ​​submit​​ 上传。

<from method="post"
action="http://api/upload"
enctype="multipart/form-data">
<input type="file">
<button type="submit">Submit </button>
</from>

使用 js 模拟

<input type="file"
name="file"
@change="handleFileChange">

从 ​​Input​​​ 获取 ​​Files​

  • ​e.target.files​​​ 是​​FileList​​ 对象,它是一个类数组,并不是真正的数组。
  • 可以通过​​files[index]​​​ 拿到对应的文件,它是​​File​​ 对象。
  • ​FormData​​​ 是针对​​XHR2​​​ 设计的数据结构,可以完美模拟​​HTML​​​ 的​​form​​ 标签。
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);
})
}
}

编写测试用例

基础结构

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();
});
});

测试初始界面渲染

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();
});

测试上传成功

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);
});

测试上传失败

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);
});

测试自定义插槽

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');
});

测试上传前检查

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

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

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);
});

测试拖拽功能

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);
});

测试手动上传

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');
});

测试文件列表展示

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');
});

编写实际代码

<template>
<div class="file-upload">
<!-- 使用 button 模拟 input 上传-->
<div v-on="events"
class="upload-area"
:class="{ 'is-dragover': drag && isDragOver }"
:disabled="isUploading">
<slot v-if="isUploading"
name="loading">
<button disabled>正在上传</button>
</slot>
<slot name="uploaded"
v-else-if="lastFileData && 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 && 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>
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 && 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 条评论