前言
上周整理博客仓库时发现一个问题:scripts/ 目录里躺着三个图片迁移脚本——那是当初做 WebP 图片迁移时写的一次性工具。问题在于,Hexo 会自动加载并执行 scripts/ 目录下的每个 .js 文件,且每次执行任何 hexo 命令都会跑一遍。也就是说,每次 hexo g,三个迁移脚本都会白白扫描全部文章 dry-run 一轮。
把它们挪到 tools/(改成手动 node tools/xxx.js 执行)之后,scripts/ 空了。空着也是空着,正好写个真的插件——顺便把 Hexo 的插件机制摸清楚。这篇文章记录整个过程:插件能干什么、怎么写、每一行在做什么。
先搞清楚:scripts/ 是个什么目录
Hexo 有个”魔法目录”:项目根下的 scripts/,里面的每个 .js 文件都会在执行任何 hexo 命令时被自动 require,不需要在 package.json 里登记。文件里有一个全局变量 hexo,就是 Hexo 实例本身——类比浏览器页面里的 window。
关键认知是:npm 插件和 scripts/ 文件用的是同一套 API。你 package.json 里那些 hexo-generator-searchdb、hexo-blog-encrypt,和 scripts/ 里的自写脚本,调的都是 hexo.extend.filter.register(...) 这类接口,区别只是加载来源不同。写本地插件,本质上就是”把 npm 插件搬到自己手里写”。
所以两个目录的分工现在是:
| 目录 |
加载方式 |
放什么 |
| scripts/ |
每次命令自动执行 |
常驻插件(每次构建都该跑的逻辑) |
| tools/ |
手动执行 |
一次性工具(迁移脚本等) |
插件能干什么:三类最常用的扩展点
Hexo 的扩展点都挂在 hexo.extend 下面,最常用的三个:
| 扩展点 |
作用 |
类比 |
| filter 过滤器 |
挂进渲染流水线,加工每篇文章 |
给每篇文章订阅 Loaded 事件 |
| generator 生成器 |
凭空造一个新页面路由 |
构建时往输出目录塞个自己算出来的文件 |
| console 命令 |
给 CLI 加一条新命令 |
自定义命令绑定 |
此外还有 renderer(教 Hexo 认识新文件格式)、helper(主题模板里用的自定义函数)、injector(往所有页面注入脚本,统计代码就这么加)等等。你现在用的搜索、RSS、站点地图、文章加密,全部是这些 API 搭出来的。
目标:一个站点统计插件
想做个能回答”我这博客到底写了多少字”的东西。产出两个入口:
npx hexo stats —— 终端直接打印统计
npx hexo generate 时产出 stats.json —— 部署后可以公网访问
先看效果,npx hexo stats:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| ━━ 站点统计 · 与买桂花同载酒 ━━ 文章 151 篇(含加密 13 篇) 总字数 353,787 字,通读约 14.7 小时
按年: 2026 ************************ 92 篇 · 226,556 字 2025 **** 16 篇 · 33,041 字 2024 * 1 篇 · 2,109 字 2023 ** 6 篇 · 11,858 字 2022 ** 9 篇 · 38,677 字 ... 2010 * 1 篇 · 2,478 字 2009 * 1 篇 · 1,306 字
最长三篇: 1. 《置身钉内》22,110 字(约 55 分钟) 2. 《暴力破解的文章-2》17,283 字(约 43 分钟) 3. 《一个程序员的奋斗史》9,446 字(约 24 分钟)
|
算完才知道:17 年、151 篇、35 万字——通读自己的博客要 14.7 个小时,还挺有成就感的。
构建时则会在 public/ 下多出 stats.json,部署后访问 https://你的域名/stats.json 就能拿到机器可读的版本,以后想做个数据面板可以直接消费它。
代码逐段讲解
完整代码放在文末附录,这里按模块拆。
工具函数:中英混排字数统计
1 2 3 4 5 6 7 8
| function countWords(text) { const plain = String(text) .replace(/```[\s\S]*?```/g, ' ') .replace(/<[^>]+>/g, ' '); const cjk = (plain.match(/[一-龥]/g) || []).length; const en = (plain.replace(/[一-龥]/g, ' ').match(/[A-Za-z0-9]+/g) || []).length; return cjk + en; }
|
几个点:
/.../ 是 JS 的正则字面量,C# 要 new Regex("..."),JS 两个斜杠包起来就是对象。结尾的 g 表示替换所有匹配——忘了加 g 只换第一处,这是 C# 转 JS 的头号陷阱。
[\s\S]*? 匹配代码块:\s 空白、\S 非空白,并集等于”任意字符包括换行”(正则的 . 默认不跨行);? 懒惰匹配,遇到第一个闭合就停,防止两段代码块连成一片。
[一-龥] 是中文区间:一 是 U+4E00,龥 是 U+9FA5,覆盖常用汉字。先数中文的”字”,再把中文抹掉数英文的”词”,两者相加——比直接 .length 更接近真实阅读量。
match(...) 匹配不到返回的是 null 而不是空数组,|| [] 就是防御这个(等价 C# 的 ?? new List<>())。
① 过滤器:给每篇文章挂上字数
1 2 3 4 5
| hexo.extend.filter.register('before_post_render', function (data) { const words = countWords(data.raw || data.content || ''); data.stats = { words, minutes: readingMinutes(words) }; return data; });
|
register(时机, 处理函数) 相当于 button.Click += handler,事件源是 Hexo 流水线,每篇文章触发一次。参数 data 就是当前文章对象,front-matter 里的字段都在上面。JS 对象随时可加属性,不用预定义类——这里直接给文章动态挂了个 stats 字段。
两个坑值得记:
- 必须
return data。过滤器是传送带,不把数据递回去,下游拿到的就是 undefined,这篇文章直接丢了。
- 时机选
before_post_render 而不是 after_post_render。我的博客用了 hexo-blog-encrypt 加密 13 篇文章,它挂在 after 阶段——等它跑完,文章内容已经是密文,字数统计会变成乱码计数。选在加密之前统计,加密文章也能算出真实字数。过滤器是有先后顺序的,时机选对就没冲突。
② 生成器:产出 stats.json
1 2 3 4 5 6 7 8
| hexo.extend.generator.register('stats', function (locals) { const stats = aggregate(locals.posts.toArray()); const payload = Object.assign( { site: hexo.config.title, url: hexo.config.url, generatedAt: new Date().toISOString() }, stats ); return { path: 'stats.json', data: JSON.stringify(payload, null, 2) }; });
|
生成器拿到 locals——Hexo 递过来的全站数据快照,locals.posts、locals.categories 都在里面。你现在用的搜索、RSS、站点地图插件,干活的入口都是这个函数。
返回值就是产出物:path 是相对 public/ 的文件路径(也是访问 URL),data 是文件内容。不写 layout 字段就是裸文件直出(json/xml/txt 都这么造);写了就会套主题模板渲染成网页。JSON.stringify(x, null, 2) 里的 2 是缩进两格美化输出。
hexo.config 就是 _config.yml 解析后的对象,YAML 键直接变属性。
③ 命令:npx hexo stats
1 2 3 4 5 6 7
| hexo.extend.console.register('stats', '打印全站文章统计', {}, function () { const h = this; return h.load().then(() => { const s = aggregate(h.locals.get('posts').toArray()); }); });
|
四个参数:命令名、描述、命令行选项(空对象占位)、执行函数。
h.load() 让 Hexo 扫描 source/ 装载数据库,返回 Promise——JS 的异步任务对象,相当于 C# 的 Task,.then(...) 相当于 ContinueWith。必须返回这个 Promise,Hexo 靠它知道命令什么时候执行完、该不该退出进程。
this 是 JS 和 C# 差异最大的地方:C# 的 this 由类定义决定,JS 的 this 由调用方式决定(谁调用指向谁)。框架回调你的函数时把 hexo 实例设成了 this,但为了稳妥,进回调先 const h = this 存进局部变量——一眼看清来源,也不怕作用域里 this 变义。
验证命令真的注册上了
写完插件跑 npx hexo --help,能看到 stats 和 13 个内置命令并排出现:
1 2 3 4 5 6 7
| Commands: clean Remove generated files and cache. generate Generate static files. new Create a new post. server Start the server. ... stats 打印全站文章统计
|
这揭示了一个有意思的事实:Hexo 本身只是个命令分发器。你敲 hexo stats,它启动 → 加载所有插件 → 拿命令名去注册表里查 → 查到就调用注册的函数,查不到就打印 Usage 报错。那些内置命令并不是什么特殊通道,同样是 Hexo 核心代码启动时用一模一样的 register() 注册进去的。这也解释了为什么 scripts/ 里的文件”每次命令都会执行”——分发器必须在启动阶段把所有注册工作跑完,才知道你输入的名字该分发给谁。
删掉这个文件,npx hexo stats 就会报 Usage 错误——命令跟着插件一起消失,零配置残留。
C# 程序员速查表
这次写下来攒的对照,给同样背景的人:
| C# |
JavaScript |
| Regex.Replace(s, @”..”, “”) |
s.replace(/../g, ‘’)(记得 g) |
| a ?? b |
a \ |
\ |
b |
| list.Select(x => …) |
arr.map(x => …) |
| list.Where(…) |
arr.filter(…) |
| list.Aggregate(0, …) |
arr.reduce(…) |
| OrderBy(不改原序列) |
slice().sort()(必须先复制) |
| $”…{x}…” |
...${x}... |
| Task + ContinueWith |
Promise + .then(…) |
| this 永远指当前类 |
this 看调用方式,进回调先存 const h = this |
特别标注 slice().sort():JS 的 sort() 是原地修改(同 C# 的 List.Sort),而 LINQ 的 OrderBy 返回新序列。从 LINQ 过来的人不先 .slice() 复制,会把原数组顺序也改掉。
后记
一些收尾细节:
- 这个插件每次构建都会跑(包括 GitHub Actions 的 CI),但 151 篇文章数一遍字数是毫秒级的,开销可忽略。
stats.json 会发布到公网,只含统计数字不含正文,介意的话删文件即可。
- 卸载 = 删掉整个 scripts/site-stats.js,无任何残留。
回头看,Hexo 的插件体系就是一套”控制反转”:你不调用框架,而是把函数登记到框架的各个时机上,框架在合适的时刻来调用你。和 WPF 里写事件处理器、写 Attached Behavior 是同一种思路——想通这一点,再打开 searchdb、encrypt 这些插件的源码,基本都能看懂了。
附录:完整代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85
| 'use strict';
function countWords(text) { const plain = String(text) .replace(/```[\s\S]*?```/g, ' ') .replace(/<[^>]+>/g, ' '); const cjk = (plain.match(/[一-龥]/g) || []).length; const en = (plain.replace(/[一-龥]/g, ' ').match(/[A-Za-z0-9]+/g) || []).length; return cjk + en; }
function readingMinutes(words) { return Math.max(1, Math.round(words / 400)); }
hexo.extend.filter.register('before_post_render', function (data) { const words = countWords(data.raw || data.content || ''); data.stats = { words, minutes: readingMinutes(words) }; return data; });
function aggregate(posts) { const items = posts.map(p => ({ title: p.title, year: p.date ? p.date.year() : 0, words: (p.stats && p.stats.words) || countWords(p.raw || ''), encrypted: Boolean(p.password), category: (p.categories.toArray()[0] || {}).name || '未分类' }));
const totalWords = items.reduce((s, x) => s + x.words, 0); const yearMap = new Map(); const catMap = new Map(); for (const it of items) { const y = yearMap.get(it.year) || { year: it.year, posts: 0, words: 0 }; y.posts++; y.words += it.words; yearMap.set(it.year, y); const c = catMap.get(it.category) || { name: it.category, posts: 0, words: 0 }; c.posts++; c.words += it.words; catMap.set(it.category, c); }
return { posts: items.length, encrypted: items.filter(x => x.encrypted).length, totalWords, readingTimeMinutes: readingMinutes(totalWords), byYear: [...yearMap.values()].sort((a, b) => b.year - a.year), byCategory: [...catMap.values()].sort((a, b) => b.posts - a.posts), longest: items.slice().sort((a, b) => b.words - a.words).slice(0, 3) .map(x => ({ title: x.title, words: x.words, minutes: readingMinutes(x.words) })) }; }
hexo.extend.generator.register('stats', function (locals) { const stats = aggregate(locals.posts.toArray()); const payload = Object.assign( { site: hexo.config.title, url: hexo.config.url, generatedAt: new Date().toISOString() }, stats ); return { path: 'stats.json', data: JSON.stringify(payload, null, 2) }; });
hexo.extend.console.register('stats', '打印全站文章统计', {}, function () { const h = this; return h.load().then(() => { const s = aggregate(h.locals.get('posts').toArray()); console.log('\n━━ 站点统计 · ' + h.config.title + ' ━━'); console.log(`文章 ${s.posts} 篇(含加密 ${s.encrypted} 篇)`); console.log(`总字数 ${s.totalWords.toLocaleString()} 字,通读约 ${(s.readingTimeMinutes / 60).toFixed(1)} 小时`); console.log('\n按年:'); const max = Math.max.apply(null, s.byYear.map(y => y.posts)); for (const y of s.byYear) { const bar = '*'.repeat(Math.max(1, Math.round(y.posts / max * 24))); console.log(` ${y.year} ${bar} ${y.posts} 篇 · ${y.words.toLocaleString()} 字`); } console.log('\n最长三篇:'); s.longest.forEach((x, i) => console.log(` ${i + 1}. 《${x.title}》${x.words.toLocaleString()} 字(约 ${x.minutes} 分钟)`)); console.log(''); }); });
|