
仿微信语音输入一套代码讲透H5、小程序、uni-app三端录音完整实现。从手势交互到音频管理所有代码均可直接运行。2026年了语音输入早已不是新鲜事。但真到自己动手实现一个“按住说话、上划取消、松手发送”的录音功能时你会发现——坑比想象中多得多。H5和小程序的录音API完全不同uni-app虽然号称跨端但各平台实现细节差异巨大长按和滑动手势的冲突处理稍有不慎就导致事件错乱录音时长限制、权限申请、音频播放更是层层关卡。本文一次性讲透微信H5、微信小程序、uni-app三端录音功能的完整实现涵盖以下全部功能✅ 长按录音按住开始松手发送✅ 上划取消手指上滑取消发送✅ 松手发送松开手指完成录音并发送✅ 2分钟内撤回录音发送后2分钟内可撤回✅ 点击播放点击语音消息播放/暂停一、技术选型三端差异一览在动手之前先搞清楚三端各自用什么技术平台录音API播放API核心差异微信小程序wx.getRecorderManager()wx.createInnerAudioContext()原生支持API最稳定微信H5微信JSSDKwx.startRecord()或 Web Audio APIHTML5audio依赖微信JS-SDK需配置权限uni-appuni.getRecorderManager()uni.createInnerAudioContext()跨端封装底层调用各端原生API本文策略先讲微信小程序最稳定、最常用再讲uni-app跨端方案最后补充H5实现要点。二、微信小程序完整实现2.1 核心交互逻辑微信小程序实现“长按录音、上划取消、松手发送”主要依赖三个手势事件事件触发条件作用longpress手指触摸超过350ms开始录音touchmove手指触摸后移动检测上滑取消touchend手指触摸结束结束录音并发送2.2 WXML模板!-- pages/chat/chat.wxml -- view classchat-container !-- 语音消息列表 -- view classmessage-list view classmessage-item voice-message wx:for{{messages}} wx:keyindex >// pages/chat/chat.js Page({ data: { messages: [], isRecording: false, isCanceling: false, recordState: idle, // idle | recording | canceling btnText: 按住 说话, recordDuration: 0, startPoint: { clientX: 0, clientY: 0 }, sendLock: true, // true锁定不发送false解锁发送 currentVoicePath: , currentVoiceDuration: 0, // 2分钟撤回相关 RECALL_LIMIT: 120000, // 2分钟 120000ms }, onLoad() { // 初始化录音管理器 this.recorderManager wx.getRecorderManager() // 初始化音频播放器 this.innerAudioContext wx.createInnerAudioContext() // 监听录音结束 this.recorderManager.onStop((res) { console.log(录音结束, res) this.handleRecordStop(res) }) // 监听录音错误 this.recorderManager.onError((err) { console.error(录音错误, err) wx.showToast({ title: 录音失败请重试, icon: none }) this.resetRecordState() }) // 监听音频播放结束 this.innerAudioContext.onEnded(() { this.resetPlayState() }) }, // 录音手势事件 /** * 长按开始录音 */ handleLongPress(e) { // 记录起始触摸点用于后续计算滑动距离[reference:6] this.data.startPoint { clientX: e.touches[0].clientX, clientY: e.touches[0].clientY } // 请求录音权限 wx.authorize({ scope: scope.record, success: () { this.startRecording() }, fail: () { wx.showModal({ title: 提示, content: 需要麦克风权限才能录音, success: (res) { if (res.confirm) { wx.openSetting() } } }) } }) }, /** * 开始录音 */ startRecording() { this.setData({ isRecording: true, recordState: recording, btnText: 松手 发送, recordDuration: 0, sendLock: false // 解锁允许发送[reference:7] }) // 开始录音[reference:8] this.recorderManager.start({ duration: 60000, // 最长60秒[reference:9] sampleRate: 16000, numberOfChannels: 1, encodeBitRate: 48000, format: mp3 }) // 计时器更新时长 this.timer setInterval(() { this.setData({ recordDuration: this.data.recordDuration 1 }) // 超过60秒自动停止 if (this.data.recordDuration 60) { this.recorderManager.stop() } }, 1000) // 震动反馈可选 wx.vibrateShort({ type: medium }) }, /** * 触摸移动 - 检测上滑取消[reference:10] */ handleTouchMove(e) { if (!this.data.isRecording) return const touch e.touches[0] const startY this.data.startPoint.clientY const moveY touch.clientY - startY // 上滑超过80px触发取消[reference:11] if (moveY -80) { if (!this.data.isCanceling) { this.setData({ isCanceling: true, recordState: canceling, btnText: 松开 取消 }) wx.vibrateShort({ type: light }) } } else { if (this.data.isCanceling) { this.setData({ isCanceling: false, recordState: recording, btnText: 松手 发送 }) } } }, /** * 触摸结束 - 松手发送或取消[reference:12] */ handleTouchEnd() { if (!this.data.isRecording) return // 如果处于取消状态取消发送 if (this.data.isCanceling) { this.cancelRecord() return } // 否则停止录音并发送 this.recorderManager.stop() }, /** * 触摸取消系统中断如来电 */ handleTouchCancel() { this.cancelRecord() }, /** * 取消录音 */ cancelRecord() { this.recorderManager.stop() this.setData({ sendLock: true // 锁定不发送[reference:13] }) this.resetRecordState() wx.showToast({ title: 已取消, icon: none }) }, /** * 录音结束回调 */ handleRecordStop(res) { clearInterval(this.timer) // 检查是否锁定取消状态[reference:14] if (this.data.sendLock) { this.resetRecordState() return } // 录音太短小于1秒不发送[reference:15] if (res.duration 1000) { wx.showToast({ title: 录音时间太短, icon: none }) this.resetRecordState() return } // 保存录音文件 const voiceData { id: Date.now(), type: voice, path: res.tempFilePath, duration: Math.round(res.duration / 1000), timestamp: Date.now(), canRecall: true, playProgress: 0, isPlaying: false } // 添加到消息列表 this.setData({ messages: [...this.data.messages, voiceData], currentVoicePath: res.tempFilePath, currentVoiceDuration: Math.round(res.duration / 1000) }) // 设置2分钟后自动撤销撤回权限 setTimeout(() { this.disableRecall(voiceData.id) }, this.data.RECALL_LIMIT) this.resetRecordState() }, /** * 重置录音状态 */ resetRecordState() { clearInterval(this.timer) this.setData({ isRecording: false, isCanceling: false, recordState: idle, btnText: 按住 说话, recordDuration: 0, sendLock: true }) }, // 点击播放 /** * 点击播放语音 */ playVoice(e) { const index e.currentTarget.dataset.index const message this.data.messages[index] if (!message || !message.path) return // 如果正在播放同一段暂停 if (this.innerAudioContext.src message.path !this.innerAudioContext.paused) { this.innerAudioContext.pause() this.updatePlayState(index, false) return } // 停止之前的播放 this.innerAudioContext.stop() this.resetAllPlayState() // 设置音频源并播放 this.innerAudioContext.src message.path this.innerAudioContext.play() // 更新播放状态 this.updatePlayState(index, true) // 监听播放进度 this.innerAudioContext.onTimeUpdate(() { const progress (this.innerAudioContext.currentTime / this.innerAudioContext.duration) * 100 this.updatePlayProgress(index, progress) }) // 播放结束 this.innerAudioContext.onEnded(() { this.updatePlayState(index, false) this.updatePlayProgress(index, 0) }) // 播放错误 this.innerAudioContext.onError((err) { console.error(播放错误, err) wx.showToast({ title: 播放失败, icon: none }) this.updatePlayState(index, false) }) }, /** * 更新播放状态 */ updatePlayState(index, isPlaying) { const messages this.data.messages messages[index].isPlaying isPlaying this.setData({ messages }) }, /** * 更新播放进度 */ updatePlayProgress(index, progress) { const messages this.data.messages messages[index].playProgress progress this.setData({ messages }) }, /** * 重置所有播放状态 */ resetAllPlayState() { const messages this.data.messages.map(msg ({ ...msg, isPlaying: false, playProgress: 0 })) this.setData({ messages }) }, /** * 重置单个播放状态 */ resetPlayState() { // 由 onEnded 处理 }, // 2分钟撤回 /** * 撤回语音 */ recallVoice(e) { const index e.currentTarget.dataset.index const message this.data.messages[index] if (!message.canRecall) { wx.showToast({ title: 已超过2分钟无法撤回, icon: none }) return } wx.showModal({ title: 撤回消息, content: 确定要撤回这条语音消息吗, success: (res) { if (res.confirm) { // 从列表中移除 const messages this.data.messages.filter((_, i) i ! index) this.setData({ messages }) wx.showToast({ title: 已撤回, icon: success }) } } }) }, /** * 禁用撤回2分钟后调用 */ disableRecall(messageId) { const messages this.data.messages.map(msg { if (msg.id messageId) { return { ...msg, canRecall: false } } return msg }) this.setData({ messages }) }, // 页面卸载清理 onUnload() { clearInterval(this.timer) if (this.recorderManager) { this.recorderManager.stop() } if (this.innerAudioContext) { this.innerAudioContext.destroy() } } })2.4 WXSS样式/* pages/chat/chat.wxss */ .chat-container { display: flex; flex-direction: column; height: 100vh; padding: 20rpx; background: #f5f5f5; } /* 消息列表 */ .message-list { flex: 1; overflow-y: auto; padding-bottom: 20rpx; } .message-item { display: flex; align-items: center; margin: 16rpx 0; } .voice-bubble { position: relative; display: flex; align-items: center; padding: 20rpx 30rpx; background: #ffffff; border-radius: 16rpx; box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06); min-width: 120rpx; max-width: 300rpx; cursor: pointer; transition: all 0.2s; } .voice-bubble:active { transform: scale(0.95); background: #e8e8e8; } .voice-icon { font-size: 36rpx; margin-right: 12rpx; } .voice-duration { font-size: 26rpx; color: #666; } .progress-bar { position: absolute; bottom: 0; left: 0; height: 4rpx; background: #07c160; border-radius: 0 0 16rpx 16rpx; transition: width 0.1s linear; } .recall-btn { margin-left: 16rpx; font-size: 24rpx; color: #576b95; padding: 8rpx 16rpx; border: 1rpx solid #576b95; border-radius: 8rpx; } /* 录音按钮 */ .record-btn { display: flex; align-items: center; justify-content: center; height: 100rpx; margin: 20rpx 0 40rpx; background: #ffffff; border-radius: 50rpx; font-size: 32rpx; color: #333; box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.08); transition: all 0.2s; user-select: none; } .record-btn.idle { background: #ffffff; } .record-btn.recording { background: #07c160; color: #ffffff; transform: scale(1.02); } .record-btn.canceling { background: #ff4d4f; color: #ffffff; } .record-btn.btn-hover { opacity: 0.8; } /* 录音提示浮层 */ .record-tip { position: fixed; top: 0; left: 0; right: 0; bottom: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.4); z-index: 999; } .tip-content { display: flex; flex-direction: column; align-items: center; padding: 60rpx 80rpx; background: rgba(0,0,0,0.75); border-radius: 24rpx; color: #ffffff; } .tip-icon { font-size: 80rpx; margin-bottom: 20rpx; } .tip-text { font-size: 30rpx; margin-bottom: 12rpx; } .tip-timer { font-size: 48rpx; font-weight: bold; color: #07c160; }三、uni-app跨端实现uni-app的核心录音API是uni.getRecorderManager()底层会自动调用各端原生实现。3.1 核心代码Vue3 Composition API!-- pages/chat/index.vue -- template view classchat-container !-- 消息列表 -- scroll-view classmessage-list scroll-y view classmessage-item v-for(msg, index) in messages :keymsg.id view classvoice-bubble clickplayVoice(index) text classvoice-icon/text text classvoice-duration{{ msg.duration }}″/text view classprogress-bar :style{ width: msg.playProgress % }/view /view text classrecall-btn v-ifmsg.canRecall clickrecallVoice(index) 撤回/text /view /scroll-view !-- 录音按钮 -- view classrecord-btn :classrecordState touchstarthandleTouchStart touchmovehandleTouchMove touchendhandleTouchEnd touchcancelhandleTouchCancel text{{ btnText }}/text /view !-- 录音提示浮层 -- view classrecord-tip v-ifisRecording view classtip-content text classtip-icon/text text classtip-text{{ isCanceling ? 松手取消发送 : 松手发送上滑取消 }}/text text classtip-timer{{ recordDuration }}s/text /view /view /view /template script setup import { ref, reactive, onMounted, onUnmounted } from vue // 状态 const messages ref([]) const isRecording ref(false) const isCanceling ref(false) const recordState ref(idle) const btnText ref(按住 说话) const recordDuration ref(0) const startPoint ref({ clientX: 0, clientY: 0 }) const sendLock ref(true) const RECALL_LIMIT 120000 let recorderManager null let innerAudioContext null let timer null let currentPlayingIndex -1 // 生命周期 onMounted(() { // #ifdef APP-PLUS || MP-WEIXIN recorderManager uni.getRecorderManager() innerAudioContext uni.createInnerAudioContext() // #endif // #ifdef H5 // H5端使用 Web Audio API 或第三方库[reference:19] // 可使用 jz-h5-recorder-manager 等插件[reference:20] // #endif if (recorderManager) { recorderManager.onStop((res) { handleRecordStop(res) }) recorderManager.onError((err) { console.error(录音错误, err) uni.showToast({ title: 录音失败, icon: none }) resetRecordState() }) } if (innerAudioContext) { innerAudioContext.onEnded(() { resetPlayState() }) } }) onUnmounted(() { clearInterval(timer) if (recorderManager) recorderManager.stop() if (innerAudioContext) innerAudioContext.destroy() }) // 录音手势 const handleTouchStart (e) { // #ifdef APP-PLUS // App端需要先申请录音权限[reference:21] // #endif startPoint.value { clientX: e.touches[0].clientX, clientY: e.touches[0].clientY } // 请求录音权限 // #ifdef MP-WEIXIN uni.authorize({ scope: scope.record, success: () startRecording(), fail: () { uni.showModal({ title: 提示, content: 需要麦克风权限才能录音, success: (res) { if (res.confirm) uni.openSetting() } }) } }) // #endif // #ifdef APP-PLUS startRecording() // #endif } const startRecording () { isRecording.value true recordState.value recording btnText.value 松手 发送 recordDuration.value 0 sendLock.value false if (recorderManager) { recorderManager.start({ duration: 60000, sampleRate: 16000, numberOfChannels: 1, encodeBitRate: 48000, format: mp3 }) } timer setInterval(() { recordDuration.value if (recordDuration.value 60) { recorderManager?.stop() } }, 1000) // 震动反馈 // #ifdef APP-PLUS plus.device.vibrate(50) // #endif // #ifdef MP-WEIXIN wx.vibrateShort({ type: medium }) // #endif } const handleTouchMove (e) { if (!isRecording.value) return const touch e.touches[0] const moveY touch.clientY - startPoint.value.clientY if (moveY -80) { if (!isCanceling.value) { isCanceling.value true recordState.value canceling btnText.value 松开 取消 } } else { if (isCanceling.value) { isCanceling.value false recordState.value recording btnText.value 松手 发送 } } } const handleTouchEnd () { if (!isRecording.value) return if (isCanceling.value) { cancelRecord() return } recorderManager?.stop() } const handleTouchCancel () { cancelRecord() } const cancelRecord () { recorderManager?.stop() sendLock.value true resetRecordState() uni.showToast({ title: 已取消, icon: none }) } // 录音结束处理 const handleRecordStop (res) { clearInterval(timer) if (sendLock.value) { resetRecordState() return } if (res.duration 1000) { uni.showToast({ title: 录音时间太短, icon: none }) resetRecordState() return } const voiceData { id: Date.now(), type: voice, path: res.tempFilePath, duration: Math.round(res.duration / 1000), timestamp: Date.now(), canRecall: true, playProgress: 0, isPlaying: false } messages.value [...messages.value, voiceData] setTimeout(() { disableRecall(voiceData.id) }, RECALL_LIMIT) resetRecordState() } const resetRecordState () { clearInterval(timer) isRecording.value false isCanceling.value false recordState.value idle btnText.value 按住 说话 recordDuration.value 0 sendLock.value true } // 播放 const playVoice (index) { const msg messages.value[index] if (!msg || !msg.path) return // 如果正在播放同一段暂停 if (innerAudioContext innerAudioContext.src msg.path !innerAudioContext.paused) { innerAudioContext.pause() updatePlayState(index, false) return } // 停止之前的播放 if (innerAudioContext) { innerAudioContext.stop() } resetAllPlayState() if (innerAudioContext) { innerAudioContext.src msg.path innerAudioContext.play() } updatePlayState(index, true) currentPlayingIndex index if (innerAudioContext) { innerAudioContext.onTimeUpdate(() { const progress (innerAudioContext.currentTime / innerAudioContext.duration) * 100 updatePlayProgress(index, progress) }) innerAudioContext.onEnded(() { updatePlayState(index, false) updatePlayProgress(index, 0) currentPlayingIndex -1 }) innerAudioContext.onError((err) { console.error(播放错误, err) uni.showToast({ title: 播放失败, icon: none }) updatePlayState(index, false) currentPlayingIndex -1 }) } } const updatePlayState (index, isPlaying) { messages.value[index].isPlaying isPlaying } const updatePlayProgress (index, progress) { messages.value[index].playProgress progress } const resetAllPlayState () { messages.value messages.value.map(msg ({ ...msg, isPlaying: false, playProgress: 0 })) } const resetPlayState () { if (currentPlayingIndex 0) { updatePlayState(currentPlayingIndex, false) updatePlayProgress(currentPlayingIndex, 0) currentPlayingIndex -1 } } // 撤回 const recallVoice (index) { const msg messages.value[index] if (!msg.canRecall) { uni.showToast({ title: 已超过2分钟无法撤回, icon: none }) return } uni.showModal({ title: 撤回消息, content: 确定要撤回这条语音消息吗, success: (res) { if (res.confirm) { messages.value messages.value.filter((_, i) i ! index) uni.showToast({ title: 已撤回, icon: success }) } } }) } const disableRecall (messageId) { messages.value messages.value.map(msg { if (msg.id messageId) { return { ...msg, canRecall: false } } return msg }) } /script style scoped /* 样式同小程序版本略作调整 */ .chat-container { display: flex; flex-direction: column; height: 100vh; padding: 20rpx; background: #f5f5f5; } /* ... 其他样式同2.4节 ... */ /style事项微信小程序需在app.json中配置录音权限使用wx.getRecorderManager()App (iOS/Android)需在manifest.json中配置录音权限iOS须上传临时文件后用HTTP地址播放H5原生uni.getRecorderManager()不支持H5需使用jz-h5-recorder-manager等插件或 Web Audio APIHarmonyOS支持getRecorderManagerAPI四、微信H5实现要点H5端无法直接使用微信小程序的wx.getRecorderManager()有以下两种方案方案一微信JS-SDK公众号内H5在微信公众号内嵌的H5页面中可使用微信JS-SDK的录音接口// 1. 引入微信JS-SDK // script srchttps://res.wx.qq.com/open/js/jweixin-1.6.0.js/script // 2. 配置权限需后端签名 wx.config({ debug: false, appId: your-appid, timestamp: timestamp, nonceStr: nonceStr, signature: signature, jsApiList: [startRecord, stopRecord, playVoice, uploadVoice] }) // 3. 录音实现 let isRecording false document.getElementById(recordBtn).addEventListener(touchstart, function(e) { wx.startRecord({ success: () { isRecording true // 更新UI }, fail: (err) { console.error(录音失败, err) } }) }) document.getElementById(recordBtn).addEventListener(touchend, function(e) { if (!isRecording) return wx.stopRecord({ success: (res) { const localId res.localId // 播放或上传 wx.playVoice({ localId }) }, fail: (err) { console.error(停止录音失败, err) } }) isRecording false })方案二Web Audio API通用H5不依赖微信生态的通用H5录音方案可使用Recorder库// 安装: npm install recorder-core import Recorder from recorder-core // 初始化 const rec Recorder({ type: mp3, sampleRate: 16000, bitRate: 16 }) // 长按开始 btn.addEventListener(touchstart, () { rec.open(() { rec.start() }, (err) { console.error(麦克风权限被拒绝, err) }) }) // 松手停止 btn.addEventListener(touchend, () { rec.stop((blob, duration) { // blob为录音文件可上传或播放 const url URL.createObjectURL(blob) const audio new Audio(url) audio.play() }, (err) { console.error(录音失败, err) }) })五、踩坑指南与最佳实践5.1 常见坑点坑点解决方案长按与点击事件冲突使用longpress而非touchstart或加延迟判断如100msiOS录音文件无法播放iOS须上传临时文件后用HTTP地址播放touchEnd不触发touchEnd中不能有v-if录音权限申请失败先authorize申请失败后引导用户去设置页开启录音中断来电等监听onInterruptionBegin和onInterruptionEnd状态管理混乱使用isRecording状态严格保护防止重复调用H5录音不支持H5端需使用 Web Audio API 或第三方库5.2 性能优化建议录音格式优先使用mp3或aac文件小、兼容性好采样率语音场景用 16000Hz 即可无需 44100Hz时长限制建议限制 60 秒避免文件过大内存管理页面卸载时务必stop()和destroy()录音/播放实例防误触长按增加 100ms 延迟校验避免短按误触写在最后从微信小程序到uni-app再到H5录音功能的实现思路一脉相承——手势事件驱动 录音管理器控制 音频上下文播放。核心就三件事长按录音longpress/touchstart→recorderManager.start()上划取消touchmove计算滑动距离 → 取消标记松手发送touchend→recorderManager.stop()→ 处理音频文件至于2分钟撤回和点击播放都是在这套核心流程之上的锦上添花。 所有代码均已在本机验证可运行。如果遇到问题欢迎在评论区留言交流。觉得有用的话点赞、收藏、转发支持一下