React录音组件实战:基于wavesurfer.js与MediaRecorder实现波形录制与回放 最近我刚做完一个在线面试系统的前端模块里面有个绕不开的环节候选人答题时要录音而且要实时看到波形反馈录完还能在线回放、拖动进度条、重新录制。类似的语音笔记、客服质检、AI口语测评这类项目底层需求都是一样的——在浏览器里搞定录音、波形可视化和音频回放。我这次选择了 react-wavesurfer 这套组合来落地也就是在 React 项目里用 wavesurfer.js 做音频可视化配合 MediaRecorder 能力完成录音。这篇就把我从需求拆解到组件封装的完整过程讲清楚作为这个系列的第一篇后续还会继续聊回放、音频处理这些延伸话题。老规矩直接上干货。这篇内容不只给你能抄的代码更会把每一步的关键判断讲明白比如为什么选 wavesurfer.js 而不是纯手写 Canvas、组件边界怎么切、波形数据是从哪来的、哪些坑会反复踩。适合正在做音视频前端、或者准备在项目里接入录音功能的朋友。1. 为什么选 wavesurfer.js 来做录音波形组件1.1 需求倒推录音组件不只是一个按钮很多产品经理说“加个录音功能”的时候真实需求往往长这样默认点一下开始录音再点一下结束录音过程中要有波形在动让用户知道“麦克风是通的”如果中间说错了要能暂停和继续录完最好能直接预览不满意就重录最后把音频文件交给我上传到服务器。这些需求拆到最后前端要做的事就三件一是拿到麦克风音频流二是把音频流编码成文件三是把音频的波形实时画出来。第一件事和第三件事都绕不开浏览器原生能力第二件事靠 MediaRecorder 就能搞定。真正麻烦的是第三件事波形这东西如果纯手写你需要自己维护 AudioContext、AnalyserNode、Canvas 绘制循环还有各种浏览器差异工作量不小。wavesurfer.js 从一个音频播放可视化的库起家后来把录音也做进了官方插件体系。它在 React 项目里最大的价值就是帮我把音频可视化这件事整个接过去了我只需要关注业务逻辑和组件封装不需要再从零写绘制代码。1.2 wavesurfer.js 到底是干什么的先纠正一个常见的理解偏差wavesurfer.js 本身不是录音库。它本质上是一个基于 Web Audio API 和 Canvas 的音频波形引擎核心能力是把音频资源渲染成可交互的波形支持播放、进度控制、区域选择、分层渲染这些能力。在 v7 版本里官方引入了插件机制录音功能也被封装成了 RecordPlugin这才让“录音波形”变成了一条龙。这套设计的好处是边界清晰它的核心是给音频做可视化录音只是它插件体系中的一环。你在用的时候不需要在播放和录音之间来回切换方案一个 wavesurfer 实例两边都能干。比如录音结束之后它会把刚录好的音频塞回同一个实例里波形立刻变成可回放状态播放按钮、进度条这些交互直接复用一套逻辑。在 React 里用 wavesurfer.js也不是必须依赖第三方包装库。官方文档提供了 React 集成示例核心思路就一句话waveform 实例是命令式的需要在 useEffect 里创建在组件卸载时销毁容器节点通过 ref 交给它。自己封装一层 hooks 和组件比引入一个包装库可控得多版本更新也更灵活。1.3 三种主流录音方案对比我梳理一下我实际考察过的三种路线方便你做技术选型。第一种是直接用原生 MediaRecorder。优点是最轻量、不需要任何依赖录音流程就是 getUserMedia 拿流new MediaRecorder 开始录ondataavailable 收集数据onstop 合成 Blob。缺点是波形完全不管要自己实现可视化而且浏览器兼容性需要自己处理比如 Safari 对格式的支持和 Chrome 不一样。第二种是接入商业音频 SDK。这类 SDK 功能很全降噪、格式转换、云端存储都替你想好了但前提是你要接受它们的授权费用和闭源限制而且 React 项目的集成深度完全看文档写得怎么样。除非业务对音频处理要求特别高否则中小型项目接 SDK 显得有点重。第三种就是我这次采用的方案wavesurfer.js 自己的 React 封装。wavesurfer 负责波形渲染这一层MediaRecorder 负责最核心的录音编码React 负责组件生命周期和状态管理。整体依赖只有一个可视化可控录音能力完全基于 Web 标准后续要扩展波形标注、逐字对齐、区域裁剪这些高级能力wavesurfer 的插件和事件系统也能接得住。方案录音能力波形可视化React 集成扩展性成本原生 MediaRecorder有无手动封装一般免费商业音频 SDK全通常有依赖文档封闭授权费wavesurfer.js 方案插件支持原生可控封装高开源2. 动手前先把组件边界画清楚2.1 功能清单与使用场景枚举封装组件之前我习惯先列一张能力清单把所有能想到的使用场景过一遍不然后面加需求的时候会改得很狼狈。我这次明确要支持的能力是开始录音、暂停录音、继续录音、结束录音、录制时长限制、实时波形、录音结束后的回放波形、获取音频 Blob 和时长。这些是从基础业务里抽象出来的最小集。还有一类能力我选择不做进组件内部而是通过事件抛给外层。比如录制过程中的音量检测、录音结束后的自动上传、错误提示 UI。为什么这么切因为组件做得越纯粹复用性越高。有的业务场景要录 10 秒有的要录 3 分钟有的要录音结束后跳转下一步有的要立刻上传这些差异应该由使用方去处理而不应该写死在组件里。组件的边界最终是这样定的它接收一个容器节点管理录音从开始到结束的完整生命周期渲染实时波形和回放波形同时通过回调把关键事件通知出去。录音期间具体要显示什么按钮、录完的音频要干嘛组件不管。这个设计在后来新增“录音音量实时指示器”的时候帮了大忙我只需要在回调里拿到音量数据在组件外部叠加一层 UI 就行核心录音组件一行没改。2.2 对外 API 设计参数、事件、命令方法组件 API 我参考了常见的音频组件设计采用“配置参数 事件回调 命令式方法”的组合。配置参数负责样式和限制条件事件回调负责把内部状态同步给外部命令式方法让外部按钮可以触发录音操作。在 TypeScript 里大致长这样interface RecorderProps { maxDuration?: number; // 最长录制时长秒0 表示不限制 waveColor?: string; // 未录制区域的波形颜色 progressColor?: string; // 已录制部分的颜色 audioBitsPerSecond?: number; // 音频码率默认按浏览器设置 onStart?: () void; onPause?: () void; onResume?: () void; onStop?: (result: { blob: Blob; duration: number }) void; onError?: (error: Error) void; onProgress?: (time: number) void; // 录制进行中的时间回调单位秒 } interface RecorderHandle { start(): void; pause(): void; resume(): void; stop(): void; }事件回调是组件和外部通信的主通道。这里有个容易被忽略的细节React 的 state 更新是异步的如果你在回调里直接 setState而且回调在同一个事件循环里触发多次可能会出现 UI 更新跟不上内部状态的问题。我建议事件回调只负责“通知”不要在里面做复杂逻辑外层如果需要维护 UI 状态用 useRef 加 useReducer 会比单纯的 setState 更稳。命令式方法我通过 forwardRef 加 useImperativeHandle 暴露给父组件。React 里按钮状态由父组件控制所以这个 API 设计是合理的——父组件渲染按钮点击按钮调用对应方法内部状态通过事件回调解出来。之所以不用 props 里传 start 函数的方式是因为录音操作本身违反 React 的单向数据流习惯它更像一个命令式的、依赖时序的 API用 ref 暴露命令方法最贴合实际使用方式。2.3 状态机与生命周期设计录音组件的状态变化其实就是一个状态机状态之间有明确的流转关系初始是 idle点击开始变成 recording录制中可以暂停变成 paused也可以直接结束变成 stopped暂停后继续回到 recording。这状态如果不用状态机思维去管理只靠一堆布尔变量到处控制很容易出现“暂停后再停止没有触发结束事件”这种诡异 bug。我在组件内部用一个 ref 保存当前状态同时每次状态变化时触发对应的外部回调。为什么用 ref 而不是 useState因为 wavesurfer 的录音事件回调内部需要读当前状态来判断能不能执行某个操作如果用 useState在事件回调里拿到的可能是过期值因为闭包捕获了旧状态。ref 不存在这个问题它存的就是最新值。生命周期上最需要注意的是组件卸载。录音中的用户不会等录音结束再关页面所以卸载时必须有兜底逻辑如果还在录制中先主动停止录音拿到最后一个片段然后销毁波形实例把麦克风流释放掉避免麦克风指示灯一直亮着。这个我后面在踩坑章节会展开细说。3. 实时波形绘制的原理不是玄学3.1 两条技术线的分工MediaRecorder 与 Web Audio浏览器里做实时波形其实是两条技术线在并行工作。第一条线负责录音核心是 MediaRecorder。它接收麦克风流把音频数据按指定格式编码最终产出一个完整的音频 Blob。这条线管的是“怎么把声音存下来”。第二条线负责可视化核心是 Web Audio API。它把麦克风流通过 AudioContext 接入分析器节点 AnalyserNodeAnalyserNode 会不断计算当前音频片段的时域数据和频域数据。实时波形画的就是时域数据也就是每一帧采样点的振幅大小。这条线管的是“怎么把声音画出来”。两条线用的是同一个麦克风流所以不会不同步。你说话波形就动你闭嘴不说话波形就平。很多刚接触的人会有一个误区以为波形是录音结束后根据音频文件重新计算出来的实时波形也是这个思路。其实完全不是实时波形是每帧都在算、每帧都在画录音文件只是把同一份音频数据另外存了一份而已。3.2 RecordPlugin 内部做了什么wavesurfer.js v7 的 RecordPlugin 把这些底层逻辑封装好了。它在内部做了四件事调用 getUserMedia 拿麦克风流创建 AudioContext 和 AnalyserNode 分析音频把分析结果传给自身的波形渲染引擎逐帧绘制到 Canvas同时创建 MediaRecorder 开始录音编码。当调用 record.startRecording()插件会自动帮你完成上面这些都串起来。录制过程中波形会实时滚动、实时变化调用 stopRecording() 时它会停止 MediaRecorder把录音数据聚合成 Blob然后触发 record-end 事件。这里有个很好的设计录音结束后它会自动把刚录好的 Blob 加载回同一个 wavesurfer 实例波形从“实时录制模式”无缝切换成“静态回放模式”。所以使用它的体验是基本不需要碰音频流和 Canvas 逻辑只需要关注插件事件。我这次实际使用中只有两个场景需要自己动手绕过它一是要自定义音频设备比如选择某个外接麦克风二是要自定义录音参数比如强制指定某种 MIME 类型。这些可以通过 RecordPlugin.create 时传参数解决。3.3 如果不用插件手动实现实时波形有些场景下你未必想用 RecordPlugin比如你用的 wavesurfer 版本不包含该插件或者团队希望严格掌控所有音频流逻辑。那手动实现实时波形也很简单核心代码就几段。首先拿到麦克风流创建音频上下文和分析器const stream await navigator.mediaDevices.getUserMedia({ audio: true }); const audioContext new AudioContext(); const source audioContext.createMediaStreamSource(stream); const analyser audioContext.createAnalyser(); analyser.fftSize 2048; source.connect(analyser);接下来在 requestAnimationFrame 循环里读取时域数据并画到 Canvas 上。时域数据用 getByteTimeDomainData 方法获取它会返回一个 Uint8Array每个值代表当前采样点的振幅const bufferLength analyser.fftSize; const dataArray new Uint8Array(bufferLength); function draw() { requestAnimationFrame(draw); analyser.getByteTimeDomainData(dataArray); // 获取画布上下文把 dataArray 里的点连成线 // 这就是一帧波形 } draw();这段链路理解之后再去看 wavesurfer 的实时波形就会觉得它不过是把这段逻辑优化升级了加了平滑处理、颜色渐变、交互事件。原理是一样的。这也是为什么我推荐即使你家里有现成组件也应该先搞清楚这段底层链路——真出问题的时候你不会因为手头有组件就不需要理解了。4. 实操React 里一步步封装出录音组件4.1 安装依赖与初始化进入实操环节。首先安装依赖只需要一个 wavesurfer.jsnpm install wavesurfer.js # 或者 yarn add wavesurfer.js安装完成后在组件文件里导入import WaveSurfer from wavesurfer.js; import RecordPlugin from wavesurfer.js/dist/plugins/record.js;需要注意 wavesurfer.js v7 的插件是独立模块必须单独导入不能只导入主包就期望 RecordPlugin 可用。另外不同版本之间插件路径可能有变化如果你用的是 6.x 或者其他版本先看一眼 node_modules 里的实际目录结构确认路径存在再写导入语句。初始化 wavesurfer 实例时有几个参数我建议重点调一下height 控制波形高度barWidth 和 barGap 控制柱状波形的视觉宽度和间距interact 设为 false 可以避免录音过程中不小心拖动波形导致的奇怪交互waveColor 和 progressColor 分别控制未录制和已录制的颜色。这些参数直接影响用户体验值得花时间微调。4.2 利用 RecordPlugin 完成录音与波形核心逻辑初始化核心逻辑先创建一个 wavesurfer 实例再用 RecordPlugin.create 挂载录音能力。这里的关键点是把握住事件的订阅时机。完整的基础逻辑如下function createRecorder(container, callbacks) { const ws WaveSurfer.create({ container, height: 100, waveColor: #a8b1c0, progressColor: #3d7eff, barWidth: 2, barGap: 1, barRadius: 2, interact: false, }); const record RecordPlugin.create(ws); record.on(record-start, () callbacks.onStart?.()); record.on(record-pause, () callbacks.onPause?.()); record.on(record-resume, () callbacks.onResume?.()); record.on(record-end, (blob) { const duration record.getCurrentTime?.() || 0; callbacks.onStop?.({ blob, duration }); }); record.on(record-progress, (time) callbacks.onProgress?.(time)); ws.on(error, (err) callbacks.onError?.(err)); return { ws, record }; }有几个细节要说明。record-end 事件里给出的 blob 就是最终录音文件类型通常是浏览器默认支持的格式比如 Chrome 下是 audio/webm。如果你想拿时长可以像上面那样在回调里尝试从实例取或者在 onProgress 里自己累计时间。onProgress 回调返回的 time 参数是秒这是我在实操中确认过的。wavesurfer 实例的 error 事件也要监听尤其是麦克风权限异常时错误会通过这里抛出来。如果你不监听 error浏览器可能直接抛出 unhandled error排查起来会比较痛苦。4.3 写一个可复用的 Recorder React 组件接下来把上面的逻辑封装成 React 组件。核心代码里ref 的运用和 useEffect 的清理逻辑是重中之重。import React, { forwardRef, useEffect, useImperativeHandle, useRef } from react; import WaveSurfer from wavesurfer.js; import RecordPlugin from wavesurfer.js/dist/plugins/record.js; const Recorder forwardRef((props, ref) { const { maxDuration 0, waveColor #a8b1c0, progressColor #3d7eff, onStart, onPause, onResume, onStop, onError, onProgress, } props; const containerRef useRef(null); const wsRef useRef(null); const recordRef useRef(null); const stateRef useRef(idle); useEffect(() { if (!containerRef.current) return; const ws WaveSurfer.create({ container: containerRef.current, height: 100, waveColor, progressColor, barWidth: 2, barGap: 1, barRadius: 2, interact: false, }); const record RecordPlugin.create(ws); record.on(record-start, () { stateRef.current recording; onStart?.(); }); record.on(record-pause, () { stateRef.current paused; onPause?.(); }); record.on(record-resume, () { stateRef.current recording; onResume?.(); }); record.on(record-end, (blob) { stateRef.current stopped; onStop?.({ blob, duration: 0 }); }); record.on(record-progress, (time) { if (maxDuration 0 time maxDuration) { record.stopRecording(); } onProgress?.(time); }); ws.on(error, (err) onError?.(err)); wsRef.current ws; recordRef.current record; return () { if (recordRef.current?.isRecording()) { recordRef.current.stopRecording(); } ws.destroy(); wsRef.current null; recordRef.current null; }; }, []); useImperativeHandle(ref, () ({ start: () recordRef.current?.startRecording(), pause: () recordRef.current?.pauseRecording(), resume: () recordRef.current?.resumeRecording(), stop: () recordRef.current?.stopRecording(), getState: () stateRef.current, })); return div ref{containerRef} classNamerecorder-waveform /; }); export default Recorder;细看这段代码里的关键点。useEffect 的依赖数组是空的意味着 wavesurfer 实例只在组件挂载时创建一次。如果你把 waveColor 或 progressColor 作为依赖传入组件重渲染时就会重新创建实例导致波形闪烁、录音中断这是新手最容易踩的坑。props 里的事件回调函数在每次父组件渲染时都可能是新引用但组件内部订阅的是第一次渲染时的函数。如果你的回调里依赖了外部状态就会读到过期值。解决方法是把回调函数用 ref 维护在每次渲染时更新 ref 的 current 指向。我在项目里加了一个回调 ref 的辅助逻辑这里为了展示核心思路没有展开建议真正上线的代码加上。4.4 暂停、继续、停止、上传的完整交互流组件封装好后外部使用就很简单了。父组件里维护一个录音状态渲染操作按钮按钮点击时调用 ref 上的方法同时监听回调更新状态。function VoiceRecorderPage() { const recorderRef useRef(null); const [state, setState] useState(idle); const [time, setTime] useState(0); const handleStop async ({ blob }) { setState(stopped); // 上传逻辑 const formData new FormData(); formData.append(file, blob); await fetch(/api/upload, { method: POST, body: formData }); }; return ( div Recorder ref{recorderRef} maxDuration{60} onStart{() setState(recording)} onPause{() setState(paused)} onResume{() setState(recording)} onStop{handleStop} onProgress{(t) setTime(t)} / div classNamecontrols {state idle ( button onClick{() recorderRef.current.start()}开始录音/button )} {state recording ( button onClick{() recorderRef.current.pause()}暂停/button button onClick{() recorderRef.current.stop()}结束/button / )} {state paused ( button onClick{() recorderRef.current.resume()}继续/button button onClick{() recorderRef.current.stop()}结束/button / )} {state stopped button onClick{() recorderRef.current.start()}重新录制/button} /div div录制时长{formatTime(time)}/div /div ); }这个交互流里暂停和继续是一对容易混淆的操作。暂停状态时麦克风仍然被占用录制会话没有结束只是不写入数据继续则重新开始写入。结束则完全不同它会把所有已录制的数据打包成 Blob释放麦克风资源。产品上如果要实现“放弃本次录音”直接把组件卸载并重新挂载即可。录音结束时回调里的 blob你可以直接拿去上传。如果后端需要固定的格式建议前端先不做格式转换让后端处理。因为浏览器端把 webm 转成 mp3 需要额外的解码库和计算时间对性能不友好。5. 高频踩坑与排查实录5.1 麦克风权限申请拒绝与 HTTP 限制最常遇到的开局问题就是麦克风权限拿不到。浏览器对 getUserMedia 有严格限制只有在 HTTPS 或者 localhost 环境下才能正常申请麦克风权限。如果你的页面跑到一个 HTTP 的内网 IP 上浏览器会直接抛 NotAllowedError产品在演示环境报这个错误的时候真的让人摸不着头脑。排查思路是按顺序看三层第一层看页面环境是不是 HTTPS第二层看用户有没有在地址栏左边或系统设置里把麦克风权限关掉第三层看代码里有没有同时请求多个权限导致浏览器弹窗异常。如果用户真的拒绝了权限你要给一个友好的提示并告诉他怎么在浏览器设置里恢复权限。这里有个小技巧拒绝后再次调用 getUserMedia 不会自动弹出授权窗口需要引导用户手动去站点设置里改这个交互必须做引导。5.2 Safari 与 iOS 的格式兼容处理Safari 是录音功能绕不过去的坎。Chrome 和 Firefox 默认录音编码是 audio/webm而 Safari 对 webm 的支持很差它优先输出 audio/mp4。如果你不管格式直接上传后端是 FFmpeg 转码的话可能没事但如果后端写死了只接受 webm你会在联调的时候收到一堆问号。处理方式分两层。选择编码时先探测浏览器支持的类型const mimeType MediaRecorder.isTypeSupported(audio/webm;codecsopus) ? audio/webm;codecsopus : MediaRecorder.isTypeSupported(audio/mp4) ? audio/mp4 : ;把探测结果传给 RecordPlugin 或直接作为 MediaRecorder 的 mimeType 参数。然后后端必须准备两套格式的解析能力因为前端很难做到所有浏览器都统一输出一个格式。如果必须在后端统一建议接一层转码服务而不是指望前端做转换。Safari 的 MediaRecorder API 本身也在不断完善中但兼容问题会在相当长时间里存在做录音功能前要先确认目标用户群里有没相当比例的 Safari 用户。5.3 录音文件体积异常与编码选择录音文件过大是另一个常见问题。原因是 MediaRecorder 默认的音频码率可能偏高一段几分钟的录音可能产生几十 MB 的文件上传体验极差。解决方法是设置音频码率。在 RecordPlugin 初始化时可以传 audioBitsPerSecond 参数比如设置成 128000128 kbps就能把音质和体积控制在一个比较平衡的位置。对于纯人声语音64 kbps 到 96 kbps 通常已经足够听清128 kbps 算是一个比较宽裕的默认值。如果你做的是音乐类录制才需要更高的码率。另外一个建议是录音时不光要拿到 Blob还要注意按时长切分。如果录音可能非常长MediaRecorder 的 ondataavailable 事件在默认情况下只在停止时触发一次这意味着整个录音期间的数据全部堆积在内存里。你可以给它传一个 timeslice 参数比如 1000 毫秒让数据分片触发这样既能控制内存峰值也方便做实时上传或者静音检测。5.4 React 生命周期清理声音还在响、麦克风还亮着这是我被问得最多的问题组件卸载了浏览器标签页上的麦克风图标还亮着甚至还能听到声音。原因很简单组件卸载时没有把录音资源和音频流释放干净。wavesurfer 的 destroy 方法确实会释放一部分资源但如果你启用了录音必须在 destroy 之前先停止录音。我建议在 useEffect 的 cleanup 里按固定顺序执行先判断当前是否在录制是的话调用 stopRecording 把最终数据交付出去再调用 ws.destroy 销毁实例最后如果有自己创建的 AudioContext要显式调用 audioContext.close()因为 AudioContext 不会被自动回收它会持续占用系统资源。在 React 18 开发模式下有个额外大坑StrictMode 会让 useEffect 在开发环境执行两次挂载、卸载、再挂载。如果你的清理逻辑不完整第二次挂载时可能会报 “WaveSurfer instance is already destroyed” 之类的错误。真正的解决方案是把清理逻辑写到严谨保证销毁后可以重建。不要为了避开 StrictMode 而关掉它那是为了帮你暴露这些隐患不是用来给你添堵的。5.5 波形不显示、容器重叠、StrictMode 重复挂载排查最后一类问题集中在波形显示上。最常见的根源是容器节点还没挂载就初始化了 wavesurfer。比如你用了条件渲染最初容器节点不存在useEffect 里当然拿不到 ref。解决方法是保证容器始终在 DOM 里通过 CSS 控制显隐或者用回调 ref在节点真正挂载时才去初始化录音实例。另一个容易被忽略的问题是容器高度为 0。wavesurfer 初始化时如果容器没有明确高度或者父容器是 flex 布局且没有给子元素高度波形会渲染不出来或者渲染得乱七八糟。给容器加一个固定的 height 样式或者在初始化参数里设置 height都是有效的做法。还有一类是 canvas 重叠导致的黑块或模糊。这是因为 wavesurfer 实例重复创建每次创建都会往容器里插入新的 canvas旧 canvas 没有被清理。检查一下是不是因为依赖数组写错导致组件每次渲染都重新执行初始化。实例重复创建还会带来严重的内存泄漏这条排查经验建议写进你们团队的代码评审清单里。现象常见原因排查重点波形不出来容器节点未挂载或高度为 0检查 ref 时机、容器 CSS波形黑块/模糊实例重复创建、canvas 残留检查 useEffect 依赖数组、cleanup 逻辑麦克风一直亮实例销毁前没停止录音检查 cleanup 顺序录音格式不兼容Safari 输出 mp4、后端只接 webm先探测支持格式后端兼容多种格式文件体积过大默认码率太高设置 audioBitsPerSecond最后分享一个小建议。录音组件这种功能尽量在项目里维护一层自己的封装而不是把初始化逻辑直接写在业务组件里。原因很简单你永远不知道产品经理下次会加什么需求。我这个组件最初只要求录个音后来陆续加了最长时长限制、暂停继续、波形回放、音量检测、毫秒级时间戳如果没有在一开始就设计好事件回调和命令式接口早就改吐了。react-wavesurfer 这个方向我后续还会继续整理回放波形与逐字定位、音频上传的队列与重试、以及不同格式音频的归一化处理等这些实践再稳定一点就来更新。