最近在学React,突然想起之前看到的一个很酷的想法:拍照学英语。想着反正也要练手,就干脆自己撸一个出来。
整个应用的流程很简单:用户上传图片 → AI识别图片内容 → 输出英语单词和例句 → 语音播放。听起来简单,但真正动手才发现里面的门道不少。
项目结构
src/
├── components/
│ └── PictureCard/
│ ├── index.jsx
│ └── style.css
├── lib/
│ └── audio.js
├── App.jsx
├── App.css
├── index.css
└── main.jsx
没搞得太复杂,就是标准的React项目结构。每个组件一个文件夹,工具函数放lib里,这样后面维护起来不会太乱。
图片上传这块踩了个坑
一开始觉得图片上传很简单,结果发现细节挺多的:
const uploadImgData = (e) => {
const file = (e.target).files?.[0];
if (!file) { return; }
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
const data = reader.result;
setImgPreview(data);
uploadImg(data);
resolve(data);
}
reader.onerror = (error) => { reject(error); };
})
}
这里几个点值得说一下:
const file = (e.target).files?.[0]; 这行代码虽然简单,但用了可选链操作符。之前我直接写files[0],结果有时候会报错,因为files可能为空。用了?.[0]之后,即使files是null也不会崩溃,直接返回undefined。
还有就是FileReader这个API,它是异步的,我把它包装成Promise,这样后面用async/await处理起来更顺手。
另外一个小技巧是把input隐藏起来,用label来触发文件选择,这样可以自定义按钮样式。
Moonshot API确实好用
AI识别这块我选了月之暗面的Kimi API,主要是因为它对图片理解比较准确,而且支持中文prompt。(其实是新用户送15元,还是白嫖的香啊😆)
const uploadImg = async (imageData) => {
const endpoint = 'https://api.moonshot.cn/v1/chat/completions';
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${import.meta.env.VITE_KIMI_API_KEY}`
},
body: JSON.stringify({
model: 'moonshot-v1-8k-vision-preview',
messages: [{
role: 'user',
content: [
{ type: "image_url", image_url: { "url": imageData }},
{ type: "text", text: userPrompt }
]
}],
stream: false
})
})
}
这个API的好处是支持多模态输入,可以同时传图片和文字指令。而且返回的结果很稳定,基本上按照我的要求来。
Prompt写了好久
这个项目最费时间的反而是写prompt,改了好几版才满意:
const userPrompt = `分析图片内容,找出最能描述图片的一个英文单词,尽量选择更简单的A1~A2的词汇。
返回JSON数据:
{
"image_discription": "图片描述",
"representative_word": "图片代表的英文单词",
"example_sentence": "结合英文单词和图片描述,给出一个简单的例句",
"explaination": "结合图片解释英文单词,段落以Look at...开头,将段落分句,每一句单独一行,解释的最后给一个日常生活有关的问句",
"explaination_replys": ["根据explaination给出的回复1", "根据explaination给出的回复2"]
}`;
写这个prompt的时候考虑了几个点:
- 词汇难度控制:专门要求A1~A2级别的词汇,因为是给初学者用的
- 输出格式:要求JSON格式,这样前端解析起来方便
- 教学习惯:要求解释以"Look at..."开头,这比较符合英语教学的习惯
- 互动性:最后要求一个日常问句,还有两个回复选项,这样更像真实对话
试了几次发现,prompt写得越详细,AI的输出就越稳定。
TTS这块有点复杂
语音合成用了专门的TTS服务,配置项还挺多:
const generateAudio = async (text) => {
const payload = {
app: { appid: appId, token, cluster: clusterId },
user: { uid: 'bearbobo' },
audio: {
voice_type: voiceName,
encoding: 'ogg_opus',
compression_rate: 1,
rate: 24000,
speed_ratio: 1.0,
volume_ratio: 1.0,
pitch_ratio: 1.0,
emotion: 'happy'
},
request: {
reqid: Math.random().toString(36).substring(7),
text,
text_type: 'plain',
operation: 'query'
}
};
const res = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify(payload)
});
const data = await res.json();
const url = getAudioUrl(data.data);
return url;
}
这里emotion: 'happy'这个参数挺有意思的,可以控制语音的情感。试了几种,happy听起来确实更适合学习场景。
跨域问题差点把我整疯
TTS API接口调用这块遇到了一个大坑:跨域问题。刚开始直接请求https://openspeech.bytedance.com,浏览器直接报错:
Access to fetch at 'https://openspeech.bytedance.com/api/v1/tts' from origin 'http://localhost:5173' has been blocked by CORS policy
试了好几种方法都不行,网上查了一圈,发现最简单的解决方案就是在开发环境用代理。
在vite.config.js里加了这个配置:
export default defineConfig({
plugins: [react()],
server: {
allowedHosts: true,
proxy: {
'/tts': {
target: 'https://openspeech.bytedance.com',
changeOrigin: true,
rewrite: path => path.replace(/^/tts/, ''),
}
},
}
})
然后把TTS请求的endpoint改成:
const endpoint = '/tts/api/v1/tts';
这样配置之后,所有以/tts开头的请求都会被代理到https://openspeech.bytedance.com,而且changeOrigin: true会修改请求头的origin,让目标服务器认为请求是从同源发出的。
rewrite函数把URL路径中的/tts前缀去掉,这样实际请求的还是原来的API路径。
这个配置解决了几个问题:
- 跨域限制:浏览器不会再拦截请求
- 开发便利性:不需要额外启动代理服务
- 调试友好:可以在浏览器Network面板正常查看请求
需要注意的是,这个代理配置只在开发环境有效。生产环境部署时,要么在服务器端配置代理,要么让API提供方开放CORS支持。
音频数据转换有门道
最有技术含量的是音频数据转换:
const getAudioUrl = (base64Data) => {
var byteCharacters = atob(base64Data);
var byteArrays = [];
for (var offset = 0; offset < byteCharacters.length; offset++) {
byteArrays.push(byteCharacters.charCodeAt(offset));
}
var blob = new Blob([new Uint8Array(byteArrays)], { type: 'audio/mp3' });
return URL.createObjectURL(blob);
}
TTS API返回的是base64编码的音频数据,不能直接播放。得先解码成二进制,然后转成字节数组,再包装成Blob对象,最后用URL.createObjectURL创建临时URL。
这个转换过程看起来复杂,但有个好处:
用URL.createObjectURL创建的临时URL比直接用base64数据URL省内存,而且浏览器会自动管理,页面关闭后就释放了。
最后播放就很简单了:
const playAudio = () => {
const audioEle = new Audio(audio);
audioEle.play();
}
一行代码搞定,完全不用考虑音频编码的问题。
组件设计思路
整个应用分了两个主要组件:
- App组件:管理所有状态,负责API调用
- PictureCard组件:处理图片上传和展示
// App.jsx里管理状态
const [word, setWord] = useState('请上传图片');
const [sentence, setSentence] = useState('');
const [explainations, setExplainations] = useState([]);
const [audio, setAudio] = useState('');
// PictureCard通过props接收数据
const PictureCard = (props) => {
const { audio, word, uploadImg } = props;
// ...
}
这种设计比较经典,父组件持有数据,子组件只负责展示和交互。数据流向清晰,后面改起来也方便。
样式上的一些想法
整个应用的视觉风格偏向现代化,用了渐变背景:
.container {
background: linear-gradient(180deg, rgb(180, 110, 227) 0%, rgb(75, 191, 220) 100%);
}
主要交互区域用了卡片式设计,还加了阴影效果:
.card {
border-radius: 8px;
box-shadow: rgb(63,38,21) 0 3px 0px 0;
background-color: rgb(105,78,62);
}
详细内容用了底部抽屉的效果,这在移动端很常见:
.details {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
}
最后的成果
踩过的坑
环境变量问题:Vite的环境变量要以VITE_开头,一开始没注意这个,API Key一直读不到。
异步状态管理:AI分析图片需要时间,得给用户反馈。我用了setWord('分析中...')来显示加载状态。
音频播放兼容性:本来想用更复杂的Web Audio API,后来发现不同浏览器支持不一样,最后还是用了最简单的Audio构造函数。
跨域问题:这个是最折腾的,浏览器的同源策略让第三方API调用变得很麻烦。好在Vite的代理配置解决了开发环境的问题,生产环境可以考虑用Nginx反向代理。
优化空间
现在这个版本功能基本够用,但还有些优化空间:
- 图片压缩:上传前压缩一下,能节省带宽
- 结果缓存:相同图片的识别结果可以缓存起来
- 错误处理:网络失败、API限额这些情况还没处理
- 响应式设计:现在主要是按手机屏幕适配的
- 生产环境代理:考虑用服务端代理替代前端代理
写在最后
这个项目虽然不大,但涉及到的技术点还挺全面的:React状态管理、异步数据处理、多媒体API、AI接口调用、跨域处理等等。最有意思的是整个数据流:图片 → AI识别 → 文本 → 语音 → 播放,形成了一个完整的多模态交互链路。特别是跨域问题,这在实际项目中很常见,掌握了Vite代理配置这个技巧,以后遇到类似问题就不会手忙脚乱了。
主要是练手,顺便体验一下现在AI API的能力。确实比想象中要强,基本上按照你的要求来,很少出错。 如果你也想做类似的项目,建议先从简单的图片上传开始,功能一点点加,不要一开始就想做得很复杂。