G09 自研插件(二):标题下的字数与阅读时长

G08 的"上一篇 / 下一篇"在文章末尾留人,这一期叶扬把目光挪到文章开头。读者点进一篇教程,第一个问题往往是"这玩意要读多久?三分钟能看完我现在就看,三十分钟我先收藏"——Medium、知乎、掘金都在标题旁边挂着阅读时长,就是给读者一个心理预算。G09 就做这个:标题下方一行小字,"📖 全文约 N 字 · 阅读约 M 分钟"。自研插件第二弹,套路大半是 G08 的熟脸。

先看现场

不用找别的文章,你现在抬头看本文大标题的正下方,应该已经躺着一行灰色小字了。数字不是叶扬手写填进正文的,是插件在你打开页面的瞬间,现数正文算出来的——所以它永远不会和实际篇幅对不上,哪怕文章以后再编辑增补。

再顺手点进 G01G07 看看,每篇头顶都有,数字各不相同;而 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>

#headerdisplay:flex 的横排容器,标题和按钮组左右分居,往这里塞东西会直接挤进横排,不好惹。而 #content 的第一个子元素正好就是正文 #postBody——在它前面插一行,是最干净的缝隙:

var content = document.getElementById('content');
content.insertBefore(meta, content.firstChild);

脚本本身通过 config 的 script 字段注入在页面底部(G06 讲过的机制),执行时 DOM 早已就绪,连 DOMContentLoaded 监听都省了。

设计决策二:字数到底怎么数

最朴素的想法是 textContent.length,在中文博客上它错得离谱:

中文和英文得用两套规则:汉字一个算一个,连续的拉丁字母/数字串算一个词,标点和空白一律不计。

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——原因是这一期真踩到一个同形字的坑,放到下一节单独说。

两个边界规则顺便交代:

插曲:一个长得一模一样的错字

CJK 第三个区段是兼容表意区,范围 U+F900U+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、去掉标签、解码 &amp; 之类的实体,喂给计数正则,结果和阅读直觉对得上:

文章 汉字 拉丁词 总计数 时长
#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(getElementByIdcreateElementhead.appendChildinsertBefore),三十行搞定。三个分支全绿才上线——最后的颜色、间距这种视觉活儿,才留给真机肉眼。

小结

五十行代码,全站文章立刻有了"阅读预算"。G08 总结的自研插件四件套——URL 正则定位、Primer 变量管主题、#content 找插入点、评论区附近收尾——这一期复用了前三件,只把数据源从 G04 系的 postList.json 换成了页面自身的 DOM。另外白捡一条经验:涉及生僻 Unicode 边界,码位转义 + 打印码位验收。

下一期 G10 要玩个大的:postList.json 里躺着全站文章的标题与日期,却只被 sitemap 和 G08 用过——叶扬打算把它按年份分组渲染成一张时间线归档页,让读者一眼看尽博客的全部家当。

参考

✍️ 原创文章,转载请注明出处~叶扬谢谢你来过 ❤️