持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第12天,点击查看活动详情
主要内容
- 使用
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);
});
复制代码
编写实际代码
<template>
<div class="file-upload">
<!-- 使用 button 模拟 input 上传-->
<div @click="triggerUpload"
class="upload-area"
: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 uploadedFiles"
:class="`uploaded-file upload-${file.status}`"
:key="file.uid">
<span class="filename">{{ file.name }}</span>
<button class="delete-icon"
@click="removeFile(file.uid)">del</button>
</li>
</ul>
</div>
</template>
// script
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 interface UploadFile {
uid: string;
size: number;
name: string;
status: UploadStatus;
raw: File;
resp?: any;
}
const props = defineProps({
action: {
type: String,
required: true,
},
beforeUpload: {
type: Function as PropType<CheckUpload>
}
})
// 上传文件列表
const uploadedFiles = ref<UploadFile[]>([])
// 最后一个文件的数据
const lastFileData = computed(() => {
const lastFile = last(uploadedFiles.value)
if (lastFile) {
return {
loaded: lastFile.status === 'success',
data: lastFile.resp
}
}
return false
})
// 是否正在上传
const isUploading = computed(() => {
return uploadedFiles.value.some((file => file.status === 'loading'))
})
// 删除文件
const removeFile = (id: string) => {
uploadedFiles.value = uploadedFiles.value.filter((file) => file.uid === id)
}
// input ref
const fileInput = ref<null | HTMLInputElement>(null)
// 点击 button 触发选择文件弹窗
const triggerUpload = () => {
fileInput?.value?.click()
}
const postFile = (uploadedFile: File) => {
// 创建 FormData 数据结构
const formData = new FormData()
// 往 FormData 中 添加数据
formData.append(uploadedFile.name, uploadedFile)
const fileObj = reactive<UploadFile>({
uid: uuidv4(),
size: uploadedFile.size,
name: uploadedFile.name,
status: 'loading',
raw: uploadedFile,
})
uploadedFiles.value.push(fileObj)
// 发送请求
axios.post(props.action, formData, {
headers: {
// 需要在请求头中设置类型
'Content-Type': "multipart/form-data"
}
}).then((resp) => {
console.log(resp.data);
fileObj.status = 'success'
fileObj.resp = resp.data
}).catch(() => {
fileObj.status = 'error'
}).finally(() => {
if (fileInput.value) {
fileInput.value.value = ''
}
})
}
// 上传文件到服务器
const handleFileChange = (e: Event) => {
// 获取文件列表
const target = e.target as HTMLInputElement
const files = target.files
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) {
postFile(processedFile)
} else {
throw new Error("beforeUpload Promise should return a file")
}
}).catch((e) => console.log(e))
} else if (result === true) {
postFile(uploadedFile)
}
} else {
postFile(uploadedFile)
}
}
}
复制代码