前言
写前端工具链的人都熟悉这套组合 Vite + Vitest + ESLint + Prettier + tsc 对吧
但是把它们装到一起 各管各的版本 各管各的配置 是不是有点烦?
本文记录用 Vite+ 重写一个纯浏览器端的图像生成工具的过程 顺带把 GPT-Image-2 这套 OpenAI 兼容接口的接入也一起讲清楚。
开源地址:https://github.com/FxRayHughes/image2-web
在线访问:https://i2.maplex.top/generate
技术栈 React 19 + MUI 9 + TypeScript + Vite+
为什么要换 Vite+
老项目用的是 Vite + pnpm 的常规组合 平时没毛病 直到要做这几件事的时候开始难受:
升级 TypeScript 版本 要去查 vitest 兼容到哪一档
加 lint 要装 eslint + plugin-react + plugin-typescript + parser 一长串
CI 脚本要分别跑
tsc --noEmitpnpm lintpnpm testpnpm build队里新人
pnpm install之后 还要pnpm exec husky install才能跑 pre-commit
每一步都不难 但加起来就是碎。Vite+ 把这层碎片整体打包成一个全局 CLI vp 然后只暴露一组动词。
Vite+ 的体验
一个 CLI 管全程
vp 是全局二进制 装一次 所有项目共用。它把开发周期切成几组动词:
Start: create migrate config install env
Develop: dev check lint fmt test
Build: build pack preview
Manage: add remove update outdated list
下面这张图把命令分组和底层工具的关系铺平 一眼看清 vp 是怎么把一坨工具集中代理掉的:
然后 vp dev 等同于 vite dev vp test 等同于 vitest vp lint 等同于 oxlint 等等。底层换工具不换命令。
这套抽象的好处是 写文档不用写"如果你用 pnpm 就 pnpm dev 如果你用 npm 就 npm run dev"了 全统一成 vp dev。
不用再装一堆 dev 依赖
旧 package.json 的 devDependencies 通常长这样:
{
"vite": "^5.x",
"vitest": "^1.x",
"eslint": "^9.x",
"@typescript-eslint/parser": "^7.x",
"prettier": "^3.x",
"typescript": "^5.x"
}
换成 Vite+ 之后 这一坨直接消失 只剩一个 vite-plus:
{
"devDependencies": {
"@vitejs/plugin-react": "^6.0.1",
"vite": "catalog:",
"vite-plus": "catalog:"
}
}
vitest oxlint oxfmt tsdown 全都被 vite-plus 这个壳包住了 不再单独出现在依赖树里。装不上 升不动 互相打架这三类经典前端依赖问题直接绕过去。
❗注意 不要 pnpm add vitest 这种操作 Vite+ 的文档明确说不能直接装 vitest oxlint oxfmt tsdown 这些工具的版本必须由 vp 统一推。
配置文件极简
vite.config.ts 现在只剩 12 行:
import { defineConfig } from "vite-plus";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
staged: {
"*": "vp check --fix",
},
fmt: {},
lint: { options: { typeAware: true, typeCheck: true } },
});
注意三件事:
defineConfig从vite-plus导入 不是vite。staged字段直接声明 pre-commit 跑什么 不再写 husky 配置文件。lint.options.typeAware: true一个开关搞定类型感知 lint 不用装oxlint-tsgolint。
之前 husky + lint-staged + commitlint 三套配置文件一共要写五六个文件 现在一行声明替代。
vp check 是个组合拳
最爽的是 vp check:一条命令同时跑 fmt 检查 lint 检查 类型检查。CI 脚本现在长这样:
- uses: voidzero-dev/setup-vp@v1
with:
cache: true
- run: vp check
- run: vp test
两行覆盖了过去的四五步。setup-vp 这个 action 把 setup-node + 包管理器 setup + 缓存 + install 全合并成一个 setup 步骤。
体感总结
不是 Vite+ 比原生 Vite 性能更强 而是它把周边工具的版本协调和命令统一这件累活拿走了。写代码本身没有变化 但写配置 写 CI 写文档的成本明显下降。
接入 GPT-Image-2 API
工具链清完之后 主体业务是接入 OpenAI 标准的 images/generations 与 images/edits 两个端点。这一节讲接入的几个关键决策。
端点选择 文生图 vs 图生图
OpenAI Images API 有两个端点 一个只接收文本 一个接收文本 + 参考图。逻辑分支按是否有参考图二选一:
const hasRefs = params.referenceFiles.length > 0;
const endpoint = hasRefs ? "/v1/images/edits" : "/v1/images/generations";
const url = cfg.apiUrl + endpoint;
文生图走 application/json 图生图走 multipart/form-data。这两条路在请求体格式 鉴权头 响应结构上是一致的 只是 body 编码不同。
下图把这条二选一的路径画清楚 包括两边 body 编码的差异和最终响应的合并:
文生图 JSON 请求
res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cfg.apiKey}`,
},
body: JSON.stringify({
model: "gpt-image-2",
prompt: params.prompt,
size: params.size,
quality: params.quality,
output_format: params.format,
n: params.n,
}),
signal,
});
要点:
model写死gpt-image-2。size常用1024x10241536x864后者是 16:9 适合做封面。quality三档lowmediumhigh。output_format默认png。signal透传AbortSignal用来支持任务取消。
图生图 FormData 请求
const fd = new FormData();
fd.append("model", "gpt-image-2");
fd.append("prompt", params.prompt);
fd.append("n", String(params.n));
fd.append("size", params.size);
fd.append("quality", params.quality);
fd.append("output_format", params.format);
validFiles.forEach((file) => fd.append("image", file, file.name));
res = await fetch(url, {
method: "POST",
headers: { Authorization: `Bearer ${cfg.apiKey}` },
body: fd,
signal,
});
注意三点:
不要手动设
Content-Type浏览器会自动带上 multipart 边界 自己写反而出错。多张参考图用同一个
image字段重复 append 即可 服务端按数组解析。空文件和非图片文件要先过滤 否则一张废文件就把整个请求毙了:
const validFiles = params.referenceFiles.filter((file) => {
if (file.size === 0) return false;
if (!file.type.startsWith("image/")) return false;
return true;
});
响应处理 b64_json vs url
响应体长这样:
{
data: [
{ b64_json?: string; url?: string }
]
}
服务商二选一返回。url 字段是临时图床地址 但有些代理服务的图床带防盗链 浏览器直接 <img src=> 加载没问题 但 fetch 下载会被拦。
这个 bug 在初版踩过 解法是请求时主动指定 response_format: "b64_json" 让服务端直接返回 base64 不再走图床:
if (item.b64_json) {
const bin = atob(item.b64_json);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i += 1) {
arr[i] = bin.charCodeAt(i);
}
return new Blob([arr], { type: `image/${fallbackFormat || "png"}` });
}
if (item.url) {
const res = await fetch(item.url);
if (!res.ok) throw new Error(`下载图片失败: HTTP ${res.status}`);
return await res.blob();
}
代码里两条路都留着 优先 b64 兜底 url 适配不同服务商。
多图生成的并发拆分
OpenAI 接口允许 n=4 一次请求生成 4 张 但实际体感很差:
整批等所有图渲染完才返回 中间用户看不到进度。
任何一张失败 整批失败。
无法独立取消其中一张。
解决方案是前端把 n=4 拆成 4 个 n=1 的并发请求。每完成一张通过回调实时回吐:
export async function generateImage(
params: GenerateParams,
onBlob?: (blob: Blob, index: number) => void,
signal?: AbortSignal,
): Promise<Blob[]> {
// ...请求逻辑
for (let i = 0; i < items.length; i++) {
const blob = await itemToBlob(items[i], params.format);
blobs.push(blob);
if (onBlob) onBlob(blob, i);
}
return blobs;
}
外层把 4 张拆开 各自 Promise 各自 AbortController。
下图把拆分前后的差异并排放在一起 进度 错误 取消 三个维度的粒度变化看得最清楚:
API 路径可配置
不是所有服务商都用 /v1/images/generations 这个标准路径 中转代理常常自定义。所以接口路径作为配置项暴露出来:
export interface ApiProfile {
id: string;
name: string;
apiUrl: string;
apiKey: string;
}
apiUrl 存的是根地址 端点路径在调用时拼接。后续版本进一步把 /v1/images/generations 和 /v1/images/edits 也独立成两个可配置字段 兼容更多代理形态。
一些边界处理
鉴权失败的报错文案
服务商返回的错误结构有两种 data.error.message 和 data.message。要兼容:
if (!res.ok) {
throw new Error(data.error?.message || data.message || `HTTP ${res.status}`);
}
不直接抛 HTTP code 要把服务端的人类可读消息透出来。
响应解析失败的兜底
代理偶尔返回 HTML 错误页 直接 JSON.parse 会扔一个含糊的 Unexpected token <。包一层:
try {
data = JSON.parse(text);
} catch {
throw new Error(`响应解析失败: ${text.slice(0, 200)}`);
}
把原始响应前 200 字符带出来 排查问题不用再去抓包。
数据本地化
整个工具是纯前端 没有后端。所有数据落到浏览器:
图片用 IndexedDB 永久存储。
API Key和配置用 localStorage 持久化。
提示词历史单独一个 key 按时间倒序保留。
好处是部署成本为零 一个静态文件就能跑。代价是换设备就丢数据 这个用户要心里有数。
效果
接完之后整个项目结构很清爽:
test-vp/
├── package.json # 12 行依赖
├── vite.config.ts # 12 行配置
├── src/
│ ├── api.ts # GPT-Image-2 接入 120 行
│ ├── config.ts # 多配置 + 持久化 130 行
│ ├── db.ts # IndexedDB 封装
│ ├── cutout.ts # 浏览器端 AI 抠图
│ └── components/ # MUI + React 19
└── README.md
Vite+ 接管了工具链的协调 OpenAI 标准接口接管了模型调用 业务代码就只剩纯前端的 UI 与状态。一个人维护起来负担明显比上一代项目轻。
结语
Vite+ 是新轮子 它也是老轮子的总装版。把 Vite Vitest Oxlint Oxfmt tsdown 的版本协调和命令统一这件烦事接管掉 让前端项目重新回到"写业务"为主的状态。
GPT-Image-2 这套 OpenAI 兼容接口的好处是协议标准 选哪家服务商都不用改代码 只换 apiUrl 和 apiKey 即可。
工具链选型本来就是为了省心。省下来的时间 拿去打磨业务。

参与讨论
(Participate in the discussion)
参与讨论