仿微信语音输入,一套代码讲透H5、小程序、uni-app三端录音完整实现。从手势交互到音频管理,所有代码均可直接运行。
2026年了,语音输入早已不是新鲜事。但真到自己动手实现一个“按住说话、上划取消、松手发送”的录音功能时,你会发现——坑比想象中多得多。
H5和小程序的录音API完全不同;uni-app虽然号称跨端,但各平台实现细节差异巨大;长按和滑动手势的冲突处理稍有不慎就导致事件错乱;录音时长限制、权限申请、音频播放更是层层关卡。
本文一次性讲透微信H5、微信小程序、uni-app三端录音功能的完整实现,涵盖以下全部功能:
-
✅ 长按录音(按住开始,松手发送)
-
✅ 上划取消(手指上滑取消发送)
-
✅ 松手发送(松开手指完成录音并发送)
-
✅ 2分钟内撤回(录音发送后2分钟内可撤回)
-
✅ 点击播放(点击语音消息播放/暂停)
一、技术选型:三端差异一览
在动手之前,先搞清楚三端各自用什么技术:
| 微信小程序 | wx.getRecorderManager() | wx.createInnerAudioContext() | 原生支持,API最稳定 |
| 微信H5 | 微信JSSDK wx.startRecord() 或 Web Audio API | HTML5 <audio> | 依赖微信JS-SDK,需配置权限 |
| uni-app | uni.getRecorderManager() | uni.createInnerAudioContext() | 跨端封装,底层调用各端原生API |
本文策略:先讲微信小程序(最稳定、最常用),再讲uni-app跨端方案,最后补充H5实现要点。
二、微信小程序完整实现
2.1 核心交互逻辑
微信小程序实现“长按录音、上划取消、松手发送”主要依赖三个手势事件:
| longpress | 手指触摸超过350ms | 开始录音 |
| touchmove | 手指触摸后移动 | 检测上滑取消 |
| touchend | 手指触摸结束 | 结束录音并发送 |
2.2 WXML模板
<!– pages/chat/chat.wxml –>
<view class="chat-container">
<!– 语音消息列表 –>
<view class="message-list">
<view
class="message-item voice-message"
wx:for="{{messages}}"
wx:key="index"
data-index="{{index}}"
>
<view class="voice-bubble" bindtap="playVoice">
<text class="voice-icon">🔊</text>
<text class="voice-duration">{{item.duration}}″</text>
<!– 播放进度条(可选) –>
<view class="progress-bar" style="width: {{item.playProgress || 0}}%"></view>
</view>
<!– 撤回按钮:2分钟内显示 –>
<text
class="recall-btn"
wx:if="{{item.canRecall}}"
bindtap="recallVoice"
>撤回</text>
</view>
</view>
<!– 录音按钮 –>
<view
class="record-btn {{recordState}}"
bindlongpress="handleLongPress"
bindtouchmove="handleTouchMove"
bindtouchend="handleTouchEnd"
bindtouchcancel="handleTouchCancel"
hover-class="btn-hover"
>
<text>{{btnText}}</text>
</view>
<!– 录音状态提示浮层 –>
<view class="record-tip" wx:if="{{isRecording}}">
<view class="tip-content">
<text class="tip-icon">🎤</text>
<text class="tip-text">{{isCanceling ? '松手取消发送' : '松手发送,上滑取消'}}</text>
<text class="tip-timer">{{recordDuration}}s</text>
</view>
</view>
</view>
2.3 核心JS实现
// 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 class="chat-container">
<!– 消息列表 –>
<scroll-view class="message-list" scroll-y>
<view
class="message-item"
v-for="(msg, index) in messages"
:key="msg.id"
>
<view class="voice-bubble" @click="playVoice(index)">
<text class="voice-icon">🔊</text>
<text class="voice-duration">{{ msg.duration }}″</text>
<view class="progress-bar" :style="{ width: msg.playProgress + '%' }"></view>
</view>
<text
class="recall-btn"
v-if="msg.canRecall"
@click="recallVoice(index)"
>撤回</text>
</view>
</scroll-view>
<!– 录音按钮 –>
<view
class="record-btn"
:class="recordState"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
@touchcancel="handleTouchCancel"
>
<text>{{ btnText }}</text>
</view>
<!– 录音提示浮层 –>
<view class="record-tip" v-if="isRecording">
<view class="tip-content">
<text class="tip-icon">🎤</text>
<text class="tip-text">{{ isCanceling ? '松手取消发送' : '松手发送,上滑取消' }}</text>
<text class="tip-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 API |
| HarmonyOS | 支持 getRecorderManager API |
四、微信H5实现要点
H5端无法直接使用微信小程序的 wx.getRecorderManager(),有以下两种方案:
方案一:微信JS-SDK(公众号内H5)
在微信公众号内嵌的H5页面中,可使用微信JS-SDK的录音接口:
// 1. 引入微信JS-SDK
// <script src="https://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,或加延迟判断(如100ms) |
| iOS录音文件无法播放 | 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分钟撤回和点击播放,都是在这套核心流程之上的锦上添花。
💡 所有代码均已在本机验证可运行。如果遇到问题,欢迎在评论区留言交流。觉得有用的话,点赞、收藏、转发支持一下!





