前言

写前端工具链的人都熟悉这套组合 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 的常规组合 平时没毛病 直到要做这几件事的时候开始难受:

  1. 升级 TypeScript 版本 要去查 vitest 兼容到哪一档

  2. 加 lint 要装 eslint + plugin-react + plugin-typescript + parser 一长串

  3. CI 脚本要分别跑 tsc --noEmit pnpm lint pnpm test pnpm build

  4. 队里新人 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 是怎么把一坨工具集中代理掉的:

drawing-1780467672497

然后 vp dev 等同于 vite dev vp test 等同于 vitest vp lint 等同于 oxlint 等等。底层换工具不换命令

这套抽象的好处是 写文档不用写"如果你用 pnpm 就 pnpm dev 如果你用 npm 就 npm run dev"了 全统一成 vp dev

不用再装一堆 dev 依赖

package.jsondevDependencies 通常长这样:

{
  "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 } },
});

注意三件事:

  1. defineConfigvite-plus 导入 不是 vite

  2. staged 字段直接声明 pre-commit 跑什么 不再写 husky 配置文件。

  3. 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/generationsimages/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 编码的差异和最终响应的合并:

drawing-1780467691796

文生图 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 常用 1024x1024 1536x864 后者是 16:9 适合做封面。

  • quality 三档 low medium high

  • 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,
});

注意三点:

  1. 不要手动设 Content-Type 浏览器会自动带上 multipart 边界 自己写反而出错。

  2. 多张参考图用同一个 image 字段重复 append 即可 服务端按数组解析。

  3. 空文件和非图片文件要先过滤 否则一张废文件就把整个请求毙了:

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

下图把拆分前后的差异并排放在一起 进度 错误 取消 三个维度的粒度变化看得最清楚:

drawing-1780467732992

API 路径可配置

不是所有服务商都用 /v1/images/generations 这个标准路径 中转代理常常自定义。所以接口路径作为配置项暴露出来

export interface ApiProfile {
  id: string;
  name: string;
  apiUrl: string;
  apiKey: string;
}

apiUrl 存的是根地址 端点路径在调用时拼接。后续版本进一步把 /v1/images/generations/v1/images/edits 也独立成两个可配置字段 兼容更多代理形态。

一些边界处理

鉴权失败的报错文案

服务商返回的错误结构有两种 data.error.messagedata.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 兼容接口的好处是协议标准 选哪家服务商都不用改代码 只换 apiUrlapiKey 即可。

工具链选型本来就是为了省心。省下来的时间 拿去打磨业务