跳转到内容
关系图谱

reading-time:中英分轨阅读时长统计

2,100 字 7 分钟

属性 1
series
VaultKit 包指南

@vaultkit/vitepress-plugin-reading-time 为 VitePress 提供阅读时长统计,架构是「一包两半」:构建期的 markdown-it 插件负责计数并写入 frontmatter,/client 子路径的可选 Vue 组件负责展示。核心卖点是中英分轨计数——中文语料按空格分词会把整段算成一个词,必须按字计,本插件对 CJK 字符逐字计数、拉丁文本按词计数,混排文章的统计才准确。

统计在构建期完成、结果写入 frontmatter.readingTime,SSR 与客户端天然一致,运行时零计算开销。本文覆盖两半的全部导出、全部选项与计数原理。

为什么需要它

「预计阅读 5 分钟」是文章头部的常见元信息,但拿现成英文方案(如 reading-time npm 包)统计中文会离谱地少:英文以空格分词,一段没有空格的中文会被数成 1 个词。三千字的中文文章按 200 词/分钟折算,「预计阅读 1 分钟」——完全失真。

正确做法是分轨:中文按计、配字速(字/分钟),英文按计、配词速(词/分钟),总时长为两轨之和。另一个架构决策是把统计放在构建期:客户端统计要么拿渲染后的 DOM 文本(水合前后不一致),要么重复传输原文;构建期写入 frontmatter 则一次计算、两端同源。

安装

sh
pnpm add -D @vaultkit/vitepress-plugin-reading-time

使用 @vaultkit/vitepress-theme-vault 主题则开箱即带——本站每篇文章头部的「全文约 N 字 · 预计阅读 M 分钟」就是它的输出。

快速上手

两半各接一处。构建半边:markdown-it 插件注册进 VitePress 的 markdown 管线:

.vitepress/config.ts
ts
import { readingTimePlugin } from '@vaultkit/vitepress-plugin-reading-time'
import { defineConfig } from 'vitepress'

export default defineConfig({
  markdown: {
    config(md) {
      md.use(readingTimePlugin) // 可传 { charsPerMinute, wordsPerMinute, includeCode }
    },
  },
})

展示半边:主题组件里从 /client 子路径引入展示组件(或干脆自己渲染 frontmatter.readingTime):

.vitepress/theme/components/PostMeta.vue
vue
<script setup lang="ts">
import { ReadingTime } from '@vaultkit/vitepress-plugin-reading-time/client'
import { useData } from 'vitepress'

const { frontmatter } = useData()
</script>

<template>
  <ReadingTime
    :stats="frontmatter.readingTime"
    :label="s => `全文约 ${(s.chars + s.words).toLocaleString()} 字 · 预计阅读 ${s.minutes} 分钟`"
  />
</template>
Important

/client 入口含 .vue 源文件,只要用了展示组件,就必须把本包加进 vite.ssr.noExternal

ts
// .vitepress/config.ts
import { defineConfig } from 'vitepress'

export default defineConfig({
  vite: { ssr: { noExternal: ['@vaultkit/vitepress-plugin-reading-time'] } },
})

只用构建半边(markdown-it 插件是纯 TS)则不受此约束。

公开导出一览

包有三个入口,../markdown-it 等价(默认入口就是构建半边):

入口导出说明
. / ./markdown-itreadingTimePlugin(md, options?)markdown-it 插件,统计写入 env.frontmatter.readingTime
同上readingTime(source, options?)纯函数形态,可脱离 markdown-it 单独调用
同上ReadingTimeOptions / ReadingTimeStats类型
./clientReadingTime(具名 + 默认导出)行内展示组件
./clientReadingTimeStats类型(与构建半边同构)

ReadingTimeStats 的三个字段:chars(CJK 字数)、words(拉丁词数)、minutes(预计阅读分钟数,非空内容 ≥1,空内容为 0)。

ReadingTimeOptions 逐项详解

插件与纯函数共享同一份选项。

charsPerMinute

  • 类型:number,默认 300
  • 语义:CJK 阅读速度,单位字/分钟。300 是中文快读的通行值。学术性内容读者会更慢,可以调低:
ts
md.use(readingTimePlugin, { charsPerMinute: 250 })

wordsPerMinute

  • 类型:number,默认 200
  • 语义:拉丁文阅读速度,单位词/分钟。200 是英文平均阅读速度的通行值。两轨速率相互独立——中文段落按 charsPerMinute 折算、英文段落按 wordsPerMinute 折算,最后求和。

includeCode

  • 类型:boolean,默认 true
  • 语义:是否把围栏代码块计入统计。默认计入——技术博客的代码是正文的一部分,读者确实要花时间读它。若你的代码块多为「可跳过的完整配置清单」,可以关掉:
ts
md.use(readingTimePlugin, { includeCode: false })

关闭时按 CommonMark 语义剥离 ``` 与 ~~~ 两式围栏(源码里分列两条正则规避回溯歧义;闭合缺失时吞到文末,与 CommonMark 行为一致)。

<ReadingTime /> props 逐项详解

stats

  • 类型:ReadingTimeStats | null,默认 null
  • 语义:统计对象,通常直接传 frontmatter.readingTime缺失或 minutes 为 0 时组件零渲染——没有装插件的页面、空页面不会渲染出「0 分钟」这种残缺信息,宿主无需包 v-if

label

  • 类型:(stats: ReadingTimeStats) => string,默认输出英文 1,234 words · 5 min read
  • 语义:文案模板函数。注意这里不是 labels 对象而是函数注入——因为阅读时长文案里数字与文字交织、各语言语序不同,函数是唯一不憋屈的注入形态。中文文案示例:
vue
<ReadingTime
  :stats="frontmatter.readingTime"
  :label="s => `全文约 ${(s.chars + s.words).toLocaleString()} 字 · 预计阅读 ${s.minutes} 分钟`"
/>

三个字段任你取用:只显示时长(s => `约 ${s.minutes} 分钟`)、区分中英字数(s => `${s.chars} 字 + ${s.words} words`)都行。组件本体是个 inline-flex<span>,前置一枚内联 lucide 时钟图标(aria-hidden),样式极简、字体颜色继承宿主。

设计机制

中英分轨计数原理

为什么中文不能按空格分词?英文的词边界由空格显式标记,text.split(/\s+/) 就能数词;中文没有词边界字符,整段中文在这条管线里是一个 token。分词器(jieba 之类)可以解决,但为「数个数」引入词典级依赖不值得——而且阅读速度研究本来就以「字/分钟」为中文单位,按字计既准又零依赖。

源码里的分轨实现干净利落:

ts
const chars = (text.match(CJK) ?? []).length // CJK 逐字计数
const words = text
  .replace(CJK, ' ') // 先把 CJK 字符抠掉
  .split(/\s+/)
  .filter(token => /[a-z0-9]/i.test(token)) // 纯符号 token 不算词
  .length

CJK 字符集覆盖统一表意文字(含扩展 A / 兼容区)+ 日文假名 + 谚文音节,所以日文、韩文内容同样按字计。第二轨先把 CJK 替换成空格再切词,保证「中文English混排」里的英文词被正确切出;filter 一步剔除纯标点 token(——... 不是词)。

时长公式:minutes = round(chars / charsPerMinute + words / wordsPerMinute),非空内容保底 1 分钟(避免短文显示「0 分钟」的尴尬),空内容为 0(配合展示组件零渲染)。

计数前的语法噪音剥离

Markdown 源文本里不少字符是「语法」而非「内容」,计数前先剥离:

  • HTML 标签只剥标记本身,保留标签间文本(<b>加粗</b> 计 2 字)
  • 任务清单标记 - [x] / - [ ] 是语法不是内容
  • 链接与图片剥掉 URL 部分、保留可读的链接文字([文字](https://very-long-url) 只计「文字」)——URL 再长也不该灌水阅读时长

frontmatter 同源写入与作者覆盖

插件注册为 markdown-it 的 core 规则,把统计写入 env.frontmatter.readingTime。关键在于 VitePress 的 pageData.frontmatter 与渲染期 env.frontmatter同一个对象引用,写进去的统计自动出现在 useData().frontmatter 里,SSR 与客户端天然一致,不需要任何额外的数据通道。

两个防御细节都写在源码注释里:

  1. 跳过 renderInline——VitePress 渲染标题、摘要片段时也走 core 链且共享页面 env,若不跳过(state.inlineMode),先执行的小片段会抢先把错误统计写进 frontmatter
  2. frontmatter 优先——作者手写 readingTime 键时插件不覆盖,你可以对某篇文章人工指定时长

纯函数形态

readingTime(source, options?) 可脱离 markdown-it 使用,比如在 RSS 生成脚本里统计:

ts
import { readingTime } from '@vaultkit/vitepress-plugin-reading-time'

const stats = readingTime('# 标题\n\n三千字的中文正文……')
// => { chars: 3000, words: 0, minutes: 10 }

高级与边界

  • 注册时机:建议在源文本清洗(如注释剥离、自定义容器展开)之后注册插件,统计口径与最终发布内容一致
  • peer 依赖markdown-it >= 14.0.0vue ^3.5.0;只用构建半边可以不装 vue(peer 警告可忽略)
  • frontmatter 本身不计数:markdown-it 拿到的 state.src 已由 VitePress 剥离 frontmatter,标题、标签不会灌水
  • 展示组件可替换frontmatter.readingTime 是普通数据,完全可以不用 <ReadingTime />,在自己的文章头组件里任意排版

常见问题

Q:为什么统计的字数和 Word 里不一样? 口径不同:本插件剥离了链接 URL、HTML 标记与任务清单语法,且英文按词计而非字符计。它优化的是「时长估计准确」而非「字数审计」。

Q:frontmatter.readingTime 是 undefined? 检查插件是否注册在 markdown.config 里,以及页面是否真的经 markdown 渲染(自定义 layout 的纯 Vue 页面没有 markdown 管线)。

Q:代码块要不要计入? 默认计入。判断标准:你的读者会「读」代码还是「跳过」代码?教程类站点建议保持默认,配置参考类站点建议 includeCode: false

Q:只用插件不用组件,需要配 noExternal 吗? 不需要。构建半边是纯 TS;只有 /client 入口含 .vue 源文件。

相关链接