很多系统里的文件上传体验都差不多:
- 点上传;
- 页面卡住或按钮置灰;
- 等后端成功或失败再提示一句。
Odoo 没走这条路。
它在前端把“上传中”这件事独立建模成一份可观察状态,因此你会看到:
- 上传开始后,界面立即出现进度卡片;
- 上传过程中,字节数和百分比持续变化;
- Kanban、列表等不同视图可以消费同一套进度对象;
- 失败、取消、完成都有统一事件可监听。
这条链路的核心都在 file_upload_service.js 一带。
一、file upload service 真正抽象的不是请求,而是“上传任务对象”
start() 里最重要的初始化有三个:
const uploads = reactive({})let nextId = 1const bus = new EventBus()
这三者一摆出来,设计意图就很明显了。
Odoo 并没有把文件上传设计成一次孤立的 xhr.send(),而是先定义了一层任务容器:
uploads保存所有正在进行中的上传;id让每个任务有稳定身份;bus允许上传生命周期被外界订阅。
也就是说,服务暴露给 UI 的不是“帮我传个文件”,而是:
这里有一批可观察、可标识、可广播状态变化的上传实体。
二、upload() 的第一步不是发请求,而是先组装一份标准化的 upload 记录
upload(route, files, params) 里会创建:
xhrformData- 一个
reactive({...})的upload对象
这个 upload 对象里有几项特别关键:
progressloadedtotalstatetitletypexhrdata
这意味着 Odoo 把“上传任务”定义成了一个既包含传输句柄,又包含展示信息的统一对象。
为什么这很重要?因为 UI 若只拿到百分比,就很难做出稳定的进度列表;若只拿到 xhr,更谈不上通用展示。
官方这里一步到位:让业务展示层和网络层围绕同一份 upload 实体对齐。
三、进度更新直接写回 reactive 对象,因此 UI 不需要手搓轮询
xhr.upload.addEventListener("progress", ...) 里会连续更新:
upload.progress = ev.loaded / ev.totalupload.loaded = ev.loadedupload.total = ev.totalupload.state = "loading"
因为 uploads 和单个 upload 都是响应式对象,所以前端展示层根本不需要自己做定时器轮询。
上传任务一变化,订阅它的组件自然就会刷新。
这一点特别值得学:
真正高质量的上传进度 UI,不是“每 300ms 去问一次当前百分比”,而是把进度本身做成响应式状态源。
四、完成、失败、取消是三条不同的前端生命周期,不该都混成“请求结束”
Odoo 在服务层把它们分得很清楚。
1)load
请求成功返回后,会先尝试 handleResponse()。若无异常:
- 从
uploads中删除该任务; - 把
upload.state置为loaded; - 广播
FILE_UPLOAD_LOADED。
2)error
错误时执行 onError(error):
- 删除任务;
- 状态置为
error; - 可选通知
notificationService.add(...); - 广播
FILE_UPLOAD_ERROR。
3)abort
取消上传也不会伪装成成功,而是:
- 删除任务;
- 状态置为
abort; - 同样广播
FILE_UPLOAD_ERROR。
这说明 Odoo 对上传结果的看法很务实:
- 成功是成功;
- 失败是失败;
- 用户取消虽然不是异常堆栈,但从“任务没有完成”的角度看,仍属于失败路径。
五、响应解析比很多人想的更保守:不只看 HTTP 码,还会读 JSON / HTML 内容
handleResponse() 这段相当值得注意。
它不会只凭 status 2xx 就立刻认定成功,而是还会继续分析返回体:
- 字符串先尝试
JSON.parse(); - 不行再尝试按 HTML 解析;
- 若内容里有
error对象,也会转成失败。
这正是在处理 Odoo 生态里常见的复杂后端返回:
- 可能是 JSON-RPC 错误对象;
- 可能是 HTML 异常页;
- 也可能是普通文本。
所以前端上传链路不是 naive 的“HTTP 200 即成功”,而是尽量把服务器错误语义再还原一遍。
六、FileUploadProgressContainer 说明进度 UI 是消费层,不是状态拥有层
file_upload_progress_container.xml 很简单,只做一件事:
- 遍历
Object.values(props.fileUploads) - 用指定
Component渲染每一条 upload
这意味着容器层完全不拥有上传状态,它只消费外部传来的 fileUploads。
这样设计的好处很明显:
- 同一份上传状态可以被多个视图复用;
- Kanban 和列表行可以用不同 record 组件;
- 是否显示哪条任务,可以交给
shouldDisplay()再做一层过滤。
也就是说,官方把“状态来源”和“展示样式”拆得很开。
七、getProgressTexts() 让进度文案和底层字节状态保持一致
FileUploadProgressRecord 里最核心的展示逻辑,就是 getProgressTexts()。
它不会凭空拼文案,而是根据 progress / loaded / total 精准产出两类状态:
- 未完成:
Uploading... (%s%)+(x/yMB) - 100% 但后端还没最终返回:
Processing...
这一点非常细。
因为用户看到 100%,并不总代表整个上传链路已经结束。文件可能已传完,但后端还在创建附件、跑校验或做二次处理。
把这个阶段明确命名为 Processing...,能有效减少“为什么都 100% 了还没出来”的误解。
八、二开时最容易踩的坑
误区 1:把上传状态放在单个按钮组件内部
这样一换视图、组件一卸载,进度就丢了。
误区 2:只做一个百分比,不保留 loaded/total/title/type
后面想扩展 UI,信息一定不够。
误区 3:HTTP 200 就当成功,不再检查内容体
在 Odoo 生态里,这很容易吞掉真正错误。
误区 4:列表视图和看板视图各写一套上传状态
其实官方已经给了统一 service 和 record 容器,没必要分裂。
九、结论
Odoo 文件上传体验之所以顺滑,不是因为它有一条好用的 XHR,而是因为它把上传前端化成了一套完整状态模型:
file_uploadservice 生产统一 upload 实体;reactive uploads承担实时状态同步;EventBus暴露生命周期事件;ProgressContainer / Record只负责消费和展示。
所以真正值得学的不是“怎么显示上传百分比”,而是:
如何把一次网络传输提升为一个可被多个界面共同消费的前端任务对象。
DISCUSSION
评论区