从「坐地铁刷文章」到「开车听文章」的需求演变,以及由此引发的一次工程实践:为什么浏览器原生 TTS 不够用、阿里云 NLS WebSocket 协议的工作方式、Next.js API 路由如何做 token 缓存、播放器的状态机设计、预取竞态修复,以及段落跟随滚动的实现。
以前上下班坐地铁,通勤时间是天然的阅读窗口——掏出手机,打开文章,四五十分钟就这么过去了。后来换成开车出行,这块时间突然消失了。两手握着方向盘,眼睛盯着路,唯一能做的是听。播客、有声书、微信公众号的语音功能……慢慢发现,「听」(至少在不得已的时候)其实是一种效率相当高的内容消费方式,而且对于自己写的文章,听还多了一层价值:用耳朵重新过一遍,很容易发现句子读起来别扭、逻辑跳跃、或者某个表达在视觉上看顺了但口语节奏完全不对的地方。
这篇文章记录了本站实现「听全文」功能的完整过程。表面上是加一个播放按钮,背后涉及的问题比预想的多:浏览器原生语音合成的质量上限、云端 TTS 的 WebSocket 协议、Next.js 服务端路由的模块生命周期、前端播放器的状态机设计,以及跟随滚动的 DOM 映射。
最省事的方案是 Web Speech API:
const utter = new SpeechSynthesisUtterance(text);
utter.lang = "zh-CN";
window.speechSynthesis.speak(utter);零成本,无需后端,三行代码搞定。我确实先做了这个版本——按钮点击、文章分段、逐段朗读、进度显示,都工作得很好。
但声音质量是硬伤。Web Speech API 使用操作系统内置的 TTS 引擎,质量完全取决于用户的设备:
更根本的问题是不一致性——同一篇文章在不同设备上听起来完全不同,没有办法作为产品体验来提供。
于是,决定升级到云端 TTS。选型上优先考虑国内用户可访问性,阿里云 NLS 在国内直连,有免费额度,中文声音模型质量可以接受。
阿里云 NLS(Natural Language Service)的认证是两层结构,初看有些绕:
用 AccessKeyId 和 AccessKeySecret 调用 nls-meta.cn-shanghai.aliyuncs.com,这是一个标准的阿里云 OpenAPI 调用,走 HMAC-SHA1 签名。返回的 Token 有效期 24 小时。
拿着 Token 和 Appkey,连接 wss://nls-gateway.cn-shanghai.aliyuncs.com/ws/v1,Token 放在 HTTP 头 X-NLS-Token 里。连接建立后,发送 JSON 格式的 StartSynthesis 消息,Gateway 回传二进制音频流,最后发 SynthesisCompleted 表示结束。
前端播放器每次需要一段音频时,向 /api/tts 发一个 POST 请求,传入文本,收到 audio/mpeg 二进制响应,用 URL.createObjectURL 转成可播放的 URL。
服务端路由做三件事:取 token、建 WebSocket、收集音频。
Token 24 小时有效,每次请求都换一个新 token 既浪费又慢。Next.js API 路由在 Node.js 进程里是模块单例,模块级变量可以跨请求复用:
let cachedToken: { id: string; expireTime: number } | null = null;
async function getToken(): Promise<string> {
if (cachedToken && Date.now() / 1000 < cachedToken.expireTime - 120) {
return cachedToken.id;
}
const
提前 120 秒过期,避免边界情况下 token 在传输途中失效。
Vercel 的 Fluid Compute 会在并发请求之间复用函数实例,所以这个缓存在生产环境里也能正常工作。冷启动时会换一次新 token,之后同一实例的所有请求都复用缓存。
NLS SDK 走 WebSocket,音频以二进制帧的形式分批推送,SynthesisCompleted 消息表示结束。服务端需要把所有帧收集起来,拼成完整的 Buffer,再作为 HTTP 响应返回:
const chunks: Buffer[] = [];
tts.on("data", (msg: Buffer) => chunks.push(msg));
await tts.start(param, false); // 等待 SynthesisCompleted
const audio = Buffer.concat(chunks);
return new NextResponse(audio, {
headers: { "Content-Type": "audio/mpeg" },
这里有一个 Next.js 的配置问题:alibabacloud-nls 依赖 ws(原生 WebSocket 库),Turbopack(Chen 注:Next.js 16 引入的新一代打包工具,基于 Rust 编写,替代 webpack。开发环境默认启用,构建速度大幅提升,但对某些依赖原生 Node.js 模块的包(如 `ws`)处理方式与 webpack 不同,需要显式声明为服务端外部包。) 默认会尝试打包它,导致 Module not found。需要在 next.config.ts 里声明为外部包:
serverExternalPackages: ["alibabacloud-nls", "@alicloud/pop-core", "ws"]另外,该包的 package.json 有 exports 字段,只暴露了顶层入口,不能用 require("alibabacloud-nls/lib/tts") 这样的深路径导入,需要用 require("alibabacloud-nls").SpeechSynthesizer。
播放器的核心是一个状态机,加上对 HTMLAudioElement 的管理。
loading 状态期间,播放/暂停按钮变为半透明不可点击,前进后退按钮禁用,文字显示「加载中…」。这避免了用户在音频未就绪时操作导致的状态混乱。
整个播放器生命周期共用一个 HTMLAudioElement,切换段落时修改 src,不新建对象:
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
const audio = new Audio();
audio.preload = "auto";
audioRef.current = audio;
return () => {
audio.pause();
audio.src = "";
urlCache.current.forEach
卸载时需要做两件事:暂停音频(避免内存中还在播放)、撤销所有 object URL(避免 blob 内存泄漏)。
每段音频都需要一次网络请求,如果等到当前段播完再请求下一段,中间会有明显的停顿感。解决方案是在当前段开始播放时就预取下一段:
for (let i = 1; i <= PREFETCH_AHEAD; i++) {
fetchChunk(index + i);
}预取引入了一个竞态问题,初版里直接踩了。
fetchChunk 用 fetchingRef(一个 Set)记录正在进行中的请求,防止同一段被并发请求多次。但当预取发起了对 index+1 的请求时,fetchingRef 里就有它了。这时如果播放器正常推进到 index+1,playChunk 会调用 fetchChunk(index+1),发现它在 fetchingRef 里,初版的处理是直接返回 null——于是 playChunk 收到 null,调用 setStatus("idle"),播放就停了。
修复方案是把「直接返回 null」改为「等待请求落地」:
if (fetchingRef.current.has(index)) {
for (let i = 0; i < 60; i++) {
await new Promise((r) => setTimeout(r, 200));
if (urlCache.current.has(index)) return urlCache.current.get(index)!;
if (!fetchingRef.current.has(index))
轮询间隔 200ms,最多等 12 秒。正常情况下预取请求会在几秒内完成,playChunk 拿到 URL 后继续播放,完全无感。
语速通过 audio.playbackRate 实时修改,无需重新请求音频。阿里云返回的 mp3 在 0.8×—2.0× 范围内都能平滑变速:
function handleRateCycle() {
const next = RATES[(RATES.indexOf(rate) + 1) % RATES.length];
rateRef.current = next;
setRate(next);
if (audioRef.current) audioRef.current.playbackRate = next;
}注意 rateRef 和 rate 两个变量:rateRef 是 ref,供 playChunk 回调读取(闭包里的 rate 是旧值);rate 是 state,驱动 UI 渲染。这是 React 并发模式下处理异步回调读取最新值的标准做法。
文章内容是 MDX 渲染出的 HTML,挂在 .prose 容器下。段落提取遍历语义块级元素:
export function extractChunks(): { chunks: string[]; elements: HTMLElement[] } {
const prose = document.querySelector(".prose");
prose.querySelectorAll("p, h1, h2, h3, h4, h5, h6, li, blockquote").forEach((el) => {
const text = (el as HTMLElement).innerText?.trim();
if
关键是同时保存 DOM 元素引用,而不仅仅是文本。文本用于请求 TTS,元素引用用于后续的跟随滚动。
每次切换到新段落,如果开关打开,找到对应的 DOM 元素执行 scrollIntoView:
if (autoScrollRef.current) {
elementsRef.current[index]?.scrollIntoView({
behavior: "smooth",
block: "center",
});
}block: "center" 让当前朗读的段落出现在视口中间,而不是顶部,阅读体验更好。
跟随滚动用 ref + state 双轨管理:autoScrollRef 给异步回调读取,autoScroll 驱动按钮的高亮状态。
封面图取自文章的 cover frontmatter,通过 NoteLayout → ArticleActions prop 传递到播放器。无封面的文章显示四根波形条:播放时做跳动动画(CSS @keyframes),暂停时静止。
播放器通过 createPortal 挂到 document.body,脱离组件树的 overflow 约束,确保始终固定在视口底部。进入时用 translateY(100%) → translateY(0) 滑入动画。
代码块的处理。目前 extractChunks 遍历 li、p、blockquote 等元素时,如果段落里包含行内代码(<code>),innerText 会把代码内容也读出来。独立的代码块(pre > code)因为不匹配选择器,不会被读到,但段落内的变量名、函数名仍然会被 TTS 逐字念出。这是一个已知的遗留问题,完整的修复需要在提取文本前先把 code 元素的内容替换成空白或省略标记。
文章加密时的朗读。受保护的文章正文在服务端加密后作为密文下发,客户端解密后 HTML 通过 dangerouslySetInnerHTML 注入。extractChunks 在 .prose 上的遍历理论上能工作,但朗读按钮应该只在解密完成后出现,目前尚未处理这个逻辑,是一个遗留项。
Vercel 环境变量。ALIBABA_NLS_APPKEY、ALIBABA_ACCESS_KEY_ID、ALIBABA_ACCESS_KEY_SECRET 三个变量需要在 Vercel 项目设置里配置,本地开发放在 .env.local。token 缓存在模块级变量里,Vercel 的 Fluid Compute 在同一实例内复用,冷启动会有一次额外的 token 请求延迟(通常 200-400ms)。
这个功能从想法到上线大概花了一个下午。坑主要集中在阿里云的认证链路上(RAM 账号权限、418 错误码的真实含义),代码本身并不复杂。如果你也在考虑给自己的站点加类似功能,希望这篇文章能省去一些调试时间。