Vue 3 的组合式 API 与更完善的工程化生态,为复杂交互能力的接入提供了良好土壤。红米的 Reddy 语音助手作为一套面向移动端与 Web 场景的语音能力方案,包含唤醒、识别、语义理解与语音合成几个核心环节。如果直接在业务组件里散落调用这些能力,代码会迅速失控:监听器泄漏、重复初始化、识别结果与视图不同步等问题会接连出现。本文以一个实际项目为例,完整拆解 Reddy 在 Vue 3 中的工程化接入路径。

一、整体架构设计:把语音能力收敛到一层适配层
工程化的第一步是隔离。Reddy 的原生 API 通常是命令式的:初始化实例、注册回调、启动监听、销毁实例。如果让每个业务组件直接触碰这些 API,那么任何一个组件的卸载遗漏都会导致全局监听器残留。正确做法是在业务与 Reddy 之间建立一个适配层,适配层只暴露语义化的事件与方法,内部消化 Reddy 的细节差异。
推荐的分层结构是三段式:最底层是 ReddyBridge,一个不依赖 Vue 的纯 TS 模块,负责 SDK 加载、实例管理与事件分发;中间层是基于组合式 API 封装的 useReddy,向组件提供响应式的状态与命令;最上层是业务组件,例如语音搜索框、语音控制面板。这样设计的好处是,即使未来更换语音服务商,只需要重写底层桥接,中间层与业务层完全不动。
在目录组织上,建议将语音模块独立成 src/services/speech/ 目录,包含 bridge.ts、types.ts、useReddy.ts 与 index.ts 四个文件。类型定义单独抽出是关键一步,Reddy 的识别结果结构、错误码、会话状态都需要有明确的 TypeScript 类型约束,避免在业务代码中出现魔法字符串。
二、用 composable 封装响应式语音状态
Vue 3 的组合式 API 天然适合封装这类有状态的外部服务。useReddy 的核心职责是把 Reddy 的回调事件转换为 Vue 的响应式状态,让模板层可以直接绑定识别文本与监听状态。
import { ref, shallowRef, onUnmounted } from 'vue'
import { reddyBridge } from './bridge'
export function useReddy() {
// 是否正在监听用户语音
const listening = ref(false)
// 实时识别的中间文本
const interimText = ref('')
// 最终确认的识别结果
const finalText = ref('')
const error = shallowRef<Error | null>(null)
const start = async () => {
try {
await reddyBridge.startSession({
onInterim: (text) => { interimText.value = text },
onFinal: (text) => {
finalText.value = text
interimText.value = ''
},
onStateChange: (state) => {
listening.value = state === 'listening'
}
})
} catch (e) {
error.value = e as Error
}
}
const stop = () => reddyBridge.stopSession()
// 组件卸载时自动停止监听,防止泄漏
onUnmounted(stop)
return { listening, interimText, finalText, error, start, stop }
}这段代码有几个值得注意的细节。第一,onUnmounted 中统一调用 stop,保证组件销毁时监听一定会被释放,这是 Vue 3 相比 Vue 2 混入方案最干净的地方。第二,中间文本与最终文本分离维护,因为语音识别在说话过程中会不断修正前面的结果,如果直接绑定一个字段,界面会出现文本跳动,分离后可以做平滑过渡。第三,error 使用 shallowRef,错误对象不需要深度响应式,减少不必要的代理开销。
在业务组件中的使用就非常简洁了,模板直接绑定状态,按钮触发命令:
<template>
<div class="voice-search">
<button @click="listening ? stop() : start()">
{{ listening ? '停止' : '按住说话' }}
</button>
<p v-if="interimText" class="interim">{{ interimText }}</p>
<p v-else-if="finalText">{{ finalText }}</p>
</div>
</template>
<script setup lang="ts">
import { useReddy } from '@/services/speech'
const { listening, interimText, finalText, start, stop } = useReddy()
</script>可以看到,组件层对 Reddy 的存在完全无感知,它只认识几个响应式变量和两个方法。这就是适配层的价值:语音能力变成了一种普通的 UI 状态,任何熟悉 Vue 的开发者都能直接使用。
三、权限、降级与按需加载的工程细节
语音能力依赖麦克风权限,而权限申请在不同环境下的表现差异很大:HTTPS 环境下浏览器会弹出授权框,HTTP 环境直接不可用,用户拒绝授权后再次进入需要引导其去系统设置开启。这些边界情况必须在适配层统一处理,而不是抛给业务组件。建议在 bridge.ts 中实现一个权限预检函数,在调用 Reddy 初始化之前先探测 navigator.permissions 与 getUserMedia 的可用性,并给出结构化的失败原因,例如 no-https、denied、unsupported,组件据此展示不同的引导文案。
降级策略同样重要。语音输入本质上是一种增强交互,不应成为唯一入口。当 Reddy 不可用时,搜索框必须保留键盘输入,语音按钮可以置灰并附提示,而不是直接报错打断用户流程。同时建议为识别结果设置置信度阈值,低于阈值的结果以可编辑的草稿形式填充到输入框,让用户确认后再提交,避免误识别直接触发业务动作。
最后是构建层面的优化。Reddy 的 SDK 体积通常不小,而多数页面并不需要语音能力,直接打包进主包会拖慢首屏。在 Vite 项目中可以结合路由懒加载与动态 import,让 SDK 只在用户首次点击语音按钮时才下载:
// bridge.ts 中的延迟加载逻辑
let sdkInstance: any = null
export async function loadReddySDK() {
if (sdkInstance) return sdkInstance
// 动态导入,Vite 会自动做代码分割
const mod = await import('reddy-sdk')
sdkInstance = await mod.default.create({
appId: import.meta.env.VITE_REDDY_APP_ID,
region: 'cn'
})
return sdkInstance
}配合 import.meta.env 管理 appId 等配置,不同环境(开发、测试、生产)可以指向不同的 Reddy 应用,避免开发期的调试请求污染线上配额。整体下来,这套方案让语音助手从一段脚本片段成长为可维护的工程模块:分层清晰、状态响应式、资源按需加载、边界情况有兜底,后续无论是扩展唤醒词还是接入语音合成,都有明确的扩展位置。