前言
通过学习 Vant4 的 Upload 组件核心实现,可以帮助我们更好地理解其设计思路,并在实际项目中进行二次封装。
源码解析
1. renderUpload ------ 渲染上传 UI 并绑定事件
-
renderUpload函数用于返回上传组件的 UI 结构,包括:- 文件上传的
<input>元素 Icon图标渲染- 自定义
slot内容渲染
- 文件上传的
-
其中,
<input type="file">用于调用浏览器原生的文件选择能力:
带有 type="file" 的 <input> 元素允许用户从本地设备中选择一个或多个文件。选择完成后,可以通过:
- 表单提交的方式上传到服务器
- 或通过 JavaScript + File API 对文件进行处理
ini
const renderUpload = () => {
const lessThanMax = props.modelValue.length < +props.maxCount;
// 创建 input,用于文件上传
const Input = props.readonly ? null : (
<input
ref={inputRef}
type="file"
class={bem('input')}
accept={props.accept}
capture={props.capture as unknown as boolean}
multiple={props.multiple && reuploadIndex.value === -1}
disabled={props.disabled}
onChange={onChange}
onClick={onInputClick}
/>
);
// 如果外部传入了 slot,则优先渲染 slot
if (slots.default) {
return (
<div
v-show={lessThanMax}
class={bem('input-wrapper')}
onClick={onClickUpload}
>
{slots.default()}
{Input}
</div>
);
}
// 默认上传 UI
return (
<div
v-show={props.showUpload && lessThanMax}
class={bem('upload', { readonly: props.readonly })}
style={getSizeStyle(props.previewSize)}
onClick={onClickUpload}
>
<Icon name={props.uploadIcon} class={bem('upload-icon')} />
{props.uploadText && (
<span class={bem('upload-text')}>{props.uploadText}</span>
)}
{Input}
</div>
);
};
2. onInputClick ------ 点击 input 时触发
-
用于初始化上传相关状态:
- 如果不是重新上传,则重置
reuploadIndex - 标记当前不是重新上传状态
- 如果不是重新上传,则重置
ini
const onInputClick = () => {
if (!isReuploading.value) {
reuploadIndex.value = -1;
}
isReuploading.value = false;
};
3. onChange ------ 文件选择后的核心入口
- 获取用户选择的文件
- 执行
beforeRead钩子(如存在) - 最终调用
readFile进行文件处理
kotlin
const onChange = (event: Event) => {
const { files } = event.target as HTMLInputElement;
// 无文件或禁用状态直接返回
if (props.disabled || !files || !files.length) {
return;
}
const file =
files.length === 1 ? files[0] : ([].slice.call(files) as File[]);
// 执行 beforeRead 钩子
if (props.beforeRead) {
const response = props.beforeRead(file, getDetail());
// 返回 false,则终止流程并重置 input
if (!response) {
resetInput();
return;
}
// 如果返回 Promise
if (isPromise(response)) {
response
.then((data) => {
readFile(data || file);
})
.catch(resetInput);
return;
}
}
// 默认执行
readFile(file);
};
// 判断是否为 Promise
export const isPromise = <T = any>(val: unknown): val is Promise<T> =>
isObject(val) && isFunction(val.then) && isFunction(val.catch);
4. readFile ------ 文件读取调度中心
- 控制上传数量(
maxCount) - 调用
readFileContent读取内容 - 最终统一进入
onAfterRead
ini
const readFile = (files: File | File[]) => {
const { maxCount, modelValue, resultType } = props;
if (Array.isArray(files)) {
const remainCount = +maxCount - modelValue.length;
// 超出数量限制则裁剪
if (files.length > remainCount) {
files = files.slice(0, remainCount);
}
Promise.all(
files.map((file) => readFileContent(file, resultType)),
).then((contents) => {
const fileList = (files as File[]).map((file, index) => {
const result: UploaderFileListItem = {
file,
status: '',
message: '',
objectUrl: URL.createObjectURL(file),
};
if (contents[index]) {
result.content = contents[index] as string;
}
return result;
});
onAfterRead(fileList);
});
} else {
readFileContent(files, resultType).then((content) => {
const result: UploaderFileListItem = {
file: files,
status: '',
message: '',
objectUrl: URL.createObjectURL(files),
};
if (content) {
result.content = content;
}
onAfterRead(result);
});
}
};
5. readFileContent ------ 文件内容读取
- 根据
resultType决定读取方式:
| 值 | 说明 |
|---|---|
| file | 不读取内容,仅返回 File 对象(推荐大文件) |
| text | 读取为文本 |
| dataUrl | 读取为 base64 |
typescript
export function readFileContent(file: File, resultType: UploaderResultType) {
return new Promise<string | void>((resolve) => {
// file 类型不读取内容
if (resultType === 'file') {
resolve();
return;
}
const reader = new FileReader();
reader.onload = (event) => {
resolve((event.target as FileReader).result as string);
};
if (resultType === 'dataUrl') {
reader.readAsDataURL(file);
} else if (resultType === 'text') {
reader.readAsText(file);
}
});
}
6. onAfterRead ------ 文件读取后的统一处理
- 校验文件大小(
maxSize) - 过滤无效文件
- 处理重新上传逻辑
- 更新
v-model - 触发用户自定义
afterRead
ini
const onAfterRead = (
items: UploaderFileListItem | UploaderFileListItem[],
) => {
// 重置 input
resetInput();
// 校验文件大小
if (isOversize(items, props.maxSize)) {
if (Array.isArray(items)) {
const result = filterFiles(items, props.maxSize);
items = result.valid;
emit('oversize', result.invalid, getDetail());
if (!items.length) return;
} else {
emit('oversize', items, getDetail());
return;
}
}
items = reactive(items);
// 处理重新上传
if (reuploadIndex.value > -1) {
const arr = [...props.modelValue];
arr.splice(reuploadIndex.value, 1, items as UploaderFileListItem);
emit('update:modelValue', arr);
reuploadIndex.value = -1;
} else {
// 正常追加
emit('update:modelValue', [...props.modelValue, ...toArray(items)]);
}
// 用户自定义回调
if (props.afterRead) {
props.afterRead(items, getDetail());
}
};
总结
-
本文梳理了 Upload 组件的核心流程:
UI 渲染 → 文件选择 → 前置校验 → 文件读取 → 后置处理 → 数据回传 -
组件的核心职责包括:
- 上传 UI 的渲染与交互
- 文件数据的读取与转换
- 上传前后钩子的扩展能力
- 通过
v-model与父组件进行数据同步
👉 理解这一流程后,你可以更灵活地实现:
- 自定义上传逻辑(如直传 OSS)
- 文件预处理(压缩 / 加密)
- 更复杂的上传状态管理