Skip to content

FormData 头像上传 ​

学员管理页要给 1 号学员更换头像:选择图片后先看预览,确认无误再上传,成功后页面显示服务端保存的头像。前面提交姓名、手机号用的是 JSON;直接对 File 调用 JSON.stringify() 并不能把图片内容上传。这里需要 FormData。

本节使用 FormData 组织文件,通过课程测试 API 完成头像上传。页面会经历一条完整流程:选择文件、检查类型和大小、本地预览、发送请求、显示服务端保存后的头像。

本节课解决七个问题:

  1. 怎样从文件输入框取得 File 对象。
  2. 怎样读取文件名、类型和大小。
  3. 怎样在上传前检查文件。
  4. 怎样使用对象 URL 预览本地图片。
  5. 怎样使用 FormData 组织上传数据。
  6. 为什么不能手动设置上传请求的 Content-Type。
  7. 怎样处理上传成功、接口错误和按钮状态。

本节按“读取与检查文件 → 预览与组织 FormData → 发送上传请求 → 使用服务端头像”的顺序推进。

文件检查最小实验 ​

先在课程目录启动静态页面服务:

bash
python -m http.server 5500

打开文件检查实验(源码文件:./.code/p04/c12-formdata-upload/01-file-inspection.html),或访问:

text
http://127.0.0.1:5500/.code/p04/c12-formdata-upload/01-file-inspection.html

选择一张不超过 2MB 的 JPG、PNG、WebP 或 SVG 图片,页面会显示文件名、MIME 类型、字节数和最后修改时间;选择其他类型或超大文件,会显示错误并清空选择。这一步只读取本地 File,还没有向 API 发请求。先观察 File 中有什么,再把它用于预览和上传。

上传接口与文件检查 ​

接口约定 ​

测试 API 运行在 http://127.0.0.1:8000,前端页面运行在 http://127.0.0.1:5500。

功能方法与地址请求数据成功状态码
查询学员GET /api/students/{id}无200
上传头像POST /api/students/{id}/avatarmultipart/form-data200

头像接口还有四条约定:

  • 文件字段名必须是 avatar。
  • 支持 JPG、PNG、WebP、SVG。
  • 文件大小不能超过 2MB。
  • 上传成功返回更新后的完整学员数据。

请求地址中的 {id} 表示学员编号。例如给 1 号学员上传头像:

http
POST /api/students/1/avatar
Content-Type: multipart/form-data; boundary=...

boundary 由浏览器自动生成。前端代码不需要填写。

文件选择 ​

先准备文件输入框:

html
<input
  id="avatar-input"
  name="avatar"
  type="file"
  accept=".jpg,.jpeg,.png,.webp,.svg"
/>

accept 会限制文件选择器优先显示哪些文件,但它只是用户体验提示,不是可靠的安全校验。用户仍可能通过其他方式选择不符合要求的文件。

文件输入框通过 files 属性返回 FileList:

js
const avatarInput = document.querySelector("#avatar-input");

avatarInput.addEventListener("change", () => {
  const file = avatarInput.files[0];

  if (!file) {
    console.log("没有选择文件");
    return;
  }

  console.log(file);
});

本案例只允许选择一张头像,所以读取 files[0]。如果输入框带有 multiple 属性,files 中可能包含多个文件。

不要使用文件输入框的 value 获取文件内容:

js
console.log(avatarInput.value);

浏览器可能只显示类似 C:\fakepath\avatar.png 的假路径。这样做是为了保护用户本地文件系统。真正可读取和上传的是 files 中的 File 对象。

File 属性 ​

取得 File 后,可以读取下面的信息:

属性作用示例
name原始文件名avatar.png
typeMIME 类型image/png
size文件字节数153600
lastModified最后修改时间戳1710000000000
js
const file = avatarInput.files[0];

console.log(file.name);
console.log(file.type);
console.log(file.size);
console.log(new Date(file.lastModified));

size 的单位是字节。页面通常把它转换成 KB 或 MB:

js
function formatFileSize(size) {
  if (size < 1024) {
    return `${size} B`;
  }

  if (size < 1024 * 1024) {
    return `${(size / 1024).toFixed(1)} KB`;
  }

  return `${(size / 1024 / 1024).toFixed(2)} MB`;
}

这里只负责显示。文件限制仍然使用原始字节数判断,避免字符串转换后比较出错。

文件校验 ​

课程接口允许四类图片,最大 2MB。先把规则写成常量:

js
const MAX_AVATAR_SIZE = 2 * 1024 * 1024;
const ALLOWED_IMAGE_TYPES = new Set([
  "image/jpeg",
  "image/png",
  "image/webp",
  "image/svg+xml",
]);

再编写独立校验函数:

js
function validateAvatar(file) {
  if (!(file instanceof File) || file.size === 0) {
    return "请选择要上传的图片";
  }

  if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
    return "只允许上传 JPG、PNG、WebP 或 SVG 图片";
  }

  if (file.size > MAX_AVATAR_SIZE) {
    return "头像大小不能超过 2MB";
  }

  return "";
}

函数返回空字符串表示通过,否则返回可以直接显示的错误信息:

js
const errorMessage = validateAvatar(file);

if (errorMessage) {
  alert(errorMessage);
  avatarInput.value = "";
  return;
}

前端检查只用于尽早提示用户,不能代替服务端检查。本课测试 API 会检查上传文件的 MIME 类型和大小;实际项目还需要由服务端检查权限和文件内容。本课只调用接口,不讲服务端实现。

本地预览与 FormData ​

本地预览 ​

用户选择图片后,可以先显示本地预览,不必等待上传完成。

html
<img id="avatar-preview" alt="待上传头像预览" />
js
const avatarPreview = document.querySelector("#avatar-preview");
const objectUrl = URL.createObjectURL(file);

avatarPreview.src = objectUrl;

URL.createObjectURL(file) 会创建一个临时的 blob: 地址:

text
blob:http://127.0.0.1:5500/...

这个地址只在当前浏览器页面中有效。它不是服务端地址,也不能保存到数据库。

对象 URL 会占用浏览器资源。用户反复换图时,应释放旧地址:

js
let previewObjectUrl = "";

function showLocalPreview(file) {
  if (previewObjectUrl) {
    URL.revokeObjectURL(previewObjectUrl);
  }

  previewObjectUrl = URL.createObjectURL(file);
  avatarPreview.src = previewObjectUrl;
}

页面关闭前也可以清理:

js
window.addEventListener("beforeunload", () => {
  if (previewObjectUrl) {
    URL.revokeObjectURL(previewObjectUrl);
  }
});

不能在设置 img.src 后立刻释放对象 URL,否则图片可能还没有读取完成。等到更换预览或页面关闭时再释放。

静态页面服务保持运行,打开本地预览实验(源码文件:./.code/p04/c12-formdata-upload/02-image-preview.html),或访问 http://127.0.0.1:5500/.code/p04/c12-formdata-upload/02-image-preview.html。选择图片后,页面无需请求 API 就能显示预览;更换图片时,旧对象 URL 会被释放。

FormData 构造 ​

FormData 可以保存文本字段和文件字段,浏览器会把它编码成 multipart/form-data 请求体。

手动添加文件

本案例的学员编号位于请求地址中,请求体只需要头像文件:

js
const formData = new FormData();
formData.append("avatar", file);

avatar 是接口字段名,必须与 API 文档完全一致。改成 image、file 或 photo 都会导致接口找不到文件字段。

append() 还可以传第三个参数指定上传文件名:

js
formData.append("avatar", file, file.name);

不传时,浏览器默认使用 File.name。

从表单创建 FormData

如果表单中的控件都有正确的 name,也可以直接读取整个表单:

html
<form id="avatar-form">
  <input name="avatar" type="file" />
  <button type="submit">上传头像</button>
</form>
js
const avatarForm = document.querySelector("#avatar-form");
const formData = new FormData(avatarForm);

这会自动收集表单中成功的控件。没有 name、被禁用或未选中的普通控件不会被收集。

两种写法都可以。本案例使用手动 append(),可以清楚看到请求体只包含接口要求的 avatar 字段。

FormData 方法 ​

方法作用
append(name, value)添加一个字段,允许同名字段重复
set(name, value)设置字段,并替换已有同名字段
get(name)取得第一个同名字段
getAll(name)取得全部同名字段
has(name)判断字段是否存在
delete(name)删除字段

直接 console.log(formData) 不一定能展开内部字段。排查时可以遍历:

js
for (const [name, value] of formData.entries()) {
  console.log(name, value);
}

上传请求与头像更新 ​

上传请求 ​

上传函数接收学员编号和文件:

js
const API_BASE_URL = "http://127.0.0.1:8000";

async function uploadAvatar(studentId, file) {
  const formData = new FormData();
  formData.append("avatar", file);

  const response = await fetch(
    `${API_BASE_URL}/api/students/${studentId}/avatar`,
    {
      method: "POST",
      body: formData,
    },
  );

  if (!response.ok) {
    throw new Error(await getErrorMessage(response));
  }

  return response.json();
}

getErrorMessage() 沿用上一节的处理规则:字符串 detail 直接显示,422 数组逐条整理,响应不是 JSON 时退回状态码。完整写法如下:

js
async function getErrorMessage(response) {
  const fallback = `请求失败,状态码:${response.status}`;

  try {
    const data = await response.json();

    if (typeof data.detail === "string") {
      return data.detail;
    }

    if (Array.isArray(data.detail)) {
      const messages = data.detail
        .map((item) => `${item.loc?.at(-1) ?? "字段"}:${item.msg}`)
        .join(";");
      return messages || fallback;
    }
  } catch (error) {
    console.warn("错误响应不是有效 JSON", error);
  }

  return fallback;
}

普通 400 会显示接口给出的提示;422 数组不会被当成普通对象直接转成 [object Object];非 JSON 错误显示状态码。fetch() 连 Response 都没拿到时会直接抛出网络异常,由页面的 catch 处理。错误响应体在这里仅读取一次。

这个请求有三个关键点:

  1. body 直接传 formData,不要调用 JSON.stringify()。
  2. 不要设置 Content-Type: application/json。
  3. 也不要手动设置 Content-Type: multipart/form-data。

浏览器会生成类似下面的请求头:

http
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary...

服务端依靠 boundary 拆分请求体中的字段。手动填写 multipart/form-data 时缺少正确边界,服务端可能无法解析文件。

在课程目录另开终端启动测试 API;首次运行前按 测试 API 启动说明(路径:./.code/api_server/README.md)安装依赖:

bash
cd .code/api_server
uv run uvicorn app:app --port 8000

然后打开 FormData 请求实验(源码文件:./.code/p04/c12-formdata-upload/03-formdata-request.html),或访问 http://127.0.0.1:5500/.code/p04/c12-formdata-upload/03-formdata-request.html,给 1 号学员上传一张图片。打开 Network,确认请求体字段是 avatar、请求头含浏览器生成的 boundary,成功响应包含新的 avatar 路径。

服务端头像地址 ​

上传前的 blob: 地址只能用于本地预览。上传成功后,接口返回完整学员数据,其中与头像有关的字段如下:

json
{
  "id": 1,
  "name": "张三",
  "avatar": "/uploads/student_1_a1b2c3.svg"
}

这是相对于 API 服务的路径。页面和 API 不同源时,需要补全 API 地址:

js
function getApiFileUrl(path) {
  if (path.startsWith("http://") || path.startsWith("https://")) {
    return path;
  }

  const normalizedPath = path.startsWith("/") ? path : `/${path}`;
  return `${API_BASE_URL}${normalizedPath}`;
}

const student = await uploadAvatar(1, file);
savedAvatar.src = getApiFileUrl(student.avatar);

最终显示和保存应使用接口返回的 avatar,不能继续使用本地对象 URL。

综合案例:学员头像上传 ​

案例需求

页面完成下面的交互:

  1. 选择学员后,查询并显示当前头像。
  2. 选择图片后,检查类型和大小。
  3. 校验通过后显示文件信息和本地预览。
  4. 点击上传时锁定学员、文件和上传按钮,防止重复提交与中途切换。
  5. 上传成功后显示服务端头像和响应数据。
  6. 上传失败时保留明确的错误提示。

完整页面见:学员头像上传(源码文件:./.code/p04/c12-formdata-upload/04-avatar-upload.html)。

学员头像上传
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>FormData 头像上传</title>
  <style>
    * {
      box-sizing: border-box;
    }

    body {
      margin: 0;
      background: #f3f6fa;
      color: #303133;
      font-family: Arial, "Microsoft YaHei", sans-serif;
    }

    button,
    input,
    select {
      font: inherit;
    }

    .page-container {
      width: min(1060px, calc(100% - 32px));
      margin: 36px auto;
    }

    .page-title {
      margin: 0 0 8px;
      font-size: 28px;
    }

    .page-description {
      margin: 0 0 22px;
      color: #606266;
      line-height: 1.7;
    }

    .workspace {
      display: grid;
      grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr);
      gap: 18px;
      align-items: start;
    }

    .panel {
      padding: 22px;
      border-radius: 10px;
      background: #fff;
      box-shadow: 0 8px 24px rgb(0 0 0 / 5%);
    }

    .panel + .panel {
      margin-top: 18px;
    }

    .workspace .panel + .panel {
      margin-top: 0;
    }

    .panel-title {
      margin: 0 0 18px;
      font-size: 18px;
    }

    .student-card {
      display: grid;
      justify-items: center;
      gap: 14px;
      padding: 22px;
      border: 1px solid #ebeef5;
      border-radius: 9px;
      background: #fafcff;
      text-align: center;
    }

    .avatar-image,
    .preview-image {
      display: block;
      width: 148px;
      height: 148px;
      border: 1px solid #e4e7ed;
      border-radius: 18px;
      background: #f2f3f5;
      object-fit: cover;
    }

    .student-name {
      margin: 0;
      font-size: 20px;
    }

    .student-id {
      margin: -7px 0 0;
      color: #909399;
      font-size: 13px;
    }

    .field {
      display: grid;
      gap: 8px;
      margin-bottom: 17px;
    }

    .field-label {
      color: #606266;
      font-size: 14px;
    }

    .field select,
    .field input[type="file"] {
      width: 100%;
      min-height: 42px;
      padding: 8px 11px;
      border: 1px solid #dcdfe6;
      border-radius: 6px;
      background: #fff;
      outline: none;
    }

    .field select:focus,
    .field input[type="file"]:focus {
      border-color: #409eff;
      box-shadow: 0 0 0 3px rgb(64 158 255 / 12%);
    }

    .field-tip {
      margin: 0;
      color: #909399;
      font-size: 13px;
      line-height: 1.6;
    }

    .selection-grid {
      display: grid;
      grid-template-columns: 170px minmax(0, 1fr);
      gap: 18px;
      align-items: center;
      margin-top: 18px;
      padding: 18px;
      border: 1px solid #ebeef5;
      border-radius: 9px;
      background: #fafcff;
    }

    .preview-frame {
      display: grid;
      width: 148px;
      height: 148px;
      place-items: center;
      overflow: hidden;
      border: 1px dashed #c0c4cc;
      border-radius: 18px;
      background: #fff;
    }

    .preview-image {
      border: 0;
    }

    .preview-image[hidden],
    .preview-placeholder[hidden] {
      display: none;
    }

    .preview-placeholder {
      padding: 16px;
      color: #909399;
      font-size: 13px;
      line-height: 1.6;
      text-align: center;
    }

    .file-info {
      display: grid;
      grid-template-columns: auto minmax(0, 1fr);
      gap: 9px 12px;
      margin: 0;
      font-size: 13px;
      line-height: 1.5;
    }

    .file-info dt {
      color: #909399;
    }

    .file-info dd {
      min-width: 0;
      margin: 0;
      overflow-wrap: anywhere;
      color: #4b5563;
    }

    .button-row {
      display: flex;
      flex-wrap: wrap;
      gap: 10px;
      margin-top: 18px;
    }

    .button {
      min-width: 112px;
      height: 42px;
      padding: 0 18px;
      border: 0;
      border-radius: 6px;
      cursor: pointer;
    }

    .primary-button {
      background: #409eff;
      color: #fff;
    }

    .primary-button:hover:not(:disabled) {
      background: #66b1ff;
    }

    .primary-button:disabled {
      background: #a0cfff;
      cursor: not-allowed;
    }

    .secondary-button {
      border: 1px solid #dcdfe6;
      background: #fff;
      color: #606266;
    }

    .secondary-button:hover {
      border-color: #409eff;
      color: #409eff;
    }

    .message-box {
      margin-top: 18px;
      padding: 13px 15px;
      border-left: 4px solid #409eff;
      border-radius: 5px;
      background: #ecf5ff;
      color: #52606d;
      line-height: 1.7;
    }

    .message-box.is-success {
      border-left-color: #1b9a59;
      background: #e8f8f0;
      color: #137a46;
    }

    .message-box.is-error {
      border-left-color: #d93026;
      background: #fef0f0;
      color: #b42318;
    }

    .request-grid {
      display: grid;
      grid-template-columns: auto minmax(0, 1fr);
      gap: 9px 14px;
      margin: 0;
      font-size: 13px;
      line-height: 1.6;
    }

    .request-grid dt {
      color: #909399;
    }

    .request-grid dd {
      min-width: 0;
      margin: 0;
      overflow-wrap: anywhere;
    }

    .request-code {
      color: #409eff;
      font-family: Consolas, Monaco, monospace;
    }

    .response-data {
      min-height: 190px;
      margin: 0;
      padding: 16px;
      overflow: auto;
      border-radius: 7px;
      background: #1f2937;
      color: #e5e7eb;
      font-family: Consolas, Monaco, monospace;
      font-size: 13px;
      line-height: 1.6;
      white-space: pre-wrap;
    }

    .boundary-tip {
      margin: 16px 0 0;
      padding: 12px 14px;
      border-left: 4px solid #d48806;
      border-radius: 5px;
      background: #fff7e6;
      color: #7a5200;
      font-size: 13px;
      line-height: 1.7;
    }

    @media (max-width: 800px) {
      .page-container {
        margin: 24px auto;
      }

      .workspace {
        grid-template-columns: 1fr;
      }

      .workspace .panel + .panel {
        margin-top: 0;
      }
    }

    @media (max-width: 520px) {
      .selection-grid {
        grid-template-columns: 1fr;
        justify-items: center;
      }

      .file-info {
        width: 100%;
      }
    }
  </style>
</head>
<body>
  <main class="page-container">
    <h1 class="page-title">学员头像上传</h1>
    <p class="page-description">
      选择学员和图片,先在浏览器中检查并预览,再使用 FormData 上传到课程测试 API。
    </p>

    <div class="workspace">
      <section class="panel">
        <h2 class="panel-title">服务端当前头像</h2>

        <article class="student-card">
          <img
            id="saved-avatar"
            class="avatar-image"
            alt="服务端保存的学员头像"
          />
          <h3 id="student-name" class="student-name">正在读取...</h3>
          <p id="student-id-label" class="student-id">学员编号:—</p>
        </article>

        <div id="message-box" class="message-box" role="status" aria-live="polite">
          正在读取学员信息...
        </div>
      </section>

      <section class="panel">
        <h2 class="panel-title">选择并上传新头像</h2>

        <form id="avatar-form" novalidate>
          <div class="field">
            <label class="field-label" for="student-select">目标学员</label>
            <select id="student-select" name="student_id">
              <option value="1">1 号学员</option>
              <option value="2">2 号学员</option>
              <option value="3">3 号学员</option>
              <option value="4">4 号学员</option>
              <option value="5">5 号学员</option>
              <option value="6">6 号学员</option>
              <option value="7">7 号学员</option>
              <option value="8">8 号学员</option>
              <option value="9">9 号学员</option>
              <option value="10">10 号学员</option>
              <option value="11">11 号学员</option>
              <option value="12">12 号学员</option>
            </select>
          </div>

          <div class="field">
            <label class="field-label" for="avatar-input">头像文件</label>
            <input
              id="avatar-input"
              name="avatar"
              type="file"
              accept=".jpg,.jpeg,.png,.webp,.svg"
            />
            <p class="field-tip">支持 JPG、PNG、WebP、SVG,大小不能超过 2MB。</p>
          </div>

          <div class="selection-grid">
            <div class="preview-frame">
              <img
                id="avatar-preview"
                class="preview-image"
                alt="待上传头像预览"
                hidden
              />
              <span id="preview-placeholder" class="preview-placeholder">
                选择图片后<br />显示本地预览
              </span>
            </div>

            <dl class="file-info">
              <dt>文件名</dt>
              <dd id="file-name">未选择</dd>
              <dt>文件类型</dt>
              <dd id="file-type">—</dd>
              <dt>文件大小</dt>
              <dd id="file-size">—</dd>
              <dt>预览地址</dt>
              <dd id="preview-url">—</dd>
            </dl>
          </div>

          <div class="button-row">
            <button
              id="upload-button"
              class="button primary-button"
              type="submit"
              disabled
            >
              上传头像
            </button>
            <button
              id="clear-button"
              class="button secondary-button"
              type="button"
            >
              取消选择
            </button>
          </div>
        </form>
      </section>
    </div>

    <section class="panel">
      <h2 class="panel-title">本次请求</h2>
      <dl class="request-grid">
        <dt>请求方法</dt>
        <dd id="request-method">GET</dd>
        <dt>请求地址</dt>
        <dd><code id="request-url" class="request-code">等待请求</code></dd>
        <dt>文件字段</dt>
        <dd><code class="request-code">avatar</code></dd>
        <dt>请求类型</dt>
        <dd>浏览器自动生成 multipart/form-data 与 boundary</dd>
      </dl>
      <p class="boundary-tip">
        不要在 fetch 中手动设置 Content-Type。打开 Network 面板,可以看到浏览器生成的 boundary。
      </p>
    </section>

    <section class="panel">
      <h2 class="panel-title">最近一次接口响应</h2>
      <pre id="response-data" class="response-data">等待接口响应</pre>
    </section>
  </main>

  <script>
    const API_BASE_URL = "http://127.0.0.1:8000";
    const MAX_AVATAR_SIZE = 2 * 1024 * 1024;
    const ALLOWED_IMAGE_TYPES = new Set([
      "image/jpeg",
      "image/png",
      "image/webp",
      "image/svg+xml",
    ]);

    const avatarForm = document.querySelector("#avatar-form");
    const studentSelect = document.querySelector("#student-select");
    const avatarInput = document.querySelector("#avatar-input");
    const uploadButton = document.querySelector("#upload-button");
    const clearButton = document.querySelector("#clear-button");
    const avatarPreview = document.querySelector("#avatar-preview");
    const previewPlaceholder = document.querySelector("#preview-placeholder");
    const savedAvatar = document.querySelector("#saved-avatar");
    const studentName = document.querySelector("#student-name");
    const studentIdLabel = document.querySelector("#student-id-label");
    const fileName = document.querySelector("#file-name");
    const fileType = document.querySelector("#file-type");
    const fileSize = document.querySelector("#file-size");
    const previewUrl = document.querySelector("#preview-url");
    const messageBox = document.querySelector("#message-box");
    const requestMethod = document.querySelector("#request-method");
    const requestUrl = document.querySelector("#request-url");
    const responseData = document.querySelector("#response-data");

    let selectedFile = null;
    let previewObjectUrl = "";
    let isUploading = false;
    let studentLoadVersion = 0;

    function formatFileSize(size) {
      if (size < 1024) {
        return `${size} B`;
      }

      if (size < 1024 * 1024) {
        return `${(size / 1024).toFixed(1)} KB`;
      }

      return `${(size / 1024 / 1024).toFixed(2)} MB`;
    }

    function validateAvatar(file) {
      if (!(file instanceof File) || file.size === 0) {
        return "请选择要上传的图片";
      }

      if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
        return "只允许上传 JPG、PNG、WebP 或 SVG 图片";
      }

      if (file.size > MAX_AVATAR_SIZE) {
        return "头像大小不能超过 2MB";
      }

      return "";
    }

    function getApiFileUrl(path) {
      if (path.startsWith("http://") || path.startsWith("https://")) {
        return path;
      }

      const normalizedPath = path.startsWith("/") ? path : `/${path}`;
      return `${API_BASE_URL}${normalizedPath}`;
    }

    function showMessage(message, type = "info") {
      messageBox.textContent = message;
      messageBox.className = "message-box";

      if (type === "success") {
        messageBox.classList.add("is-success");
      }

      if (type === "error") {
        messageBox.classList.add("is-error");
      }
    }

    function updateUploadControls() {
      uploadButton.disabled = isUploading || !selectedFile;
      uploadButton.textContent = isUploading ? "上传中..." : "上传头像";
      studentSelect.disabled = isUploading;
      avatarInput.disabled = isUploading;
      clearButton.disabled = isUploading;
    }

    function releasePreviewObjectUrl() {
      if (!previewObjectUrl) {
        return;
      }

      URL.revokeObjectURL(previewObjectUrl);
      previewObjectUrl = "";
    }

    function showLocalPreview(file) {
      releasePreviewObjectUrl();
      previewObjectUrl = URL.createObjectURL(file);
      avatarPreview.src = previewObjectUrl;
      avatarPreview.hidden = false;
      previewPlaceholder.hidden = true;
      previewUrl.textContent = previewObjectUrl;
    }

    function clearSelectedFile() {
      releasePreviewObjectUrl();
      selectedFile = null;
      avatarInput.value = "";
      avatarPreview.hidden = true;
      avatarPreview.removeAttribute("src");
      previewPlaceholder.hidden = false;
      fileName.textContent = "未选择";
      fileType.textContent = "—";
      fileSize.textContent = "—";
      previewUrl.textContent = "—";
      updateUploadControls();
    }

    function formatValidationDetail(detail) {
      if (!Array.isArray(detail)) {
        return "";
      }

      return detail
        .map((item) => {
          const field = item.loc?.at(-1) ?? "字段";
          return `${field}:${item.msg}`;
        })
        .join(";");
    }

    async function getErrorMessage(response) {
      try {
        const data = await response.json();

        if (typeof data.detail === "string") {
          return data.detail;
        }

        const validationMessage = formatValidationDetail(data.detail);
        if (validationMessage) {
          return validationMessage;
        }
      } catch (error) {
        console.warn("错误响应不是 JSON", error);
      }

      return `请求失败,状态码:${response.status}`;
    }

    async function loadStudent(studentId) {
      const requestVersion = ++studentLoadVersion;
      const url = `${API_BASE_URL}/api/students/${studentId}`;
      requestMethod.textContent = "GET";
      requestUrl.textContent = url;
      showMessage("正在读取学员信息...");

      try {
        const response = await fetch(url);

        if (!response.ok) {
          throw new Error(await getErrorMessage(response));
        }

        const student = await response.json();
        if (requestVersion !== studentLoadVersion) {
          return;
        }

        studentName.textContent = student.name;
        studentIdLabel.textContent = `学员编号:${student.id}`;
        savedAvatar.src = getApiFileUrl(student.avatar);
        responseData.textContent = JSON.stringify(student, null, 2);
        showMessage(`已加载 ${student.name} 的当前头像。`);
      } catch (error) {
        if (requestVersion !== studentLoadVersion) {
          return;
        }

        console.error(error);
        studentName.textContent = "读取失败";
        studentIdLabel.textContent = `学员编号:${studentId}`;
        savedAvatar.removeAttribute("src");
        responseData.textContent = JSON.stringify(
          { name: error.name, message: error.message },
          null,
          2,
        );
        showMessage(error.message, "error");
      }
    }

    async function uploadAvatar(studentId, file) {
      const formData = new FormData();
      formData.append("avatar", file);

      const url = `${API_BASE_URL}/api/students/${studentId}/avatar`;
      requestMethod.textContent = "POST";
      requestUrl.textContent = url;

      const response = await fetch(url, {
        method: "POST",
        body: formData,
      });

      if (!response.ok) {
        throw new Error(await getErrorMessage(response));
      }

      return response.json();
    }

    avatarInput.addEventListener("change", () => {
      const file = avatarInput.files[0];
      const errorMessage = validateAvatar(file);

      if (errorMessage) {
        clearSelectedFile();
        showMessage(errorMessage, "error");
        return;
      }

      selectedFile = file;
      showLocalPreview(file);
      fileName.textContent = file.name;
      fileType.textContent = file.type;
      fileSize.textContent = formatFileSize(file.size);
      updateUploadControls();
      showMessage("图片检查通过,可以上传。", "success");
    });

    studentSelect.addEventListener("change", () => {
      clearSelectedFile();
      loadStudent(Number(studentSelect.value));
    });

    clearButton.addEventListener("click", () => {
      clearSelectedFile();
      showMessage("已取消本地文件选择。后端头像没有变化。");
    });

    avatarForm.addEventListener("submit", async (event) => {
      event.preventDefault();

      if (isUploading) {
        return;
      }

      const errorMessage = validateAvatar(selectedFile);
      if (errorMessage) {
        showMessage(errorMessage, "error");
        return;
      }

      isUploading = true;
      studentLoadVersion += 1;
      updateUploadControls();
      showMessage("正在上传头像...");

      try {
        const studentId = Number(studentSelect.value);
        const student = await uploadAvatar(studentId, selectedFile);
        savedAvatar.src = getApiFileUrl(student.avatar);
        studentName.textContent = student.name;
        studentIdLabel.textContent = `学员编号:${student.id}`;
        responseData.textContent = JSON.stringify(student, null, 2);
        clearSelectedFile();
        showMessage(`上传成功:${student.name} 的服务端头像已更新。`, "success");
      } catch (error) {
        console.error(error);
        responseData.textContent = JSON.stringify(
          { name: error.name, message: error.message },
          null,
          2,
        );
        showMessage(error.message, "error");
      } finally {
        isUploading = false;
        updateUploadControls();
      }
    });

    window.addEventListener("beforeunload", releasePreviewObjectUrl);

    loadStudent(Number(studentSelect.value));
  </script>
</body>
</html>

:::

文件选择与状态 ​

先保存当前文件和对象 URL:

js
let selectedFile = null;
let previewObjectUrl = "";
let isUploading = false;
let studentLoadVersion = 0;

function updateUploadControls() {
  uploadButton.disabled = isUploading || !selectedFile;
  uploadButton.textContent = isUploading ? "上传中..." : "上传头像";
  studentSelect.disabled = isUploading;
  avatarInput.disabled = isUploading;
  clearButton.disabled = isUploading;
}

avatarInput.addEventListener("change", () => {
  const file = avatarInput.files[0];
  const errorMessage = validateAvatar(file);

  if (errorMessage) {
    clearSelectedFile();
    showMessage(errorMessage, "error");
    return;
  }

  selectedFile = file;
  showLocalPreview(file);
  fileName.textContent = file.name;
  fileType.textContent = file.type;
  fileSize.textContent = formatFileSize(file.size);
  updateUploadControls();
  showMessage("图片检查通过,可以上传。", "success");
});

清空函数同时重置文件框、状态和旧对象 URL:

js
function clearSelectedFile() {
  if (previewObjectUrl) {
    URL.revokeObjectURL(previewObjectUrl);
    previewObjectUrl = "";
  }

  selectedFile = null;
  avatarInput.value = "";
  avatarPreview.removeAttribute("src");
  updateUploadControls();
}

把 value 设为空字符串是允许的,可以让用户重新选择同一个文件。前端不能反过来给文件输入框指定本地文件路径。

学员切换时要清空待上传文件并重新查询头像。快速连续切换时,旧 GET 可能晚于新 GET 返回。完整附件在 loadStudent() 开始时记录 const requestVersion = ++studentLoadVersion,得到响应后只在 requestVersion === studentLoadVersion 时更新页面。上传开始后,updateUploadControls() 会锁定学员选择、文件框和取消按钮,避免旧上传结果覆盖新学员页面。

上传与状态恢复 ​

提交时再校验一次,不依赖之前的界面状态:

js
avatarForm.addEventListener("submit", async (event) => {
  event.preventDefault();

  if (isUploading) {
    return;
  }

  const errorMessage = validateAvatar(selectedFile);
  if (errorMessage) {
    showMessage(errorMessage, "error");
    return;
  }

  isUploading = true;
  studentLoadVersion += 1; // 让上传前尚未完成的学员查询失效。
  updateUploadControls();

  try {
    const studentId = Number(studentSelect.value);
    const student = await uploadAvatar(studentId, selectedFile);

    savedAvatar.src = getApiFileUrl(student.avatar);
    responseData.textContent = JSON.stringify(student, null, 2);
    clearSelectedFile();
    showMessage(`上传成功:${student.name}`, "success");
  } catch (error) {
    console.error(error);
    showMessage(error.message, "error");
  } finally {
    isUploading = false;
    updateUploadControls();
  }
});

studentLoadVersion 让上传前尚未完成的学员查询失效。finally 会在成功和失败后恢复控件。成功时 clearSelectedFile() 已清空文件,上传按钮保持禁用;失败时文件仍在,按钮重新启用,用户可以再次尝试。

运行与验收 ​

在课程目录分别打开两个终端,启动 API 与静态页面服务:

bash
cd .code/api_server
uv run uvicorn app:app --port 8000
bash
python -m http.server 5500

第二条命令在课程目录执行。浏览器访问:

text
http://127.0.0.1:5500/.code/p04/c12-formdata-upload/04-avatar-upload.html

选择学员和符合规则的图片,页面应先显示本地 blob: 预览和文件信息。

可以按下面步骤验证:

  1. 选择非图片或超过 2MB 的文件,应显示错误并清空当前选择。
  2. 选择有效图片后,上传按钮可用;点击上传时按钮显示“上传中...”,学员选择、文件框和取消按钮暂时不可用。
  3. 上传成功后,左侧头像应更新为接口返回的服务端地址,不再使用临时 blob: 地址。
  4. 在 Network 中检查请求为 POST /api/students/{id}/avatar,请求体包含 avatar,且 Content-Type 带浏览器生成的 boundary。
  5. 页面加载并选图后,可在点击上传前暂时停止 API 模拟网络失败;页面应显示错误并保留已选文件。恢复 API 后重试,不必重新选图。
  6. 快速连续切换学员时,页面最终应显示最后选中的学员;上传期间不能切换学员。

可复制的练习源码 ​

复制后保持导入路径中的目录结构。纯 HTML 页面可直接在浏览器打开;需要 ES Module 或测试 API 的案例,按本节前文的启动命令运行并访问对应路径。

.code/p04/c12-formdata-upload/01-file-inspection.html
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>读取并检查 File 对象</title>
  <style>
    body { max-width: 680px; margin: 40px auto; padding: 0 20px; font-family: sans-serif; }
    input { padding: 10px; }
    pre { padding: 16px; background: #18222d; color: #daf4e8; overflow: auto; }
  </style>
</head>
<body>
  <h1>检查头像文件</h1>
  <input id="avatar" type="file" accept=".jpg,.jpeg,.png,.webp,.svg" />
  <pre id="result">请选择不超过 2 MB 的 JPG、PNG、WebP 或 SVG 图片</pre>

  <script>
    const avatarInput = document.querySelector("#avatar");
    avatarInput.addEventListener("change", () => {
      const file = avatarInput.files[0];
      const result = document.querySelector("#result");
      if (!file) { result.textContent = "没有选择文件"; return; }
      const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/svg+xml"];
      if (file.size === 0) { result.textContent = "文件不能为空"; avatarInput.value = ""; return; }
      if (!allowedTypes.includes(file.type)) { result.textContent = "文件类型不符合要求"; avatarInput.value = ""; return; }
      if (file.size > 2 * 1024 * 1024) { result.textContent = "文件不能超过 2 MB"; avatarInput.value = ""; return; }
      result.textContent = JSON.stringify({ name: file.name, type: file.type, size: file.size, lastModified: file.lastModified }, null, 2);
    });
  </script>
</body>
</html>

:::

.code/p04/c12-formdata-upload/02-image-preview.html
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>本地头像预览</title>
  <style>
    body { max-width: 640px; margin: 40px auto; padding: 0 20px; font-family: sans-serif; text-align: center; }
    img { display: block; width: 180px; height: 180px; margin: 20px auto; border-radius: 50%; object-fit: cover; background: #e8edf2; }
  </style>
</head>
<body>
  <h1>使用对象 URL 本地预览</h1>
  <input id="avatar" type="file" accept=".jpg,.jpeg,.png,.webp,.svg" />
  <img id="preview" alt="头像预览" hidden />
  <p id="message">选择图片后无需上传即可预览</p>

  <script>
    const avatarInput = document.querySelector("#avatar");
    const preview = document.querySelector("#preview");
    let previewUrl = "";

    avatarInput.addEventListener("change", () => {
      const file = avatarInput.files[0];
      if (previewUrl) {
        URL.revokeObjectURL(previewUrl);
        previewUrl = "";
      }
      preview.hidden = true;
      preview.removeAttribute("src");
      if (!file) {
        document.querySelector("#message").textContent = "选择图片后无需上传即可预览";
        return;
      }
      const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/svg+xml"];
      if (!allowedTypes.includes(file.type) || file.size === 0 || file.size > 2 * 1024 * 1024) {
        avatarInput.value = "";
        document.querySelector("#message").textContent = "请选择不超过 2 MB 的 JPG、PNG、WebP 或 SVG 图片";
        return;
      }
      previewUrl = URL.createObjectURL(file);
      preview.src = previewUrl;
      preview.hidden = false;
      document.querySelector("#message").textContent = `本地预览:${file.name}`;
    });
    window.addEventListener("beforeunload", () => { if (previewUrl) URL.revokeObjectURL(previewUrl); });
  </script>
</body>
</html>

:::

.code/p04/c12-formdata-upload/03-formdata-request.html
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>使用 FormData 上传文件</title>
  <style>
    body { max-width: 680px; margin: 40px auto; padding: 0 20px; font-family: sans-serif; }
    form { display: grid; gap: 12px; }
    input, button { padding: 10px; }
    pre { padding: 16px; background: #18222d; color: #daf4e8; overflow: auto; }
  </style>
</head>
<body>
  <h1>构造并发送 FormData</h1>
  <form id="upload-form">
    <label>学员 ID <input name="student_id" type="number" value="1" min="1" /></label>
    <label>头像文件 <input name="avatar" type="file" accept=".jpg,.jpeg,.png,.webp,.svg" required /></label>
    <button>上传头像</button>
  </form>
  <pre id="result">不要手动设置 Content-Type,浏览器会补充 boundary。</pre>

  <script>
    async function getErrorMessage(response) {
      const fallback = `上传失败:HTTP ${response.status}`;

      try {
        const data = await response.json();
        if (typeof data.detail === "string") return data.detail;
        if (Array.isArray(data.detail)) {
          const messages = data.detail.map((item) => `${item.loc?.at(-1) ?? "字段"}:${item.msg}`).join(";");
          return messages || fallback;
        }
      } catch (error) {
        console.warn("错误响应不是有效 JSON", error);
      }

      return fallback;
    }

    document.querySelector("#upload-form").addEventListener("submit", async (event) => {
      event.preventDefault();
      const form = event.currentTarget;
      const studentId = form.elements.namedItem("student_id").value;
      const file = form.elements.namedItem("avatar").files[0];
      const result = document.querySelector("#result");
      const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/svg+xml"];
      if (!file || file.size === 0 || !allowedTypes.includes(file.type) || file.size > 2 * 1024 * 1024) {
        result.textContent = "请选择不超过 2 MB 的 JPG、PNG、WebP 或 SVG 图片";
        return;
      }
      const formData = new FormData();
      formData.append("avatar", file);
      try {
        const response = await fetch(`http://127.0.0.1:8000/api/students/${studentId}/avatar`, { method: "POST", body: formData });
        if (!response.ok) throw new Error(await getErrorMessage(response));
        const data = await response.json();
        result.textContent = JSON.stringify({ status: response.status, data }, null, 2);
      } catch (error) {
        result.textContent = `上传失败:${error.message}`;
      }
    });
  </script>
</body>
</html>

:::

小结 ​

本节课最核心的是掌握七件事:

  1. 使用 input.files 取得 File,不要依赖文件框的 value。
  2. 使用 name、type、size 读取并检查文件信息。
  3. accept 和前端校验用于改善体验,不能替代服务端校验。
  4. 使用对象 URL 预览本地图片,并在适当时机释放。
  5. 使用 FormData.append() 添加文件字段。
  6. 发送 FormData 时不要设置 Content-Type,让浏览器生成边界。
  7. 上传成功后使用接口返回的头像路径,不要保存临时 blob: 地址。