FormData 头像上传
学员管理页要给 1 号学员更换头像:选择图片后先看预览,确认无误再上传,成功后页面显示服务端保存的头像。前面提交姓名、手机号用的是 JSON;直接对 File 调用 JSON.stringify() 并不能把图片内容上传。这里需要 FormData。
本节使用 FormData 组织文件,通过课程测试 API 完成头像上传。页面会经历一条完整流程:选择文件、检查类型和大小、本地预览、发送请求、显示服务端保存后的头像。
本节课解决七个问题:
- 怎样从文件输入框取得
File对象。 - 怎样读取文件名、类型和大小。
- 怎样在上传前检查文件。
- 怎样使用对象 URL 预览本地图片。
- 怎样使用
FormData组织上传数据。 - 为什么不能手动设置上传请求的
Content-Type。 - 怎样处理上传成功、接口错误和按钮状态。
本节按“读取与检查文件 → 预览与组织 FormData → 发送上传请求 → 使用服务端头像”的顺序推进。
文件检查最小实验
先在课程目录启动静态页面服务:
python -m http.server 5500打开文件检查实验(源码文件:./.code/p04/c12-formdata-upload/01-file-inspection.html),或访问:
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}/avatar | multipart/form-data | 200 |
头像接口还有四条约定:
- 文件字段名必须是
avatar。 - 支持 JPG、PNG、WebP、SVG。
- 文件大小不能超过 2MB。
- 上传成功返回更新后的完整学员数据。
请求地址中的 {id} 表示学员编号。例如给 1 号学员上传头像:
POST /api/students/1/avatar
Content-Type: multipart/form-data; boundary=...boundary 由浏览器自动生成。前端代码不需要填写。
文件选择
先准备文件输入框:
<input
id="avatar-input"
name="avatar"
type="file"
accept=".jpg,.jpeg,.png,.webp,.svg"
/>accept 会限制文件选择器优先显示哪些文件,但它只是用户体验提示,不是可靠的安全校验。用户仍可能通过其他方式选择不符合要求的文件。
文件输入框通过 files 属性返回 FileList:
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 获取文件内容:
console.log(avatarInput.value);浏览器可能只显示类似 C:\fakepath\avatar.png 的假路径。这样做是为了保护用户本地文件系统。真正可读取和上传的是 files 中的 File 对象。
File 属性
取得 File 后,可以读取下面的信息:
| 属性 | 作用 | 示例 |
|---|---|---|
name | 原始文件名 | avatar.png |
type | MIME 类型 | image/png |
size | 文件字节数 | 153600 |
lastModified | 最后修改时间戳 | 1710000000000 |
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:
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。先把规则写成常量:
const MAX_AVATAR_SIZE = 2 * 1024 * 1024;
const ALLOWED_IMAGE_TYPES = new Set([
"image/jpeg",
"image/png",
"image/webp",
"image/svg+xml",
]);再编写独立校验函数:
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 "";
}函数返回空字符串表示通过,否则返回可以直接显示的错误信息:
const errorMessage = validateAvatar(file);
if (errorMessage) {
alert(errorMessage);
avatarInput.value = "";
return;
}前端检查只用于尽早提示用户,不能代替服务端检查。本课测试 API 会检查上传文件的 MIME 类型和大小;实际项目还需要由服务端检查权限和文件内容。本课只调用接口,不讲服务端实现。
本地预览与 FormData
本地预览
用户选择图片后,可以先显示本地预览,不必等待上传完成。
<img id="avatar-preview" alt="待上传头像预览" />const avatarPreview = document.querySelector("#avatar-preview");
const objectUrl = URL.createObjectURL(file);
avatarPreview.src = objectUrl;URL.createObjectURL(file) 会创建一个临时的 blob: 地址:
blob:http://127.0.0.1:5500/...这个地址只在当前浏览器页面中有效。它不是服务端地址,也不能保存到数据库。
对象 URL 会占用浏览器资源。用户反复换图时,应释放旧地址:
let previewObjectUrl = "";
function showLocalPreview(file) {
if (previewObjectUrl) {
URL.revokeObjectURL(previewObjectUrl);
}
previewObjectUrl = URL.createObjectURL(file);
avatarPreview.src = previewObjectUrl;
}页面关闭前也可以清理:
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 请求体。
手动添加文件
本案例的学员编号位于请求地址中,请求体只需要头像文件:
const formData = new FormData();
formData.append("avatar", file);avatar 是接口字段名,必须与 API 文档完全一致。改成 image、file 或 photo 都会导致接口找不到文件字段。
append() 还可以传第三个参数指定上传文件名:
formData.append("avatar", file, file.name);不传时,浏览器默认使用 File.name。
从表单创建 FormData
如果表单中的控件都有正确的 name,也可以直接读取整个表单:
<form id="avatar-form">
<input name="avatar" type="file" />
<button type="submit">上传头像</button>
</form>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) 不一定能展开内部字段。排查时可以遍历:
for (const [name, value] of formData.entries()) {
console.log(name, value);
}上传请求与头像更新
上传请求
上传函数接收学员编号和文件:
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 时退回状态码。完整写法如下:
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 处理。错误响应体在这里仅读取一次。
这个请求有三个关键点:
body直接传formData,不要调用JSON.stringify()。- 不要设置
Content-Type: application/json。 - 也不要手动设置
Content-Type: multipart/form-data。
浏览器会生成类似下面的请求头:
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary...服务端依靠 boundary 拆分请求体中的字段。手动填写 multipart/form-data 时缺少正确边界,服务端可能无法解析文件。
在课程目录另开终端启动测试 API;首次运行前按 测试 API 启动说明(路径:./.code/api_server/README.md)安装依赖:
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: 地址只能用于本地预览。上传成功后,接口返回完整学员数据,其中与头像有关的字段如下:
{
"id": 1,
"name": "张三",
"avatar": "/uploads/student_1_a1b2c3.svg"
}这是相对于 API 服务的路径。页面和 API 不同源时,需要补全 API 地址:
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。
综合案例:学员头像上传
案例需求
页面完成下面的交互:
- 选择学员后,查询并显示当前头像。
- 选择图片后,检查类型和大小。
- 校验通过后显示文件信息和本地预览。
- 点击上传时锁定学员、文件和上传按钮,防止重复提交与中途切换。
- 上传成功后显示服务端头像和响应数据。
- 上传失败时保留明确的错误提示。
完整页面见:学员头像上传(源码文件:./.code/p04/c12-formdata-upload/04-avatar-upload.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:
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:
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() 会锁定学员选择、文件框和取消按钮,避免旧上传结果覆盖新学员页面。
上传与状态恢复
提交时再校验一次,不依赖之前的界面状态:
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 与静态页面服务:
cd .code/api_server
uv run uvicorn app:app --port 8000python -m http.server 5500第二条命令在课程目录执行。浏览器访问:
http://127.0.0.1:5500/.code/p04/c12-formdata-upload/04-avatar-upload.html选择学员和符合规则的图片,页面应先显示本地 blob: 预览和文件信息。
可以按下面步骤验证:
- 选择非图片或超过 2MB 的文件,应显示错误并清空当前选择。
- 选择有效图片后,上传按钮可用;点击上传时按钮显示“上传中...”,学员选择、文件框和取消按钮暂时不可用。
- 上传成功后,左侧头像应更新为接口返回的服务端地址,不再使用临时
blob:地址。 - 在 Network 中检查请求为
POST /api/students/{id}/avatar,请求体包含avatar,且Content-Type带浏览器生成的 boundary。 - 页面加载并选图后,可在点击上传前暂时停止 API 模拟网络失败;页面应显示错误并保留已选文件。恢复 API 后重试,不必重新选图。
- 快速连续切换学员时,页面最终应显示最后选中的学员;上传期间不能切换学员。
可复制的练习源码
复制后保持导入路径中的目录结构。纯 HTML 页面可直接在浏览器打开;需要 ES Module 或测试 API 的案例,按本节前文的启动命令运行并访问对应路径。
.code/p04/c12-formdata-upload/01-file-inspection.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
<!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
<!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>:::
小结
本节课最核心的是掌握七件事:
- 使用
input.files取得File,不要依赖文件框的value。 - 使用
name、type、size读取并检查文件信息。 accept和前端校验用于改善体验,不能替代服务端校验。- 使用对象 URL 预览本地图片,并在适当时机释放。
- 使用
FormData.append()添加文件字段。 - 发送
FormData时不要设置Content-Type,让浏览器生成边界。 - 上传成功后使用接口返回的头像路径,不要保存临时
blob:地址。