琢磨着写这篇文章有一阵子了,起因是自己真的动手跑通了一个用 AI 变现的小产品,技术栈就是 React + TypeScript。市面上讲 AI 接口调用、讲支付集成的文章不少,但把这两件事串起来,从前端视角完整拆解一整套"AI 变现代码流程"的内容却并不多。很多细节,像是流式输出怎么跟扣费逻辑配合,前端如何在不暴露 Key 的前提下做安全控制,支付完成之后怎么优雅地刷新用户配额,这些不亲自踩一遍坑很难有切身体会。
为什么选这个技术栈,以及我想做什么
先交代一下背景。我想做的产品是一个简单的 AI 写作辅助工具,用户可以输入一些关键词或开头,由 GPT 模型续写内容,按次计费或者按月订阅。听起来并不新鲜,但我给自己的目标是:必须从前端到后端、从鉴权到收款全部跑通,而且代码要干净,方便以后复用。
选择 React + TypeScript 几乎是本能。React 的生态对于构建交互丰富的工具来说太成熟了,而 TypeScript 在对接 API、定义数据模型时提供的安全感,是裸写 JavaScript 完全给不了的。尤其一旦涉及付费,前后端的数据结构一致性直接关系到会不会少扣钱、多扣钱,这事马虎不得。
后端的部分我不打算展开太多,但为了保证文章的完整性,会提到一些必要的配合点。整个流程的重心放在前端,也就是你打开编辑器,一行一行写出来的那些代码。
项目骨架:Vite 一把梭,目录分清楚
起步直接用 Vite 初始化,模板选 react-ts,几秒钟就能跑起来一个干净的项目。
bash
npm create vite@latest scribe-ai -- --template react-ts
目录结构我没有搞得太花哨,坚持按功能分层,这样后面加支付、加配额的时候,各个模块边界清晰,改起来不慌。
bash
src/
api/ # 所有网络请求,包括后端和第三方 AI 接口
components/ # 可复用组件,按钮、输入框、加载动画等
hooks/ # 自定义 hooks,放最核心的逻辑
pages/ # 路由对应的页面
store/ # 全局状态,用 zustand
types/ # 全局类型定义
utils/ # 纯函数工具,比如格式化、防抖
状态管理我选了 zustand,它够轻,没有 Provider 包裹那一套,直接在组件里按需取用。对于需要全局共享的用户登录态、套餐信息、剩余次数这类数据,zustand 写起来几乎没什么心智负担。
一个简单的 store 长这样:
typescript
import { create } from 'zustand';
interface AppState {
user: User | null;
quota: number;
setUser: (user: User | null) => void;
setQuota: (quota: number) => void;
decrementQuota: () => void;
}
export const useAppStore = create<AppState>((set) => ({
user: null,
quota: 0,
setUser: (user) => set({ user }),
setQuota: (quota) => set({ quota }),
decrementQuota: () => set((state) => ({ quota: Math.max(0, state.quota - 1) })),
}));
这个 store 到后面配合支付和 AI 调用时会被频繁访问,但完全不会引起不必要的渲染,因为 zustand 的 selector 机制非常精确。
路由我用的是 react-router-dom v6,懒加载页面组件,保证首次加载速度。
让应用拥有大脑:封装 AI 接口的安全实践
AI 能力是产品的核心,而调用 OpenAI 或其他大模型接口,最忌讳的就是在前端暴露 API Key。曾经见过有人直接把 key 写在 .env 里然后用 VITE_ 前缀暴露出去,这在生产环境就是一场灾难。所以我的做法很坚决:前端从来不直接和 OpenAI 通信,所有 AI 请求都走自己的后端转发。
后端用 Node.js 搭了一个简单的 API 网关,前端通过 /api/chat 这个端点发送对话历史,后端附加 API Key、进行配额校验,然后把请求转发给 OpenAI,再把模型返回的流式数据原样输送给前端。这样一来,前端代码里没有任何敏感信息,而且还能在中间层做很多事情,比如记录日志、限制并发、按用户扣费。
前端要做的,就是把请求发好,把流处理好。为了类型安全,我先定义清楚对话消息的接口:
typescript
export interface ChatMessage {
role: 'user' | 'assistant' | 'system';
content: string;
}
export interface ChatRequest {
messages: ChatMessage[];
// 可以扩展更多参数,比如 temperature、max_tokens,但为了安全这些最好在后端控制
}
封装流式请求的核心是一个异步生成器函数。我选择不用 EventSource,因为 EventSource 只支持 GET,而我们需要 POST 发送消息体,还可能要带 Authorization 头。直接用 fetch 并读取 ReadableStream,控制力强太多。
typescript
async function* streamChatCompletion(
messages: ChatMessage[],
signal?: AbortSignal
): AsyncGenerator<string> {
const response = await fetch('/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${await getAccessToken()}`, // 后面会讲令牌获取
},
body: JSON.stringify({ messages }),
signal,
});
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
throw new Error(errorBody.message || `请求失败,状态码 ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error('无法读取响应流');
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// 后端按行发送,每行是一段文本增量
const lines = buffer.split('\n');
// 最后一个元素可能不完整,保留到下一次
buffer = lines.pop() || '';
for (const line of lines) {
if (line.trim()) {
yield line;
}
}
}
// 处理剩余 buffer
if (buffer.trim()) {
yield buffer;
}
}
这里用 TextDecoder 的 stream 模式处理 UTF-8 多字节字符截断问题,同时通过 buffer 与换行分割保证了完整性。生成器函数让消费方可以用 for await...of 来逐块获取文本,非常符合 React 的渲染模型。
在此基础上,封装一个 useChatCompletion 的自定义 hook,把状态管理、终止控制、错误处理全部包起来,组件只需要调用并渲染即可。
typescript
export function useChatCompletion() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const sendMessage = useCallback(async (
messages: ChatMessage[],
onChunk: (text: string) => void,
onComplete: () => void
) => {
// 如果正在加载,不允许重复发送
if (isLoading) return;
setIsLoading(true);
setError(null);
const abortController = new AbortController();
abortControllerRef.current = abortController;
try {
for await (const chunk of streamChatCompletion(messages, abortController.signal)) {
onChunk(chunk);
}
onComplete();
} catch (err: any) {
if (err.name !== 'AbortError') {
setError(err.message || '发生未知错误');
}
} finally {
setIsLoading(false);
abortControllerRef.current = null;
}
}, [isLoading]);
const stop = useCallback(() => {
abortControllerRef.current?.abort();
}, []);
return { sendMessage, stop, isLoading, error };
}
在组件里的使用就变得异常简洁:
typescript
function ChatPanel() {
const [input, setInput] = useState('');
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [currentReply, setCurrentReply] = useState('');
const { sendMessage, stop, isLoading, error } = useChatCompletion();
const handleSend = () => {
const userMessage: ChatMessage = { role: 'user', content: input };
const newMessages = [...messages, userMessage];
setMessages(newMessages);
setInput('');
setCurrentReply('');
sendMessage(newMessages, (chunk) => {
setCurrentReply((prev) => prev + chunk);
}, () => {
setMessages((prev) => [...prev, { role: 'assistant', content: currentReply }]);
setCurrentReply('');
});
};
// 渲染部分省略...
}
打字机的效果就这样实现了,而且任何时刻用户点击"停止生成",都可以干净地中断请求。
流式处理的心得:SSE 不是银弹,纯文本流更香
这个过程中绕了一个弯。最开始我和很多教程一样,让后端使用 Server-Sent Events,前端用 EventSource 来接。结果发现问题不少:第一,EventSource 不支持 POST,而我们显然需要发送用户输入的上下文;第二,它没办法在请求头里加 Authorization,除非把 token 放在 URL 参数里,既不安全又受长度限制;第三,自动重连的机制在某些场景下反而会导致重复消费。
后来果断切换到普通 HTTP 流式响应的方案。后端设置 Transfer-Encoding: chunked,并简单地每收到模型的一段输出就 write 一段,末尾加个换行符。前端用上面那种 fetch + reader 的方式逐行解析,不但完全可控,而且配合 React 18 的并发渲染,打字机效果顺滑得像是在本地运行。
还有一个细节:流式传输中如果网络抖动导致连接中断怎么办?我在生成器里并没有做自动重连,因为 AI 文本生成上下文无法简单地"断点续传"。我的做法是在 hook 层加了一个重试机制:如果发生非 AbortError 的网络错误,自动把当前已经接收到的部分文本作为 assistant 的消息,带上之前所有的 messages 重新发起请求,并在提示词里加上"继续"的指令。这个策略虽然不能保证完美衔接,但在大多数情况下,用户几乎无感知。实现的时候需要注意防止无限重试,限制最多 2 次。
typescript
// 在 sendMessage 内部 catch 块里
if (retryCount < 2 && err.message.includes('网络')) {
const partialMessage = { role: 'assistant' as const, content: currentReply };
sendMessage([...originalMessages, partialMessage], onChunk, onComplete, retryCount + 1);
}
这种设计需要很小心地管理 retryCount 和避免重复扣费。扣费逻辑放在后端,且只在一次完整生成成功后才执行扣费,这样就保证了即便前端重试 N 次,用户也只会被收一次钱。
用户体系搭建:Auth0 带来的便捷与踩坑
变现就不得不建立用户系统。自己写注册登录不是不行,但密码重置、邮箱验证、社交登录这些功能非常耗时,直接上 Auth0 这类身份认证服务是明智的。它提供 React SDK,配置简单,能快速拿到 JWT token。
安装并配置 @auth0/auth0-react,在应用根组件包一层 Auth0Provider:
typescript
import { Auth0Provider } from '@auth0/auth0-react';
function App() {
return (
<Auth0Provider
domain="your-tenant.auth0.com"
clientId="your-client-id"
authorizationParams={{
redirect_uri: window.location.origin,
audience: 'https://api.scribe-ai.com', // 自定义 API 标识
}}
>
<RouterProvider router={router} />
</Auth0Provider>
);
}
然后在需要保护的路由组件里用 withAuthenticationRequired 包裹,或者自定义一个 ProtectedRoute 组件,里面用 useAuth0 的 isAuthenticated 和 loginWithRedirect 来做重定向。
获取 token 是通过 getAccessTokenSilently,这个方法会利用隐藏 iframe 静默刷新 token,用户体验很好。我把获取 token 的逻辑封装成一个自定义 hook,方便所有 API 调用复用:
typescript
export function useAuthToken() {
const { getAccessTokenSilently, isAuthenticated, loginWithRedirect } = useAuth0();
const getToken = useCallback(async () => {
if (!isAuthenticated) {
await loginWithRedirect();
return null;
}
try {
return await getAccessTokenSilently();
} catch (e) {
// 静默获取失败,可能是 refresh token 过期,需要重新登录
loginWithRedirect();
return null;
}
}, [getAccessTokenSilently, isAuthenticated, loginWithRedirect]);
return getToken;
}
之前 streamChatCompletion 里的 getAccessToken() 就是调这个函数。每个请求都会带最新的 token,后端验证 token 并从 sub 字段取出用户 ID,与数据库关联。这样整个身份链路就通了。
有个小坑:在开发环境如果使用 StrictMode,Auth0 的 token 刷新可能会导致两次渲染的 token 不一致,注意组件在第二次渲染时重新获取即可,或者适当关闭 StrictMode 来避免开发时的困惑。
配额系统的前后端协作:别把逻辑全堆后端
AI 接口调用是烧钱的,必须精确控制每个用户的用量。很多教程会把配额管理完全扔给后端,前端只负责展示。但一个良好的体验是:在用户打字甚至点击发送按钮之前,前端就应该知道剩余次数并做出拦截。这不但能减少无效请求对后端的压力,也给用户即时反馈,避免那种"生成到一半弹窗说额度不足"的糟糕体验。
所以我的做法是前后端协同。后端数据库中存储每个用户的剩余额度(例如剩余点数或调用次数)。前端在用户登录成功后,立即拉取一次配额信息并存入 zustand store:
typescript
const useFetchQuota = () => {
const getToken = useAuthToken();
const setQuota = useAppStore((s) => s.setQuota);
const { isAuthenticated } = useAuth0();
useEffect(() => {
if (!isAuthenticated) return;
getToken().then((token) => {
if (!token) return;
fetch('/api/account/usage', {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => res.json())
.then((data) => setQuota(data.remaining))
.catch(console.error);
});
}, [isAuthenticated, getToken, setQuota]);
};
组件中使用时,发送按钮在 quota <= 0 时直接禁用,并显示"前往购买"的提示。同时每次成功完成一次 AI 生成,后端会返回最新的剩余次数,前端据此更新 store。但这里存在一个时间差:如果在请求进行中,用户快速连续点击怎么办?虽然我们做了 isLoading 状态下的禁用,但更彻底的做法是利用 zustand 的 decrementQuota 在前端先乐观扣减,等请求返回后再根据实际值修正。
typescript
const handleSend = async () => {
if (quota <= 0) {
setShowPurchaseModal(true);
return;
}
// 乐观扣减
decrementQuota();
try {
// ... 发送请求,并在完成回调里根据后端返回的真实 quota 值更新
} catch (error) {
// 如果失败,需要把扣减的额度加回去
incrementQuota();
}
};
这里的边界情况需要仔细处理:如果用户关闭页面刚好在请求发送后、服务端扣费完成前,可能导致用户被扣费但前端没更新剩余次数。解决办法是在后端设置一个"预扣"机制:生成开始前先标记一个 pending 的消费记录,生成成功后再确认,并且无论前端如何,额度最终由后端数据库说了算。前端只是辅助体验。
我在后端实现了一个简单的幂等扣费:每次扣费请求必须携带一个唯一的 idempotency-key(可以用对话 session id + 消息序号生成),保证同一个生成操作绝不重复扣费。前端在发送请求时生成这个 key,放在 header 里。这个设计对于金融相关的操作是必须的。
收款环节:Stripe 集成的完整前端视角
变现的最后一步是让用户付钱。我选择 Stripe,因为它对开发者的友好程度以及和 webhook 的配合非常成熟。用户购买额度或订阅,都是通过 Stripe Checkout Session 实现的。前端并不直接处理任何支付信息,而是引导用户跳转到 Stripe 托管的支付页面,安全省事。
购买流程是:用户点击"购买"按钮,前端向后端发起一个请求,后端调用 Stripe API 创建一个 Checkout Session,指定价格、数量、成功和取消的回调地址,然后返回该 session 的 url。前端直接跳转。
typescript
const handlePurchase = async (priceId: string) => {
const token = await getToken();
if (!token) return;
const res = await fetch('/api/create-checkout-session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ priceId, successUrl: window.location.origin + '/payment-success?session_id={CHECKOUT_SESSION_ID}', cancelUrl: window.location.origin + '/pricing' }),
});
const { url } = await res.json();
if (url) {
window.location.href = url;
} else {
alert('创建支付会话失败');
}
};
这里 successUrl 模板里的 {CHECKOUT_SESSION_ID} 会被 Stripe 自动替换成真实的 session ID。当用户支付成功并被重定向回来时,URL 里会带有 session_id 参数。前端在 /payment-success 页面的 useEffect 里读取这个参数,再次调用后端接口验证支付状态并更新用户额度。
但 Stripe 的 webhook 通知可能存在延迟(通常几秒内),所以用户回到页面时后端可能还没处理完 webhook。如果直接拉取配额发现没变,用户会以为支付失败。处理策略是:展示一个"支付确认中"的状态,并每隔 2 秒轮询一次 /api/account/usage,直到额度发生变化或者超过最大等待时间。一般 webhook 在 5 秒内就会到达,这个体验很平滑。
typescript
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const sessionId = params.get('session_id');
if (!sessionId) return;
let attempts = 0;
const maxAttempts = 15; // 30秒
const interval = setInterval(async () => {
const token = await getToken();
if (!token) return;
const res = await fetch('/api/account/usage', { headers: { Authorization: `Bearer ${token}` } });
const data = await res.json();
if (data.remaining > previousQuota) {
setQuota(data.remaining);
clearInterval(interval);
// 提示用户购买成功
}
attempts++;
if (attempts >= maxAttempts) {
clearInterval(interval);
// 建议用户联系支持
}
}, 2000);
return () => clearInterval(interval);
}, []);
另外,为了防止用户反复使用同一个 session_id 刷新来非法获得额度,后端在确认支付后会将 session_id 标记为已处理,避免重复加款。
防滥用与错误处理:细节里全是用户体验
AI 接口又贵又不稳定,如果前端不多做几道防线,用不了多久就会被异常流量打垮,或者用户被频繁的错误搞到崩溃。下面是我在实际项目中积累的几条经验。
首先是防重复提交。除了最简单的 isLoading 状态下禁用按钮外,我还加入了时间窗口限流。比如普通用户 1 分钟内最多发起 5 次生成请求。前端维护一个队列记录最近的时间戳,如果超过限制就弹一个剩余等待时间的提示。
typescript
const rateLimitWindow = 60 * 1000; // 1分钟
const maxRequests = 5;
const requestTimestamps = useRef<number[]>([]);
const checkRateLimit = () => {
const now = Date.now();
requestTimestamps.current = requestTimestamps.current.filter(ts => now - ts < rateLimitWindow);
if (requestTimestamps.current.length >= maxRequests) {
const oldest = requestTimestamps.current[0];
const waitTime = Math.ceil((oldest + rateLimitWindow - now) / 1000);
throw new Error(`请求太频繁,请 ${waitTime} 秒后再试`);
}
requestTimestamps.current.push(now);
};
在 sendMessage 的一开始就调用它,如果触发限制就直接抛出错误,由 UI 展示。当然,后端也要有严格限流,前端限流更多是为了用户引导和减少无效请求。
然后是优雅降级。网络错误、服务端 429、500 等都需要给用户明确的指引,而不是一句"出错了"完事。我在错误处理层对不同的错误类型做了映射,比如 429 会提示"当前使用人数较多,请稍后再试",500 则是"服务暂时不可用,我们正在修复",并且提供一个"重试"按钮,点击后重新发起完整请求。
对于流式生成到一半突然中断的情况,我前面提到了自动重试机制。但如果重试也失败呢?起码要把已经生成的内容保留下来,不要清空。所以在组件中,currentReply 的状态更新是实时的,即便 sendMessage 抛出异常,已渲染的文本依然存在,用户可以手动复制走,不至于白费。
我还加了一个"继续生成"的功能,本质上是手动触发的重试。当用户察觉生成似乎卡住(其实可能已经中断),可以点击按钮,代码会用之前的消息加上已生成的部分作为新消息再次请求,同时在 system prompt 里加入"请从以下内容之后继续:'...已生成文本...'"。这样成功率很高。
全局的错误边界组件也不可少。在 ChatPanel 外面包一个 ErrorBoundary,防止未捕获的渲染错误导致整个应用白屏。
typescript
class ErrorBoundary extends React.Component<Props, { hasError: boolean; error: Error | null }> {
state = { hasError: false, error: null };
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
// 上报错误到监控服务
logError({ error, info });
}
render() {
if (this.state.hasError) {
return <ErrorFallback error={this.state.error} onReset={() => this.setState({ hasError: false })} />;
}
return this.props.children;
}
}
前端性能优化与监控:让产品跑得更稳
随着用户增多,我开始关注页面性能和用户行为数据。Vite 生产构建本身已经很快了,但 AI 功能区域的组件可能因为频繁更新而出现卡顿。我做的第一件事就是利用 React.memo 和 useMemo 来避免不必要的渲染。
聊天消息列表如果很长,每次新的 chunk 到达都会更新整个列表。我用了一个小技巧:将消息列表拆分为"历史消息"和"当前生成中的消息"。历史消息使用 React.memo 包裹的组件,并且只在数组引用变化时重新渲染;当前生成中的消息单独管理,只更新它自己。这样大大减少了 diff 开销。
对于极长的对话历史(超过几十轮),虚拟滚动就很有必要了。我引入了 react-virtuoso,只渲染可见区域的聊天条目,滚动时动态加载,性能提升非常明显。
前端监控方面,我没有使用成熟的第三方服务,而是利用 Vercel Analytics 来观察页面性能,同时自己写了一个简单的埋点函数,在关键操作点(点击生成、生成成功、生成失败、支付按钮点击等)向自己的后端发送统计数据。数据量不大,用免费的 MongoDB Atlas 存起来完全足够。后期可以据此分析转化漏斗、用户留存。
typescript
const track = (event: string, properties?: Record<string, any>) => {
fetch('/api/analytics', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ event, properties, timestamp: Date.now() }),
}).catch(() => {}); // 不影响主流程
};
安全方面的考虑也值得一提。虽然 API Key 不暴露在前端,但输入框内容需要做过滤,防止恶意 Prompt 注入。我在提交前对用户输入进行简单清洗,去除可能的越狱指令。当然主要防御还是靠后端设置 system prompt 强约束。另外,所有用户输入在展示时都通过 React 默认的转义处理了,避免 XSS。
部署上线:从本地到生产
前端部署在 Vercel 上,连接 GitHub 仓库,推送 main 分支自动触发构建部署。环境变量在 Vercel 项目设置里配好,比如 Auth0 的 domain、clientId 以及后端 API 地址。构建命令就是 npm run build,输出目录 dist。配合 Vercel 的 Serverless Function 还能写一点简单的后端逻辑,但我的主后端跑在 Railway 上,因为它对长时间运行的流式连接支持更好。
后端的部署也有几个注意点。因为流式响应需要保持连接不断开,Railway 的超时设置要适当调大。另外 CORS 配置要精确,只允许自己前端域名的请求。在 Vercel 前端里,通过环境变量区分开发和生产 API 地址。
上线之后,我做的第一件事就是模拟支付流程,确保从点击购买、支付、返回到额度生效整个闭环毫无摩擦。然后自己作为第一批用户,每天用,发现小 bug 立刻修。很多时候,代码逻辑正确不代表用户体验好,比如加载状态的缺失、按钮没有防抖、移动端键盘遮挡输入框,这些小问题都会影响付费转化。
测试策略:为关键路径加保险
涉及金钱的业务,测试必不可少。我使用 Vitest + React Testing Library 对手动 hooks 和核心组件进行单元测试。最难测的是流式请求,因为依赖真实的 ReadableStream。我用 MSW (Mock Service Worker) 来模拟 /api/chat 接口,返回一个自定义的流。
通过创建一个 ReadableStream 并用 TextEncoder 分块写入,可以逼真地模拟打字机效果:
typescript
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode('你好'));
controller.enqueue(encoder.encode(',世界'));
controller.close();
},
});
然后在 MSW handler 里用 new Response(stream) 返回,就能在测试环境里验证 useChatCompletion 的行为。
E2E 测试我选了 Playwright,模拟用户登录、输入、发送、等待生成、检查输出文本、点击购买等完整流程。虽然编写成本不低,但上线前跑一遍全流程能避免很多低级错误,比如支付回调 URL 写错、token 刷新导致请求失败等等。
当这整套代码流程跑通,看着用户真的在付费使用自己写的工具时,会有一种很奇妙的感觉。回过头看,React + TypeScript 在这件事里扮演的绝不仅仅是画界面的角色。它管理着复杂的异步流,协调用户身份和计费状态,用类型系统约束着每一个可能出错的环节。技术选型最大的价值,不是它让你写得更快,而是让你在业务变复杂的时候还能掌控全局。