成为全栈·Taro 小程序篇·图片上传:选择、取消、会话切换与提交协调
上传不是一个 Promise 而已。用户选择、平台取消、账号变化、文件返回与正文提交,共同决定图片最后属于哪份内容。

前言:拿到地址之前,文章应该保存哪一版
移动投稿很常见的一条路径是:写到一半,选一张图片,继续输入,然后提交。若保存与上传各自运行,保存可能先完成,图片 URL 稍后才插入,用户以为提交了有图文章,后台实际只有旧正文。
还有一个更敏感的情况:A 会员打开媒体选择后退出,B 登录,选图结果回来。如果直接读取当前 token 上传,就可能把 A 的选择交给 B 的会话。
当前实现因此同时处理上传流程与编辑协调。它仍有明确限制:没有自动认证刷新重传,也没有声明完整真机相册矩阵已通过。这篇把这两个层面一起讲。
选择文件是第一阶段,不是已经上传
uploadImage 先确认会话,捕获 epoch,再使用平台媒体选择。epoch 是会话代次,账号切换时递增,用它就能判断这次选择是不是还属于当前登录者:
// src/core/api.ts(节选)
const generation = sessionEpoch();
const choice = await Taro.chooseMedia({
count: 1,
mediaType: ["image"],
sizeType: ["compressed"],
});
if (sessionEpoch() !== generation) throw new Error("账号已切换");
选择之前先要求已登录,选择之后再比对代次。一次选择一张、请求压缩形式,是当前产品范围。压缩选项只降低体积,文件仍可能超出限制,清晰度也随原图变化,选定后仍要检查大小。
用户关闭选择界面属于取消,EditorForm 与头像页当前通过错误信息中的 cancel 区分,不展示上传错误。这个判断依赖平台的错误文本,还不是一层正式封装好的取消类型。后续若发现别的返回格式,需要补充分类,而不是把所有异常一并吞掉。
这一阶段的产出只有一个临时文件路径,它还没有和任何服务器资源绑定,也还没有进入正文。选择与上传拆成两步,是为了让“用户还没有真正决定”这件事可被观察:选择阶段放弃,不产生任何远端痕迹。
A 退出、B 登录,然后 A 选的那张图进了 B 的文章
这是我这批开发里最想拿出来讲的一个 bug,因为它看起来完全不可能发生。
场景:会员 A 在编辑器里点"插入图片",chooseMedia 打开了相册选择器。用户这时候做了别的事 —— 退出了登录,B 登录了。 然后 A 之前打开的选择器返回了一个文件。
我原来的写法是:
// ❌ 第一版:拿到文件就直接用「当前会话」
const choice = await Taro.chooseMedia({ ... });
const file = choice.tempFiles[0];
if (!file) throw new Error("未选择图片");
// …这里读 session(),拿到的是 B 的令牌
结果:B 的账号下多了一张 A 选的图。 而从代码看,它完全没毛病 —— 每个变量都在作用域里。
修法是在打开选择器之前记下代次,回来之后核对:
// src/core/api.ts(节选):选择前后各核对一次代次。
const generation = sessionEpoch();
const choice = await Taro.chooseMedia({
count: 1,
mediaType: ["image"],
sizeType: ["compressed"],
});
if (sessionEpoch() !== generation) throw new Error("账号已切换");
而真正让它可靠的是上传之后再核对一次:
// src/core/api.ts(节选):上传完成后再次确认账号没变。
const r = await Taro.uploadFile({
url: API + "/upload",
filePath: file.tempFilePath,
name: "file",
header: { Authorization: `Bearer ${session()!.accessToken}` },
});
if (sessionEpoch() !== generation) throw new Error("账号已切换");
两次检查各自挡住一件事:第一次挡住"还没选就换人",第二次挡住"选到一半换人"(比如用户在选择器里选了很久,中途在另一个页面登出了)。
这类 bug 的共同点:跨越 await 的异步过程 + 一个"期间可能变化"的外部状态。
而"外部状态"不只是会话 —— 还有网络、文件、甚至是用户对另一个页面的操作。凡是跨越 await 的东西,都必须在回来时重新确认,而不是假设它没变。

文件限制前置,服务端验证仍保留
选中后先确认 tempFiles[0] 存在,文件超过十兆字节则拒绝。随后用 tempFilePath 发 multipart 上传,字段名为 file,并带上当前访问令牌:
// src/core/api.ts(节选)
const file = choice.tempFiles[0];
if (!file) throw new Error("未选择图片");
if (file.size > 10 * 1024 * 1024) throw new Error("图片不能超过 10MB");
const r = await Taro.uploadFile({
url: API + "/upload",
filePath: file.tempFilePath,
name: "file",
header: { Authorization: `Bearer ${session()!.accessToken}` },
});
这是客户端体验检查,服务于尽早失败,真正的类型、大小与权限仍由后端控制。客户端没有检查文件魔数,也无法保证被修改过的请求依然遵守大小约定。把检查放前面是为了少一次无效传输,而不是替代服务端校验。
限制口径按字节。压缩前图片的尺寸并不能直接推断上传是否可行,因为压缩结果取决于原图格式与内容。文章也不把 chooseMedia 的 compressed 当作无限存储或质量保证。它降低的是体积,不会让大小检查变得多余。
uploadFile 返回字符串,要单独拆信封
普通请求由 Taro.request 泛型消费 JSON,uploadFile 的 data 则需要 JSON.parse。解析之后再检查 HTTP 状态和业务 code,最后返回 data.url:
// src/core/api.ts(节选)
if (sessionEpoch() !== generation) throw new Error("账号已切换");
const body = JSON.parse(r.data) as Envelope<{ url: string }>;
if (r.statusCode >= 400 || body.code !== 0) throw new Error(body.message || "上传失败");
return body.data.url;
注意这一步之前又检查了一次代次。选择、上传、解析是三个不同的异步边界,每一步之后会话都可能已经改变。整个上传各阶段的结果如下:
| 阶段 | 可能结果 | 当前界面含义 |
|---|---|---|
| 选择前未登录 | 直接拒绝 | 不上传,提示先登录 |
| 选择 | 取消 | 不当作失败提示,保留输入 |
| 文件检查 | 缺文件或过大 | 停止上传,保留稿件 |
| HTTP 上传 | 网络失败、认证错误 | 显示错误,允许后续人工重试 |
| 信封解析 | 服务端成功返回 URL | 更新封面或插入 Markdown |
| 返回后身份检查 | epoch 改变 | 拒绝旧身份结果 |
返回体若不是合法 JSON,当前会抛普通解析异常,经页面错误出口显示;这里没有独立的“上传响应异常”分类。这个边界保留在文章里,而不是替现有代码补上一套源码里并没有的转换。

为什么检查两次身份变化
选择之后检查,阻止旧选择借新账号上传;上传之后再检查,阻止旧身份结果更新当前页面。请求发出前后一共跨过几个不同的异步边界,两次检查各有价值。
但上传中途账号变化也不会取消底层网络,服务器可能已经接收文件。epoch 能拒绝界面采用,却无法撤销一个已经落在远端的资源。这个区别与缓存一致:客户端能决定的只是“我认不认这个结果”,而不是“服务器做没做这件事”。
因此本次没有声明上传具备自动清理孤儿文件的完整生命周期。文件上传成功、正文最终没保存,资源是否清理属于后端存储治理的范围,需要另行定义,客户端不替它承担。
父页需要知道上传还没结束
EditorForm 同时维护本地 uploading,并通过 onUploading 通知父编辑器。父页在 uploading 时拒绝保存与提交:
// src/pages/editor/index.tsx(节选)
<EditorForm
draft={draft}
onChange={change}
categories={categories.data || []}
disabled={busy}
onUploading={setUploading}
/>
两个提交按钮也把 uploading 加入禁用条件:
// src/pages/editor/index.tsx(节选)
<Button
className="button"
disabled={busy || uncertain || uploading}
loading={busy}
onClick={() => void save(true)}
>
提交审核
</Button>
这解决的是“地址还没出现就提交”。正文输入仍可继续,上传完成时用 latest draft 插入图片;封面模式则只更新 coverImage。操作完成或失败都在 finally 里通知父页结束。
这样没有把整个页面锁死,却让依赖资源的动作等待必要前提。若未来支持多张并发,需要把布尔状态改成更明确的任务集合或计数,防止一张完成就提前允许提交。当前一次一张,没有批量上传队列。
页面预览切换会改变表单挂载状态,上传回调如何处理组件中断仍需要对应的人工验收。代码里有结束回调,把它推广到所有离开场景还需要真机证据。
上传不自动刷新,是一个必须公开的差异
普通 API 在符合条件的 401 后会单飞刷新并重放一次,判断条件刻意排除了重放写入,我们把它原样引出来:
// src/core/api.ts(节选)
if (
!(error instanceof ApiError) ||
error.status !== 401 ||
!token ||
path.startsWith("/auth/") ||
sessionEpoch() !== generation
)
throw error;
if (session()?.accessToken === token) await refreshSession();
if (sessionEpoch() !== generation) throw new Error("账号已切换,请重新操作");
刷新自己也是单飞的,并发请求只会触发一次:
// src/core/api.ts(节选)
.then((auth) => {
if (sessionEpoch() !== generation) throw new Error("账号已切换,请重新操作");
setSession(auth, false);
})
uploadImage 当前直接调用 uploadFile,没有接入这条链路,所以令牌过期时上传会直接失败。
这条差异值得写进文章,而不是悄悄略过:读者容易以为整个应用共享同一套 401 处理,一旦上传遇到过期令牌,行为却与普通请求不同。把“上传不走刷新”明说出来,用户重试时心里就有依据,也不会怀疑是自己选错了图片。
本轮选择显示错误,用户在会话刷新后重试,而不是自动重传文件。原因不止是少写代码:重传需要确认失败是否发生在认证之前、临时文件是否仍然可用、当前身份是否相同、服务端有没有已经保存的资源。
未来要扩展,应为上传设计清楚的认证重放边界,而不是在 catch 里无条件再上传一次。网络异常则留待人工判断,自动重传的结果往往无法确认。
图片进入正文与进入资料,各有第二步
正文插图在拿到 URL 后拼入 Markdown,封面更新草稿字段;头像则更新本地表单,普通资料保存才 PATCH 到服务端。
所以“上传成功”只是文件进入了存储,“文章已保存”与“头像资料已更新”是另外两步。当前这些动作有各自的按钮与反馈,文章要帮助读者理解中间状态,而不是用一个 toast 盖住整条流程。
移除封面只把草稿地址清空,存储中的文件仍然存在:
// src/features/editor-form.tsx(节选)
{draft.coverImage && (
<Button className="link" onClick={() => change({ coverImage: "" })}>
移除封面
</Button>
)}
后端资源删除与引用关系不在当前客户端动作里。从一个移除按钮读不出文件回收已经实现。
用“继续输入”场景检查异步结果合并
作者开始上传时正文是甲,等待中输入成甲加乙。上传返回后,正确结果应是甲加乙再插入图片,而不是回到甲加图片。
EditorForm 的 latest 引用就是为这个顺序服务。保存草稿的拥有者仍在父页,上传回调只产生 patch,不接管整份旧 Draft。它也让封面上传不会覆盖等待期间新改的标题和标签。
不过,图片插入位置取当前保存的 cursor,而不是一份独立的上传开始选区。等待期间如果光标变化,结果会插到更新后的位置。这是当前语义,应通过人工场景确认用户是否理解;把它描述成始终插回原位置就与实现不符。
不确定上传与不确定创建的区别
创建文章会产生作者可在“我的文章”检查的资源,当前有明确的 uncertain 处理。图片上传结果丢失时,客户端没有可用资源列表去精确认领,也不自动重传。
所以,失败时保留文本是必要的,但它回收不了远端可能已经保存的图片。更完整的方案可能需要上传任务标识、资源查询或后端过期清理;这些都不在当前客户端的承诺之内。
用户取消与平台拒绝也要区分
取消表示用户不继续选择;权限拒绝、找不到文件和网络错误则需要不同的修正路径。当前只做 cancel 字符串的兜底分类,后续要依据实际平台返回补上更稳定的类型与文案。
错误记录可以保留阶段与状态码,不必输出完整文件路径或访问凭据。诊断有助于复现,前提是它不会把私人资源和令牌变成文档素材。
如果本期继续加强上传,先跑真实选择、拒绝和弱网矩阵,再决定重试策略,比先加一个“重试三次”的参数更安全。文件写入与正文写入的时间顺序,始终是验收的核心。
验收不要用接口脚本替代平台选择
本轮构建通过,编辑器交互经过工具操作,真实相册上传与用户照片没有逐项人工验收。集成测试也没有证明微信媒体权限和真机临时文件行为。
后续矩阵应包括取消、超限、网络中断、认证过期、选择期间换人、上传时继续输入、上传未结束点击提交,以及成功后再保存。每项都观察文本是否保留、归属是否正确、错误能否修正。
更多内容归属见 编辑器 与 本机草稿。上传做得可靠,是因为我们知道每个“完成”到底完成到哪一步——是选择、上传,还是保存,而不是把三者混成一句“已上传”。
延伸阅读
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer

转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/FungLeo/article/details/168963527




