摘要:「给文章加一行字数统计」听起来是个 10 分钟的小功能,真正的难点不在统计,而在放哪里:主题文章头部那一行 meta 是模板里硬编码的,没有 apply_filters,你没法把自己的项插进去。改模板会在主题升级时丢,用 JS 整行重绘会把原样式全弄没。本文给出第三条路 —— 渐进增强:服务端照常渲染、客户端把子项逐个搬进去,搬不动就原地保留。
适合阅读:做子主题 / 插件、需要往父主题 UI 里”塞东西”的开发者。文末附零依赖交付代码与三层测试(44 项纯函数断言 + 11 项 DOM 断言 + 线上端到端)。
一、需求很小,坑很大
需求一句话:在文章里显示「阅读约 X 分钟 · 全文 N 字」。第一版很自然地做成了一颗胶囊,丢在正文开头 —— 能用,但看起来像是贴上去的:主题头部那一行已经有「评论 / 浏览 / 点赞」,我们的胶囊孤零零躺在正文里,风格不搭。
于是想把这两项并进头部那一行。然后撞上了第一堵墙。
二、卡点:那一行是硬编码的,没有过滤器
先做一件最该做的事 —— 去读父主题源码,确认到底有没有过滤器可用:
grep -n "function zib_get_posts_meta" inc/functions/zib-theme.php
# 结果:函数存在,但整段实现里**没有任何 apply_filters**,
# meta 项是一个个字符串直接拼出来的 —— 无法从外部插入。
这时候只有三条路:
| 方案 | 做法 | 代价 |
|---|---|---|
| 改父主题模板 | 直接编辑主题文件,把新项拼进那一行 | 红线:升级即丢,且不允许改父主题 |
| JS 整行重绘 | 用脚本把整行 innerHTML 重新拼一遍 | 原样式全丢(每个子项的 class 都带着主题的独立样式)、还会闪 |
| 渐进增强(本方案) | 服务端渲染自己的块;客户端把它的子项逐个搬进目标行 | 几乎为零 —— 搬不进去就原地保留 |
第二条路的坑值得多说一句:那一行里的每个子项都带自己的 class,主题对它们有独立样式(字号、间距、图标尺寸)。你整块塞一个新元素进去,得到的是一个「块级陌生元素」,样式全失效 —— 位置对了,观感更差。
而逐个 appendChild 已有的子项,目标行对 item 的样式会自动生效,等于「借用」了主题已经调好的那一份样式。
三、方案:服务端渲染 + 客户端并入
3.1 服务端只做自己那部分,保持可独立使用
服务端把信息行渲染成一颗自带样式的胶囊,附一个标记属性:
<div class="zdw-reading-meta" data-zdw-move="1">
<item class="meta-time">阅读约 33 分钟</item>
<item class="meta-words">9,727 字</item>
</div>
注意两点:① 样式是自带的(不依赖主题),所以就算搬运失败也能独立显示;② 子项用的是 item 元素 —— 和父主题 meta 行里既有的子项同构,搬进去以后天然享受同一套样式。
3.2 客户端:搬子项,不搬外壳
var w = document.querySelector('.zdw-reading-meta[data-zdw-move]');
var t = document.querySelector('.post-metas'); // 目标行(可配置)
if (!w || !t) return; // 🧨 找不到目标 → 原地保留,功能不丢
while (w.firstChild) {
if (w.firstChild.nodeType === 1) t.appendChild(w.firstChild); // 只搬元素子项
else w.removeChild(w.firstChild); // 空白文本节点直接丢弃
}
w.parentNode.removeChild(w); // 最后移除外壳
细节:appendChild 会移动节点(不是复制),所以搬完原位置自然就空了;只搬 nodeType === 1 的元素、丢掉空白文本节点,避免往目标行里塞进一堆无意义的换行符。
3.3 为什么不会闪烁
这是很多人会质疑的一点:先用 CSS 藏起来再搬,不是必然闪一下吗?
这里根本不需要藏。关键在于 DOM 顺序:头部的 meta 行在 HTML 里早于正文输出,而这段脚本是随正文一起到达的 —— 脚本执行时,目标行早已存在。所以整段逻辑是同步执行的:没有 DOMContentLoaded 等待、没有 setTimeout、没有重排窗口。浏览器把这一帧画出来的时候,子项已经在目标行里了。
如果目标行是 AJAX 异步渲染的(有些主题的用户中心是这样),上面这个前提就不成立了 —— 那种场景需要 MutationObserver 等待,本方案的降级行为是「胶囊留在正文里」,功能不受影响。
四、字数到底怎么算(这里也有一个真 bug)
4.1 两种口径,差别很大
「字数」至少有两种常见口径:
| 口径 | 算法 | 一篇 800 词英文文章显示为 |
|---|---|---|
| 非空白字符数 | 去掉空白后的字符长度(中文站点常用) | 约 6,000 字(明显不对) |
| 中文按字 + 英文按词 | 汉字与拉丁词分别计数再相加 | 约 800 字 |
中英混排站点用第一种口径会闹笑话。所以交付代码默认用第二种,同时保留一个开关可以切回字符口径(老站点口径迁移时用得上)。
4.2 单测抓出来的真 bug:先删空白,再按词切
第一版实现是这样的(当时想的是「先把空白都去掉,好统计」):
$text = preg_replace('/s/', '', $text); // 先删掉所有空白
$words = preg_match_all('/[A-Za-z0-9]+/', $text, $m); // 再数英文词
纯函数单测直接把这个实现打回来了:"中文 abc def" 期望 4,实际得到 3;"a b cntd" 期望 4 个词,实际只数出 1 个。
根因很简单:删掉空白后,abc def 变成了 abcdef,三个词被粘成了一个。「先删空白」这个动作只在字符口径下是正确的,词口径下必须保留空白作为分隔。
修正后的管线是:「剥标签 → 解码实体 → 中文按字(直接全文匹配)→ 把中文替换成空格 → 剩下按词切」。
顺带一个必须记住的坑:preg_replace('/s/', ...) 里的 s 写成 /s/ 的话,删掉的是字母 s而不是空白符 —— 文本里所有 s 会被悄悄抹掉,字数统计无声变少。这个坑 v1 真踩过。
五、三层实测
交付代码用三层测试覆盖(纯计算 → 浏览器 DOM → 线上真实页面):
| 层 | 覆盖内容 | 规模 | 结果 |
|---|---|---|---|
| ① 纯函数单测(PHP CLI,无需 WP) | 中英混排计数 / 空白符处理 / HTML 与实体 / 参数边界 / 千分位 / 输出结构 / 样式不写死背景 / 目标为空时不输出脚本 | 22 项 × 2 口径 | 44/44 通过 |
| ② 浏览器 DOM 测试(真实生成片段 + headless) | 未配目标→原地保留 / 命中目标→子项搬入且外壳移除 / 目标未命中→原地保留 / 重复执行幂等 | 3 场景 11 断言 | 全绿 |
| ③ 线上端到端(真实站点) | 文章页输出、首页与归档不输出;真实浏览器里子项确实落进 .post-metas、外壳残留 0;卸载后无残留 | 3 页 + 1 次 DOM 抓取 | 全绿 |
第 ② 层的做法值得一提:不手抄 JS,而是先用本地 PHP 生成真实输出片段,再注入浏览器。这样测的就是交付代码的真实产物,而不是我抄错一版。
六、踩坑记录
| # | 坑 | 现象 | 根因 / 解法 |
|---|---|---|---|
| 1 | 重复给核心类加同名方法 | Fatal:Cannot redeclare Zhiji_Adapter::svg() | 辅助方法已在别处定义过。加方法/函数前先 grep -n "function xxx" |
| 2 | 用 in_the_loop() 判断”是否正文” | 正常文章被整体挡掉,信息行不显示 | 某些主题用模板直接渲染正文,该值为恒 false。只判 is_singular('post'),再排除 feed/preview |
| 3 | 搬运脚本重复执行 | 子项被搬两次(出现两组字数) | 脚本要自带执行标记(window.__zdwRmMoved),保证幂等 |
| 4 | 只删空白导致英文词粘连 | 4 个英文词被数成 1 个 | 词口径下不能先删空白;字符口径下才需要 |
| 5 | 样式里写死背景色 | 深色主题下胶囊变成一块白底 | 用 currentColor / color-mix 派生,不写死颜色 |
另外提醒一句:如果主题本身已经有「字数 + 阅读时间」,同时开启会显示两组。接入前先确认,二选一。
附:测试方法
这套功能有两块逻辑,要用两种测试手段,别指望一种覆盖:
① 纯计算(字数/阅读时间)—— 用 PHP CLI 单测,不需要 WordPress
把计算写成纯函数(输入文本 / 输出数组),再 define 几个桩函数(add_filter 等)就能跑
⚠️ 关键:把边界值都写进断言(空文本 / 速度 0 / 中英混排 / HTML 实体 / 只有符号)
② DOM 搬运 —— 用无头浏览器,且**不要手抄 JS**
先用本地 PHP 生成真实输出片段(HTML + 脚本)→ 写进测试页 → chrome --dump-dom 读回断言
断言要点:搬之前的目标行状态、搬之后的子项数、外壳是否移除、原有哪些子项还在不在
第 ② 层最容易犯的错是脚本执行顺序:真实页面里搬运脚本随正文先到、断言脚本必须在它之后跑,顺序反过来就会得到「没搬成功」的假失败(我第一版就栽在这里)。
📚 zibll 子主题开发实录(共 22 篇) · 系列总目录与阅读路径 →
① 前端性能与加载
② 排版与样式系统
- 用 alt 后缀驱动的 WordPress 密集墙排版:从 float 到 flex-wrap 与纵向叠列
- 当主题模板没有过滤器:把「字数+阅读时间」并进它的 meta 行 (本文)
- 设计令牌的最小实现:一个颜色变量驱动全站(附阅读进度条)
③ 功能实现与边界
- 一个 81 行的朗读按钮改了三轮:三个静默失效的坑
- 删数据类功能怎么写才不闯祸:一个「删文清附件」的六条边界
- 令牌模型与 lost update:一个只在并发时出现的计数 bug
- 「我填了 1 天,它却按 7 天算」:文章过期提示的四个边界
- 「历史上的今天」为什么永远是空的:三个不报错的死写法,与两个时区坑
- 文章系列卡片:一个 PHP 8 致命错误,和三处「不报错但白干」的写法
- 友链申请表单:一次越权写入,和一处「写了但不发」的死角
④ 系统与运维
- 404 监控的四个坑:计数翻倍、键名推导、点路径取值、与「自己看不到数据」
- 评论反垃圾的四个坑:先被自己拦住、节流伪装失败、空内容归属、与「WP 原生背锅」
- 页面缓存一次都没命中:六个静默失效,与一条 nonce 结构性保证
- 维护模式:五个「看起来在工作、其实开了后门」的写法
- WebP 自动转换:三个「上传成功、图片没变」的静默失效
- 安全扫描器:五个「扫了但没扫到」的坑,和一个实测确认的路径逃逸
⑤ 成就与活动系统









暂无评论内容