面向哲思的编程与架构
笔记哲思阅读动态搜索RSS 订阅
切换到深色模式
搜索
RSS 订阅
切换到深色模式
© 2026 Vic Chen. All rights reserved.CC BY-NC-ND 4.0
← 笔记
给网站加朗读功能(一):从 Web Speech API 到云端 TTS 的完整实现

给网站加朗读功能(一):从 Web Speech API 到云端 TTS 的完整实现

2026年3月27日2,2297分钟

从「坐地铁刷文章」到「开车听文章」的需求演变,以及由此引发的一次工程实践:为什么浏览器原生 TTS 不够用、阿里云 NLS WebSocket 协议的工作方式、Next.js API 路由如何做 token 缓存、播放器的状态机设计、预取竞态修复,以及段落跟随滚动的实现。


目录
  • 1. 为什么不用浏览器原生 TTS
  • 2. 阿里云 NLS 的认证模型
  • 3. Next.js API 路由:WebSocket 在服务端
  • 3.1 Token 缓存
  • 3.2 收集音频流
  • 4. 前端播放器:状态机与音频管理
  • 4.1 状态机
  • 4.2 音频对象管理
  • 4.3 预取、缓存与竞态修复
  • 4.4 语速调整
  • 5. 段落提取与跟随滚动
  • 5.1 文章分段
  • 5.2 跟随滚动
  • 6. 播放器 UI 组成
  • 7. 一些细节
目录
  • 1. 为什么不用浏览器原生 TTS
  • 2. 阿里云 NLS 的认证模型
  • 3. Next.js API 路由:WebSocket 在服务端
  • 3.1 Token 缓存
  • 3.2 收集音频流
  • 4. 前端播放器:状态机与音频管理
  • 4.1 状态机
  • 4.2 音频对象管理
  • 4.3 预取、缓存与竞态修复
  • 4.4 语速调整
  • 5. 段落提取与跟随滚动
  • 5.1 文章分段
  • 5.2 跟随滚动
  • 6. 播放器 UI 组成
  • 7. 一些细节
目录
  1. 1. 为什么不用浏览器原生 TTS
  2. 2. 阿里云 NLS 的认证模型
  3. 3. Next.js API 路由:WebSocket 在服务端
  4. 3.1 Token 缓存
  5. 3.2 收集音频流
  6. 4. 前端播放器:状态机与音频管理
  7. 4.1 状态机
  8. 4.2 音频对象管理
  9. 4.3 预取、缓存与竞态修复
  10. 4.4 语速调整
  11. 5. 段落提取与跟随滚动
  12. 5.1 文章分段
  13. 5.2 跟随滚动
  14. 6. 播放器 UI 组成
  15. 7. 一些细节
TTS
相关文章
  • 01
    给网站加朗读功能(二):声音复刻而不是通用音色2026/07
  • 02
    AI 搜索内核(一):从字符串到符号2026/07
  • 03
    Claude Code 规模化实践(一):如何在大型代码库中工作2026/07
← 上一篇从预测一个词开始(二):词怎么变成数
下一篇 →现代计算的基础设施(一):隔离的代价

评论

© 2026 Vic Chen · 面向哲思的编程与架构CC BY-NC-ND 4.0

以前上下班坐地铁,通勤时间是天然的阅读窗口——掏出手机,打开文章,四五十分钟就这么过去了。后来换成开车出行,这块时间突然消失了。两手握着方向盘,眼睛盯着路,唯一能做的是听。播客、有声书、微信公众号的语音功能……慢慢发现,「听」(至少在不得已的时候)其实是一种效率相当高的内容消费方式,而且对于自己写的文章,听还多了一层价值:用耳朵重新过一遍,很容易发现句子读起来别扭、逻辑跳跃、或者某个表达在视觉上看顺了但口语节奏完全不对的地方。

这篇文章记录了本站实现「听全文」功能的完整过程。表面上是加一个播放按钮,背后涉及的问题比预想的多:浏览器原生语音合成的质量上限、云端 TTS 的 WebSocket 协议、Next.js 服务端路由的模块生命周期、前端播放器的状态机设计,以及跟随滚动的 DOM 映射。


1. 为什么不用浏览器原生 TTS(Chen 注:Text-to-Speech,文本转语音。广义上指把文字转成可播放音频的技术,既包括浏览器内置的 Web Speech API,也包括云端神经网络语音合成服务。)

最省事的方案是 Web Speech API:

const utter = new SpeechSynthesisUtterance(text);
utter.lang = "zh-CN";
window.speechSynthesis.speak(utter);

零成本,无需后端,三行代码搞定。我确实先做了这个版本——按钮点击、文章分段、逐段朗读、进度显示,都工作得很好。

但声音质量是硬伤。Web Speech API 使用操作系统内置的 TTS 引擎,质量完全取决于用户的设备:

  • macOS / iOS:Apple 的神经语音引擎,中文效果尚可
  • Windows:默认引擎机械感很强,中文尤其明显
  • Android:差异很大,依赖厂商定制

更根本的问题是不一致性——同一篇文章在不同设备上听起来完全不同,没有办法作为产品体验来提供。

于是,决定升级到云端 TTS。选型上优先考虑国内用户可访问性,阿里云 NLS 在国内直连,有免费额度,中文声音模型质量可以接受。


2. 阿里云 NLS 的认证模型

阿里云 NLS(Natural Language Service)的认证是两层结构,初看有些绕:

图1:阿里云 NLS 两层认证
  • 第一层:AccessKey 换 Token

用 AccessKeyId 和 AccessKeySecret 调用 nls-meta.cn-shanghai.aliyuncs.com,这是一个标准的阿里云 OpenAPI 调用,走 HMAC-SHA1 签名。返回的 Token 有效期 24 小时。

  • 第二层:Token 调语音服务

拿着 Token 和 Appkey,连接 wss://nls-gateway.cn-shanghai.aliyuncs.com/ws/v1,Token 放在 HTTP 头 X-NLS-Token 里。连接建立后,发送 JSON 格式的 StartSynthesis 消息,Gateway 回传二进制音频流,最后发 SynthesisCompleted 表示结束。


3. Next.js API 路由:WebSocket 在服务端

前端播放器每次需要一段音频时,向 /api/tts 发一个 POST 请求,传入文本,收到 audio/mpeg 二进制响应,用 URL.createObjectURL 转成可播放的 URL。

服务端路由做三件事:取 token、建 WebSocket、收集音频。

3.1 Token 缓存

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,之后同一实例的所有请求都复用缓存。

3.2 收集音频流

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。


4. 前端播放器:状态机与音频管理

播放器的核心是一个状态机,加上对 HTMLAudioElement 的管理。

4.1 状态机

图2:播放器状态机

loading 状态期间,播放/暂停按钮变为半透明不可点击,前进后退按钮禁用,文字显示「加载中…」。这避免了用户在音频未就绪时操作导致的状态混乱。

4.2 音频对象管理

整个播放器生命周期共用一个 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 内存泄漏)。

4.3 预取、缓存与竞态修复

每段音频都需要一次网络请求,如果等到当前段播完再请求下一段,中间会有明显的停顿感。解决方案是在当前段开始播放时就预取下一段:

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"),播放就停了。

图3:预取竞态导致播放中断的时序

修复方案是把「直接返回 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 后继续播放,完全无感。

4.4 语速调整

语速通过 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 并发模式下处理异步回调读取最新值的标准做法。


5. 段落提取与跟随滚动

5.1 文章分段

文章内容是 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,元素引用用于后续的跟随滚动。

5.2 跟随滚动

每次切换到新段落,如果开关打开,找到对应的 DOM 元素执行 scrollIntoView:

if (autoScrollRef.current) {
  elementsRef.current[index]?.scrollIntoView({
    behavior: "smooth",
    block: "center",
  });
}

block: "center" 让当前朗读的段落出现在视口中间,而不是顶部,阅读体验更好。

跟随滚动用 ref + state 双轨管理:autoScrollRef 给异步回调读取,autoScroll 驱动按钮的高亮状态。


6. 播放器 UI 组成

封面图取自文章的 cover frontmatter,通过 NoteLayout → ArticleActions prop 传递到播放器。无封面的文章显示四根波形条:播放时做跳动动画(CSS @keyframes),暂停时静止。

播放器通过 createPortal 挂到 document.body,脱离组件树的 overflow 约束,确保始终固定在视口底部。进入时用 translateY(100%) → translateY(0) 滑入动画。


7. 一些细节

  • 代码块的处理。目前 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 错误码的真实含义),代码本身并不复杂。如果你也在考虑给自己的站点加类似功能,希望这篇文章能省去一些调试时间。

{
RPCClient
}
=
require
(
"@alicloud/pop-core"
);
const client = new RPCClient({ /* ... */ });
const result = await client.request("CreateToken");
cachedToken = { id: result.Token.Id, expireTime: result.Token.ExpireTime };
return cachedToken.id;
}
});
((
u
)
=>
URL
.
revokeObjectURL
(u));
};
}, []);
break
;
// 请求已结束但未入缓存(失败)
}
return urlCache.current.get(index) ?? null;
}
(text
&&
text.
length
>
1
) {
chunks.push(text);
elements.push(el as HTMLElement);
}
});
}