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,
};
}