去年做的一个移动端项目里,带有一个仿微信语音聊天的 H5 模块,我到现在都还记得那段时间被录音兼容性支配的感觉。这个需求表面看很简单:按住说话、松开发送、点击播放,几个交互而已。但真正动手之后才发现,麦克风权限、录音格式、浏览器自动播放限制、触摸事件冲突、滚动性能这些坑,每一个都能让功能“看起来能用,实体验很拉垮”。
这篇内容把我实际落地的方案完整拆开讲,包括交互状态机设计、getUserMedia 与 MediaRecorder 的用法、按住说话的手势处理、播放器状态管理、跨端格式兼容,以及最后整理出的一份真实问题排查清单。无论是你在做在线客服、社区聊天,还是企业内部沟通工具,只要涉及 H5 语音消息,应该都能直接参考。
1. 功能拆解与技术方案选型
1.1 还原微信语音的交互状态机
微信语音表面上是一个气泡加一段录音条,但拆开之后至少有六个状态:空闲、准备录音、正在录音、准备取消、正在播放、播放结束。每一个状态都对应一套 UI 和一套数据。做 H5 版本之前,我习惯先把状态机画出来,而不是直接写触摸事件。
我在项目里定义的状态机是这样的:默认是 IDLE,用户按下录音按钮进入 RECORDING,此时如果手指滑出取消区则进入 WILL_CANCEL,松开后回到 IDLE 并丢弃录音;如果正常松开则进入 SENDING,把音频传给服务端并发送消息;消息落地之后气泡处于 UNREAD 或 READ 状态,点击气泡进入 PLAYING,播放自然结束回到 IDLE。
这个状态机的好处在于,所有 UI 变化都由状态去驱动,而不是在 touchstart、touchmove、touchend 里一遍遍改 class。后来排查“录音断了但界面还停在录音中”的问题时,我第一时间就是去查哪个分支状态没有正确回退,很快就能定位。如果一开始就散乱地写事件回调,这种问题会变得极难追踪。
1.2 H5 方案的原委与场景限制
有人会问,既然要“仿微信”,为什么不用原生 SDK。原因很简单,我们只需要在微信内置浏览器以及其他第三方浏览器里打开一个 H5 页面就能完成语音对话,不想为了这一个功能单独下载 App,也没有原生开发资源投入。H5 的边界就在这里:浏览器只给你一个录音能力和一个播放能力,其余的交互逻辑全部要靠自己堆。
移动端 H5 仿微信语音功能的核心 API 很明确:navigator.mediaDevices.getUserMedia采集麦克风音频,MediaRecorder把音频流编码成文件,HTMLAudioElement负责播放。现代安卓 WebView 和 iOS Safari 对这三者的支持已经比较完整,不过具体的编码格式和事件行为仍有不少差异,这一点后面会专门讲。
1.3 技术路线对比:纯 H5、原生混合、服务端转码
这块我在选型时做了个快速对比,本质上不是哪个技术先进,而是哪种方式能最快把体验做到可接受:
| 方案 | 录音端 | 上传格式 | 播放端 | 优点 | 缺点 |
|---|---|---|---|---|---|
| H5 MediaRecorder 录音 | 浏览器 | webm/opus 或 mp4 | H5 播放 | 不用装 SDK,开发快 | 格式兼容性要处理 |
| 原生录音 + H5 播放 | 原生 SDK | aac/mp3 | H5 播放 | 音质、格式可控 | 需要原生开发配合 |
| H5 录音 + 服务端转码 | 浏览器 | 原始格式 | H5 播放 | 服务端统一处理 | 转码链路长,实时性差 |
我最终选择了第一个方案,也就是纯 H5 MediaRecorder,重点解决格式兼容问题。录音端统一采集,前端负责触摸交互和上传,服务端只做存储和消息分发,不参与音频处理。整个链路最短,问题最容易定位。服务端转码这条路我评估过,适合用户量特别大、产品形态复杂的场景,但对一个聊天模块来说过早优化了。
1.4 为什么坚持用 Web API 而不是录音插件
市面上也有一些 H5 录音插件,比如基于 Flash 的旧方案早就废了,还有通过 WebSocket 把音频流传给服务端处理的方案,但这些都增加了一层代理,出问题的时候很难判断是插件的问题还是自己的问题。浏览器原生提供的 getUserMedia 和 MediaRecorder 虽然 API 不算友好,但胜在无依赖、可控性高。
这一点在处理线上 bug 时特别明显。原生 API 出了问题,我可以直接在浏览器调试工具里看错误信息,也可以写一段最小复现代码快速验证。用第三方的封装,遇到问题就得先去翻它的源码,时间成本完全不一样。所以我一般建议团队里如果只是做语音消息这类中等复杂度的功能,原生 API 完全够用。
2. 录音能力适配与实现详解
2.1 权限申请:首次进入就申请,还是按需申请?
亲测的结论是:按需申请,但只给用户一个明确的引导层。微信语音按钮在输入框附近,用户第一次点“按住说话”时,先弹一个引导层,上面写明“需要使用麦克风录制语音消息”,然后再触发 getUserMedia。这样用户有心理预期,不会觉得浏览器莫名其妙要权限。
为什么不能一进页面就申请?不是因为技术上做不到,而是体验上太冒犯。很多用户点进来只是想看看消息列表,结果先弹一个麦克风权限框,大概率会点拒绝。而且一旦拒绝,浏览器会把该域的麦克风权限记成“拒绝”,再想重新请求时,很多浏览器会直接不弹窗,开发者只能引导用户去设置页手动开启,非常被动。
获取权限的标准写法:
async function requestMicAccess() { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { throw new Error('当前浏览器不支持麦克风采集'); } const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1 } }); return stream; }这里我特意开了回声消除和降噪,实测在嘈杂环境下录出来的声音比不开降噪的明显干净一些。channelCount 设置成 1,也就是单声道,后面生成的音频文件会小不少,对语音消息这类场景完全够用。如果你是做音乐类产品,那另说,单声道肯定不够,但语音消息的核心目标是“听清楚人声”,不需要立体声。
有一点必须提醒:getUserMedia 要求页面是 HTTPS 环境。本地开发用 localhost 可以,但一旦上真机联调,就必须挂证书或者用内网穿透工具把测试环境变成 HTTPS。否则权限请求压根不会触发,这是很多人第一次真机调试时最容易懵的地方。我在某个项目里就遇到过一次,代码逻辑完全没错,但总是报undefined is not an object,最后发现是因为测试环境是 HTTP。
2.2 MediaRecorder 初始化:格式、码率与兼容检测
拿到 stream 之后,就可以创建 MediaRecorder 实例了:
let recorder = null; let chunks = []; let startTime = 0; let timer = null; function startRecording(stream) { chunks = []; const options = { audioBitsPerSecond: 32000 }; // 优先选浏览器默认支持的格式,再手动指定 if (typeof MediaRecorder !== 'undefined') { const mimeType = pickSupportedMimeType(); if (mimeType) { options.mimeType = mimeType; } } recorder = new MediaRecorder(stream, options); recorder.ondataavailable = (e) => { if (e.data && e.data.size > 0) { chunks.push(e.data); } }; recorder.onstop = handleRecordingComplete; recorder.start(); startTime = Date.now(); startTimer(); } function pickSupportedMimeType() { const candidates = [ 'audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/aac' ]; for (let i = 0; i < candidates.length; i++) { if (MediaRecorder.isTypeSupported(candidates[i])) { return candidates[i]; } } return ''; }audioBitsPerSecond 我选了 32000,也就是 32kbps。语音消息不需要高保真,32kbps 的 opus 编码已经完全能听清人声,而且一段 60 秒的录音大概只有 240KB,比 128kbps 的版本小四倍。如果你的场景对音质要求高,可以提升到 64kbps,但一般情况下没必要,低码率可以明显降低上传压力和 CDN 成本。
分段逻辑上,我没有用 timeslice 参数,而是让录音结束时统一触发 ondataavailable。微信语音是“按住录音,松开发送”,录音时长通常在 1 秒到 60 秒之间,一次请求生成一整段音频即可,不需要分片上传。如果真的要录很长的音,可以用recorder.start(1000),让每秒钟产生一个 Blob 分片,这样中途崩溃也能抢救一部分数据。不过 60 秒以内的语音,分片不会带来实际收益。
2.3 结束录音生成文件并上传
停止录音的代码要考虑优雅停止,MediaRecorder 的 stop 是异步的,必须等到 onstop 回调里才能拿到完整数据:
function stopRecording() { if (!recorder || recorder.state === 'inactive') { return; } recorder.stop(); stream.getTracks().forEach((track) => track.stop()); // 一定要释放麦克风 } function handleRecordingComplete() { const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' }); const duration = Math.round((Date.now() - startTime) / 1000); const url = URL.createObjectURL(blob); // 后续处理:上传、生成气泡、记录时长 }最容易被忽略的是stream.getTracks().forEach(track => track.stop())。如果不手动停掉麦克风 track,页面上会一直显示“正在使用麦克风”,部分手机顶部还会出现红色录音标识,很尴尬。而且不释放的话,下一次再 getUserMedia 有可能因为占用冲突而失败。我见过有同事开发时只调了recorder.stop(),结果连着录几条消息之后麦克风就再也打不开了,其实就是 track 没释放导致资源被占满。
2.4 录音中的实时时长反馈
除了拿到最后的音频,按住说话期间还要给用户实时反馈。微信的做法是录音时按钮区域变化、显示计时条、上滑显示取消提示。我这边用 setInterval 每秒更新一次录音时长:
function startTimer() { stopTimer(); timer = setInterval(() => { const seconds = Math.floor((Date.now() - startTime) / 1000); updateRecordingUI(seconds); }, 500); } function stopTimer() { if (timer) { clearInterval(timer); timer = null; } }这里间隔设 500ms 而不是 1000ms,是为了让秒数跳变更平滑。虽然最终显示的整数秒是一样的,但 500ms 的刷新节奏让计时数字看起来更跟手,代价只是多几次 setInterval 回调,可以忽略。
2.5 录音格式的跨端问题与处理策略
移动端浏览器的 MediaRecorder 支持格式不统一。Android Chrome 和多数安卓 WebView 输出audio/webm;codecs=opus,iOS Safari 则输出audio/mp4或audio/aac。这两个格式不能跨端播放,安卓录的 webm 在 iOS 上基本放不出声音,iOS 录的 mp4 在部分安卓 WebView 里也播放不了。
这个问题有两条解决路径:
路径一:统一前端采集 PCM,再编码成 WAV。用AudioContext的AudioWorklet或ScriptProcessorNode拿 PCM 原始数据,自己拼 WAV 文件。这个方案兼容性最好,WAV 在两端都能放,但代码量很大,而且 AudioWorklet 在 iOS 老版本上支持不完整。
路径二:服务端转码。MediaRecorder 拿到 blob 上传给服务端,服务端统一转成 mp3 或 aac 再落库。这个方案实现成本最低,只需在后端加一个转码接口。缺点是上传的文件格式多样,后端要能识别 mp4、webm、opus 等不同容器。
我实际采用路径二。录音 blob 上传时带上 sourceType,后端按格式转成 mp3 落库,前端播放统一用 mp3。这样所有端播放格式一致,音质稳定,排查起来也很省心。路径一适合没有服务端转码条件、纯前端实现的场景,但代价是代码复杂度会上升一个量级。
3. 按住说话与滑动手势交互落地
3.1 事件选型:touch 事件而不是 click
微信语音的核心交互是按住说话、松开发送、上滑取消,这个只能用 touch 事件,click 做不到按住的效果。需要注意的是 touch 和 click 会同时触发,所以要在 touchstart 里调用preventDefault(),或者在 touchend 里屏蔽后续的合成 click。否则会出现一个很典型的 bug:用户按住说话时,不仅触发了录音,还在松开后触发了一次点击事件,把录音按钮点成了“切换到文字输入模式”。
我封装了一个 PressRecorder 类,管理按下、移动、释放三个动作,并对外提供回调:
class PressRecorder { constructor(options) { this.button = options.button; this.startCallback = options.onStart; this.cancelCallback = options.onCancel; this.sendCallback = options.onSend; this.uncancelCallback = options.onUncancel; this.cancelDistance = options.cancelDistance || 80; this.startY = 0; this.cancelled = false; this.longPressStarted = false; } bind() { this.button.addEventListener('touchstart', this.handleTouchStart.bind(this)); this.button.addEventListener('touchmove', this.handleTouchMove.bind(this)); this.button.addEventListener('touchend', this.handleTouchEnd.bind(this)); this.button.addEventListener('touchcancel', this.handleTouchCancel.bind(this)); } handleTouchStart(e) { e.preventDefault(); this.startY = e.touches[0].clientY; this.cancelled = false; this.longPressStarted = true; this.startCallback && this.startCallback(e); } handleTouchMove(e) { if (!this.longPressStarted) return; const moveY = e.touches[0].clientY; const distance = this.startY - moveY; if (distance > this.cancelDistance) { if (!this.cancelled) { this.cancelled = true; this.cancelCallback && this.cancelCallback(e); } } else if (this.cancelled) { this.cancelled = false; // 手指又滑回录音区域,需要恢复录音中的 UI this.uncancelCallback && this.uncancelCallback(e); } } handleTouchEnd(e) { if (!this.longPressStarted) return; this.longPressStarted = false; if (this.cancelled) { this.cancelCallback && this.cancelCallback(e); } else { this.sendCallback && this.sendCallback(e); } } handleTouchCancel() { this.longPressStarted = false; this.cancelCallback && this.cancelCallback(); } }这里上滑取消的判断用了 startY 和当前 clientY 的差值,也就是手指向上滑动的距离,没有依赖按钮自身的坐标区域。微信内部其实还会判断手指是否滑出按钮区域,但简化成距离阈值已经够用。阈值我设为 80px,因为不同屏幕尺寸下绝对坐标差会很大,按距离算更通用。
3.2 触摸交互的两个坑
第一个坑是 touchcancel。来电、系统通知下拉、指纹支付、页面切后台这些场景,浏览器会触发 touchcancel 而不是 touchend。如果只在 touchend 里处理发消息,这些场景会造成录音一直不结束、界面一直卡在录音中的 bug。所以我单独处理了 touchcancel,统一按取消处理,并且确保所有状态都回退到 IDLE。
第二个坑是极快速的点击。用户有时候只是在按钮上快速点一下,间隔不到 200ms。这种操作不应该真的开始录音,否则会生成一堆 0.1 秒的无效语音。我加了一个最小录音时长判断,长按不足 300ms 就不发送,UI 上提示“说话时间太短”。
function handleTouchEnd() { const duration = Date.now() - pressStartTime; if (duration < 300) { resetRecording(); showToast('说话时间太短'); return; } stopAndSend(); }3.3 录音中的视觉反馈与页面滚动处理
录音态我做了整套 UI:录音按钮切换到按下态,上方浮出录音面板,面板里是一个麦克风图标和计时器,手指上滑时图标变成取消样式,并伴随轻微震动感。这个震动在支持navigator.vibrate的安卓机上做了,iOS 不支持也没关系,只是增强反馈而已。
面板的显示和隐藏用 transition 过渡,录音面板用一个全屏透明的遮罩层包住。为什么用全屏遮罩?因为要保证手指在屏幕上滑动时,移动端浏览器不会因为触发了页面滚动而打断 touch 事件。遮罩层配合 touchmove 的 preventDefault,能保证整个滑动过程完全可控。
实际编码时要注意,preventDefault 不能加在 document 上,否则可能把整个页面的滚动都禁掉。我是在录音面板的触摸区域上统一处理:
recordMask.addEventListener('touchmove', (e) => { if (isRecording) { e.preventDefault(); } }, { passive: false });passive: false 是关键。现代浏览器默认把 touchmove 监听器当成 passive,也就是告诉浏览器“我不会阻止默认行为”,这样可以提升滚动性能。但如果我要 preventDefault,就必须显式声明 passive: false,否则浏览器会直接忽略 preventDefault,页面照样滚动,取消滑动手势就会失效。
4. 语音播放、消息气泡与未读状态管理
4.1 点击播放和浏览器自动播放限制
播放部分,一开始我以为直接用new Audio(url).play()就完事了,结果在移动端踩了自动播放限制的坑。浏览器要求用户必须在页面做了点击等手势之后,才能播放带声音的媒体。我们的场景是“点击气泡才播放”,这本身满足用户手势触发的条件,所以正常点击是人没有问题的。麻烦的是消息列表里有一堆语音,用户连续点击第一条、第二条、第三条的时候,中间的暂停和再播放并不是每次都有新的手势,有些浏览器会把第二、第三次 play 当成非手势触发而拦截。
我的解决办法是复用一个 Audio 实例,而不是每条消息 new 一个:
const audioPlayer = new Audio(); let currentMessageId = null; function playVoice(message) { if (currentMessageId === message.id) { if (!audioPlayer.paused) { audioPlayer.pause(); setMessageState(currentMessageId, 'idle'); return; } } if (audioPlayer.src) { audioPlayer.pause(); } audioPlayer.src = message.audioUrl; audioPlayer.play().catch(() => { // 自动播放被拦截,提示用户再次点击 showToast('再次点击播放语音'); }); currentMessageId = message.id; setMessageState(currentMessageId, 'playing'); }复用同一个播放器实例的另一个好处是,当用户点击新的一条语音时,旧那条一定会停止,不会出现两条语音同时响的诡异状态。用户点击正在播放的语音,一般约定是暂停,这也是上面代码里先判断 currentMessageId 的原因。如果后续需要做“连续播放多条语音”的功能,这个单例播放器也更容易扩展,只要在 ended 事件里找下一条即可。
4.2 播放过程中的音频事件处理
播放状态必须要监听 audio 的 timeupdate 和 ended。timeupdate 用来驱动播放动画,ended 用来把动画归位并标记已读状态。
audioPlayer.addEventListener('timeupdate', () => { if (currentMessageId) { const currentTime = audioPlayer.currentTime; const duration = audioPlayer.duration; updateWaveAnimation(currentMessageId, currentTime, duration); } }); audioPlayer.addEventListener('ended', () => { if (currentMessageId) { setMessageState(currentMessageId, 'idle'); markAsRead(currentMessageId); currentMessageId = null; } });还有个坑:iOS 上duration在音频刚加载完时可能返回 NaN,要等loadedmetadata事件之后再获取。如果拿到 NaN 就去计算播放进度,会导致气泡动画闪一下然后卡住。我习惯把 duration 保存到 message 对象里,录音结束时就已经拿到了时长,播放进度直接用这个值,不依赖 audio.duration。这样即使 metadata 加载慢,动画也不会受影响。
4.3 气泡宽度换算与播放动画
微信语音气泡的宽度跟时长正相关,大概范围是 80px 到 200px。换算我用了线性比例:1 秒以内取最短宽度,60 秒以上封顶,中间每秒增加约 2px。具体公式:
function getBubbleWidth(duration) { const min = 80; const max = 200; const ratio = 2.2; const width = min + duration * ratio; return Math.min(max, Math.max(min, Math.round(width))); }播放动画我对比过两种方案。第一种是纯 CSS 动画,把一段播放中的音节图标无限循环左右跳动;第二种是用 CSS 变量或 JS 驱动一个填充进度条。微信那种双音节跳动在低端机上如果使用 JavaScript 频繁修改样式,性能会比较差。我最终使用 CSS animation,动画帧限定在 transform 和 opacity 上,性能会好很多。进度条则是用 transform: scaleX 实现,从 0 渐变到 1,同样避免 reflow。
4.4 未读小红点的本地标记
未读状态不需要后端参与太多,只要一条消息收到时默认 unread,播放结束后标记为 read,并同步到本地 storage。如果后端也要做已读回执,则在 ended 事件里调 markRead(messageId) 接口即可。
function markAsRead(messageId) { const key = `voice_read_${sessionId}`; const readMap = JSON.parse(localStorage.getItem(key) || '{}'); readMap[messageId] = true; localStorage.setItem(key, JSON.stringify(readMap)); // 同时更新页面上的气泡小红点 const bubble = document.querySelector(`[data-message-id="${messageId}"]`); if (bubble) { bubble.classList.remove('unread'); } }这里有一点要注意,不要用 index 下标当消息的唯一标识。消息列表一旦有删除、插入操作,下标会变,很容易出现“上一条消息被标记成已读”的错乱。我在实际开发中遇到过,后来统一改用服务端返回的消息 ID 作为 key,问题才彻底解决。
5. 兼容性、性能优化与问题排查记录
5.1 最大兼容性坑:录音格式的浏览器差异
前面提过,MediaRecorder 在 Android 上输出 webm/opus,在 iOS 上输出 mp4,这两种格式不能互换播放。我在这里再补充一个真实排查过程,方便你踩到时能快速判断。
现象是:安卓用户录完音发给 iOS 用户,iOS 用户点击语音气泡,进度条在走,但完全没有声音。iOS 用户录的音发给安卓,部分安卓机型同样没声,部分机型能放但只有沙沙声。
当时我先用浏览器控制台检查 audio 元素的 error 事件,发现报的是MEDIA_ELEMENT_ERROR: Format error,基本可以确认是格式不兼容。后来在后端加了转码接口后,统一把音频转成 mp3,这个线上问题就消失了。所以我的建议是:如果你的消息可能会在多个端之间互通,不要在 H5 层硬扛格式问题,去后端加一层转码是最省事的。
5.2 典型问题排查速查表
我把开发过程中反复出现过的问题整理成了表格,方便团队新人排查:
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 点击录音没反应 | 页面不是 HTTPS,或麦克风权限被拒绝 | 确认 HTTPS,重新引导授权 |
| 录音前 0.5 秒没声音 | 部分浏览器在 track 激活后才开始采集 | 录音前先 getUserMedia,延迟 start 100ms |
| iOS 静音开关拨到静音后播放没声音 | 浏览器遵循静音开关 | 播放时提示用户关闭静音,或用 Web Audio 播放(有限支持) |
| 录音时手机自动熄屏 | 录音未阻止休眠 | 录音中请求 Wake Lock API |
| 上滑取消时页面跟着滚动 | 未阻止 touchmove 默认行为 | 在录音面板上 touchmove preventDefault |
| 播放时两条语音同时响 | 每个消息独立创建了 Audio 实例 | 全局唯一 audioPlayer |
| 录音文件太大 | 码率设置过高 | audioBitsPerSecond 调低到 32000 |
| 安卓录的语音 iOS 播放无声音 | webm 与 mp4 容器不互通 | 服务端统一转码 mp3 或 aac |
5.3 长时间录音的内存与并发上传优化
微信限制语音最长 60 秒,我们产品也做了同样限制,时间到了强制结束并发送。为什么做这个限制?一是产品形态合适,二是 MediaRecorder 在部分浏览器上长时间录制会积累大量 Blob,内存压力很大。一分钟 32kbps 的录音只有几百 KB,但如果放任用户录 10 分钟,就算内存不爆,上传体验也会很差。
上传时我用 FormData 直接传 blob:
function uploadVoice(blob, duration, conversationId) { const formData = new FormData(); formData.append('file', blob, 'voice.webm'); formData.append('duration', duration); formData.append('conversationId', conversationId); // 用 fetch 或 XHR 上传 }上传过程中,UI 会处于“正在发送”的 loading 状态。松开发送后,气泡先不发出去,而是显示一个本地半透明占位气泡,等上传成功再替换成正式气泡。如果上传失败就提示重发,不要直接把气泡发出去。否则用户看到自己发了一条语音,但对方始终收不到,体验非常糟糕。
并发上传也要考虑。如果用户快速连发几条语音,浏览器会同时建立多个上传请求,这在弱网环境里可能互相抢占带宽,导致每条都很慢。我在项目中加了简单的队列,同一时间最多上传两条,其余排队。这个优化虽然不起眼,但和“快速连发”场景非常相关。
5.4 页面滚动性能与渲染优化
语音消息多起来之后,滚动性能下降是必然的。我做了三件事:
第一,气泡列表加了虚拟滚动,只渲染可视区域内的消息。语音消息的 DOM 结构比纯文本复杂,有气泡、时长、未读点、播放动画,如果几百条一次性渲染,低端机的滚动帧率会直接掉到二十几帧,甚至白屏。虚拟滚动实现不难,关键是每条消息要有确定的高度,语音消息的高度比较稳定,适合做虚拟列表。
第二,所有动画都用 transform 和 opacity,不碰 top、left、height。CSS 动画如果触发 reflow,滚动时会有明显卡顿。特别是录音面板弹出和气泡跳动,我都改成了 transform 实现。
第三,语音消息的音频 URL 用 CDN 地址或 blob 地址,不要用 base64。base64 会额外膨胀约 33% 的体积,还会卡渲染主线程。上传完成后直接用返回的 CDN 地址加载音频,HTMLAudio 本身就是流式加载,不用等整段下载完再播。
6. 实测体验优化与返工后的心得
6.1 “仿微信”体验清单里容易漏掉的小点
很多产品需求只写“仿微信语音功能”,如果不把体验清单列清楚,做出来就是四不像。我复盘自己这个模块,真正提升体验的点包括这些:
- 录音时按钮区域高亮并轻微缩放,释放时要有回弹效果。微信有一个极短促的缩放动画,掉了的话会明显觉得生硬。
- 录音面板里的计时数字采用等宽字体,避免数字跳动时宽度变化造成视觉抖动。中文用户可能不太在意,但 UI 同学提过好多次。
- 取消录音时用 toast 提示“已取消”,不能毫无反馈。
- 播放中的语音气泡要有明显的进度反馈,不能只有开始和结束两个状态。哪怕只是底色缓慢填充,也比静悄悄的播放体验好很多。
- 新收到的语音消息默认显示红色未读点,播放后消失。这个细节看似小,但辨识度极高,也是微信用户最熟悉的一个记忆点。
我个人觉得,H5 仿原生语音,交互的“手感”比录音质量更影响用户评价。录音质量只要别太差,用户都能接受。但松手没有反馈、录音一秒就断、上滑取消时页面跟着滚这类问题,用户会立刻感知到,而且很容易定性为“这个功能是坏的”。
6.2 与后端协作时提前对齐的事项
如果只是前端一个人把功能做完再丢给后端,很容易返工。我建议提前对齐四件事:
一是录音文件的最终存储格式,mp3 还是 aac。如果后端不转码,只是原样存,那前端就必须自己解决 webm 和 mp4 的播放兼容。二是时长字段是前端传还是后端解析。我建议前端传 duration,后端同时二次解析实际时长,两者取较小值,防止伪造数据。三是消息顺序的并发问题。语音消息上传速度受网络影响,有可能先点发送的反而是后到服务端,要约定用本地时间戳或客户端自增 id 排序,否则消息列表乱序,排查成本极高。四是上传超时策略。弱网下 60 秒录音上传可能需十几秒,前端超时要放宽到 30 秒以上,并在 UI 上保留重发入口。
6.3 后续可以扩展的方向
这个 H5 语音模块上线后,有几个方向被证明是高频需求:语音转文字,服务端接 ASR,把文本作为消息附带的字段下发,前端在气泡下方展示转写结果;多级手势,把上滑取消做成更细腻的幂等逻辑,避免手指在取消区边缘反复横跳时动画来回闪烁;音质自适应,弱网时自动降码率,强网时用更高的码率。
我最看好语音转文字,因为语音消息阅读成本高,有文字附带后,消息打开率会有明显提升。不过那是另一个项目了,这里不再展开。
写到最后,再把这次移动端 H5 仿微信语音功能的经验浓缩一下:录音、播放这些浏览器 API 是基本功,真正决定功能成败的,是那个看不见的状态机、对触摸事件的细心处理,以及浏览器兼容性的穷举。做这类功能,别急着铺代码,先把状态流转想清楚,把兼容性问题列表列好,后面的实现就是顺水推舟。真踩到坑的时候,也别慌,把现象记下来,用最小示例去验证原因,多数都是格式或者权限的问题。