搜索 K
主题
@vaultkit/vitepress-plugin-reading-time 为 VitePress 提供阅读时长统计,架构是「一包两半」:构建期的 markdown-it 插件负责计数并写入 frontmatter,/client 子路径的可选 Vue 组件负责展示。核心卖点是中英分轨计数——中文语料按空格分词会把整段算成一个词,必须按字计,本插件对 CJK 字符逐字计数、拉丁文本按词计数,混排文章的统计才准确。
统计在构建期完成、结果写入 frontmatter.readingTime,SSR 与客户端天然一致,运行时零计算开销。本文覆盖两半的全部导出、全部选项与计数原理。
「预计阅读 5 分钟」是文章头部的常见元信息,但拿现成英文方案(如 reading-time npm 包)统计中文会离谱地少:英文以空格分词,一段没有空格的中文会被数成 1 个词。三千字的中文文章按 200 词/分钟折算,「预计阅读 1 分钟」——完全失真。
正确做法是分轨:中文按字计、配字速(字/分钟),英文按词计、配词速(词/分钟),总时长为两轨之和。另一个架构决策是把统计放在构建期:客户端统计要么拿渲染后的 DOM 文本(水合前后不一致),要么重复传输原文;构建期写入 frontmatter 则一次计算、两端同源。
pnpm add -D @vaultkit/vitepress-plugin-reading-time使用 @vaultkit/vitepress-theme-vault 主题则开箱即带——本站每篇文章头部的「全文约 N 字 · 预计阅读 M 分钟」就是它的输出。
两半各接一处。构建半边:markdown-it 插件注册进 VitePress 的 markdown 管线:
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):
<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>/client 入口含 .vue 源文件,只要用了展示组件,就必须把本包加进 vite.ssr.noExternal:
// .vitepress/config.ts
import { defineConfig } from 'vitepress'
export default defineConfig({
vite: { ssr: { noExternal: ['@vaultkit/vitepress-plugin-reading-time'] } },
})只用构建半边(markdown-it 插件是纯 TS)则不受此约束。
包有三个入口,. 与 ./markdown-it 等价(默认入口就是构建半边):
| 入口 | 导出 | 说明 |
|---|---|---|
. / ./markdown-it | readingTimePlugin(md, options?) | markdown-it 插件,统计写入 env.frontmatter.readingTime |
| 同上 | readingTime(source, options?) | 纯函数形态,可脱离 markdown-it 单独调用 |
| 同上 | ReadingTimeOptions / ReadingTimeStats | 类型 |
./client | ReadingTime(具名 + 默认导出) | 行内展示组件 |
./client | ReadingTimeStats | 类型(与构建半边同构) |
ReadingTimeStats 的三个字段:chars(CJK 字数)、words(拉丁词数)、minutes(预计阅读分钟数,非空内容 ≥1,空内容为 0)。
插件与纯函数共享同一份选项。
number,默认 300md.use(readingTimePlugin, { charsPerMinute: 250 })number,默认 200charsPerMinute 折算、英文段落按 wordsPerMinute 折算,最后求和。boolean,默认 truemd.use(readingTimePlugin, { includeCode: false })关闭时按 CommonMark 语义剥离 ``` 与 ~~~ 两式围栏(源码里分列两条正则规避回溯歧义;闭合缺失时吞到文末,与 CommonMark 行为一致)。
<ReadingTime /> props 逐项详解 ReadingTimeStats | null,默认 nullfrontmatter.readingTime。缺失或 minutes 为 0 时组件零渲染——没有装插件的页面、空页面不会渲染出「0 分钟」这种残缺信息,宿主无需包 v-if。(stats: ReadingTimeStats) => string,默认输出英文 1,234 words · 5 min read<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 之类)可以解决,但为「数个数」引入词典级依赖不值得——而且阅读速度研究本来就以「字/分钟」为中文单位,按字计既准又零依赖。
源码里的分轨实现干净利落:
const chars = (text.match(CJK) ?? []).length // CJK 逐字计数
const words = text
.replace(CJK, ' ') // 先把 CJK 字符抠掉
.split(/\s+/)
.filter(token => /[a-z0-9]/i.test(token)) // 纯符号 token 不算词
.lengthCJK 字符集覆盖统一表意文字(含扩展 A / 兼容区)+ 日文假名 + 谚文音节,所以日文、韩文内容同样按字计。第二轨先把 CJK 替换成空格再切词,保证「中文English混排」里的英文词被正确切出;filter 一步剔除纯标点 token(——、... 不是词)。
时长公式:minutes = round(chars / charsPerMinute + words / wordsPerMinute),非空内容保底 1 分钟(避免短文显示「0 分钟」的尴尬),空内容为 0(配合展示组件零渲染)。
Markdown 源文本里不少字符是「语法」而非「内容」,计数前先剥离:
<b>加粗</b> 计 2 字)- [x] / - [ ] 是语法不是内容[文字](https://very-long-url) 只计「文字」)——URL 再长也不该灌水阅读时长插件注册为 markdown-it 的 core 规则,把统计写入 env.frontmatter.readingTime。关键在于 VitePress 的 pageData.frontmatter 与渲染期 env.frontmatter 是同一个对象引用,写进去的统计自动出现在 useData().frontmatter 里,SSR 与客户端天然一致,不需要任何额外的数据通道。
两个防御细节都写在源码注释里:
renderInline——VitePress 渲染标题、摘要片段时也走 core 链且共享页面 env,若不跳过(state.inlineMode),先执行的小片段会抢先把错误统计写进 frontmatterreadingTime 键时插件不覆盖,你可以对某篇文章人工指定时长readingTime(source, options?) 可脱离 markdown-it 使用,比如在 RSS 生成脚本里统计:
import { readingTime } from '@vaultkit/vitepress-plugin-reading-time'
const stats = readingTime('# 标题\n\n三千字的中文正文……')
// => { chars: 3000, words: 0, minutes: 10 }markdown-it >= 14.0.0、vue ^3.5.0;只用构建半边可以不装 vue(peer 警告可忽略)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 源文件。