一个 81 行的朗读按钮改了三轮:三个静默失效的坑

一个 81 行的朗读按钮改了三轮:三个静默失效的坑-知集
一个 81 行的朗读按钮改了三轮:三个静默失效的坑
此内容为付费阅读,请付费后查看
¥1
立即购买
您当前未登录!建议登陆后购买,可保存购买订单
付费阅读
阅读约 51 分钟15,248 字

摘要:给文章页加一个「朗读」按钮,核心代码不到 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 篇) · 系列总目录与阅读路径 →

① 前端性能与加载

② 排版与样式系统

③ 功能实现与边界

④ 系统与运维

⑤ 成就与活动系统

© 版权声明
THE END
喜欢就支持一下吧
点赞12 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容