Vue项目AI扩图实战:方案选型、代码实现与坑点优化 上个月有个朋友来找我说他们后台管理系统的商品图片比例总是不统一设计师给的图换到不同位置就要重新裁用户上传的图尺寸更是五花八门。我第一个想到的就是在 Vue 项目里直接做一个AI 扩图功能让用户上传原图AI 自动把图片四周的内容补全输出一张比例达标的图不用再频繁切回 PS。这个想法落地之后效果很好整个过程涉及方案选型、前端交互设计、接口调试和一堆容易踩的坑今天就把这套完整思路和实操过程写下来给想在 Vue 项目里接 AI 能力的朋友做个参考。这套功能适合谁如果你是前端开发者业务里有图片处理、海报生成、电商主图适配这类需求或者你正在做 AI 工具类产品需要给用户提供图片延展的操作入口这篇文章可以直接帮你少走不少弯路。内容从原理拆解到代码实现都会覆盖不想看原理的也可以直接跳到实战部分抄作业。1. 先弄清楚这套功能前端该管什么、不该管什么1.1 AI扩图的本质不是拉伸图片而是补画画面很多人第一次听到AI扩图第一反应是把图片变大一点。这里我必须先纠正一下普通的放大是把像素撑开图放大了但画面内容没有任何新增而 AI 扩图是让模型阅读原图里的语义信息——比如地面纹理、天空颜色、人物的朝向——然后顺着画面逻辑把图四周的画面补出来。用一个生活化的类比你画了一幅画画纸不够大画面里的草地和树延伸到纸边缘就断了。AI 扩图不是把纸硬扯大而是让一个很懂画画的助手顺着你的笔触把草地继续画出去、把天空继续晕染过去最后整张画看起来浑然一体。所以它的技术核心是生成式模型的 Outpainting 能力。模型的输入是原图 扩展方向和扩展比例输出是补全后的完整图像。这和 Photoshop 里的内容识别填充Content-Aware Fill不完全一样AI 扩图对画面结构的理解更深遇到人的身体延伸到画面外的情况它甚至会生成合理的手臂、衣角而不只是贴一块相似的纹理。我在做这个功能之前先确认了一件很重要的事这个能力的真正计算发生在服务端前端只是传参数、收结果的角色。想清楚这一点后面所有的架构设计都顺了。1.2 前端清晰的任务边界与数据流向既然 AI 计算发生在服务端前端的工作就非常明确了一共四件收集用户的原始图片收集用户选择的扩展方向上、下、左、右、四周和扩展比例把图片和参数提交给后端接口并展示处理中的状态拿到结果后进行预览、对比、下载。后端要做的事情其实也不多但非常关键接收文件或 base64和扩图参数调用已配置好的 AI 服务云厂商能力或自建模型把生成的图片存储起来或直接回传 base64返回给前端一个可访问的图片地址或二进制数据。这条链路看起来简单但分工必须明确。我做第一版的时候犯过一个错误想着省事把云厂商的接口直接在前端调。结果不仅密钥暴露风险非常高而且云厂商接口的跨域限制、并发限制、鉴权方式都不是为前端环境设计的调试过程极其痛苦。后面我把调用逻辑全部收敛到后端前端只对上自己后端的接口一下子就清爽了。1.3 两种常见的接口返回方式接 AI 扩图接口之前先搞清楚后端返回的格式是什么这直接影响前端代码怎么写。我做了市场调研后发现常见的返回方式有两种一种是直接返回完整的扩图后图片地址或 base64。这种最省事前端拿过来直接渲染、下载就完事了。缺点是如果做原图 vs 扩图对比展示前端需要自己把原图和结果图拼起来或者用两张图叠放实现对比效果。另一种是返回新增区域图片即只是四周边扩展出来的部分需要前端自己用 Canvas 把原图和新增区域拼起来。这种接口极少一般在自建模型服务里出现。如果遇到这种前端就要自己画 Canvas处理拼接位置、图片编码等逻辑。我在第三节会讲一个简单可靠的拼接实现思路。提示不管后端用什么方式返回前端都必须拿到一张可以直接渲染的图片 URL 或 Blob 对象。如果后端返回的是二进制的 ArrayBuffer前端可以用URL.createObjectURL(new Blob([data]))转成可渲染的地址。这个技巧后面也会用到。2. 方案选型为什么我坚持用后端中转而不是前端直连2.1 密钥不能暴露在浏览器端我在和不少同行聊这个需求时发现很多人第一版都是直接在前端接云厂商的 AI 接口因为云厂商文档里直接给了前端直连的代码示例复制粘贴就能跑通。但我要泼一盆冷水这种写法只适合本地 demo绝对不要带到生产环境。原因很简单。浏览器里跑 JS所有代码对用户来说都是透明的。你在代码里写的 accessKey、secretKey只要用户打开开发者工具在 Network 面板里稍微翻一下密钥就泄露了。而 AI 扩图这类生成式接口是按次数计费的密钥一旦泄露等于把你的钱包敞开了给别人刷。所以我的方案很明确后端做一个薄薄的代理层前端只向后端发起请求后端持有密钥去调用云服务拿到结果再返回给前端。这样密钥永远留在服务端前端拿到的最多是一个临时凭证。顺带说一句如果公司有网关层再在网关做一层二级缓存会更好。因为同一张图用户高频重复扩的时候后端可以判断参数相同就直接返回上次的图能省不少调用的钱。2.2 云厂商怎么选只看三个硬指标市面上支持图片扩展能力的云厂商很多各有各的 SDK 和接入方式。我调研了一圈之后总结出三个硬指标按重要程度排序第一生成质量稳定性。这个必须实测。我建议接入前拿自己业务里最典型的一批图去测不要拿官方的样张图测。官方样张都是精挑细选的而你的业务图可能是手机随手拍的、带水印的、画面里有人物的。重点看扩展出来的区域有没有明显的拼接感、色差和结构错误。人物扩图最容易翻车手脚可能多出一截或者扭曲这个后面我会讲怎么规避。第二计费方式与并发限制。很多厂商对并发数和单张图片的大小都有上限。比如有的限制单张最大 10MB有的限制分辨率不得超过 4096 像素有的限制 QPS 是 1。这些数字影响的是用户体验如果并发太低用户多了以后排队现象会很明显前端需要做好排队反馈。第三接口是同步还是异步回调。同步接口是发请求等着返回异步是发请求拿到一个任务 ID过几秒再查结果。异步接口对前端来说要多做一步轮询体验上反而更好控制因为你可以明确展示任务排队中之类的状态。我用一个表格整理一下我当时对比的三家方案厂商具体名字就不说了避免广告嫌疑对比项方案 A同步返回方案 B异步任务方案 C异步任务生成质量普通色差偶发较好细节保留不错优秀人物边缘处理最自然并发限制较宽松中等严格需要排队计费梯度按次收费分档计费相对较贵前端复杂度最简单需要轮询需要轮询和任务状态管理最终我选了方案 B质量能满足业务需求、计费适中、异步接口有清晰的状态管理机制前端能做出比较完整的交互体验。2.3 接口参数设计要按业务来后端代理层不是把云厂商的参数原样透传就完事了。我建议根据自己业务的需要重新定义一套接口契约。我业务里最终定的扩图参数是interface OutpaintParams { file: File // 原始图片文件 direction: up | down | left | right | all // 扩图方向 scale: 25 | 50 | 100 // 扩展比例相对原图短边的百分比 mode?: ratio | pixel // 可选按比例扩展或按固定像素 }为什么要把扩展比例限制成三个档位因为我测下来发现扩展比例越大生成耗时越长、失败率越高。25% 的扩展基本稳定在 5-10 秒50% 可能要 20-30 秒100% 以上不仅慢生成结果出现结构变形的概率也会高不少。限制档位一是为了保障体验二是让后端可以做预热和缓存。还有一个细节参数设计最好包含一个requestId字段前端每次发起请求时生成一个 UUID 传给后端。这样后端排查问题时有据可查前端在轮询时也能精确匹配每一个任务的结果。3. 从零搭建一个可用的扩图组件3.1 初始化Vue 3工程与基础依赖先说工程环境。我用的是 Vue 3 Vite TypeScript组件库用的是 Element Plus。如果你还在用 Vue 2也不用慌核心逻辑都是一样的只是组合式 API 的写法要改回选项式。初始化命令npm create vuelatest ai-outpaint-demo # 选择 TypeScript、Vue Router、Pinia cd ai-outpaint-demo npm install axios element-plus顺手把 Element Plus 全局注册一下。我平时喜欢按需引入配unplugin-auto-import但为了少折腾demo 就直接全量引入了// main.ts import { createApp } from vue import { createPinia } from pinia import ElementPlus from element-plus import element-plus/dist/index.css import App from ./App.vue const app createApp(App) app.use(createPinia()) app.use(ElementPlus) app.mount(#app)接着封装 axios。这里有两个点要单独说超时时间一定要调大默认的 10 秒肯定不够错误处理统一做因为 AI 接口报错信息往往很笼统后端得把云厂商的原始错误透传给你前端才能给用户展示有用的提示。// src/api/request.ts import axios from axios import { ElMessage } from element-plus const request axios.create({ baseURL: import.meta.env.VITE_API_BASE, timeout: 120000 // 生成类接口耗时较长至少给到 120 秒 }) request.interceptors.response.use( (response) response.data, (error) { // 后端统一返回格式{ code, message, data } const message error.response?.data?.message || 服务暂时不可用请稍后再试 ElMessage.error(message) return Promise.reject(error) } ) export default request3.2 上传组件的三个关键细节扩图的第一步是上传图片。用 Element Plus 的 Upload 组件可以很快实现但有三个细节我建议认真处理第一个是本地预览。上传后立刻用URL.createObjectURL(file)生成一个临时地址给用户看这样既能确认图片选对了也能让用户在后续扩图参数选择时直观地看到原图画面。第二个是对图片做初步校验。除了常见的文件类型和大小校验外还要检查图片的分辨率。云厂商通常对图片分辨率有上限比如 4096px。前端可以在before-upload里用createImageBitmap或new Image()读取宽高超了就直接提示用户先压缩避免后端调用 AI 接口时报错。第三个是保留一份 base64。虽然上传走 FormData 更高效但部分后端接口为了把图片和参数一起传给 AI 服务可能会要求 base64 格式。前端双向准备上传 FormData 的同时把 base64 存一份后面需要的话直接可用不用二次读取。上传的核心代码template el-upload :http-requestuploadRequest :before-uploadbeforeUpload acceptimage/* drag el-iconUploadFilled //el-icon div拖拽图片到这里或点击上传/div /el-upload /template script setup langts import { ElMessage } from element-plus import { ref } from vue const originalUrl ref() const originalBase64 ref() function beforeUpload(file: File) { if (!file.type.startsWith(image/)) { ElMessage.warning(只能上传图片文件) return false } if (file.size 10 * 1024 * 1024) { ElMessage.warning(图片大小不能超过 10MB) return false } // 生成本地预览地址 originalUrl.value URL.createObjectURL(file) // 读取 base64 备用 const reader new FileReader() reader.onload (e) { originalBase64.value (e.target?.result as string) || } reader.readAsDataURL(file) return true } /script3.3 扩图参数面板与接口数据格式上传完图片用户需要选择往哪个方向扩和扩多少。这个交互做得好不好直接影响用户对 AI 能力的感知。方向选择我用的是一组单选按钮向上、向下、向左、向右、四周全扩。实测下来电商主图和社交媒体封面用户用得最多的选项是四周全扩和向上扩展。因为主图往往需要从方形适配成横幅向上扩展的场景非常多。比例选择我用的是滑块绑定scale取值 25、50、100。为了防误操作提交前会再让用户确认一遍参数interface OutpaintParams { file: File direction: string scale: number } async function submitOutpaint() { const params: OutpaintParams { file: selectedFile.value, direction: direction.value, scale: scale.value } // 改为后端中转前端不直接持有云厂商密钥 const result await runOutpaint(params) // result.data 是扩图结果的 URL resultUrl.value result.data }后端接口接收的参数设计为 FormData 或者 JSON 都可以。我最终采用的是 FormData 传文件 JSON 传参数的方式理由是有文件的时候用 FormData 最自然后端解析也顺手。// src/api/outpaint.ts import request from ./request export function runOutpaint(params: OutpaintParams { fileBase64: string }) { const formData new FormData() // 如果有 file 对象用 file否则用 base64 formData.append(file, params.file) formData.append(direction, params.direction) formData.append(scale, String(params.scale)) return request.post(/api/ai/outpaint, formData, { headers: { Content-Type: multipart/form-data } }) }注意后端接口最好把direction和scale的枚举值写死校验。因为前端表单再怎么限制也防不住有人直接调接口传一个乱写的 direction。后端校验严格了AI 服务的报错概率会小很多。3.4 结果预览与图片拼接的实现思路扩图结果拿到之后展示是第一位的。我要给用户看到原图和扩图后的对比因为 AI 生成结果有随机性用户只有在对比中才能感知到确实扩了一块新的画面出来。最简单的对比方式是左右排列两张图片再做一个小滑块交互。我见过很多产品用拖拽对比组件本质上是两层图片叠放用clip-path控制上层图片露出的区域。核心代码如下template div classcompare-wrapper refwrapperRef img :srcoriginalUrl classcompare-bg / img :srcresultUrl classcompare-overlay :styleclipStyle / input typerange min0 max100 v-modelsliderValue / /div /template script setup langts import { computed, ref } from vue const sliderValue ref(50) const clipStyle computed(() ({ clipPath: inset(0 ${100 - sliderValue.value}% 0 0) })) /script如果后端返回的是整张拼接好的图前端就不用做额外的拼图处理。但如果后端返回的是单独的扩展区域图就需要用 Canvas 把原图和扩展区域拼起来。这里我给大家一个稳妥的思路假设向后扩展direction down原图宽 W、高 H扩展区域图宽 W、高 extH在这个设计里扩展区域只有纵向新增。Canvas 的最终高度是 H extH。先把原图画到 canvas 顶部再把扩展区域图画到 canvas 底部最后导出成新的图片。function mergeImages(original: HTMLImageElement, extended: HTMLImageElement, extPos: top | bottom) { const canvas document.createElement(canvas) canvas.width original.naturalWidth canvas.height original.naturalHeight extended.naturalHeight const ctx canvas.getContext(2d) if (extPos bottom) { ctx!.drawImage(original, 0, 0) ctx!.drawImage(extended, 0, original.naturalHeight) } else { ctx!.drawImage(extended, 0, 0) ctx!.drawImage(original, 0, extended.naturalHeight) } return canvas.toDataURL(image/png) }这个思路可以扩展到四个方向。核心规则就一条先定位原图在最终画布中的坐标再把扩展区域放到对应的一侧。另外注意多方向扩展的时候一定要确保原图像素不被缩放否则拼接完图和原图比例对不上用户一眼就能看出来。4. 上线前一定要处理的坑与优化4.1 超时生成类接口最容易被忽略的问题AI 扩图接口的耗时和传统的业务接口完全不是一个量级。普通接口 1 秒内返回很正常但扩图接口耗时受图片复杂度和扩展比例影响5 秒到 30 秒都很常见极端情况可能超过 60 秒。如果前端把 axios 超时设成 30 秒用户生成一张复杂的图时大概率超时前端弹一个请求失败而实际上后端 AI 任务还在跑。这个体验非常糟糕。我建议的处理方式axiostimeout设置到 120 秒以上交互上做进度提示用步骤条展示上传中 - 识别中 - 生成中 - 完成如果后端是异步任务前端就要做轮询轮询间隔建议 3-5 秒不要用太短的间隔避免白白消耗后端接口压力。轮询的核心代码就一个定时器和清理逻辑let timer: number | null null function startPolling(taskId: string) { timer window.setInterval(async () { const res await queryTask(taskId) if (res.status success) { window.clearInterval(timer!) resultUrl.value res.data } else if (res.status failed) { window.clearInterval(timer!) ElMessage.error(生成失败请重试) } }, 3000) }4.2 多方向并发扩图的状态管理用户如果选了四周全扩后端可能会把它拆成四个方向的子任务并行处理。这种情况下前端的状态管理要格外小心关键注意两点第一不要在循环里直接 await 串行调用那样整体时间等于四次生成时间之和太慢。正确做法是让后端一次性接收all参数由后端并发处理。如果后端只能处理单个方向前端再用Promise.all并发请求。第二四个方向的结果可能不会同时返回前端需要分别维护每个方向的状态。最好的做法是把四个方向的请求状态放进一个响应式对象里const directionStates reactive({ up: idle, down: idle, left: idle, right: idle }) async function runAllDirections() { const tasks directions.map(async (dir) { directionStates[dir] loading try { const res await runOutpaint({ direction: dir }) directionStates[dir] done directionResults[dir] res.data } catch (e) { directionStates[dir] error } }) await Promise.all(tasks) }这样在界面上用户可以实时看到哪些方向已经生成好了哪些还在跑。别把所有方向的请求状态都塞一个loading里不然有一个方向失败了你连哪个方向失败都没法告诉用户。4.3 图片体积与内存控制这是我在移动端踩过最深的一个坑。用户上传一张 12MB 的高清图片前端URL.createObjectURL生成了预览地址读取 base64 之后又是一个字符串再给 canvas 拼接、预览。整个流程下来浏览器内存瞬间涨了几百 MB老一点的手机直接白屏。解决方案有两个方向。一个是上传前压缩利用 Canvas 把图片缩放到合理的最大尺寸比如最长边 2048px然后导出 JPEG把体积控制到 1MB 左右。另一个是及时释放对象 URL不用了之后就调用URL.revokeObjectURL把临时地址回收掉。压缩代码function compressImage(file: File, maxEdge 2048): PromiseFile { return new Promise((resolve) { const img new Image() img.onload () { const scale Math.min(1, maxEdge / Math.max(img.width, img.height)) const canvas document.createElement(canvas) canvas.width img.width * scale canvas.height img.height * scale const ctx canvas.getContext(2d) ctx!.drawImage(img, 0, 0, canvas.width, canvas.height) canvas.toBlob((blob) { resolve(new File([blob!], file.name, { type: image/jpeg })) }, image/jpeg, 0.9) } img.src URL.createObjectURL(file) }) }提示压缩时如果原图是 PNG 带透明通道的转成 JPEG 后透明区域会变黑。对这种图导出格式要用image/png或者压缩前先把透明通道填充成白色背景。这个细节如果不处理用户扩图完拿到一张黑底图肯定要来找你。4.4 常见问题速查表最后整理一张我实际开发中遇到的问题速查表基本覆盖了 90% 的报错场景问题现象可能原因解决方案接口报 401 / 403后端密钥过期或未在前端携带鉴权 token检查后端代理层的密钥配置前端请求头加 token上传后接口一直等待图片过大或分辨率超限前端增加压缩逻辑限制最大边长扩图结果有色差云模型对特殊色调如暗光、黄昏理解偏差换更高质量的厂商方案或前端增加滤镜校正生成的图片人物变形原图中人物肢体延伸到画面边缘页面提示用户裁剪一下主体内容再扩图轮询接口频繁请求轮询间隔太短间隔调整为 3-5 秒并限制最大轮询次数下载图片跨域失败结果图存储在其他域名直接下载被 CORS 拦截后端下载接口做代理或图片地址加crossOrigin属性生成结果与原图分辨率不一致后端没有做对齐处理前端用 Canvas 统一输出尺寸这里重点说一下人物变形的处理。AI 扩图最容易出问题的就是人物在画面边缘时AI 生成的手脚会多一截或者扭曲。我实测下来让用户把图片稍微裁剪一下、让主体人物不贴合边缘生成成功率会高非常多。所以我在参数面板上加了一句提示文案建议主体内容距离画面边缘至少留 20% 的空白生成效果更稳定。 上线后这条提示明显减少了用户的吐槽。最后再分享一个小经验。这类功能上线第一周用户问得最多的问题永远是为什么扩出来的人物手是变形的。别指望一次性解决生成式模型的随机性决定了它不可能 100% 稳定。我的做法是在结果页加了一个重试一次按钮实测下来用户点重试一两次大部分都能得到满意的结果。用户对 AI 的容错度比我们想象的高关键是别让他觉得卡死了——把状态反馈做足把重试入口给到这个功能就能真正用起来。