Vant4源码阅读之Upload

前言

通过学习 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)
  • 文件预处理(压缩 / 加密)
  • 更复杂的上传状态管理
相关推荐
河阿里40 分钟前
HTML5标准完全教学手册
前端·html·html5
吴声子夜歌41 分钟前
Vue3——新语法
前端·javascript·vue.js
jiayong2343 分钟前
第 36 课:任务详情抽屉快捷改状态
开发语言·前端·javascript·vue.js·学习
FFF_634560231 小时前
通用 vue 页面 js 下载任何文件的方法
开发语言·前端·javascript
光影少年1 小时前
中级前端需要会的东西都有那些?
前端·学习·前端框架
琢磨先生TT1 小时前
为什么很多后台系统功能不少,看起来却还是很廉价?
前端·vue.js·设计
ekuoleung1 小时前
量化平台中的 DSL 设计与实现:从规则树到可执行策略
前端·后端
小研说技术1 小时前
实时通信对比,一场MCP协议的技术革命
前端·后端·面试
kyriewen1 小时前
React Hooks原理:为什么不能写在if里?揭开Hook的“魔法”面纱
前端·react.js·前端框架
敲代码的彭于晏1 小时前
Claude Code Token 烧得太快?这8个方案帮你立省90%!
前端·ai编程·claude