Appearance
音频录制
🎤 面试回答稿:浏览器音频录制模块封装
在项目中我封装过一个浏览器端的音频录制模块,主要用于用户留言或语音输入场景,技术上是基于
MediaRecorder
和 Vue 3 的组合式 API 来实现的,整体采用了工厂函数的形式封装,便于多处复用和状态隔离。
当调用这个模块的工厂函数时,会生成一个包含录音控制方法、播放、上传、时长计算等功能的实例对象。使用者可以直接调用这些方法来控制录音全流程,状态数据也是响应式的,方便在组件中直接绑定 UI。
🚀 功能点方面主要包括:
开始录音:会清空上次的音频缓存,设置为录音中状态,并启动 MediaRecorder 进行录音,同时开启一个定时器,每秒计算一次录音时长,并转换成分钟:秒格式展示出来。
录音时长控制:支持传入最大录音时长参数,内部会自动对时长做限制,一旦超过时间会自动调用停止录音,防止过长录音。
结束录音:停止 MediaRecorder 的同时清除定时器,更新状态,保存录音片段数据。
上传功能:使用
axios
将录音片段合并成一个 Blob 文件,通过 FormData 上传到后端接口,后端返回音频 URL 后会保存下来用于播放或展示。播放功能:基于 Blob 创建临时 URL,通过原生
Audio
类实现预览播放功能,用户可在上传前先试听录音。
⚙️ 技术实现方面:
所有录音过程中的状态,包括是否正在录音、录音时长、Blob 数据队列、音频 URL 等,都是使用 Vue 的
ref
来实现响应式管理,组件中可以无缝绑定。模块本身是一个
async
工厂函数,在调用时会异步请求麦克风权限,只在真正需要的时候才初始化资源,避免在页面加载时过早弹出权限弹窗。在录音结束后,通过
Blob
对象来合并音频片段,不依赖第三方库就能完成录音上传、预览等核心功能。
✅ 模块亮点:
模块结构清晰、职责分离,每个功能都通过独立的方法封装,如
startRecord()
、stopRecord()
、uploadAudio()
等,便于维护和调用。使用了懒初始化和响应式状态管理结合的方式,提升了性能和使用体验。
实现了自动录音时长监控,能在达到最大录音时间时自动处理逻辑,提升用户友好度。
可轻松扩展到更多场景,如语音留言、语音识别前处理等。
🔧 后续可优化方向:
- 支持更多编码格式,如接入
lamejs
转 MP3,提高兼容性; - 支持波形可视化,增强用户体验;
- 抽离出为通用插件,支持 Vue 以外的框架;
- 引入节流防抖机制,避免重复 start/stop 触发冲突;
- 增加错误处理逻辑,比如权限拒绝时的提示,MediaRecorder 不支持的浏览器降级方案等。
📝 代码实现
ts
import axios from 'axios'
import { ref, Ref } from 'vue'
export interface InstanceRecord {
startRecord: () => void
stopRecord: () => void
uploadAudio: () => Promise<void>
playAudioStream: () => void
audioStream: Ref<Blob[]>
audioUrl: Ref<string>
checkRecordedAudio: Ref<boolean>
durationStr: Ref<string>
duration: Ref<number>
}
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs < 10 ? '0' : ''}${secs}`
}
export default async function useRecord(maxDuration = 60): Promise<InstanceRecord> {
const audioStream = ref<Blob[]>([])
const audioUrl = ref('')
const checkRecordedAudio = ref(false)
const durationStr = ref('00:00')
const duration = ref(0)
let timer: number | undefined
let startTime = 0
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const mediaRecorder = new MediaRecorder(stream)
const updateDuration = () => {
const now = Date.now()
const seconds = (now - startTime) / 1000
duration.value = seconds
durationStr.value = formatTime(seconds)
if (seconds >= maxDuration) stopRecord()
}
const clearTimer = () => {
if (timer) clearInterval(timer)
timer = undefined
}
mediaRecorder.addEventListener('dataavailable', (e) => {
if (e.data.size > 0) {
audioStream.value.push(e.data)
}
})
mediaRecorder.addEventListener('stop', () => {
checkRecordedAudio.value = false
updateDuration()
clearTimer()
})
const startRecord = () => {
if (mediaRecorder.state === 'recording') return
audioStream.value = []
duration.value = 0
durationStr.value = '00:00'
startTime = Date.now()
checkRecordedAudio.value = true
mediaRecorder.start()
timer = window.setInterval(updateDuration, 1000)
}
const stopRecord = () => {
if (mediaRecorder.state !== 'recording') return
mediaRecorder.stop()
}
const uploadAudio = async () => {
try {
const blob = new Blob(audioStream.value, { type: 'audio/ogg; codecs=opus' })
const formData = new FormData()
formData.append('file', blob)
const { data } = await axios.post('http://localhost:3000/upload/record', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
audioUrl.value = data.url
} catch (error) {
console.error('音频上传失败:', error)
}
}
const playAudioStream = () => {
const blob = new Blob(audioStream.value, { type: 'audio/ogg; codecs=opus' })
const audio = new Audio(URL.createObjectURL(blob))
audio.play()
}
return {
startRecord,
stopRecord,
uploadAudio,
playAudioStream,
audioStream,
audioUrl,
checkRecordedAudio,
durationStr,
duration,
}
}