G09 自研插件(二):标题下的字数与阅读时长
G08 的"上一篇 / 下一篇"在文章末尾留人,这一期叶扬把目光挪到文章开头。读者点进一篇教程,第一个问题往往是"这玩意要读多久?三分钟能看完我现在就看,三十分钟我先收藏"——Medium、知乎、掘金都在标题旁边挂着阅读时长,就是给读者一个心理预算。G09 就做这个:标题下方一行小字,"📖 全文约 N 字 · 阅读约 M 分钟"。自研插件第二弹,套路大半是 G08 的熟脸。
先看现场
不用找别的文章,你现在抬头看本文大标题的正下方,应该已经躺着一行灰色小字了。数字不是叶扬手写填进正文的,是插件在你打开页面的瞬间,现数正文算出来的——所以它永远不会和实际篇幅对不上,哪怕文章以后再编辑增补。
再顺手点进 G01 或 G07 看看,每篇头顶都有,数字各不相同;而 About 页 干干净净,一个字都没有。
设计决策一:插在哪个缝里
先看 Gmeek 文章页的骨架:
<div id="header">
<h1 class="postTitle">文章标题</h1>
<div class="title-right">首页 / Issue / 主题切换 三个按钮</div>
</div>
<div id="content">
<div class="markdown-body" id="postBody"> …正文… </div>
<!-- 版权小字、评论按钮 -->
</div>#header 是 display:flex 的横排容器,标题和按钮组左右分居,往这里塞东西会直接挤进横排,不好惹。而 #content 的第一个子元素正好就是正文 #postBody——在它前面插一行,是最干净的缝隙:
var content = document.getElementById('content');
content.insertBefore(meta, content.firstChild);脚本本身通过 config 的 script 字段注入在页面底部(G06 讲过的机制),执行时 DOM 早已就绪,连 DOMContentLoaded 监听都省了。
设计决策二:字数到底怎么数
最朴素的想法是 textContent.length,在中文博客上它错得离谱:
- 空白、换行、每一个标点都被算成"字";
- 英文更冤,
Markdown一个词要算 8 个字。
中文和英文得用两套规则:汉字一个算一个,连续的拉丁字母/数字串算一个词,标点和空白一律不计。
var text = body.textContent || '';
var count = 0;
// CJK 统一表意文字:基本区、扩展A、兼容区三个区段
var cjk = text.match(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/g);
if (cjk) count += cjk.length;
// 拉丁单词:连续字母/数字,允许词内撇号与连字符(don't / well-known)
var words = text.match(/[A-Za-z0-9]+(?:['’-][A-Za-z0-9]+)*/g);
if (words) count += words.length;教程展示里写汉字区间很直观,但插件源码里叶扬最终用的是纯 ASCII 的码位写法 \u4e00-\u9fff——原因是这一期真踩到一个同形字的坑,放到下一节单独说。
两个边界规则顺便交代:
- 代码块算字数。
#postBody的 textContent 包含所有代码块,变量名按拉丁词计入。技术教程读代码比读散文慢,把代码算进去反而更诚实; - 正文不足 10 个计数就不显示,给错误页、占位页留个退路。
插曲:一个长得一模一样的错字
CJK 第三个区段是兼容表意区,范围 U+F900–U+FAFF。坑在于:U+F900 这个码位对应的字形就是"豈"(岂的繁体),而常用字里 U+8C48 长的也是"豈"——两个码位,肉眼完全同形。
叶扬在正则里写区间边界时,工具链层层转译,落进文件的边界字悄悄变成了 U+8C48。于是区间从 F900–FAFF 错成了 8C48–FAFF:除了覆盖一大片重复的汉字区,还把 A000–F8FF 之间的彝文音节、彝文部首等文字也划进了"汉字",中文博客里虽然永远撞不上,但这就是错的。
排查办法是别相信眼睛,让 node 把码位打出来:
[...'豈-'].forEach(c => console.log('U+' + c.codePointAt(0).toString(16)));
// 期望看到 f900 和 faff;看到 8c48 就是被同形字偷换了修复后的规矩也简单:字符类的边界一律写 \uXXXX 码位转义。它是纯 ASCII,git diff 里可读,复制到任何编辑器、任何语言的字符串里都不会被"智能"转换:
var cjk = text.match(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/g);Tip
凡是依赖生僻 Unicode 字符当边界的代码(各种分词、emoji 匹配同理),都写码位、都打印码位验收。同形字是这一类 bug 里最难缠的,因为代码"看起来完全正确"。
设计决策三:400 字/分钟从哪来的
Note
关于中文默读速度,各类语言文字研究给出的成人均值大约在 300–500 字/分钟;屏幕阅读偏慢端,技术文章还要边读边想代码,所以叶扬取 400 字/分钟 这个略宽松的值,只给数量级、不装精确。
var minutes = Math.max(1, Math.round(count / 400));两个小处理:Math.round 四舍五入,再用 Math.max(1, …) 兜底——再短的文章也显示"约 1 分钟",不会出现"阅读约 0 分钟"这种滑稽结果。
主题与手机:这一期难得没翻车
颜色照旧全部借 Primer 变量(G08 已拿到 issue #196 的官方背书):次要文字用 --color-fg-muted,中间的分钟数字加粗并用 --color-fg-default 提亮,明暗三态自动跟随,一行媒体查询都不用写。
.gmeek-rt{display:flex;align-items:center;gap:8px;flex-wrap:wrap;
margin:0 0 14px;font-size:13px;line-height:1.6;
color:var(--color-fg-muted,#656d76);}
.gmeek-rt b{font-weight:600;color:var(--color-fg-default,#1f2328);}
@media (max-width:600px){.gmeek-rt{font-size:12px;margin-bottom:12px;}}手机端这次叶扬学乖了。G08 的教训是"响应式不能靠想象",但反过来也要记住:不是什么元素都需要在手机上重排。这行 meta 满打满算二十来个字符(📖 全文约 2,927 字 · 阅读约 7 分钟),375px 宽的屏幕上只占小半行,纵排、通栏、放大热区统统不需要——小屏只把字号从 13px 收到 12px。先想内容会不会真的放不下,再决定要不要动布局。
完整代码
新建 static/plugins/GmeekReadTime.js,五十多行,零依赖:
/* GmeekReadTime —— 文章标题下的"字数 · 阅读时长"
* 数据源:#postBody 渲染后正文(构建期 GitHub gfm 渲染,运行时统计)
* 计数规则:CJK 汉字按字计;连续拉丁字母/数字串按 1 个词计;标点空白不计
* 估算速度:中文约 400 字/分钟,不足 1 分钟按 1 分钟
* 边界:仅 /post/N.html 生效;about 等固定页虽注入脚本但正则自退
*/
(function () {
var match = location.pathname.match(/\/post\/(\d+)\.html/);
if (!match) return;
var body = document.getElementById('postBody');
if (!body) return;
var text = body.textContent || '';
var count = 0;
var cjk = text.match(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/g);
if (cjk) count += cjk.length;
var words = text.match(/[A-Za-z0-9]+(?:['’-][A-Za-z0-9]+)*/g);
if (words) count += words.length;
if (count < 10) return;
var minutes = Math.max(1, Math.round(count / 400));
var meta = document.createElement('div');
meta.className = 'gmeek-rt';
meta.setAttribute('aria-label', '字数与阅读时长');
meta.innerHTML =
'<span class="gmeek-rt-item">📖 全文约 ' + count.toLocaleString('en-US') + ' 字</span>'
+ '<span class="gmeek-rt-sep" aria-hidden="true">·</span>'
+ '<span class="gmeek-rt-item">阅读约 <b>' + minutes + '</b> 分钟</span>';
injectStyle();
var content = document.getElementById('content');
if (content) content.insertBefore(meta, content.firstChild);
function injectStyle() {
if (document.getElementById('gmeek-rt-style')) return;
var style = document.createElement('style');
style.id = 'gmeek-rt-style';
style.textContent =
'.gmeek-rt{display:flex;align-items:center;gap:8px;flex-wrap:wrap;'
+ 'margin:0 0 14px;font-size:13px;line-height:1.6;'
+ 'color:var(--color-fg-muted,#656d76);}'
+ '.gmeek-rt b{font-weight:600;color:var(--color-fg-default,#1f2328);}'
+ '.gmeek-rt-sep{opacity:.55;}'
+ '@media (max-width:600px){.gmeek-rt{font-size:12px;margin-bottom:12px;}}';
document.head.appendChild(style);
}
})();config 的 script 字段拼成第四个标签(多插件同字符串拼接的写法,G05 讲过):
"script": "<script src='/plugins/GmeekTOC.js'></script><script src='/plugins/lightbox.js'></script><script src='/plugins/GmeekPrevNext.js'></script><script src='/plugins/GmeekReadTime.js'></script>"改了 config 和 static,老规矩:push、等几秒、手动触发一次全局重建。
不上浏览器,怎么测运行时插件
和 G08 一样,导航/统计是运行时才发生的事,curl 只能验到静态部分:插件文件 200、各文章页底部都有 GmeekReadTime.js 的 script 标签(About 页也有标签,但插件第二行正则就退出了)。数字对不对,curl 看不见。
叶扬用 node 做了两层离线验收。第一层直接拿本地构建产物 docs/post/N.html 试算:抠出 #postBody、去掉标签、解码 & 之类的实体,喂给计数正则,结果和阅读直觉对得上:
| 文章 | 汉字 | 拉丁词 | 总计数 | 时长 |
|---|---|---|---|---|
| #1 搭建篇 | 1,700 | 382 | 2,082 | 5 分钟 |
| #7 G01 | 1,518 | 248 | 1,766 | 4 分钟 |
| #13 G07 | 1,832 | 496 | 2,328 | 6 分钟 |
| #14 G08 | 2,196 | 731 | 2,927 | 7 分钟 |
篇幅越长代码越多,拉丁词占比越高,数字梯度也合理。
第二层更有意思:不给浏览器,伪造一个最小的 document,用 new Function('location','document', code) 把插件源码真跑一遍,然后断言:
new Function('location', 'document', code)({ pathname: '/about.html' }, documentStub);
// content.insertBefore 调用次数应为 0 —— About 自退
new Function('location', 'document', code)({ pathname: '/post/14.html' }, documentStub);
// 应插入 1 个 meta,innerHTML 里含 "2,927" 和 "7",注入 1 个 style
new Function('location', 'document', code)({ pathname: '/post/99.html' }, emptyDocStub);
// 空正文插入数应为 0桩对象只需实现插件真正碰过的几个 API(getElementById、createElement、head.appendChild、insertBefore),三十行搞定。三个分支全绿才上线——最后的颜色、间距这种视觉活儿,才留给真机肉眼。
小结
五十行代码,全站文章立刻有了"阅读预算"。G08 总结的自研插件四件套——URL 正则定位、Primer 变量管主题、#content 找插入点、评论区附近收尾——这一期复用了前三件,只把数据源从 G04 系的 postList.json 换成了页面自身的 DOM。另外白捡一条经验:涉及生僻 Unicode 边界,码位转义 + 打印码位验收。
下一期 G10 要玩个大的:postList.json 里躺着全站文章的标题与日期,却只被 sitemap 和 G08 用过——叶扬打算把它按年份分组渲染成一张时间线归档页,让读者一眼看尽博客的全部家当。
参考
- 本站插件源码:https://github.com/yeyangchen2009/yeyangchen2009.github.io/blob/main/static/plugins/GmeekReadTime.js
- 自研插件第一弹:G08 文章末尾的"上一篇 / 下一篇"
- 全局 script 注入机制:G06 文章末尾的秘密
- Primer CSS 变量体系:https://primer.style/foundations/color
- 系列总揽:Gmeek 插件与功能全景调研(第 0 篇)