拍照就能记单词?我用 React + AI 撸了个「智能背词神器」,前端也能玩转多模态!

211 阅读8分钟

当你还在为 “如何让前端页面看一张图片” 抓秃头发时,有人已经用几行 JS 代码让网页和 AI 聊了起来 —— 拍下一只猫,前端直接甩给大模型一句 “这图里的东西用 A1 级单词怎么说?”,3 秒后就拿到 “cat” 和配套例句。

这就是当下最火的 “智能前端”:它不是让你死磕算法,而是教你把大模型当 “超级实习生”,把复杂的图片识别、文本生成、逻辑判断交给 AI,前端只需要专注 “指挥 AI 干活” 和 “把结果变好看”。

这篇文章,咱们就从这个 “拍照记单词” 的实战项目出发,看看智能前端到底怎么玩:从用 pnpm 提速开发,到用 React 的单向数据流管理 AI 返回的数据,再到怎么写一个让 AI “听话” 的 prompt,每一步都带着 “智能味儿”,新手也能跟着敲出一个会 “思考” 的 APP。

核心技术栈与工具选型

pnpm 包管理的正确打开方式

使用pnpm提升安装效率(比npm快3倍)

它像个 “共享书架”,把 react 这类常用包存在一个公共文件夹里,不同项目要用时直接 “借”(创建链接),之前装过的包,第二次 “零安装”。

用 vite 初始化的项目,自带完整的工程化配置(热更新、打包优化等),咱们只需要关注业务逻辑。

# 安装 pnpm(全局只需要一次)
npm install -g pnpm

# 用 pnpm 创建项目(代替 npm init)
pnpm create vite@latest ai-word-app --template react

# 安装依赖(比 npm 快 3 倍+)
cd ai-word-app
pnpm install

这一步就能省出大量时间,尤其在多项目开发时,谁用谁知道香~

多模态AI服务选型

服务类型推荐方案特点
图像理解月之暗面Kimi支持多图分析,长文本推理
语音合成火山引擎TTS高保真发音,低延迟

项目骨架:用 React 思想搭个 “智能背词” 框架

核心需求:拍照→AI 识别单词→生成例句→发音

我们要做的 app 核心流程很简单:

  1. 用户拍一张照片(比如拍一只狗);
  2. 前端把照片传给 月之暗面(Moonshot)多模态大模型(能“看懂”图片的AI),精准识别核心单词(比如"dog");
  3. 月之暗面同步生成场景化例句与单词解析;
  4. 调用火山引擎TTS接口生成发音,实现「看+听」沉浸式学习。

我们首先配置环境变量,API 密钥(如 VITE_KIMI_API_KEY)是调用 AI 接口的 “钥匙”,绝对不能硬编码在代码里(会被别人偷走)。正确做法是用 .env.local 存。

月之暗面:platform.moonshot.cn/docs/introd…

image.png 火山引擎:console.volcengine.com

选择豆包语音

image.png 有很多语音模型供选择

image.png 从中获取API 并填入:

# Kimi API 密钥
VITE_KIMI_API_KEY=你的kimi_api_key

# 字节跳动 TTS 配置
VITE_AUDIO_ACCESS_TOKEN=你的access_token
VITE_AUDIO_APP_ID=你的app_id
VITE_AUDIO_CLUSTER_ID=你的cluster_id
VITE_AUDIO_VOICE_NAME=你的voice_name

在代码中用 import.meta.env.VITE_XXX 读取,既安全又方便切换环境(开发 / 生产用不同密钥)。

核心组件设计:前端的 “智能分工” 哲学

智能前端的核心不是堆代码,而是让每个组件各司其职:父组件管 “思考”(数据和 AI 交互),子组件管 “展示”(UI 和用户操作),配合 React 的单向数据流,数据像 “指令” 一样从父到子有序流动。

核心文件夹:

image.png

├── src/
│   ├── components/  # 组件目录(前端的“功能积木”)
│   │   └── PictureCard/  # 图片上传组件(拍照核心)
│   ├── libs/  # 工具库(AI 调用、音频处理等)
│   │   └── audio.js  # TTS 语音合成工具
│   ├── App.jsx  # 根组件(统筹全局的“总指挥”)
│   └── App.css  # 全局样式
└── .env.local  # 环境变量(存 API 密钥,安全第一)

这种结构的好处:组件复用性高,工具函数集中管理,后期维护像找东西一样方便

根组件 App.jsx:AI 交互的 “总指挥”

App 组件是整个应用的 “大脑”,负责三件事:

  • 存数据(单词、例句、音频等状态);
  • 调 AI 接口(让多模态模型识别图片、TTS 生成发音);
  • 把数据传给子组件(PictureCard)。
import { useState } from 'react'
import './App.css'
import PictureCard from './components/PictureCard';  // 导入图片上传子组件
import { generateAudio } from './lib/audio';  // 导入TTS语音合成工具

function App() {
  // 1. 用 userPrompt 告诉 AI 该做什么(智能交互的核心指令)
  const userPrompt = `分析图片内容,找出最能描述图片的一个英文单词,尽量选择更简单的A1~A2的词汇。

  返回JSON数据:
  { 
  "image_discription": "图片描述", 
  "representative_word": "图片代表的英文单词", 
  "example_sentence": "结合英文单词和图片描述,给出一个简单的例句", 
  "explaination": "结合图片解释英文单词,段落以Look at...开头,将段落分句,每一句单独一行,解释的最后给一个日常生活有关的问句", 
  "explaination_replys": ["根据explaination给出的回复1", "根据explaination给出的回复2"]
  }`;

  // 2. 用 useState 管理响应式状态(数据变了,页面自动更)
  const [word, setWord] = useState('请上传图片');  // 识别出的单词(如“cat”)
  const [sentence, setSentence] = useState('');  // AI生成的例句(如“I see a cat”)
  const [explainations, setExplainations] = useState([]);  // 单词解释(拆分成数组方便渲染)
  const [expReply, setExpReply] = useState([]);  // 互动回复(如“你喜欢猫吗?”)
  const [audio, setAudio] = useState('');  // 音频地址(用于播放发音)
  const [detailExpand, setDetailExpand] = useState(false);  // 控制详情展开/折叠的状态
  const [imgpreview, setImgPreview] = useState('https://res.bearbobo.com/resource/upload/W44yyxvl/upload-ih56twxirei.png');  // 图片预览地址

  // 3. 核心函数:上传图片并调用AI处理
  const uploadImg = async (imageData) => {
    setImgPreview(imageData);  // 先更新预览图(提升用户体验)
    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}`
    };

    setWord('分析中...');  // 显示加载状态(让用户知道“正在处理”)

    // 调用多模态AI接口(核心:让AI“看懂”图片并返回结果)
    const response = await fetch(endpoint, {
      method: 'POST',
      headers: headers,
      body: JSON.stringify({
        model: 'moonshot-v1-8k-vision-preview',  // 支持图片识别的大模型
        messages: [
          {
            role: 'user',
            content: [
              {
                type: "image_url",
                image_url: { "url": imageData, }  // 传入图片的base64地址
              },
              {
                type: "text", text: userPrompt  // 传入指令(告诉AI要返回什么)
              }]
          }],
        stream: false  // 不使用流式返回,一次性获取结果
      })
    });

    // 处理AI返回的结果
    const data = await response.json();
    const replyData = JSON.parse(data.choices[0].message.content);  // 解析AI返回的JSON

    // 更新状态(数据驱动视图,React自动重新渲染页面)
    setWord(replyData.representative_word);  // 存单词
    setSentence(replyData.example_sentence);  // 存例句
    setExplainations(replyData.explaination.split('\n'));  // 存解释(按换行拆分)
    setExpReply(replyData.explaination_replys);  // 存互动回复

    // 调用TTS生成音频(让单词“会说话”)
    const audioUrl = await generateAudio(replyData.example_sentence);
    setAudio(audioUrl);  // 存音频地址
  };

  return (
    <div className="container">
      {/* 把数据和方法传给子组件(单向数据流:父→子) */}
      <PictureCard
        audio={audio}  // 音频地址子组件用来播放word={word}  // 单词子组件用来显示uploadImg={uploadImg}  // 上传图片的方法子组件触发)
      />
      
      {/* 显示例句和详情区域 */}
      <div className="output">
        <div>{sentence}</div>  {/* 显示例句 */}
        
        {/* 详情展开/折叠功能 */}
        <div className="details">
          <button onClick={() => setDetailExpand(!detailExpand)}>Talk about it</button>
          {
            detailExpand ? (
              <div className="expand">
                <img src={imgpreview} alt="preview"/>  {/* 显示图片预览 */}
                {/* 渲染解释内容 */}
                {
                  explainations.map((explain, index) => (
                    <div key={index} className="explaination">
                      {explain}
                    </div>
                  ))
                }
                {/* 渲染互动回复 */}
                {
                  expReply.map((reply, index) => (
                    <div key={index} className="explaination_reply">
                      {reply}
                    </div>
                  ))
                }
              </div>
            ) : (
              <div className="fold"/>  {/* 折叠状态显示的占位区域 */}
            )
          }
        </div>
      </div>
    </div>
  );
}

export default App;

智能前端亮点:这里的 userPrompt 是 “指挥 AI 干活” 的关键 —— 我们明确告诉 AI 要返回什么格式(JSON)、什么难度(A1~A2 词汇),让 AI 成为 “听话的实习生”,而不是返回一堆无用信息。

子组件PictureCard:图片上传组件

PictureCard只负责两件事:

  • 让用户上传图片(拍照功能);
  • 显示单词和播放发音(把父组件传来的数据展示出来)。
import { useState } from 'react';
import './style.css';

const PictureCard = (props) => {
    // 接收父组件传来的数据和方法(props 就像“快递包裹”)
    const {
        word,
        audio,
        uploadImg
    } = props
    // 子组件自己的状态(私有数据,存储图片预览地址)
    const [imgPreview, setImgPreview] = useState('https://res.bearbobo.com/resource/upload/W44yyxvl/upload-ih56twxirei.png')

    // 处理图片上传(用户选完图片后触发)
    const uploadImgData=(e)=>{
        const file = (e.target).files?.[0];  // 获取用户选择的图片(可选链操作符避免报错)
        if (!file) { return; }  // 没选图片就直接返回

        // 用 Promise 封装 FileReader,方便后续处理
        return new Promise((resolve, reject) => {
            const reader = new FileReader();
            reader.readAsDataURL(file);  // 把图片转成 base64 格式
            reader.onload = () => {
                const data = reader.result;  // 得到 base64 字符串(如“data:image/png;base64,...”)
                setImgPreview(data);  // 更新预览图(用户能立即看到选中的图片)
                uploadImg(data);  // 调用父组件的方法,把图片传给 AI 处理
                resolve(data);  // 成功时返回 base64 数据
            }
            reader.onerror = (error) => { reject(error); };  // 失败时抛出错误
        })
    }

    // 播放音频(点击喇叭图标时触发)
    const playAudio = () => {
        const audioEle = new Audio(audio);  // 创建音频对象
        audioEle.play();  // 播放发音(如例句的朗读)
    }

    return (
        <div className='card'>
            {/* 隐藏原生文件输入框,用 label 美化成图片样式 */}
            <input 
                type="file" 
                id="selectImage" 
                accept='.jpg,.jpeg,.png,.gif'  // 只接受图片格式
                onChange={uploadImgData}  // 选完图片后触发上传逻辑
            />
            {/* 点击图片区域触发文件选择(用户友好的交互设计) */}
            <label htmlFor='selectImage' className='upload'>
                <img src={imgPreview} alt='preview'/>  // 显示预览图(默认图或用户选中的图)
            </label>
            <div className='word'>{word}</div>  // 显示父组件传来的单词(如“cat”)
            
            {/* 有音频时才显示播放按钮(条件渲染,避免空状态报错) */}
            {audio && (
                <div className="playAudio" onClick={playAudio}>
                    <img width="20px" src="https://res.bearbobo.com/resource/upload/Omq2HFs8/playA-3iob5qyckpa.png" alt="播放发音" />
                </div>
            )}
        </div>
    )
}

export default PictureCard;

audio.js TTS 语音合成:让单词 “会说话”

用字节跳动的 TTS 接口把文字转语音,前端处理音频的过程用到了 HTML5 的 Blob 技术

// 利用字节的tts 文字转语音
const getAudioUrl = (base64Data) => {
    // 创建一个数组来存储字节数据
    var byteArrays = [];
    // 使用atob()将Base64编码的字符串解码为原始二进制字符串
    // atob: ASCII to Binary
    var byteCharacters = atob(base64Data);
    // 遍历解码后的二进制字符串的每个字符
    for (var offset = 0; offset < byteCharacters.length; offset++) {
        // 将每个字符转换为其ASCII码值(0-255之间的数字)
        var byteArray = byteCharacters.charCodeAt(offset);
        // 将ASCII码值添加到字节数组中
        byteArrays.push(byteArray);
    }
    // 创建一个Blob对象
    // new Uint8Array(byteArrays)将普通数组转换为8位无符号整数数组
    // { type: 'audio/mp3' } 指定Blob的MIME类型为MP3音频
    var blob = new Blob([new Uint8Array(byteArrays)], { type: 'audio/mp3' });
    // 使用URL.createObjectURL创建一个临时的URL
    // 这个URL可以用于<audio>标签的src属性
    // 这个URL在当前页面/会话有效,页面关闭后会自动释放
    // 创建一个临时 URL 供音频播放
    return URL.createObjectURL(blob);
}

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;

    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: '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',
            // language: 'cn',
        },
        request: {
            reqid: Math.random().toString(36).substring(7),
            text,
            text_type: 'plain',
            operation: 'query',
            silence_duration: '125',
            with_frontend: '1',
            frontend_type: 'unitTson',
            pure_english_opt: '1',
        },
    };

    const res = await fetch(endpoint, {
        method: 'POST',
        headers,
        body: JSON.stringify(payload),
    })

    const data = await res.json();
    const url = getAudioUrl(data.data);
    return url; // 返回音频URL,用于播放或下载
}

image.png 可以从官方文档中获取请求体

css reset

初始化,消除不同浏览器的默认样式差异

:root {
  font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
  line-height: 1.5;
  font-weight: 400;

  color-scheme: light dark;
  color: rgba(255, 255, 255, 0.87);
  background-color: #242424;

  font-synthesis: none;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

a {
  font-weight: 500;
  color: #646cff;
  text-decoration: inherit;
}

a:hover {
  color: #535bf2;
}

body {
  margin: 0;
  display: flex;
  place-items: center;
  min-width: 320px;
  min-height: 100vh;
}

h1 {
  font-size: 3.2em;
  line-height: 1.1;
}

button {
  border-radius: 8px;
  border: 1px solid transparent;
  padding: 0.6em 1.2em;
  font-size: 1em;
  font-weight: 500;
  font-family: inherit;
  background-color: #1a1a1a;
  cursor: pointer;
  transition: border-color 0.25s;
}

button:hover {
  border-color: #646cff;
}

button:focus,
button:focus-visible {
  outline: 4px auto -webkit-focus-ring-color;
}

.card {
  padding: 2em;
}

#app {
  max-width: 1280px;
  /* margin: 0 auto;
  padding: 2rem; */
  text-align: center;
  color: white;
}

@media (prefers-color-scheme: light) {
  :root {
    color: #213547;
    background-color: #ffffff;
  }

  a:hover {
    color: #747bff;
  }

  button {
    background-color: #f9f9f9;
  }
}

.container {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: start;
  margin: 0;
  padding: 0;
  width: 100vw;
  height: 100vh;
  font-size: .85rem;
  background: linear-gradient(180deg, rgb(127, 224, 148) 0%, rgba(8, 92, 161, 0.783) 100%);
}

#selectImage {
  display: none;
}

.input {
  width: 200px;
}

.output {
  margin-top: 20px;
  /* min-height: 300px; */
  width: 80%;
  text-align: center;
  font-weight: bold;
}

.preview img {
  max-width: 100%;
}

button {
  padding: 0 10px;
  margin-left: 6px;
}

.details {
  position: absolute;
  bottom: 0;
  left: 50%;
  transform: translateX(-50%);
}

.details button {
  background-color: black;
  color: white;
  width: 160px;
  height: 32px;
  border-radius: 8px 8px 0 0;
  border: none;
  font-size: 12px;
  font-weight: bold;
  cursor: pointer;
}

.details .fold {
  width: 200px;
  height: 30px;
  background-color: white;
  border-top-left-radius: 8px;
  border-top-right-radius: 8px;
}

.details .expand {
  width: 200px;
  height: 88vh;
  background-color: white;
  border-top-left-radius: 8px;
  border-top-right-radius: 8px;
}

.expand img {
  width: 60%;
  margin-top: 20px;
  border-radius: 6px;
}

.expand .explaination {
  color: black;
  font-weight: normal;
}

.expand .explaination p {
  margin: 0 10px 10px 10px;
}

.expand .reply {
  color: black;
  font-weight: normal;
  margin-top: 20px;
}

.expand .reply p {
  padding: 4px 10px;
  margin: 0 10px 10px 10px;
  border-radius: 6px;
  border: solid 1px grey;
}

#selecteImage {
  display: none;
}

.card {
  border-radius: 8px;
  padding: 20px;
  margin-top: 40px;
  height: 280px;
  box-shadow: rgb(63, 38, 21) 0 3px 0px 0;
  background-color: rgb(105, 78, 62);
  box-sizing: border-box;
}

.upload {
  width: 160px;
  height: 160px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}

.upload img {
  width: 100%;
  height: 100%;
  object-fit: contain;
}

.word {
  margin-top: 20px;
  font-size: 16px;
  color: rgb(255, 255, 255);
}

.playAudio {
  margin-top: 16px;
}

.playAudio img {
  cursor: pointer;
}

以上就是该项目的所有代码,可直接复制运行,记得换成自己的 API 密钥哦~

屏幕截图 2025-07-06 114519.png

屏幕截图 2025-07-06 114528.png 这个项目看起来涉及 AI、React、组件化,但拆开后每一步都很简单:本质是 “前端调接口 + 数据展示”,只是接口对面站着 AI 而已。

现在的智能前端开发,早就不是 “写静态页面” 了,而是用代码让产品 “变聪明”。下次背单词时,掏出自己写的 app 拍照识词,这种成就感,比死记硬背爽多了~

下次想做 AI 小应用,直接拿这篇当模板!