主要内容
- 使用
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 模拟
代码语言:javascript复制 <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);
})
}
}
编写测试用例
基础结构
代码语言: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 && 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>
代码语言: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 && 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
}
}