摘要:给文章页加一个「朗读」按钮,核心代码不到 100 行,但它前后改了三轮,每一轮都是一个静默失效的坑:按钮样式明明写了却在空闲态露馅、白天看得见晚上看不见、桌面缩小窗口后按钮不跟着搬家。三个坑的共同点是 —— 页面能开,功能看着在,只是不对,没有报错、没有异常,最难发现的一类。
适合阅读:WordPress 主题 / 插件开发者,以及做前端交互组件的工程师。文末附零依赖交付代码(含 18 项浏览器自动化断言)。
一、先决定:自己合成,还是调云端 TTS
做朗读功能的第一件事不是写代码,是选路线。两条路的差别比想象中大:
| 维度 | 浏览器原生 SpeechSynthesis | 云端 TTS API(讯飞/阿里/OpenAI 等) |
|---|---|---|
| 费用 | 0 | 按字符计费,长文按次朗读成本可观 |
| 延迟 | 本地合成,按下即响 | 需上传文本 → 云端合成 → 回传音频,首字节通常 0.5~2s |
| 隐私 | 正文不出浏览器 | 整篇正文要发到第三方 |
| 音色 | 依赖用户系统(中文多为系统默认音) | 可挑音色、可调情感 |
| 前置条件 | 部分浏览器要求先有用户交互 | 需要 API Key 与配额管理 |
| 可用性 | 现代桌面/移动浏览器普遍支持 | 受网络与配额影响 |
对一个内容站点来说,朗读是「锦上添花」的功能:正文里有代码、有表格、有英文术语,任何 TTS 读出来都不完美。既然如此,成本、隐私、延迟这三项就成了决定因素 —— 我们选了浏览器原生方案:零费用、文本不出浏览器、按下即响。
代价是要接受「音色不由我们定」。这个取舍我认为是划算的:如果哪天要做「高质量有声文章」,那应该是一个单独的付费功能,而不是给每个读者默认加载的东西。
二、把它做对要处理的四件事
2.1 长文必须切分:一个引擎的硬限制
直接把整篇正文塞给一个 SpeechSynthesisUtterance,在 Chrome 上会遇到两个现象:读一半就停,或者干脆不发声且不报错。原因是 utterance 有长度上限,超长文本会被截断。
所以必须自己做队列:先按中英文句末标点断句,再把超长单句二次切成固定长度,一段读完在 onend 里播下一段。
var MAX_CHUNK = 160; // 切分上限,留足余量
function splitChunks(text) {
// 先按句末标点断句(。!?;!?;)
var sentences = text.replace(/s+/g, ' ').split(/(?<=[。!?;!?;])/);
// 再把过长的句子切成 <= MAX_CHUNK 的片段,最后合并相邻短句
...
}
这里有个兼容性细节:String.prototype.split 的 lookbehind 断言((?<=...))在旧引擎上会直接抛异常。所以要么 try/catch 降级,要么用「捕获组切分再两两合并」的写法 —— 交付代码里两条路都留了。
2.2 三态控制:播放 / 暂停 / 停止
speechSynthesis 的三个方法对应三种语义,但状态要自己维护:
speechSynthesis.pause()/resume()是真暂停(保持当前位置);speechSynthesis.cancel()是清空队列 —— 所以「停止」后要从头开始;- 浏览器还有一个怪癖:
pause()之后如果用户手动触发,’ . ‘状态可能不同步 → 必须自己记state,而不是去问speechSynthesis.speaking。
另外别忘了 beforeunload 里 cancel():否则访客切到别的标签页,朗读会在后台继续,体验很糟。
2.3 挂载点:宽屏贴 meta 行,窄屏回落正文顶
主题的文章页头部有一行 meta(评论/浏览/点赞/字数)。宽屏时把朗读钮做成绝对定位放进那一行的容器左侧,与右侧的 meta 项视觉对称;窄屏(≤767px)那一行放不下,就回落到正文顶部原位,避免叠压统计数字。
2.4 纯图标,进度藏进 title
按钮里不放文字(「朗读本文」四个字会明显挤压移动端头部),只放 14×14 的 SVG 图标;播报进度(第 n/m 段)放到 title 与 aria-label,鼠标悬停能看到,读屏软件也能读到。
三、三个静默失效的坑(本文重点)
坑 1:style.cssText 是「整体覆盖」,会抹掉 innerHTML 里的 display:none
控件是先用 innerHTML 建出来的,「停止」按钮初始就写死隐藏:
bar.innerHTML = '<button class="tts-play">▶</button>'
+ '<button class="tts-stop" style="display:none">⏹</button>';
然后为了统一设置样式,用了 style.cssText 整串赋值:
btnStop.style.cssText = 'cursor:pointer;width:28px;height:28px;...';
cssText 的语义是「用这串文本替换整个 style 属性」,不是「追加」。于是 innerHTML 阶段的 display:none 被一起抹掉了 —— 空闲状态下「停止」按钮是可见的,用户还没点播放就看到了一个没有任何作用的停止钮。页面正常、无报错,只有仔细看才会觉得别扭。
修法只有一句,但必须记住:cssText 覆盖之后,状态渲染要重新跑一遍。
btnPlay.style.cssText = '...';
btnStop.style.cssText = '...';
render(); // ← 覆盖后重设状态:空闲态把停止钮重新隐藏
坑 2:硬编码白底,深色主题下按钮直接消失
第一版按钮样式是这样的:background:#fff; border:1px solid #2e7cf6; color:#2e7cf6。白天看着挺好,切到夜间模式就出问题了 —— 深色页面上一块白底圆钮极其突兀,而后来的某一版把颜色改成继承色后,白底 + 继承的浅色文字 = 完全看不见。
结论:这类主题内小组件,永远不要写死背景色与文字色,要用「透明底 + 继承色 / CSS 变量」:
btn.style.cssText = 'background:transparent;'
+ 'border:1px solid var(--zhiji-brand,currentColor);'
+ 'color:var(--zhiji-brand,inherit);'; // 两个都带兜底
这样亮色/暗色主题、以及未来换品牌色,都不用再动这个组件。
坑 3:只在 boot 判定一次视口,窗口缩放后按钮不会搬家
挂载点依赖 matchMedia 对 min-width:768px 的判定结果。第一版只在页面加载时判定一次:
var wide = matchMedia('(min-width:768px)').matches; // ← 只算一次
var host = wide ? metaBox.parentNode : null;
桌面端把窗口从 1200px 拖到 600px,按钮还留在 meta 行里,和统计数字挤在一起。这在桌面浏览器上是很常见的操作,但自动化测试几乎测不到(测试通常在固定视口跑)。
交付版把这段改成 matchMedia 的 change 监听:宽窄切换时主动重挂载,并顺手解决了另一个陈旧状态的问题(换挂载点后必须重跑渲染)。
var mq = matchMedia('(min-width:768px)');
mq.addEventListener('change', mount); // 旧 Safari 用 mq.addListener(mount)
附带一个坑:测试工具自己也会骗你
用 headless Chrome 测这个组件时,我踩了两个「工具侧」的坑,它们会让人误以为是产品代码有问题:
| 现象 | 根因 | 正确做法 |
|---|---|---|
注入的 speechSynthesis stub 不生效,真引擎抛 parameter 1 is not of type ... | window.speechSynthesis 是只读 accessor,直接赋值静默失败 | 用 Object.defineProperty(window, 'speechSynthesis', {value: stub, configurable: true}) |
想测「浏览器不支持 TTS」的降级,却发现 typeof speechSynthesis === 'object' | headless Chrome 确实有 speechSynthesis 对象,只是永远不发声 | 显式把两个全局都 defineProperty 成 undefined 再加载脚本 |
| 手机端测试「按钮不回落」 | 先加载页面再切视口 → matchMedia 判定的是旧视口 | CDP 的 Emulation.setDeviceMetricsOverride 必须在 navigate 之前执行 |
四、配置注入的顺序陷阱(WordPress 侧)
语速这类配置要从后台传给前端。用 wp_add_inline_script() 把配置内联到脚本句柄上是最省事的做法,但有一个静默失效点:它必须在句柄入队之后调用。
add_action('wp_enqueue_scripts', 'my_enqueue', 20); // 功能声明
function my_enqueue() {
// 资源入队通常挂在优先级 99
...
}
function my_inline_cfg() {
wp_add_inline_script('my-handle', 'window.CFG=...;', 'before');
}
// 🧨 必须挂在入队之后(100)——
// 早于入队 = 句柄还没进队列 = 这段内联脚本被静默丢弃
add_action('wp_enqueue_scripts', 'my_inline_cfg', 100);
同一个钩子上,同优先级按注册先后执行。把内联配置挂在 20,而资源入队在 99,你的配置就跑在入队前面 —— 页面照常打开,只是语速设置永远不生效,控制台一片安静。
五、交付代码的实测结果
交付版(见第八、九章)在一份 81 行的基础上补了 resize 重挂载与异常兜底,并用两层测试验证过:
| 测试层 | 覆盖内容 | 结果 |
|---|---|---|
| 浏览器级(Chrome headless + stub,18 项) | 按钮注入 / 空闲态隐藏停止钮 / 背景透明 / 宽屏绝对定位 / 播放·暂停·继续·停止四态 / 图标切换 / 长文分片 / 宽窄屏重挂载 / 自动连播到底 / cancel 调用 / 无 TTS 时降级 | 18/18 通过 |
| 站点级(线上真实注入) | 文章页输出脚本 + 配置;首页与分类归档不输出;卸载后无残留 | 全绿 |
分片实测:一段约 300 字的中文正文,被切成 2 段播报(上限 160 字符),队列连播到底后状态自动回到 idle、停止钮自动隐藏。
资源开销:脚本经空句柄内联(src = false),不产生任何额外 HTTP 请求;只在文章页输出,首页/归档零开销。
六、什么时候别用原生 TTS
最后给一条反向建议。下面这些场景,原生方案不合适,应该直接上云端:
- 需要固定音色/品牌声音(比如站点的「专属主播」);
- 要下载音频文件、做播客分发;
- 正文以公式、代码、表格为主,需要专门的处理策略;
- 面向大量非中文读者,需要多语种音色与发音纠错。
而如果只是「让读者可以听着看」,原生方案在成本、隐私、速度三项上是压倒性的。工程上最优解往往不是最强的方案,而是刚好够用且零依赖的那个。
附:本文测试方法
本文的 18 项浏览器断言可以用任何支持 headless 的浏览器复现,思路是用 stub 顶替浏览器能力,再在真实 DOM 上跑真实脚本:
1) 造一个最小页面:.single-metabox + .article-content + 长正文
2) 在脚本之前注入 stub:
Object.defineProperty(window, 'speechSynthesis', {value: {...}, configurable: true});
Object.defineProperty(window, 'SpeechSynthesisUtterance', {value: function(){...}, configurable: true});
并 stub matchMedia(暴露一个 __setWide() 手动翻转视口宽度)
3) 加载脚本 → 断言 DOM 与状态机(把结果写进 #result)
4) chrome --headless=new --dump-dom --virtual-time-budget=9000 读回结果
⚠️ 关键点:stub 必须用 defineProperty —— 直接给 window.speechSynthesis 赋值是**静默失败**的。
📚 zibll 子主题开发实录(共 22 篇) · 系列总目录与阅读路径 →
① 前端性能与加载
② 排版与样式系统
- 用 alt 后缀驱动的 WordPress 密集墙排版:从 float 到 flex-wrap 与纵向叠列
- 当主题模板没有过滤器:把「字数+阅读时间」并进它的 meta 行
- 设计令牌的最小实现:一个颜色变量驱动全站(附阅读进度条)
③ 功能实现与边界
- 一个 81 行的朗读按钮改了三轮:三个静默失效的坑 (本文)
- 删数据类功能怎么写才不闯祸:一个「删文清附件」的六条边界
- 令牌模型与 lost update:一个只在并发时出现的计数 bug
- 「我填了 1 天,它却按 7 天算」:文章过期提示的四个边界
- 「历史上的今天」为什么永远是空的:三个不报错的死写法,与两个时区坑
- 文章系列卡片:一个 PHP 8 致命错误,和三处「不报错但白干」的写法
- 友链申请表单:一次越权写入,和一处「写了但不发」的死角
④ 系统与运维
- 404 监控的四个坑:计数翻倍、键名推导、点路径取值、与「自己看不到数据」
- 评论反垃圾的四个坑:先被自己拦住、节流伪装失败、空内容归属、与「WP 原生背锅」
- 页面缓存一次都没命中:六个静默失效,与一条 nonce 结构性保证
- 维护模式:五个「看起来在工作、其实开了后门」的写法
- WebP 自动转换:三个「上传成功、图片没变」的静默失效
- 安全扫描器:五个「扫了但没扫到」的坑,和一个实测确认的路径逃逸
⑤ 成就与活动系统










暂无评论内容