这几天做了个小项目 ------ 用户上传图片后,AI能自动识别出对应的英文单词,还能生成例句和语音。整个过程踩了不少坑,也有很多值得说道的细节,今天就从头到尾捋一捋实现思路,尤其是那些容易被忽略的技术点。
从主组件开始
任何 React 项目都需要一个主组件来串联各个功能,App.jsx
就是这个项目的 "大脑",负责管理全局状态和协调各个模块。先看最基础的结构:
jsx
import { useState } from "react";
import "./App.css";
import PictureCard from "./components/PictureCard";
import { generateAudio } from "./lib/audio";
function App() {
// 后面会逐步添加状态和方法
return (
<div className='container'>
{/* 内容后续填充 */}
</div>
);
}
export default App;
这个组件的核心任务有三个:存储项目中所有需要共享的数据(比如识别出的单词、语音地址)、处理 AI 接口调用逻辑、把数据和方法传递给子组件。
定义状态
首先要明确,这个项目需要存储哪些数据?我列了一下,至少包括这些:
jsx
function App() {
// 英文单词
const [word, setWord] = useState("请上传图片");
// 英文例句
const [sentence, setSentence] = useState("");
// 语音地址
const [audio, setAudio] = useState("");
// 控制详情区域是否展开
const [detailExpand, setDetailExpand] = useState(false);
// 图片的预览地址
const [imgPreview, setImgPreview] = useState("");
// 单词的详细解释
const [explanation, setExplanation] = useState([]);
// 解释中问句的回复内容
const [expReply, setExpReply] = useState([]);
}
这些状态各自有明确的用途:word
展示核心单词,sentence
展示例句,audio
存储语音地址...... 最关键的是,这些状态会在不同组件间流转,比如 word
和 audio
会传给子组件 PictureCard
展示,而 imgPreview
则会在详情区域显示。
搭建页面结构
状态定义好了,接下来要考虑如何把这些数据展示给用户。页面结构其实很简单,主要分为两部分:图片上传区和结果展示区。
jsx
return (
<div className='container'>
<PictureCard
word={word}
audio={audio}
uploadImg={uploadImg}
/>
{/* 结果展示区域 */}
<div className="output">
<div className="sentence">{sentence}</div>
{/* 详情展开/收起区域 */}
<div className="details">
<button onClick={() => setDetailExpand(!detailExpand)}>Talk about it</button>
{
detailExpand ? (
<div className="expand">
<img src={imgPreview} alt="preview"/>
{
explanation.map((explanation, index) => (
<div key={index} className="explanation">
{explanation}
</div>
))
}
{
expReply.map((reply, index) => (
<div key={index} className="reply">
{reply}
</div>
))
}
</div>
): (
<div className="fold" />
)
}
</div>
</div>
</div>
);
这段代码的逻辑很清晰:
- 用
PictureCard
组件处理图片上传,把需要展示的数据(word
、audio
)和处理方法(uploadImg
)传进去 - 结果展示区分为两部分:直接显示的例句,和可展开的详情(包含图片、解释、回复)
- 点击按钮时通过
setDetailExpand
切换详情的显示状态,这是 React 中常见的条件渲染方式
图片上传与预览
PictureCard
组件是用户交互的入口,负责接收用户上传的图片并实时预览,同时把图片数据传给 App
组件处理。先看它的基础结构:
jsx
import { useState } from "react";
import "./style.css";
const PictureCard = (props) => {
const { word, audio, uploadImg } = props;
const [imgPreview, setImgPreview] = useState(
"https://res.bearbobo.com/resource/upload/W44yyxvl/upload-ih56twxirei.png"
);
return (
<div className="card">
{/* 上传相关内容 */}
</div>
);
};
export default PictureCard;
这个组件的核心任务是:让用户能选择图片 → 读取图片内容 → 实时预览 → 把图片数据传给父组件。
实现图片选择与预览功能
jsx
return (
<div className="card">
<input
type="file"
id="selectImage"
accept=".jpg,.jpeg,.png,.gif"
onChange={uploadImgData}
/>
{/* label 与 input 关联,点击图片就会打开文件选择框 */}
<label htmlFor="selectImage" className="upload">
<img src={imgPreview} alt="preview" />
</label>
</div>
);
选择图片需要一个文件输入框,但原生的输入框样式不好控制,所以用了个小技巧:把 input[type="file"]
隐藏起来,用 label
关联它,这样就能自定义上传按钮的样式。点击 label
里的图片时,其实触发的是文件选择框,体验会好很多。 接下来是关键的 uploadImgData
方法,它负责读取用户选择的图片并转换格式:
jsx
const uploadImgData = async (e) => {
// 获取用户选择的第一个文件
const file = e.target.files?.[0];
if (!file) return;
// 用 Promise 包装,方便父组件知道图片处理完成的时机
return new Promise((resolve, reject) => {
// 创建 FileReader 实例,用于读取文件内容
const reader = new FileReader();
// 以 DataURL 格式读取文件(会把文件转成 base64 字符串)
reader.readAsDataURL(file);
reader.onload = () => {
const data = reader.result;
setImgPreview(data);
uploadImg(data);
resolve(data);
};
// 读取失败的回调
reader.onerror = (error) => {
reject(error);
};
});
};
最关键的是 uploadImgData
函数,这里用到了 FileReader
API。刚开始我想直接把文件对象传给后端,后来发现前端预览必须先读文件内容。readAsDataURL
方法会把文件转换成 base64 格式的字符串,长得像 data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
这种。
为什么要用 base64?因为它可以直接作为 img
标签的 src
属性值,不用上传到服务器就能预览。当 reader.onload
触发时,reader.result
就是转换好的 base64 字符串,把它存到 imgPreview
状态里,图片就实时显示出来了。
这里踩过一个坑:如果取消选择文件,e.target.files
会是空的,所以必须加个判断 if (!file) return
,否则会报错。另外,用 Promise 包装读取过程,是为了让父组件能知道图片什么时候处理完,方便后续调用 API。
调用月之暗面 API
图片数据传到 App
组件后,下一步就是调用 AI 接口分析图片内容。这部分逻辑在 uploadImg
方法里,这个方法是 PictureCard
组件传图片数据时触发的。
准备提示词
调用 AI 接口时,提示词(prompt)非常关键,直接决定返回结果的质量。我专门写了一段提示词,明确告诉 AI 需要做什么、返回什么格式的数据:
jsx
// 定义提示词,指导 AI 如何分析图片
const picPrompt = `
请分析图片内容,找出最能描述图片的一个英文单词,尽量选择 A1-A2 难度的基础词汇。
必须严格按照以下 JSON 格式返回结果,不要添加任何额外内容:
{
"image_description": "对图片内容的简要描述",
"representative_word": "最能代表图片的英文单词",
"example_sentence": "包含该单词的简单英文例句",
"explanation": "用英文解释这个单词,要求每句单独一行,以 Look at... 开头,最后加一个与日常生活相关的问句",
"explanation_reply": ["对问句的第一个回复(英文)", "对问句的第二个回复(英文)"]
}
`;
提示词里强调了几点:单词难度(A1-A2)、严格的 JSON 格式、解释的结构(每句一行 + 问句)。刚开始没限制格式时,AI 经常返回大段文本,解析起来很麻烦,加了格式限制后就顺畅多了。
实现 API 调用逻辑
有了提示词,就可以编写调用接口的代码了:
jsx
const uploadImg = async (imageData) => {
setImgPreview(imageData);
setWord("分析中...");
// 月之暗面 API 的地址
const endpoint = "https://api.moonshot.cn/v1/chat/completions";
// 请求头
const headers = {
"Content-Type": "application/json",
// 从环境变量获取 API 密钥,避免明文暴露
Authorization: `Bearer ${import.meta.env.VITE_KIMI_API_KEY}`
};
try {
// 发送 POST 请求
const response = await fetch(endpoint, {
method: "POST",
headers,
body: JSON.stringify({
model: "moonshot-v1-8k-vision-preview",
messages: [
{
role: "user",
content: [
{
type: "image_url",
image_url: { url: imageData }
},
{
type: "text",
text: picPrompt
}
]
}
],
stream: false
})
});
// 解析接口返回的 JSON 数据
const data = await response.json();
const replyData = JSON.parse(data.choices[0].message.content);
// 更新状态,让页面显示识别结果
setWord(replyData.representative_word);
setSentence(replyData.example_sentence);
setExplanation(replyData.explanation.split("\n"));
setExpReply(replyData.explanation_reply);
// 调用 TTS 接口生成语音(后面详细讲)
const audioUrl = await generateAudio(replyData.example_sentence);
setAudio(audioUrl);
} catch (error) {
// 出错时显示错误提示
setWord("分析失败,请重试");
console.error("API 调用出错:", error);
}
};
这段代码的关键步骤:
imageData
是从子组件传过来的 base64 字符串,直接作为图片 URL 传给 API,省了先上传到服务器的步骤。- 请求头里的
Authorization
用了环境变量VITE_KIMI_API_KEY
,这是为了安全,密钥不能明文写在代码里,用 Vite 的环境变量管理很方便。 - 调用 API 时指定了
model: "moonshot-v1-8k-vision-preview"
,这是月之暗面支持图片识别的模型。 - 拿到返回结果后,先用
JSON.parse
解析,因为 AI 返回的是字符串格式的 JSON,必须转成对象才能用。 - 最后调用
generateAudio
生成语音,把例句读出来,这个函数的实现是另一个重点。
按理说我们拿到了数据应该去渲染页面,但是我为了偷懒直接把页面放在前面了😁😁
展示单词和语音播放按钮
除了上传功能,这个组件还要显示识别出的单词和语音播放按钮。在 return 里补充这部分内容:
jsx
return (
<div className="card">
<input type="file" id="selectImage" ... />
<label htmlFor="selectImage" className="upload">
<img src={imgPreview} alt="preview" />
</label>
<div className="word">{word}</div>
{/* 语音播放按钮,只有当 audio 有值时才显示 */}
{audio && (
<div className="playAudio" onClick={playAudio}>
<img
width="20px"
src="https://res.bearbobo.com/resource/upload/Omq2HFs8/playA-3iob5qyckpa.png"
alt="play"
/>
</div>
)}
</div>
);
再实现播放语音的方法:
jsx
// 播放语音的方法
const playAudio = () => {
const audioEle = new Audio(audio);
audioEle.play();
};
这里的逻辑很简单:当父组件传来 audio
地址时,渲染播放按钮;点击按钮时,用 Audio
构造函数创建音频实例并播放。
实现 TTS 功能
最后一步是把生成的例句转成语音,这部分逻辑放在 lib/audio.js
里,涉及到 base64 解码和 Blob 处理,是项目的技术亮点之一。
调用 TTS 接口生成音频数据
首先实现调用 TTS 服务的方法,获取音频的 base64 数据:
js
// lib/audio.js
export const generateAudio = async (text) => {
// 从环境变量获取配置
const token = import.meta.env.VITE_AUDIO_ACCESS_TOKEN;
const appId = import.meta.env.VITE_AUDIO_APP_ID;
const clusterId = import.meta.env.VITE_AUDIO_CLUSTER_ID;
const voiceName = import.meta.env.VITE_AUDIO_VOICE_NAME;
// TTS 接口地址
const endpoint = "/tts/api/v1/tts";
// 请求头
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer;${token}`
};
// 构造请求体,不同服务的参数格式可能不同
const payload = {
app: {
appid: appId,
token,
cluster: clusterId
},
user: {
uid: "ai-word-user" // 自定义用户 ID
},
audio: {
voice_type: voiceName, // 发音人
encoding: "mp3", // 音频格式
rate: 24000, // 采样率
speed_ratio: 1.0, // 语速
emotion: "neutral" // 情感
},
request: {
reqid: Math.random().toString(36).substring(2, 10), // 随机请求 ID
text, // 要转换的文本(例句)
text_type: "plain" // 文本类型为纯文本
}
};
try {
// 调用 TTS 接口
const response = await fetch(endpoint, {
method: "POST",
headers,
body: JSON.stringify(payload)
});
const data = await response.json();
// 把返回的 base64 音频数据转成可播放的 URL
return getAudioUrl(data.data);
} catch (error) {
console.error("语音生成失败:", error);
return null;
}
};
这个函数的作用是把例句文本传给 TTS 服务,拿到音频数据。这里的 reqid
用随机字符串生成,是为了避免请求冲突。返回的 data.data
是 base64 编码的音频内容,不能直接用,调用 getAudioUrl
方法把数据转成浏览器能播放的格式。
将 base64 音频数据转换为可播放的 URL
TTS 服务返回的是 base64 编码的音频数据,不能直接播放,需要将其转换为浏览器可以识别的格式:
js
const getAudioUrl = (base64Data) => {
// 1. 解码 base64 字符串,得到二进制字符串
const byteCharacters = atob(base64Data);
// 2. 创建字节数组
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
// 3. 创建 Uint8Array(8 位无符号整数数组)
const byteArray = new Uint8Array(byteNumbers);
// 4. 创建 Blob 对象(二进制大对象)
const blob = new Blob([byteArray], { type: 'audio/mp3' });
// 5. 生成可播放的 URL
return URL.createObjectURL(blob);
};
这个过程涉及几个关键步骤:
-
解码 base64 :使用
atob()
函数将 base64 字符串解码为原始二进制数据 -
转换为字节数组:将二进制字符串转换为数字数组,每个数字对应一个字节
-
创建类型化数组 :使用
Uint8Array
将普通数组转换为 JavaScript 可以处理的二进制数据 -
创建 Blob 对象 :将二进制数据封装为 Blob 对象,并指定 MIME 类型为
audio/mp3
-
生成 URL :使用
URL.createObjectURL()
为 Blob 对象生成一个临时 URL,这个 URL 可以直接作为<audio>
元素的 src
为什么不直接使用 base64 字符串作为音频源?虽然可以这样做,但性能会较差,尤其是对于较大的音频文件。而使用 Blob URL 可以让浏览器更高效地处理音频数据,并且在不再需要时可以通过 URL.revokeObjectURL()
释放资源。
好了,整个项目的流程就是这样了,下面让我们上传一张图片来看看具体效果吧!

这个项目虽然功能不复杂,但把图片处理、AI 接口调用、语音生成这些知识点串起来了。尤其是前端直接处理文件和二进制数据的部分,以前总觉得很深奥,实际做起来才发现,掌握 FileReader
、Blob
、URL
这些 API 后,很多需求都能迎刃而解。最后想问一句,这个苹果是不是看起来很好吃?