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:usemarked:renderermarked: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.htmllayout: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.htmlGenerater类的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的旅程至此结束,它已成为网站上一个可被访问的真实页面