☰
浏览器原生 Web Speech API 实战:React 语音转文字应用完整拆解
2026/10/17 11:49:57 网站建设 项目流程

简介:这是一份面向前端开发者与React初学者的实战项目源码,聚焦语音转文字场景,通过调用浏览器内置API实现语音识别与文本输出,适合想练习React组件化开发、熟悉脚手架工程配置的初中级开发者参考。压缩包共17个文件,约117KB,以JavaScript源码为主,包含核心识别组件与入口文件,辅以JSON配置、CSS样式、Markdown说明及SVG、HTML等静态资源,结构紧凑、依赖清晰。项目基于引导式脚手架搭建,涵盖自动格式化、代码分割、样式预处理、环境变量、代理配置与路由等常见工程化主题,README中整理了从安装依赖到与后端集成的完整指引。目前已有672人学习下载,读者可借此理解语音识别组件的实现思路、掌握React项目目录组织方式,并对照文档完成本地运行与二次开发,快速积累可复用的前端实践案例。

1. 浏览器原生 API 做语音转文字:一个 React 应用的完整拆解

有个需求我最近在几个项目里反复碰到:用户对着麦克风说话,页面上实时把文字吐出来。第一反应可能是接第三方云服务,但有个更轻的路子——直接用浏览器内置的 Web Speech API,零后端、零费用、零密钥。这份资源就是一个用 React 搭的语音转文字应用,核心逻辑全部跑在浏览器里,不依赖任何外部服务。它适合两类人:一类是想快速给自己的工具加语音输入的前端开发者,另一类是正在学 React 状态管理和浏览器 API 配合的新手。整个项目体量不大,但麻雀虽小,从语音识别实例的创建、状态同步到错误处理,该有的链路都齐了。下面我按“能干什么 → 怎么跑 → 怎么改”的顺序,把这份源码拆开讲一遍。

2. 技术选型与项目结构:为什么是 Web Speech API 而不是云服务

2.1 浏览器原生识别的能力边界

Web Speech API 里的SpeechRecognition接口,是浏览器直接调用系统级语音识别引擎的入口。Chrome 和 Edge 支持得比较好,Safari 从 14.1 开始也跟上了,Firefox 默认没开。它的工作方式是:浏览器把麦克风采集的音频送到识别引擎,引擎返回文本结果,整个过程对前端来说就是一个事件驱动的对象。

选它而不是云服务,理由很直接:不需要后端中转,不需要申请 API Key,没有网络请求延迟,用户隐私数据不出浏览器。代价是识别精度依赖系统引擎,中文识别在 Chrome 上走的是系统语音服务,效果因设备而异。常见做法是把它当作“够用就好”的方案,对精度要求极高的场景再考虑云端。

这个项目里,识别逻辑被封装在一个自定义 Hook 里,组件层只负责渲染和交互。这种拆分方式让识别逻辑可以独立测试,也方便后续替换成别的识别方案。

2.2 目录结构与模块职责

项目基于 Create React App 或 Vite 搭建,目录结构大致如下:

src/ ├── App.jsx # 根组件,组合 UI 和识别逻辑 ├── hooks/ │ └── useSpeechRecognition.js # 封装 SpeechRecognition 的核心 Hook ├── components/ │ ├── MicButton.jsx # 麦克风按钮,控制开始/停止 │ └── Transcript.jsx # 展示识别结果 ├── App.css # 样式 └── index.js # 入口

useSpeechRecognition.js是整个项目的心脏,它管理识别实例的生命周期、监听识别事件、把结果同步到 React 状态。App.jsx负责把这些状态和回调串起来。MicButton和Transcript是纯展示组件,不碰识别逻辑。

这种结构的价值在于:如果哪天要把 Web Speech API 换成别的方案,只需要改 Hook 内部实现,组件层完全不用动。我一般会在 Hook 里把isListening、transcript、error这几个状态暴露出去,组件按需消费。

2.3 核心依赖与运行环境

项目依赖很干净,除了 React 本身,没有额外的语音处理库。package.json里主要就是react、react-dom、react-scripts(或vite)。这意味着npm install不会拉一堆东西,装完就能跑。

运行环境有两个硬性要求:一是浏览器必须支持SpeechRecognition或webkitSpeechRecognition,二是页面必须运行在 HTTPS 或 localhost 下,否则浏览器不会授予麦克风权限。本地开发用localhost没问题,部署到线上必须配 HTTPS。

注意:SpeechRecognition在 Chrome 里需要加webkit前缀,代码里要做兼容判断,不能直接new SpeechRecognition()。

3. 核心 Hook 拆解:从创建识别实例到状态同步

3.1 识别实例的创建与配置

Hook 的第一件事是拿到浏览器提供的构造函数,并创建一个实例。这段代码是整个应用的起点:

// useSpeechRecognition.js import { useState, useRef, useCallback, useEffect } from 'react'; const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; export function useSpeechRecognition() { const [isListening, setIsListening] = useState(false); const [transcript, setTranscript] = useState(''); const [error, setError] = useState(null); const recognitionRef = useRef(null); // 初始化识别实例,只创建一次 useEffect(() => { if (!SpeechRecognition) { setError('当前浏览器不支持语音识别'); return; } const recognition = new SpeechRecognition(); recognition.continuous = true; // 持续识别,不自动停止 recognition.interimResults = true; // 返回临时结果,实现实时显示 recognition.lang = 'zh-CN'; // 识别语言,按需修改 recognitionRef.current = recognition; }, []); }

这里有几个参数值得说清楚。continuous设为true时,识别不会在用户停顿后自动结束,适合长时间听写;设为false则每次停顿就触发onend。interimResults设为true会返回中间结果,用户说话过程中就能看到文字在变,体验更接近实时字幕;设为false则只在识别出完整句子后才返回。lang决定识别引擎用哪种语言模型,中文场景写zh-CN,英文写en-US。

useRef存实例而不是useState,是因为实例不需要触发重渲染,而且要在多次事件回调里保持同一个引用。这是 React 里管理非渲染状态的标准做法。

3.2 事件监听与结果处理

实例创建后,要挂上onresult、onerror、onend这几个关键回调。onresult是核心,它负责把识别结果拼成完整文本:

// 在 useEffect 里继续挂载事件 recognition.onresult = (event) => { let finalText = ''; let interimText = ''; // 从 resultIndex 开始遍历,只处理新增结果 for (let i = event.resultIndex; i < event.results.length; i++) { const result = event.results[i]; if (result.isFinal) { finalText += result[0].transcript; } else { interimText += result[0].transcript; } } // 最终结果追加到 transcript,临时结果单独展示 if (finalText) { setTranscript((prev) => prev + finalText); } setInterim(interimText); }; recognition.onerror = (event) => { setError(event.error); setIsListening(false); }; recognition.onend = () => { setIsListening(false); };

event.results是一个类数组,每个元素包含isFinal标志和识别文本。event.resultIndex告诉我们从哪个位置开始是本次新增的结果,避免重复处理旧数据。最终结果追加到transcript,临时结果放在interim里单独渲染,这样用户能看到“正在识别”的灰色文字。

onerror里event.error的常见值有no-speech(没检测到语音)、audio-capture(麦克风不可用)、not-allowed(用户拒绝了权限)。这些错误要分别处理,不能笼统地显示“出错了”。

3.3 开始与停止的控制逻辑

Hook 对外暴露start和stop两个方法,内部要处理状态切换和异常:

const start = useCallback(() => { const recognition = recognitionRef.current; if (!recognition || isListening) return; try { recognition.start(); setIsListening(true); setError(null); } catch (err) { // 重复调用 start 会抛 InvalidStateError setError(err.message); } }, [isListening]); const stop = useCallback(() => { const recognition = recognitionRef.current; if (!recognition || !isListening) return; recognition.stop(); setIsListening(false); }, [isListening]);

start里用isListening做守卫,防止用户连点按钮导致重复启动。recognition.start()在已经启动的状态下再调用会抛InvalidStateError,所以 try-catch 不能省。stop调用后,浏览器会触发onend,状态在onend里也会被置为false,两处都设是为了保证 UI 及时响应。

组件卸载时还要记得调recognition.abort(),否则麦克风可能一直占用。这个清理逻辑放在useEffect的返回函数里:

return () => { if (recognitionRef.current) { recognitionRef.current.abort(); } };

4. 组件层实现:按钮、结果展示与状态联动

4.1 麦克风按钮的交互设计

MicButton是个受控组件,它接收isListening和onToggle两个 props,根据状态切换图标和样式:

function MicButton({ isListening, onToggle }) { return ( <button className={`mic-btn ${isListening ? 'active' : ''}`} onClick={onToggle} aria-label={isListening ? '停止录音' : '开始录音'} > {isListening ? '停止' : '开始'} </button> ); }

按钮的aria-label根据状态变化,这对无障碍访问很重要。样式上通过active类切换颜色,给用户明确的视觉反馈。点击时调用父组件传下来的onToggle,由父组件决定是 start 还是 stop。

4.2 识别结果的实时渲染

Transcript组件负责展示transcript和interim两部分文本:

function Transcript({ transcript, interim }) { return ( <div className="transcript-box"> <p className="final-text">{transcript}</p> {interim && <p className="interim-text">{interim}</p>} </div> ); }

最终结果用正常颜色,临时结果用灰色斜体,用户一眼就能区分哪些是已确认的、哪些还在识别中。这种视觉分层是实时听写类应用的标配,少了它用户会困惑为什么文字在跳。

4.3 App 组件里的状态串联

App.jsx把 Hook 返回的状态和方法分发给子组件:

function App() { const { isListening, transcript, interim, error, start, stop } = useSpeechRecognition(); const handleToggle = () => { isListening ? stop() : start(); }; return ( <div className="app"> <h1>语音转文字</h1> <MicButton isListening={isListening} onToggle={handleToggle} /> {error && <p className="error-msg">{error}</p>} <Transcript transcript={transcript} interim={interim} /> </div> ); }

这里没有多余的状态,所有识别相关的状态都来自 Hook。handleToggle根据当前是否在监听来决定调 start 还是 stop。错误信息单独渲染,不混在结果里。整个组件树很浅,数据流向清晰,改起来不会牵一发动全身。

5. 避坑与常见问题:权限、兼容性与状态异常

5.1 麦克风权限被拒绝后无法再次弹窗

现象:用户第一次点了“拒绝”,之后再点开始按钮,没有任何反应,控制台报not-allowed。

原因:浏览器记住用户的拒绝决定,不会主动再次弹窗。recognition.start()在这种情况下直接触发onerror,不会重新请求权限。

解决:在 UI 上给出明确提示,引导用户去浏览器地址栏的权限设置里手动改回“允许”。代码层面监听not-allowed错误,展示对应的文案,而不是只显示一个通用错误。

5.2 Chrome 下必须加 webkit 前缀

现象:代码里写new SpeechRecognition(),在 Chrome 里报SpeechRecognition is not defined。

原因:Chrome 至今仍然只暴露webkitSpeechRecognition,标准名SpeechRecognition没挂到 window 上。

解决:创建实例前做兼容判断,const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;,拿不到就提示浏览器不支持。

5.3 连续识别模式下 onend 意外触发

现象:明明设了continuous = true,但用户停顿几秒后识别就停了,按钮状态也变回未监听。

原因:部分浏览器在检测到较长静音后仍会触发onend,continuous不是绝对保证。另外网络波动也可能导致识别引擎断开。

解决:在onend里判断如果用户仍处于“期望监听”状态,自动调recognition.start()重启。用一个shouldListenRef记录用户意图,避免用户主动停止后又被重启。

5.4 临时结果与最终结果重复显示

现象:一句话说完后,临时结果没有消失,和最终结果同时出现在页面上。

原因:onresult里只更新了transcript,没有清空interim。当isFinal为 true 时,对应的临时文本应该被移除。

解决:每次onresult处理完后,把interim设为当前批次里非最终结果拼接的文本。如果没有临时结果,就设为空字符串。这样最终结果出现时,临时文本自然消失。

5.5 组件卸载后麦克风仍被占用

现象:切换路由或关闭页面后,浏览器标签页上的录音指示器还亮着。

原因:SpeechRecognition实例没有在组件卸载时中止,识别引擎还在运行。

解决:在 Hook 的useEffect清理函数里调recognition.abort()。abort()会立即停止识别并释放麦克风,比stop()更彻底。

6. 二次开发改造:从能跑到好用

这份源码跑通之后,真正花时间的是把它改成符合自己业务的样子。我拿它做过几个小工具,下面说几个改动方向。

第一个是加“复制结果”和“清空”按钮。transcript就是个字符串,复制用navigator.clipboard.writeText,清空就是setTranscript('')。但要注意清空时如果还在识别中,interim也要一起清,否则会出现“最终结果没了但临时结果还在”的怪状态。我一般会在 Hook 里额外暴露一个reset方法,把两个状态一起重置。

第二个是支持多语言切换。recognition.lang可以在运行时修改,但修改后需要重新创建实例或者至少重新 start 才生效。我的做法是在 Hook 里把lang做成参数,用useEffect监听它的变化,变化时 abort 旧实例、创建新实例。这样切换语言时不会残留上一个语言的识别状态。

第三个是加时间戳。如果要做会议记录类的应用,每段最终结果需要带上时间。可以在onresult里finalText非空时,记录new Date().toLocaleTimeString(),把结果存成{ text, time }的数组而不是单个字符串。展示时按时间列表渲染。这个改动会影响transcript的数据结构,组件层要跟着调整。

第四个是错误重试。no-speech错误在安静环境下很常见,用户没说话就触发了。我一般会忽略这个错误,不打断监听状态,让识别继续。其他错误才真正停止并提示。这个判断放在onerror里,根据event.error的值分流处理。

改造点涉及文件关键改动
复制/清空Hook + App暴露 reset,加剪贴板调用
多语言切换Hooklang 作为参数,变化时重建实例
时间戳记录Hook + Transcripttranscript 改为对象数组
错误重试Hookonerror 里区分 no-speech

验证改动是否生效,我有个习惯:每次改完 Hook,先在控制台手动调start(),对着麦克风说三句话,看transcript是否完整、interim是否及时消失、isListening是否在 stop 后变回 false。这三项都对了,再去看 UI。从那以后我每次动识别相关的代码,都强制走一遍这个手动验证流程,比写单元测试还快。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询