Hexo源代码分析【2】:「generate」的奇幻漂流
使用 Hexo,痛骂 Hexo,理解 Hexo,成为 Hexo。
这篇文章是用来记录我阅读 Hexo 源代码的过程和分析。
其实这篇文章去年暑假就在筹备写,但是当时的思维被局限在了线性的代码导览。
看过上一篇文章的朋友应该能明显感觉出,一个小小的 index.js
都能水写那么多内容,很多还是占篇幅的代码。实际上根本不需要把代码一次又一次贴出来,只要贴出来最核心的几行代码就好了,难道真的会有人去跟着我的文章去一个一个对 Hexo 的源代码吗2333。重点还得是如何用文字描述内容、让读者更聚焦。
那么我又为什么没有继续写第二篇了呢?自然不是因为我又鸽了 (当然可能性不是零),而是我当时计划写 Hexo 的 extend
目录,即扩展(官方文档 /api
路由下就能在左侧边栏看到蓝蓝的 扩展 二字,它包含了控制台、部署器、过滤器等 Hexo 很核心的内容。而这就是问题所在,它并不是一个线性的代码、和第一篇的入口文件 index.js
不一样。extend
目录下的模块那么多,逐一分析过去非常耗时并且容易失去焦点,我那样写又是折磨自己还折磨读者,花费那么多精力根本不讨好。
问题又来了:我为什么又跑来写这个第二篇,并且在开头加了这么长一个警告块?
答案很简单,因为我在睡梦中受高人指点。高人曰:“不要做代码的导游,要做故事的讲述者。”
我顿时恍然大悟,之前那种线性分析方法,就像是拿着一本字典,一页一页地给读者讲定义、枯燥且乏味。而一个优秀的技术分享,应该像一部引人入胜的侦探小说、有一条清晰的主线。巴拉巴拉,这个这个,那个那个。开玩笑的。
所以这篇文章的风格将会和上一篇不同。我们不再按部就班地解刨 extend
目录下的每一个文件,而是会以一个核心的用户行为为线索,将这些分散的扩展模块串联起来,讲述一个它们如何协同工作的完整故事。这意味着本文会更侧重于流程、数据流和模块间的交互,而不是孤立地分析某个函数的实现。
实际上写完后感觉风格没变多少,晕。
在上一篇文章中,我们了解了 Hexo 源代码中的入口文件,并且在没有讲太多细节的情况下过了一遍 Hexo 运行的流程:
1 | class Hexo extends events_1.EventEmitter { |
简单来说是:
- 初始化了各种目录路径、设置环境变量、初始化各种扩展,设置配置、日志、渲染器、路由等。
_bindLocals
方法将数据库中的数据绑定到locals
对象上。init
方法初始化Hexo
,加载插件和配置。call
方法调用控制台命令。model
方法创建或获取数据库模型。resolvePlugin
和loadPlugin
方法用于解析和加载插件。load
和watch
方法加载数据并处理源文件,watch
方法还会设置文件监听。_generate
方法生成静态文件。exit
方法退出程序,执行清理工作。
但一个静态的类如何响应我们的命令,并将一堆散乱的 Markdown 文件变成一个精美的网站的呢?
熟悉 Hexo 的朋友都知道,我们最常用的命令之一——hexo generate
——就是用来做这个的,而这篇文章要剥下 hexo generate
的皮,看看 Hexo 的核心是如何运作的。
1. 解析命令
无奖竞猜:当我们在终端敲下 hexo generate
时,Hexo 中第一个被激活的部件是什么?
答案是 Console
扩展。
我们可以把它想象成 Hexo 的“总机”或者“前台”。它的核心职责就是:接收你输入的命令,然后把它转接给正确的内部处理函数。这个过程分为两步:注册和调用
一个命令能被调用,前提是它得先被“注册在案”。这个注册过程发生在 Hexo 的初始化阶段,也就是 hexo.init()
方法中。记性很好的朋友应该可以瞬间想起这行关键代码:
1 | require('../plugins/console')(this); |
这行代码会加载 Hexo 内置的所有控制台命令。对于 generate
命令,它会执行 /plugins/console/index.js
文件中的代码,该文件负责注册包括 generate
在内的多个核心命令。我们来看 generate
的注册部分:
1 | console.register('generate', 'Generate static files.', { |
register
方法在这里接收了四个参数:
-
name
:命令名称 -
desc
:命令描述 -
options
:定义了所有hexo g
支持的命令行参数,比如我们-d
(生成完成后部署)和-w
(监视文件变动) -
fn
:命令的执行函数。这里通过require('./generate')
加载了同目录下的generate.js
文件,该文件导出的generateConsole
函数就是generate
命令的真正入口:/plugins/console/generate.jsjavascript1
2
3
4
5
6
7
8
9
10
11
12function generateConsole(args = {}) {
const generator = new Generater(this, args); // 这个类之后会提起
if (generator.watch) {
return generator.execWatch();
}
return this.load().then(() => generator.firstGenerate()).then(() => {
if (generator.deploy) {
return generator.execDeploy();
}
});
}
module.exports = generateConsole;
这样一来,generate
命令、它的描述、它的所有参数,以及它的执行函数,就通过这几行代码被清晰地关联起来,并存放在 hexo.extend.console
这个登记簿内。
注册完成后,当 hexo-cli
工具解析到你的 generate
命令时,它就会调用我们在 Hexo
类中看到的方法:hexo.call('generate', args)
。
也就是这一段:
/hexo/index.jsjavascript
1
2
3
4
5
6
7
8
9
10 call(name, args, callback) {
if (!callback && typeof args === 'function') {
callback = args;
args = {};
}
const c = this.extend.console.get(name); // 1. 从登记簿里查找命令
if (c)
return Reflect.apply(c, this, [args]).asCallback(callback); // 2. 执行找到的函数
return bluebird_1.default.reject(new Error(`Console \`${name}\` has not been registered yet!`));
}
就这样,通过注册和调用这两步简单的操作,Hexo 的 Console
扩展干净利落地完成了它的使命……可喜可贺、可喜可贺。
现在 generateConsole
函数接过了指挥棒,而它要做的第一件事,就是调用 this.load()
。
2. 原材料加工
我们来看 this.load
方法的核心:
1 | load(callback) { |
this.load()
的任务很明确:将 source
文件夹里所有零散的文件,加工成结构化、可供后续使用的内存数据。
this.source.process()
是故事的起点。那么 this.source
是什么?它的 process()
方法又是什么?
2.1. Box
类
在 Hexo
类的结构函数中,this.source
被实例化:
1 | const source_1 = __importDefault(require("./source")); |
而 Source
类的定义出奇地简单:
1 | ; |
原因也很简单:Source
类继承自 Box
类。记性很好的朋友此时就要一拍脑袋了:上一篇文章提到过。
当我们调用 this.source.process()
时,我们实际上是在调用 Box
类中定义的 process
方法:
1 | process(callback) { |
Box
类是 Hexo 通用的文件处理引擎。它的 process
方法负责扫描指定目录(这里的话扫描的是 source/
)、与缓存对比、找出被删除/新增/修改的文件,然后为每个文件调用 _processFile
方法:
1 | _processFile(type, path) { |
_processFile
则是处理单个文件的核心,它会遍历一个名为 this.processors
的数组,用数组中的每个 processor
的 pattern
与文件路径进行匹配。如果匹配成功,就执行该 processor
的 process
方法。
那么,this.processors
这个关键的数组是从哪里来的呢?这引出了 Processor
的注册机制。
2.2. Processor
扩展
Processor
扩展遵循一个清晰的注册与加载流程。
首先是注册,在 hexo.init()
阶段,Hexo 会加载 /plugins/processor/index.js
文件,该文件负责注册所有内置的 Processor
:
1 | ; |
接着是加载。当 new source_1.default(this)
执行时,Source
类的构造函数会从 Processor
扩展的注册器中,获取所有已注册的 Processor
列表,并赋值给自身的 this.processors
属性。
1 | class Source extends box_1.default { |
至此,调用链完全闭合:
init
阶段,/plugins/processor/index.js
将post
等处理器注册到hexo.extend.processor
。Source
实例将hexo.extend.processor
的完整列表复制到自身的this.processors
数组中。source.process()
调用_processFile
,_processFile
遍历this.processors
数组,从而找到了post
处理器来处理匹配的文件。
2.3. post
处理器
现在我们以 _posts/hello-world.md
为例,看看 post
处理器是如何工作的。
2.3.1. 资格审查
当 hello-world.md
开始要被 Box
引擎处理时,它首先会遇到 post
的资格审查。
post
会通过其 pattern
函数来决定是否处理一个文件。这个函数非常智能,它不仅仅是匹配路径,更是进行了一系列的检查:
1 | module.exports = (ctx) => { |
这个函数返回的 result
对象,会被 Box
附加到 File
实例的 params
属性上,供 process
函数使用。
2.3.2. 分流与处理
post
处理器的 process
函数会读取 file.params.renderable
的值,决定下一步操作:
1 | module.exports = (ctx) => { |
2.3.3. 精加工与入库
当 process
函数决定一个文件是可渲染的文章时,它就会把 file
对象交给 processPost
函数。这个函数使整个 Processor
中最复杂的部分,它的任务是读取文件、解析并融合所有元数据,最后将结构化的文章数据存入数据库。
整个过程可以分解为以下几个关键步骤:
-
准备与前置处理
函数首先会进行一些准备工作,并处理几种简单的文件状态。
/plugins/processor/post.jsjavascript1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17function processPost(ctx, file) {
const Post = ctx.model('Post');
const { path } = file.params;
const doc = Post.findOne({ source: file.path }); // 1. 检查数据中是否已存在该文章
const { config } = ctx;
const { timezone: timezoneCfg, updated_option, use_slug_as_post_title } = config;
let categories, tags;
// 2. 根据文件类型进行处理
if (file.type === 'skip' && doc) {
return; // 文件未修改,直接跳过
}
if (file.type === 'delete') {
if (doc) {
return doc.remove(); // 文件已删除,从数据库移除
}
return;
} -
异步读取与初步解析
对于
create
或update
状态的文件,处理正式开始。Hexo 会并行地执行两个异步操作:读取文件内容和获取文件系统状态。/plugins/processor/post.jsjavascript1
2
3
4
5
6
7
8return bluebird_1.default.all([
file.stat(), // 获取文件系统信息,例如创建、修改时间
file.read() // 读取文件内容
]).spread((stats, content) => {
// 1. 解析 Front-matter
const data = (0, hexo_front_matter_1.parse)(content);
// 2. 解析文件名
const info = parseFilename(config.new_post_name, path);hexo_front_matter_1.parse(content)
会将文件头部的 YAML Front-matter(---
开头)解析成一个 JavaScript 对象data
。文件的正文内容会存放在data._content
属性中:front_matter.jsjavascript1
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
27function parse(str, options) {
if (typeof str !== 'string')
throw new TypeError('str is required!');
const splitData = split(str);
const raw = splitData.data;
if (!raw)
return { _content: str };
let data;
if (splitData.separator.startsWith(';')) {
data = parseJSON(raw);
}
else {
data = parseYAML(raw, options);
}
if (!data)
return { _content: str };
// Convert timezone
Object.keys(data).forEach(key => {
const item = data[key];
if (item instanceof Date) {
data[key] = new Date(item.getTime() + (item.getTimezoneOffset() * 60 * 1000));
}
});
data._content = splitData.content;
return data;
}
exports.parse = parse;parseFilename
函数则会根据我们在_config.yml
中配置的new_post_name
格式,从文件名中尝试提取year
、month
、day
、title
等信息,存放在info
对象中:/plugins/processor/post.jsjavascript1
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
28function parseFilename(config, path) {
config = config.substring(0, config.length - (0, path_1.extname)(config).length);
path = path.substring(0, path.length - (0, path_1.extname)(path).length);
if (!permalink || permalink.rule !== config) {
permalink = new hexo_util_1.Permalink(config, {
segments: {
year: /(\d{4})/,
month: /(\d{2})/,
day: /(\d{2})/,
i_month: /(\d{1,2})/,
i_day: /(\d{1,2})/,
hash: /([0-9a-f]{12})/
}
});
}
const data = permalink.parse(path);
if (data) {
if (data.title !== undefined) {
return data;
}
return Object.assign(data, {
title: (0, hexo_util_1.slugize)(path)
});
}
return {
title: (0, hexo_util_1.slugize)(path)
};
} -
数据融合与标准化
这是
processPost
最核心的部分。它会将上一步得到的data
和info
,以及stats
这三个来源的数据进行智能融合,并进行标准化处理,最终形成一篇完整、规范的文章数据。这个融合过程遵循明确的优先级:Front-matter > 文件名 > 文件系统信息。
元数据确定逻辑:
slug
和source
:直接从文件名解析结果或文件路径中获取title
:优先使用 Front-matter 中的title
。如果没有,并且use_slug_as_post_title
配置为true
,则会使用从文件名中解析出的slug
作为标题date
:- 优先使用 Front-matter 中的
date
- 其次尝试从文件名中解析(
info.year
等) - 最后使用文件的创建时间
stats.birthtime
- 优先使用 Front-matter 中的
updated
:- 优先使用 Front-matter 里的
- 其次根据
_config.yml
的updated_options
配置,可能使用date
的值或者留空 - 最后默认使用文件的修改时间
stats.mtime
tags
和categories
:函数会进行标准化处理,确保它们最终都是数组格式,并处理了tag
(单数)->tags
(复数)这种别名情况
/plugins/processor/post.jsjavascript1
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
73const keys = Object.keys(info);
data.source = file.path;
data.raw = content;
data.slug = info.title;
if (file.params.published) {
if (!Object.prototype.hasOwnProperty.call(data, 'published'))
data.published = true;
}
else {
data.published = false;
}
for (let i = 0, len = keys.length; i < len; i++) {
const key = keys[i];
if (!preservedKeys[key])
data[key] = info[key];
}
// use `slug` as `title` of post when `title` is not specified.
// https://github.com/hexojs/hexo/issues/5372
if (use_slug_as_post_title && !('title' in data)) {
data.title = info.title;
}
if (data.date) {
data.date = (0, common_1.toDate)(data.date);
}
else if (info && info.year && (info.month || info.i_month) && (info.day || info.i_day)) {
data.date = new Date(info.year, parseInt(info.month || info.i_month, 10) - 1, parseInt(info.day || info.i_day, 10));
}
if (data.date) {
if (timezoneCfg)
data.date = (0, common_1.timezone)(data.date, timezoneCfg);
}
else {
data.date = stats.birthtime;
}
data.updated = (0, common_1.toDate)(data.updated);
if (data.updated) {
if (timezoneCfg)
data.updated = (0, common_1.timezone)(data.updated, timezoneCfg);
}
else if (updated_option === 'date') {
data.updated = data.date;
}
else if (updated_option === 'empty') {
data.updated = undefined;
}
else {
data.updated = stats.mtime;
}
if (data.category && !data.categories) {
data.categories = data.category;
data.category = undefined;
}
if (data.tag && !data.tags) {
data.tags = data.tag;
data.tag = undefined;
}
categories = data.categories || [];
tags = data.tags || [];
if (!Array.isArray(categories))
categories = [categories];
if (!Array.isArray(tags))
tags = [tags];
if (data.photo && !data.photos) {
data.photos = data.photo;
data.photo = undefined;
}
if (data.photos && !Array.isArray(data.photos)) {
data.photos = [data.photos];
}
if (data.permalink) {
data.__permalink = data.permalink;
data.permalink = undefined;
} -
数据库持久化
当所有数据都准备就绪后,便进入了最终的数据库操作阶段。
/plugins/processor/post.jsjavascript1
2
3
4
5
6
7
8
9
10if (doc) { // 如果在步骤一中找到了记录
if (file.type !== 'update') {
ctx.log.warn(`Trying to "create" ${(0, picocolors_1.magenta)(file.path)}, but the file already exists!`);
}
// 文件已存在,执行更新操作
return doc.replace(data);
}
// 文件是新增的,执行插入操作
return Post.insert(data);
}) -
建立关联
数据入库后,工作还没有完全结束。
insert/replace
操作会返回一个 Promise,其结果是处理后的数据库文档doc
。接下来的.then()
中,Hexo 会完成最后一步:建立模型之间的关联。/plugins/processor/post.jsjavascript1
2
3
4
5
6.then(doc => bluebird_1.default.all([
doc.setCategories(categories), // 将文章与分类模型关联
doc.setTags(tags), // 将文章与标签模型关联
scanAssetDir(ctx, doc) // 如果开启,处理文章的资源文件夹
]));
}doc.setCategories
和doc.setTags
方法会处理categories
和tags
数组,在Category
、Tag
以及中间关联表中创建或更新记录。这使得我们之后可以轻松地通过post.tags
或category.posts
来查询关联数据。
至此,processPost
的全部工作完成。一篇原始的 Markdown 文件,经过这一系列精密的加工,已经变成了一条结构完整、关系清晰的数据库记录。
3. 从 Markdown 到 HTML
上一章,我们看到 post
处理器将 Markdown 源文件加工成了包含 _content
(原始 Markdown 文本)的数据库记录。但此时,文章的 content
属性仍然是空的。从 _content
到 content
(渲染后的 HTML),是整个生成过程中最关键的“炼金”步骤。
这个转换不是在 source.process()
阶段发生的,而是在稍后的一个特殊时机,由 Filter
机制驱动。
3.1. before_generate
过滤器
在 _generate
方法的核心逻辑中,_runGenerators
执行之前,有一个关键的前置步骤:
1 | _generate(options = {}) { |
execFilter
方法会执行所有注册在 before_generate
这个钩子上的函数。Hexo 默认在这里注册了一个名为 renderPostFilter
的过滤器,它的任务就是:确保所有文章在生成页面蓝图之前,都已经被渲染成 HTML。
这个过滤器的注册和定义如下:
1 | ; |
1 | ; |
3.2. post.render
this.post.render
是整个内容转换过程的核心。它确保了 Markdown 在转换为 HTML 的过程中,内嵌的特殊标签(例如 Swig 模版标签)不会被破坏,并且能被正确地执行。
那么 this.post.render
从哪里来的?回到 Hexo 实例:
1 | const post_1 = __importDefault(require("./post")); |
我们再来深入 Post
类的 render
方法:
1 | render(source, data = {}, callback) { |
这个过程可以分解为七个步骤:
- 实例化
PostRenderEscape
对象,专门用来“保护”和“恢复”特殊标签 - 执行一轮
before_post_render
过滤器,允许插件对原始 Markdown 内容进行修改 - 保护性转义
escapeCodeBlocks
和escapeAllSwigTags
方法会用正则表达式查找所有的代码块和 Nunjucks/Swig 标签(例如{% post_link %}
或{{ post.title }}
)- 它会将这些找出的内容从字符串中“挖”出来,存入
cacheObj.stored
数组,然后在原文中留下一个无害的占位符 - 这么做的理由是为了让下一步的 Markdown 渲染器知道,这一块是 Nunjucks 语法,不要把它当做普通文本并转义
- 调用
ctx.render.render()
。它会根据文件扩展名(.md
)找到对应的渲染器插件,然后调用其render
方法,将已经被保护起来的 Markdown 文本转换成 HTML。此时生成的 HTML 中依然包含着占位符 - Markdown 渲染器完成工作后,
onRenderEnd
回调函数会立刻执行,开始逆向工程restoreAllSwigTags
方法被调用。它会查找 HTML 中的所有占位符,并用之前存储在cacheObj.stored
数组中的原始标签替换它们。现在我们得到了一个混合了 HTML 和 Nunjucks 标签的字符串tag.render()
被调用、用于处理这个混合字符串,执行其中的{& post_link &}
等标签,并将其替换为最终的 HTML 片段
- 经过 Nunjucks 渲染后,
restoreCodeBlocks
被调用,以同样的方式将代码块占位符恢复成原始的代码块 HTML - 所有渲染和模版执行都已完成。这最后一轮
after_post_render
过滤器让插件有机会对最终生成的 HTML 内容进行处理,例如添加target="_blank"
、图片懒加载等
3.3. Render
类
ctx.render
是 Render
类的一个实例,它扮演着渲染任务“总调度员”的角色。它不关心具体如何渲染,只负责找到正确的渲染器并把任务交给它。
1 | class Render { |
也就是说,核心渲染是由具体的渲染器插件完成的。
3.3.1. hexo-renderer-marked
hexo-renderer-marked
是 Hexo 默认的 Markdown 渲染器,也是 Renderer
类调度的完美范例。它本身不是一个简单的函数,而是一个集配置、扩展和定制于一体的小系统。
1 | module.exports = function(data, options) { |
该渲染器用了
marked
库。
它的入口函数接收从 Renderer
类传来的 data
和 options
,然后执行以下步骤:
-
为了保证每次渲染的纯粹性,它首先会清空
marked
库的默认扩展。然后,它立刻通过this.execFilterSync
触发三个专属于marked
的过滤器:marked:use
marked:renderer
marked:tokenizer
允许其他插件来进一步修改和扩展
marked
的行为,体现了 Hexo 插件系统的层次性。 -
定义一个
renderer
对象,重写marked
库的默认渲染行为,以实现 Hexo 的特有功能:heading
:为标题标签自动生成id
属性,用于页面内锚点跳转,并添加一个可点击的“链接”图标。因此我们能在 Hexo 文章标题旁看到那个小链接图标link
:根据_config.yml
中的external_link
配置、自动为外部链接添加target="_blank"
和rel="noopener"
等属性image
:智能处理图片路径,可以根据配置自动在图片路径前加上根目录(root
),并处理与文章关联的资源文件夹中的图片paragraph
:增加了一个小功能,可以识别特定的语法(Term<br>: Definition
)并将其转换为定义列表(<dl>
)
renderer.jsjavascript1
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
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132const renderer = {
// Add id attribute to headings
heading({ tokens, depth: level }) {
let text = this.parser.parseInline(tokens);
const { anchorAlias, headerIds, modifyAnchors, _headingId } = this.options;
if (!headerIds) {
return `<h${level}>${text}</h${level}>`;
}
const transformOption = modifyAnchors;
let id = anchorId(text, transformOption);
const headingId = _headingId;
const anchorAliasOpt = anchorAlias && text.startsWith('<a href="#');
if (anchorAliasOpt) {
const customAnchor = text.match(rATag)[1];
id = anchorId(customAnchor, transformOption);
}
// Add a number after id if repeated
if (headingId[id]) {
id += `-${headingId[id]++}`;
} else {
headingId[id] = 1;
}
if (anchorAliasOpt) {
text = text.replace(rATag, (str, alias) => {
return str.replace(alias, id);
});
}
// add headerlink
return `<h${level} id="${id}"><a href="#${id}" class="headerlink" title="${stripHTML(text)}"></a>${text}</h${level}>`;
},
link({ tokens, href, title }) {
const text = this.parser.parseInline(tokens);
const { external_link, sanitizeUrl, hexo, mangle } = this.options;
const { url: urlCfg } = hexo.config;
if (sanitizeUrl) {
if (href.startsWith('javascript:') || href.startsWith('vbscript:') || href.startsWith('data:')) {
href = '';
}
}
if (mangle) {
if (href.startsWith('mailto:')) {
const email = href.substring(7);
const mangledEmail = mangleEmail(email);
href = `mailto:${mangledEmail}`;
}
}
let out = '<a href="';
try {
out += encodeURL(href);
} catch (e) {
out += href;
}
out += '"';
if (title) {
out += ` title="${escape(title)}"`;
}
if (external_link) {
const target = ' target="_blank"';
const noopener = ' rel="noopener"';
const nofollowTag = ' rel="noopener external nofollow noreferrer"';
if (isExternalLink(href, urlCfg, external_link.exclude)) {
if (external_link.enable && external_link.nofollow) {
out += target + nofollowTag;
} else if (external_link.enable) {
out += target + noopener;
} else if (external_link.nofollow) {
out += nofollowTag;
}
}
}
out += `>${text}</a>`;
return out;
},
// Support Basic Description Lists
paragraph({ tokens }) {
const text = this.parser.parseInline(tokens);
const { descriptionLists = true } = this.options;
if (descriptionLists && text.includes('<br>:')) {
if (rDlSyntax.test(text)) {
return text.replace(rDlSyntax, '<dl><dt>$1</dt><dd>$2</dd></dl>');
}
}
return `<p>${text}</p>\n`;
},
// Prepend root to image path
image({ href, title, text }) {
const { options } = this;
const { hexo } = options;
const { relative_link } = hexo.config;
const { lazyload, figcaption, prependRoot, postPath } = options;
if (!/^(#|\/\/|http(s)?:)/.test(href) && !relative_link && prependRoot) {
if (!href.startsWith('/') && !href.startsWith('\\') && postPath) {
const PostAsset = hexo.model('PostAsset');
// findById requires forward slash
const asset = PostAsset.findById(join(postPath, href.replace(/\\/g, '/')));
// asset.path is backward slash in Windows
if (asset) href = asset.path.replace(/\\/g, '/');
}
href = url_for.call(hexo, href);
}
let out = `<img src="${encodeURL(href)}"`;
if (text) out += ` alt="${escape(text)}"`;
if (title) out += ` title="${escape(title)}"`;
if (lazyload) out += ' loading="lazy"';
out += '>';
if (figcaption && text) {
return `<figure>${out}<figcaption aria-hidden="true">${text}</figcaption></figure>`;
}
return out;
}
}; -
定制
tokenizer
,用于影响marked
如何解析原始的 Markdown 文本renderer.jsjavascript1
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
32const tokenizer = {
// Support autolink option
url(src) {
const { autolink } = this.options;
if (!autolink) return;
// return false to use original url tokenizer
return false;
},
// Override smartypants
inlineText(src) {
const { options, rules } = this;
const { quotes, smartypants: isSmarty } = options;
// https://github.com/markedjs/marked/blob/b6773fca412c339e0cedd56b63f9fa1583cfd372/src/Tokenizer.js#L643-L658
const cap = rules.inline.text.exec(src);
if (cap) {
let text;
if (this.lexer.state.inRawBlock || this.rules.inline.url.exec(src)) {
text = cap[0];
} else {
text = escape(isSmarty ? smartypants(cap[0], quotes) : cap[0]);
}
return {
type: 'text',
raw: cap[0],
text
};
}
}
};inlineText
主要是实现了smartypants
功能,可以将普通的直引号自动转换成更美观的弯引号,以及将多个连字符转换成破折号
renderer.jsjavascript1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21const smartypants = (str, quotes) => {
const [openDbl, closeDbl, openSgl, closeSgl] = typeof quotes === 'string' && quotes.length === 4
? quotes
: ['\u201c', '\u201d', '\u2018', '\u2019'];
return str
// em-dashes
.replace(/---/g, '\u2014')
// en-dashes
.replace(/--/g, '\u2013')
// opening singles
.replace(/(^|[-\u2014/([{"\s])'/g, '$1' + openSgl)
// closing singles & apostrophes
.replace(/'/g, closeSgl)
// opening doubles
.replace(/(^|[-\u2014/([{\u2018\s])"/g, '$1' + openDbl)
// closing doubles
.replace(/"/g, closeDbl)
// ellipses
.replace(/\.{3}/g, '\u2026');
}; -
在准备好所有定制的
renderer
和tokenizer
之后,插件通过marked.use()
将它们应用到marked
实例上。最后调用marked.parse()
,传入原始文本和所有配置,得到最终的 HTML- 如果用户开启了
dompurify
选项,它还会在返回结果前对 HTML 进行一次安全过滤,防止恶意的脚本注入
- 如果用户开启了
4. 页面的生成
还记得第二章最开始出现的那段代码吗?
1 | load(callback) { |
hexo.load()
方法成功地将所有 source
目录下的源文件处理完毕,并将结构化的数据存入了内存数据库(this.database
)。也就是说,我们拥有了所有文章、独立页面、分类和标签的原子数据。但是,一个完整的网站还包含许多由这些原子数据聚合而成的页面,例如:首页的文章列表、归档页、分类列表页等。这些页面在 source
目录中并没有对应的源文件。
那么这些“无源之页”是如何被创建的呢?答案就在 Generator
扩展中。上一章节讲到了 this.source.process()
,本章节则重点探讨之后的 _generate()
方法,看 Hexo 是如何调用所有 Generator
插件,并生产出网站所有页面的“蓝图”。
眼尖的朋友应该发现了,我跳过了
this.theme.process()
。搭建过个人博客的朋友都知道主题这一大山有多么重要,而这段代码就是调用了主题的处理方法。不过本文并不注重于主题,而是注重于“Markdown 文件如何变成网页”这条主线。之后的文章就会讲一讲主题(挖坑)(说实话 Hexo 开发主题也是一堆坑)。
4.1. _runGenerators
方法解析
Generator
的调用发生在 _generate
方法内部。_generate
会调用 _runGenerators
,并将该方法的返回值——一个包含了所有页面蓝图的数组——传递给下一阶段的 _routerRefresh
。
1 | _generate(options = {}) { |
_runGenerators
的职责非常专一:执行所有已注册的 Generator
插件,并收集它们的返回结果。
1 | _runGenerators() { |
这里的 this.locals
是 Hexo
实例的一个属性。第一篇文章中,它被我们分析过的 _bindLocals
方法初始化。_bindLocals
将数据库查询函数与 posts
, pages
, tags
, categories
等键名绑定。
而 this.locals.toObject()
方法的作用,就是执行所有这些绑定的函数,从数据库中查询出最新的数据,并组装成一个巨大的 site
对象。这个对象包含了:
site.posts
: 一个Warehouse
模型集合,包含了所有文章site.pages
: 包含了所有独立页面site.categories
和site.tags
: 分别包含了所有分类和标签site.data
: 包含了source/_data
目录下的所有数据
这个 site
对象,就是所有 Generator
赖以生存的“食材库”。
看过 Hexo 官方文档的朋友或许会回忆起,
site
对象实际上就是用于模板渲染的局部变量。
4.2. 页面蓝图 { path, layout, data }
Generator
的核心产出是一种标准化的数据结构,我们可以称之为“页面蓝图”。它不是最终的 HTML,而是描述了如何生成一个页面的指令。
每个蓝图对象通常包含三个核心属性:
path
:String
- 页面最终生成的文件路径,例如archives/index.html
layout
:String | Array
- 渲染这个页面所使用的主题布局文件。例如,'archive'
会对应到主题的layout/archive.ejs
文件。可以是一个数组,Hexo 会依次查找并使用第一个找到的布局data
:Object
- 渲染布局时需要的数据
示例:
一篇文章的蓝图:
json
1
2
3
4
5 {
"path": "2025/07/23/hello-world/index.html",
"layout": "post",
"data": { "post": { ...文章数据 } }
}归档页的蓝图:
json
1
2
3
4
5 {
"path": "archives/index.html",
"layout": "archive",
"data": { "posts": site.posts, "page": { "current": 1, ... } }
}
4.3. Generator
的来源
Hexo 默认注册了几个核心的 Generator
来生成网站的基础部分:
post
&page
:这两个Generator
负责处理“有源文件”的页面。它们遍历site.posts
和site.pages
集合,为每篇文章和每个独立页面生成对应的页面蓝图archive
,category
,tag
: 这三个Generator
负责处理“无源之页”。它们分别遍历site.posts
,site.categories
,site.tags
集合,生成归档页、所有分类页和所有标签页的蓝图
我们可以深入一个具体的 Generator
,来了解它是如何利用 site
对象创建页面蓝图的。我们以 archive
生成器为例。
不过在分析之前,有一个关键点需要明确:并非所有核心的生成功能都内置在 Hexo 的主代码库中。Hexo 遵循“保持核心精简”的设计哲学,许多基础功能都是通过默认插件提供的。
当我们 hexo init
一个新项目时,package.json
会自动包含 hexo-generator-archive
, hexo-generator-category
, hexo-generator-tag
等插件。
这意味着:
post
&page
生成器位于 Hexo 核心代码库(/plugins/generator/
)中,负责处理有源文件的页面archive
、category
、tag
等生成器,虽然是默认安装,但它们是独立的 npm 包。它们负责处理无源页面
archive
生成器的核心代码可以分解为三个主要步骤:
-
生成主归档页
Generator
首先会处理根归档页,也就是/archives/
目录。hexo-generator-archive/lib/generator.jsjavascript1
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;
const pagination = require('hexo-pagination');
const fmtNum = num => num.toString().padStart(2, '0');
module.exports = function(locals) {
const { config } = this;
let archiveDir = config.archive_dir;
const paginationDir = config.pagination_dir || 'page';
// 1. 获取所有文章,并根据配置排序
const allPosts = locals.posts.sort(config.archive_generator.order_by || '-date');
const perPage = config.archive_generator.per_page;
const result = [];
if (!allPosts.length) return;
if (archiveDir[archiveDir.length - 1] !== '/') archiveDir += '/';
// 2. 定义一个通用的页面蓝图生成函数
function generate(path, posts, options = {}) {
options.archive = true;
// 3. 调用 hexo-pagination 工具生成分页数据
result.push(...pagination(path, posts, {
perPage,
layout: ['archive', 'index'], // 指定布局
format: paginationDir + '/%d/', // 分页路径格式
data: options
}));
}
// 4. 为根归档页调用 generate 函数
generate(archiveDir, allPosts); -
按日期对文章进行分组
如果用户在
_config.yml
中开启了yearly
或monthly
归档,Generator
接下来会执行一个精巧的数据预处理步骤:将所有文章按年、月、日进行分组。hexo-generator-archive/lib/generator.jsjavascript1
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
41if (!config.archive_generator.yearly) return result; // 如果没开启,直接返回
const posts = {};
// 遍历所有文章
allPosts.forEach(post => {
const date = post.date;
const year = date.year();
const month = date.month() + 1;
// 创建一个嵌套结构来存储文章
if (!Object.prototype.hasOwnProperty.call(posts, year)) {
posts[year] = [
[],
[],
[],
[],
[],
[],
[],
[],
[],
[],
[],
[],
[]
];
}
posts[year][0].push(post); // 按年分组
posts[year][month].push(post); // 按月分组
if (config.archive_generator.daily) {
const day = date.date(); // 按日分组
if (!Object.prototype.hasOwnProperty.call(posts[year][month], 'day')) {
posts[year][month].day = {};
}
(posts[year][month].day[day] || (posts[year][month].day[day] = [])).push(post);
}
});这段代码执行完毕后,会得到一个类似
posts[2025][7]
的数据结构,其中存储了 2025 年 7 月的所有文章。 -
生成年、月、日归档页
最后,代码会遍历上一步中分组好的
posts
对象,为每个时间单位生成对应的归档页面。
1 | const { Query } = this.model('Post'); |
通过嵌套循环,Generator
复用了第一步中定义的 generate
辅助函数,为所有存在文章的年、月、日都创建了对应的分页归档页面蓝图。
最终,这个 Generator
返回一个巨大的 result
数组,其中包含了主归档页、以及所有年、月、日归档页的完整页面蓝图。
5. 路由的建立
_runGenerators
方法为我们产出了一份包含了网站所有页面生成指令的“蓝图清单”。但这些蓝图还只是数据,系统需要一个机制来管理它们,并将它们与最终的文件路径关联起来。
这个机制就是 Hexo 的 Router
。
5.1. Router
类:内存中的 public
目录
在分析 _routerRefresh
之前,我们必须先理解它的工作对象:this.route
。这个属性是 Router
类的一个实例。
需要明确的是,Hexo 的 Router
不是一个网络服务器中的路由(用于匹配 URL 请求),而是一个内存中的键值对集合,可以理解为一个虚拟的文件系统。
- 键:将要生成的文件路径,例如
archives/index.html
- 值:该路径对应的文件内容
Router
类(位于 /lib/hexo/router.js
)提供了一系列方法来管理这个集合,核心方法包括 list()
、get(path)
、set(path, data)
和 remove(path)
。
1 | class Router extends events_1.EventEmitter { |
_routerRefresh
的核心任务便是清空并重新填充这个 this.routes
对象。
5.2. _routerRefresh
_routerRefresh
方法接收 _runGenerators
返回的蓝图数组作为输入、遍历这个数组、为每一个蓝图在 this.route
中创建一条对应的记录。
1 | _routerRefresh(runningGenerators, useCache) { |
_generateLocals
方法见这里。
这个方法的逻辑非常清晰,但要真正理解,我们需要回到 Router
类。
5.3. Router
类深度解析
-
set
方法负责将内容存入路由。它的实现非常巧妙,能够区分处理不同类型的数据。/hexo/router.jsjavascript1
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
33set(path, data) {
if (typeof path !== 'string')
throw new TypeError('path must be a string!');
if (data == null)
throw new TypeError('data is required!');
let obj;
if (typeof data === 'object' && data.data != null) {
obj = data;
}
else {
obj = {
data,
modified: true
};
}
// 检查 data 是否为函数
if (typeof obj.data === 'function') {
// 使用 bluebird 将其包装成一个标准的 Promise-returning 函数
if (obj.data.length) { // 有回调参数的函数
obj.data = bluebird_1.default.promisify(obj.data);
}
else { // 无参数的函数
obj.data = bluebird_1.default.method(obj.data);
}
}
path = this.format(path); // 格式化路径
this.routes[path] = {
data: obj.data,
modified: obj.modified == null ? true : obj.modified
};
this.emit('update', path);
return this;
}set
方法的逻辑揭示了路由内容的两种形态:- 成品 (静态资源):如果传入的
data
是 Buffer 或字符串(比如一张图片的内容),它会被直接存入this.routes
- 半成品 (待渲染页面):如果传入的
data
是一个函数(由createLoadThemeRoute
创建的那个),set
方法会用bluebird
将其包装成一个标准的异步函数。存入路由的,是这个被包装后的函数本身
这种设计就是 Hexo 高性能的延迟执行 (Lazy Evaluation) 策略的核心。它避免了在生成路由时就渲染所有页面,而是将渲染工作推迟到最后一刻。
- 成品 (静态资源):如果传入的
-
如果说
set
方法是把“速冻料理包”(待执行的渲染函数)放入冰箱,那么get
方法就是从冰箱里取出料理包并准备加热。get
方法本身很简单,但它返回的东西却大有文章:/hexo/router.jsjavascript1
2
3
4
5
6
7
8
9get(path) {
if (typeof path !== 'string')
throw new TypeError('path must be a string!');
const data = this.routes[this.format(path)];
if (data == null)
return;
// 返回一个 RouteStream 实例
return new RouteStream(data);
}它并不直接返回值,而是将从
this.routes
中取出的数据(无论是 Buffer 还是那个待执行的函数)传递给RouteStream
类的构造函数,并返回一个RouteStream
实例。RouteStream
是一个 Node.js 的可读流 (Readable Stream)。它的魔法藏在_read()
方法中:javascript1
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
60class RouteStream extends Readable {
constructor(data) {
super({ objectMode: true });
this._data = data.data;
this._ended = false;
this.modified = data.modified;
}
_toBuffer(data) {
if (data instanceof Buffer) {
return data;
}
if (typeof data === 'object') {
data = JSON.stringify(data);
}
if (typeof data === 'string') {
return Buffer.from(data);
}
return null;
}
_read() {
const data = this._data;
// 情况一:内容是“成品”(Buffer、String 等)
if (typeof data !== 'function') {
const bufferData = this._toBuffer(data);
if (bufferData) {
this.push(bufferData); // 直接将数据推入流中
}
this.push(null); // 结束流
return;
}
// 情况二:内容是“半成品”(待执行函数)
if (this._ended)
return false;
this._ended = true;
// 执行函数
data().then(data => {
if (data instanceof stream_1.default && data.readable) {
data.on('data', d => {
this.push(d);
});
data.on('end', () => {
this.push(null);
});
data.on('error', err => {
this.emit('error', err);
});
}
else {
const bufferData = this._toBuffer(data);
if (bufferData) {
this.push(bufferData); // 将渲染后的 HTML 推入流中
}
this.push(null); // 结束流
}
}).catch(err => {
this.emit('error', err);
this.push(null);
});
}
}之后我们讲到“文件写入”时,模块会从这个流中读取数据,
_read()
方法会被触发。它会检查this._data
:- 如果是普通数据,就直接推入流
- 如果是一个函数,它就会执行这个函数(
data()
)。渲染过程在这一刻才真正发生。函数执行完毕后(.then()
),_read
会将返回的 HTML 结果推入流中,供文件写入模块消费
6. 从内存到硬盘
经过前面四个阶段的精密运作,Hexo 已经在内存中构建了一个完整的虚拟网站(this.route
)。现在,我们来到了这趟旅程的终点站:将这个虚拟世界实体化,写入到硬盘的 public
文件夹中。
这个最终的执行任务,由 generate
命令的总指挥——Generater
类——来完成。
6.1. 怎么又是你 Generator
类
回顾第一章,generateConsole
函数在最开始就实例化了 Generater
类:
1 | function generateConsole(args = {}) { |
generator.firstGenerate()
就是文件写入流程的入口。这个方法负责比对文件差异、创建任务队列,并最终调用 writeFile
和 deleteFile
来同步 public
目录。
6.2. firstGenerate
firstGenerate
方法做的第一件大事,不是写入,而是比对,以确定一份精准的“施工清单”。
1 | firstGenerate() { |
这里的逻辑非常清晰:
- 获取新蓝图:从
this.context.route.list()
获取我们在第四章构建的内存虚拟网站的文件列表 - 获取旧清单:从
Cache
模型中读取上次生成时写入public/
的所有文件记录 - 计算差异并生成任务:
- 如果一个文件在“旧清单”里,但不在“新蓝图”里,就为它创建一个
deleteFile
任务 - 所有“新蓝图”里的文件,都为它们创建一个
generateFile
任务
- 如果一个文件在“旧清单”里,但不在“新蓝图”里,就为它创建一个
- 并发执行:使用
bluebird.map
并发处理所有任务,以提高生成效率
6.3. 触发延迟执行
writeFile
方法是整个流程中最关键的“临门一脚”,它负责触发第四章中我们埋下的“延迟执行”机制。
1 | writeFile(path, force) { |
当我们开始监听 data
事件,也就是开始从流中“拉取”数据时,RouteStream
内部的 _read()
方法就会被触发。
如果这个路由的内容是一个“待执行的渲染函数”,这个函数在此时此刻才会被执行,渲染出最终的 HTML,然后将结果推入流中,被这里的 data
事件监听器接收。
6.4. 哈希值对比
在 writeFile
方法的后半部分,还隐藏着 Hexo 再次生成时速度飞快的秘密——增量生成。
1 | return finishedPromise.then(() => { |
在将文件内容写入磁盘之前,writeFile
会计算出内容的 SHA1 哈希值,并与缓存中的旧哈希值进行对比。如果两者相同,意味着文件内容没有变化,Hexo 就会聪明地跳过这次文件写入操作,从而极大地提升了二次生成的效率。
7. 总结
现在,让我们回到最初的起点,以 _posts/hello-world.md
的视角,快速回顾它在 hexo generate
命令下经历的完整旅程:
- 我们在终端敲下回车,
Console
扩展接收到generate
命令 Box
引擎扫描source
目录,发现了hello-world.md
。post
处理器接管了它,解析其文件名和 Front-matter,将标题、日期等元数据连同原始 Markdown 内容(_content
)一起,存入了数据库的Post
模型中- 在生成页面之前,
before_generate
过滤器被触发。renderPostFilter
找到了数据库中content
字段尚为空的hello-world.md
,并调用post.render
对其进行“炼金”:post.render
暂时将文中的 Nunjucks 标签(如果有的话)替换为占位符ctx.render
调度hexo-renderer-marked
将 Markdown 转换为 HTML- 在
onRenderEnd
回调中,post.render
恢复 Nunjucks 标签并立刻执行它们。最终,一篇完整的 HTML 被存入数据库记录的 content 字段
_runGenerators
开始执行。post
生成器遍历数据库,找到了我们已渲染好的hello-world.md
,为它创建了一张包含最终路径、主题布局(layout: 'post'
)和所有页面数据的“页面蓝图”_routerRefresh
接收到这张蓝图。它没有立即渲染整个页面,而是创建了一个“待办任务”——一个包含了“使用post
布局”和“填充hello-world.md
内容”指令的函数,并将这个任务存入了内存中的Router
里,键名为最终的文件路径posts/hello-world/index.html
Generater
类的firstGenerate
方法开始工作。它对比Router
和public
目录的缓存,确定posts/hello-world/index.html
是一个需要生成的文件,并为其创建了一个writeFile
任务- 当
writeFile
任务执行时,它向Router
请求posts/hello-world/index.html
的内容。RouteStream
机制被触发,直到这一刻,第五步中创建的那个“待办任务”才被真正执行。它读取主题的post
布局文件,将hello-world.md
的content
(HTML)填充进去,生成了包含页头、页脚的完整页面 HTML - 最终的 HTML 内容通过流被写入
public/posts/hello-world/index.html
文件。hello-world.md
的旅程至此结束,它已成为网站上一个可被访问的真实页面