使用 Hexo,痛骂 Hexo,理解 Hexo,成为 Hexo。

这篇文章是用来记录我阅读 Hexo 源代码的过程和分析。

其实这篇文章去年暑假就在筹备写,但是当时的思维被局限在了线性的代码导览。

看过上一篇文章的朋友应该能明显感觉出,一个小小的 index.js 都能写那么多内容,很多还是占篇幅的代码。实际上根本不需要把代码一次又一次贴出来,只要贴出来最核心的几行代码就好了,难道真的会有人去跟着我的文章去一个一个对 Hexo 的源代码吗2333。重点还得是如何用文字描述内容、让读者更聚焦。

那么我又为什么没有继续写第二篇了呢?自然不是因为我又鸽了 (当然可能性不是零),而是我当时计划写 Hexo 的 extend 目录,即扩展(官方文档 /api 路由下就能在左侧边栏看到蓝蓝的 扩展 二字,它包含了控制台、部署器、过滤器等 Hexo 很核心的内容。而这就是问题所在,它并不是一个线性的代码、和第一篇的入口文件 index.js 不一样。extend 目录下的模块那么多,逐一分析过去非常耗时并且容易失去焦点,我那样写又是折磨自己还折磨读者,花费那么多精力根本不讨好。

问题又来了:我为什么又跑来写这个第二篇,并且在开头加了这么长一个警告块?

答案很简单,因为我在睡梦中受高人指点。高人曰:“不要做代码的导游,要做故事的讲述者。”

我顿时恍然大悟,之前那种线性分析方法,就像是拿着一本字典,一页一页地给读者讲定义、枯燥且乏味。而一个优秀的技术分享,应该像一部引人入胜的侦探小说、有一条清晰的主线。巴拉巴拉,这个这个,那个那个。开玩笑的。

所以这篇文章的风格将会和上一篇不同。我们不再按部就班地解刨 extend 目录下的每一个文件,而是会以一个核心的用户行为为线索,将这些分散的扩展模块串联起来,讲述一个它们如何协同工作的完整故事。这意味着本文会更侧重于流程、数据流和模块间的交互,而不是孤立地分析某个函数的实现。

实际上写完后感觉风格没变多少,晕。

上一篇文章中,我们了解了 Hexo 源代码中的入口文件,并且在没有讲太多细节的情况下过了一遍 Hexo 运行的流程:

js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Hexo extends events_1.EventEmitter {
constructor(base = process.cwd(), args = {}) { ... }
_bindLocals() { ... }
init() { ... }
call(name, args, callback) { ... }
model(name, schema) { ... }
resolvePlugin(name, basedir) { ... }
loadPlugin(path, callback) { ... }
_showDrafts() { ... }
load(callback) { ... }
watch(callback) { ... }
unwatch() { ... }
_generateLocals() { ... }
_runGenerators() { ... }
_routerRefresh(runningGenerators, useCache) { ... }
_generate(options = {}) { ... }
exit(err) { ... }
execFilter(type, data, options) { ... }
execFilterSync(type, data, options) { ... }
}

简单来说是:

  1. 初始化了各种目录路径、设置环境变量、初始化各种扩展,设置配置、日志、渲染器、路由等。
  2. _bindLocals 方法将数据库中的数据绑定到 locals 对象上。
  3. init 方法初始化 Hexo,加载插件和配置。
  4. call 方法调用控制台命令。
  5. model 方法创建或获取数据库模型。
  6. resolvePluginloadPlugin 方法用于解析和加载插件。
  7. loadwatch 方法加载数据并处理源文件,watch 方法还会设置文件监听。
  8. _generate 方法生成静态文件。
  9. exit 方法退出程序,执行清理工作。

但一个静态的类如何响应我们的命令,并将一堆散乱的 Markdown 文件变成一个精美的网站的呢?

熟悉 Hexo 的朋友都知道,我们最常用的命令之一——hexo generate——就是用来做这个的,而这篇文章要剥下 hexo generate 的皮,看看 Hexo 的核心是如何运作的。

1. 解析命令

无奖竞猜:当我们在终端敲下 hexo generate 时,Hexo 中第一个被激活的部件是什么?

答案是 Console 扩展。

我们可以把它想象成 Hexo 的“总机”或者“前台”。它的核心职责就是:接收你输入的命令,然后把它转接给正确的内部处理函数。这个过程分为两步:注册调用

一个命令能被调用,前提是它得先被“注册在案”。这个注册过程发生在 Hexo 的初始化阶段,也就是 hexo.init() 方法中。记性很好的朋友应该可以瞬间想起这行关键代码:

/hexo/index.jsjavascript
1
require('../plugins/console')(this);

这行代码会加载 Hexo 内置的所有控制台命令。对于 generate 命令,它会执行 /plugins/console/index.js 文件中的代码,该文件负责注册包括 generate 在内的多个核心命令。我们来看 generate 的注册部分:

/plugins/console/index.jsjavascript
1
2
3
4
5
6
7
8
9
console.register('generate', 'Generate static files.', {
options: [
{ name: '-d, --deploy', desc: 'Deploy after generated' },
{ name: '-f, --force', desc: 'Force regenerate' },
{ name: '-w, --watch', desc: 'Watch file changes' },
{ name: '-b, --bail', desc: 'Raise an error if any unhandled exception is thrown during generation' },
{ name: '-c, --concurrency', desc: 'Maximum number of files to be generated in parallel. Default is infinity' }
]
}, require('./generate'));

register 方法在这里接收了四个参数:

  1. name:命令名称

  2. desc:命令描述

  3. options:定义了所有 hexo g 支持的命令行参数,比如我们 -d(生成完成后部署)和 -w(监视文件变动)

  4. fn:命令的执行函数。这里通过 require('./generate') 加载了同目录下的 generate.js 文件,该文件导出的 generateConsole 函数就是 generate 命令的真正入口:

    /plugins/console/generate.jsjavascript
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    function 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 方法的核心:

/hexo/index.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
load(callback) {
return (0, load_database_1.default)(this).then(() => { // 确保数据库已加载
this.log.info('Start processing');
return bluebird_1.default.all([
this.source.process(), // 我们的主角
this.theme.process()
]);
}).then(() => {
mergeCtxThemeConfig(this);
return this._generate({ cache: false });
}).asCallback(callback);
}

this.load() 的任务很明确:将 source 文件夹里所有零散的文件,加工成结构化、可供后续使用的内存数据。

this.source.process() 是故事的起点。那么 this.source 是什么?它的 process() 方法又是什么?

2.1. Box

Hexo 类的结构函数中,this.source 被实例化:

/hexo/index.jsjavascript
1
2
3
const source_1 = __importDefault(require("./source"));

this.source = new source_1.default(this);

Source 类的定义出奇地简单:

/hexo/source.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
const box_1 = __importDefault(require("../box"));
class Source extends box_1.default {
constructor(ctx) {
super(ctx, ctx.source_dir);
this.processors = ctx.extend.processor.list();
}
}
module.exports = Source;
//# sourceMappingURL=source.js.map

原因也很简单:Source 类继承自 Box 类。记性很好的朋友此时就要一拍脑袋了:上一篇文章提到过。

当我们调用 this.source.process() 时,我们实际上是在调用 Box 类中定义的 process 方法:

/box/index.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
process(callback) {
const { base, Cache, context: ctx } = this;
return (0, hexo_fs_1.stat)(base).then(stats => {
if (!stats.isDirectory())
return;
// Check existing files in cache
const relativeBase = escapeBackslash(base.substring(ctx.base_dir.length));
const cacheFiles = Cache.filter(item => item._id.startsWith(relativeBase)).map(item => item._id.substring(relativeBase.length));
// Handle deleted files
return this._readDir(base)
.then((files) => cacheFiles.filter((path) => !files.includes(path)))
.map((path) => this._processFile(file_1.default.TYPE_DELETE, path));
}).catch(err => {
if (err && err.code !== 'ENOENT')
throw err;
}).asCallback(callback);
}

Box 类是 Hexo 通用的文件处理引擎。它的 process 方法负责扫描指定目录(这里的话扫描的是 source/)、与缓存对比、找出被删除/新增/修改的文件,然后为每个文件调用 _processFile 方法:

/box/index.jsjavascript
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
_processFile(type, path) {
if (this._processingFiles[path]) {
return bluebird_1.default.resolve();
}
this._processingFiles[path] = true;
const { base, File, context: ctx } = this;
this.emit('processBefore', {
type,
path
});
return bluebird_1.default.reduce(this.processors, (count, processor) => {
const params = processor.pattern.match(path);
if (!params)
return count;
const file = new File({
// source is used for filesystem path, keep backslashes on Windows
source: (0, path_1.join)(base, path),
// path is used for URL path, replace backslashes on Windows
path: escapeBackslash(path),
params,
type
});
return Reflect.apply(bluebird_1.default.method(processor.process), ctx, [file])
.thenReturn(count + 1);
}, 0).then(count => {
if (count) {
ctx.log.debug('Processed: %s', (0, picocolors_1.magenta)(path));
}
this.emit('processAfter', {
type,
path
});
}).catch(err => {
ctx.log.error({ err }, 'Process failed: %s', (0, picocolors_1.magenta)(path));
}).finally(() => {
this._processingFiles[path] = false;
}).thenReturn(path);
}

_processFile 则是处理单个文件的核心,它会遍历一个名为 this.processors 的数组,用数组中的每个 processorpattern 与文件路径进行匹配。如果匹配成功,就执行该 processorprocess 方法。

那么,this.processors 这个关键的数组是从哪里来的呢?这引出了 Processor 的注册机制。

2.2. Processor 扩展

Processor 扩展遵循一个清晰的注册与加载流程。

首先是注册,在 hexo.init() 阶段,Hexo 会加载 /plugins/processor/index.js 文件,该文件负责注册所有内置的 Processor

/plugins/processor/index.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
"use strict";
module.exports = (ctx) => {
// 1. 从 Hexo 上下文中获取 processor 扩展的注册器实例
const { processor } = ctx.extend;
// 2. 定义一个内部的 register 辅助函数
function register(name) {
// 2.1. 加载指定 processor 的定义文件
const obj = require(`./${name}`)(ctx);
// 2.2. 调用注册器实例的 register 方法
// 将 processor 的 pattern 和 process 函数正式注册到 Hexo 的扩展管理器中
processor.register(obj.pattern, obj.process);
}
// 3. 为每一个内置的 processor 调用 register 函数
register('asset'); // 静态资源处理器
register('data'); // _data 目录处理器
register('post'); // 文章处理器
};
//# sourceMappingURL=index.js.map

接着是加载。当 new source_1.default(this) 执行时,Source 类的构造函数会从 Processor 扩展的注册器中,获取所有已注册的 Processor 列表,并赋值给自身的 this.processors 属性。

/hexo/source.jsjavascript
1
2
3
4
5
6
7
class Source extends box_1.default {
constructor(ctx) {
super(ctx, ctx.source_dir);
// 从 ctx.extend.processor.list() 获取完整的 processor 列表
this.processors = ctx.extend.processor.list();
}
}

至此,调用链完全闭合:

  1. init 阶段,/plugins/processor/index.jspost 等处理器注册到 hexo.extend.processor
  2. Source 实例将 hexo.extend.processor 的完整列表复制到自身的 this.processors 数组中。
  3. 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 函数来决定是否处理一个文件。这个函数非常智能,它不仅仅是匹配路径,更是进行了一系列的检查:

/plugins/processor/post.jsjavascript
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
module.exports = (ctx) => {
return {
pattern: new hexo_util_1.Pattern(path => {
// 1. 临时的文件直接拒绝
if ((0, common_1.isTmpFile)(path))
return;
// 2. 文件必须来自 _posts/ 或者 /_drafts 目录
let result;
if (path.startsWith(postDir)) {
result = {
published: true,
path: path.substring(postDir.length)
};
}
else if (path.startsWith(draftDir)) {
result = {
published: false,
path: path.substring(draftDir.length)
};
}
// 3. 隐藏的文件直接拒绝
if (!result || (0, common_1.isHiddenFile)(result.path))
return;
// 4. 判断其可渲染性(ctx.render 会在下一章提起)
result.renderable = ctx.render.isRenderable(path) && !(0, common_1.isMatch)(path, ctx.config.skip_render);
// 5. 处理文章资源文件夹
// 如果开启了 post_asset_folder,那么只有和默认文章扩展名相同的文件才被认为是可渲染的文章
if (result.renderable && ctx.config.post_asset_folder) {
result.renderable = ((0, path_1.extname)(ctx.config.new_post_name) === (0, path_1.extname)(path));
}
return result;
}),
};
};

这个函数返回的 result 对象,会被 Box 附加到 File 实例的 params 属性上,供 process 函数使用。

2.3.2. 分流与处理

post 处理器的 process 函数会读取 file.params.renderable 的值,决定下一步操作:

/plugins/processor/post.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
module.exports = (ctx) => {
return {
process: function postProcessor(file) {
// 如果可渲染,就作为文章处理
if (file.params.renderable) {
return processPost(ctx, file);
}
// 否则,如果开启了 post_asset_folder,则作为文章的静态资源处理
else if (ctx.config.post_asset_folder) {
return processAsset(ctx, file);
}
}
}
}

2.3.3. 精加工与入库

process 函数决定一个文件是可渲染的文章时,它就会把 file 对象交给 processPost 函数。这个函数使整个 Processor 中最复杂的部分,它的任务是读取文件、解析并融合所有元数据,最后将结构化的文章数据存入数据库

整个过程可以分解为以下几个关键步骤:

  1. 准备与前置处理

    函数首先会进行一些准备工作,并处理几种简单的文件状态。

    /plugins/processor/post.jsjavascript
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    function 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;
    }
  2. 异步读取与初步解析

    对于 createupdate 状态的文件,处理正式开始。Hexo 会并行地执行两个异步操作:读取文件内容和获取文件系统状态。

    /plugins/processor/post.jsjavascript
    1
    2
    3
    4
    5
    6
    7
    8
    return 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.jsjavascript
    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
    function 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 格式,从文件名中尝试提取 yearmonthdaytitle 等信息,存放在 info 对象中:

    /plugins/processor/post.jsjavascript
    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
    function 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)
    };
    }
  3. 数据融合与标准化

    这是 processPost 最核心的部分。它会将上一步得到的 datainfo,以及 stats 这三个来源的数据进行智能融合,并进行标准化处理,最终形成一篇完整、规范的文章数据。

    这个融合过程遵循明确的优先级:Front-matter > 文件名 > 文件系统信息

    元数据确定逻辑:

    • slugsource:直接从文件名解析结果或文件路径中获取
    • title:优先使用 Front-matter 中的 title。如果没有,并且 use_slug_as_post_title 配置为 true,则会使用从文件名中解析出的 slug 作为标题
    • date
      1. 优先使用 Front-matter 中的 date
      2. 其次尝试从文件名中解析(info.year 等)
      3. 最后使用文件的创建时间 stats.birthtime
    • updated
      1. 优先使用 Front-matter 里的
      2. 其次根据 _config.ymlupdated_options 配置,可能使用 date 的值或者留空
      3. 最后默认使用文件的修改时间 stats.mtime
    • tagscategories:函数会进行标准化处理,确保它们最终都是数组格式,并处理了 tag(单数)-> tags(复数)这种别名情况
    /plugins/processor/post.jsjavascript
    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
    const 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;
    }
  4. 数据库持久化

    当所有数据都准备就绪后,便进入了最终的数据库操作阶段。

    /plugins/processor/post.jsjavascript
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
      if (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);
    })
  5. 建立关联

    数据入库后,工作还没有完全结束。

    insert/replace 操作会返回一个 Promise,其结果是处理后的数据库文档 doc。接下来的 .then() 中,Hexo 会完成最后一步:建立模型之间的关联。

    /plugins/processor/post.jsjavascript
    1
    2
    3
    4
    5
    6
    .then(doc => bluebird_1.default.all([
    doc.setCategories(categories), // 将文章与分类模型关联
    doc.setTags(tags), // 将文章与标签模型关联
    scanAssetDir(ctx, doc) // 如果开启,处理文章的资源文件夹
    ]));
    }

    doc.setCategoriesdoc.setTags 方法会处理 categoriestags 数组,在 CategoryTag 以及中间关联表中创建或更新记录。这使得我们之后可以轻松地通过 post.tagscategory.posts 来查询关联数据。

至此,processPost 的全部工作完成。一篇原始的 Markdown 文件,经过这一系列精密的加工,已经变成了一条结构完整、关系清晰的数据库记录。

3. 从 Markdown 到 HTML

上一章,我们看到 post 处理器将 Markdown 源文件加工成了包含 _content(原始 Markdown 文本)的数据库记录。但此时,文章的 content 属性仍然是空的。从 _contentcontent(渲染后的 HTML),是整个生成过程中最关键的“炼金”步骤。

这个转换不是在 source.process() 阶段发生的,而是在稍后的一个特殊时机,由 Filter 机制驱动。

3.1. before_generate 过滤器

_generate 方法的核心逻辑中,_runGenerators 执行之前,有一个关键的前置步骤:

/hexo/index.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
_generate(options = {}) {
if (this._isGenerating)
return;
const useCache = options.cache;
this._isGenerating = true;
this.emit('generateBefore');
return this.execFilter('before_generate', null, { context: this }) // 在这里
.then(() => this._routerRefresh(this._runGenerators(), useCache)).then(() => {
this.emit('generateAfter');
return this.execFilter('after_generate', null, { context: this });
}).finally(() => {
this._isGenerating = false;
});
}

execFilter 方法会执行所有注册在 before_generate 这个钩子上的函数。Hexo 默认在这里注册了一个名为 renderPostFilter 的过滤器,它的任务就是:确保所有文章在生成页面蓝图之前,都已经被渲染成 HTML。

这个过滤器的注册和定义如下:

/plugins/filter/before_generate/index.jsjavascript
1
2
3
4
5
6
7
"use strict";
module.exports = (ctx) => {
const { filter } = ctx.extend;
// 注册 render_post.js 到 before_generate 钩子
filter.register('before_generate', require('./render_post'));
};
//# sourceMappingURL=index.js.map
/plugins/filter/before_generate/render_post.jsjavascript
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
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
const bluebird_1 = __importDefault(require("bluebird"));
function renderPostFilter() {
// 定义一个处理指定模型的函数
const renderPosts = model => {
// 1. 找出所有 content 属性为 null 的文章/页面
const posts = model.toArray().filter(post => post.content == null);
// 2. 遍历这些未渲染的条目
return bluebird_1.default.map(posts, (post) => {
// 2.1. 将原始 Markdown 内容赋值给 content
post.content = post._content;
// 2.2. 调用核心的渲染方法
return this.post.render(post.full_source, post).then(() => post.save());
});
};
// 并行处理 Post 和 Page 两个模型
return bluebird_1.default.all([
renderPosts(this.model('Post')),
renderPosts(this.model('Page'))
]);
}
module.exports = renderPostFilter;
//# sourceMappingURL=render_post.js.map

3.2. post.render

this.post.render 是整个内容转换过程的核心。它确保了 Markdown 在转换为 HTML 的过程中,内嵌的特殊标签(例如 Swig 模版标签)不会被破坏,并且能被正确地执行。

那么 this.post.render 从哪里来的?回到 Hexo 实例:

/hexo/index.jsjavascript
1
2
const post_1 = __importDefault(require("./post"));
this.post = new post_1.default(this);

我们再来深入 Post 类的 render 方法:

/hexo/post.jsjavascript
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
render(source, data = {}, callback) {
const ctx = this.context;
const { config } = ctx;
const { tag } = ctx.extend;
// 文件读取和非文章类型文件的处理逻辑
const ext = data.engine || (source ? (0, path_1.extname)(source) : '');
let promise;
if (data.content != null) {
promise = bluebird_1.default.resolve(data.content);
}
else if (source) {
promise = (0, hexo_fs_1.readFile)(source);
}
else {
return bluebird_1.default.reject(new Error('No input file or string!')).asCallback(callback);
}
const isPost = !data.source || ['html', 'htm'].includes(ctx.render.getOutput(data.source));
if (!isPost) {
return promise.then(content => {
data.content = content;
ctx.log.debug('Rendering file: %s', (0, picocolors_1.magenta)(source));
return ctx.render.render({
text: data.content,
path: source,
engine: data.engine,
toString: true
});
}).then(content => {
data.content = content;
return data;
}).asCallback(callback);
}
let disableNunjucks = ext && ctx.render.renderer.get(ext) && !!ctx.render.renderer.get(ext).disableNunjucks;
if (typeof data.disableNunjucks === 'boolean')
disableNunjucks = data.disableNunjucks;
const cacheObj = new PostRenderEscape(); // 1. 创建转义工具实例
return promise.then(content => { // promise 在之前已读取文件内容
data.content = content;
// 2. 执行 before_post_render 过滤器
return ctx.execFilter('before_post_render', data, { context: ctx });
}).then(() => {
// 3. 转义代码和 Swig 标签
data.content = cacheObj.escapeCodeBlocks(data.content);
if (disableNunjucks === false) {
data.content = cacheObj.escapeAllSwigTags(data.content);
}
const options = data.markdown || {};
if (!config.syntax_highlighter)
options.highlight = null;
ctx.log.debug('Rendering post: %s', (0, picocolors_1.magenta)(source));
// 4. 调用核心渲染器
return ctx.render.render({
text: data.content,
path: source,
engine: data.engine,
toString: true,
onRenderEnd(content) { // 5. 回调
// 5.1. 恢复 Swig 标签
data.content = cacheObj.restoreAllSwigTags(content);
if (disableNunjucks)
return data.content;
// 5.2. 执行 Swig 标签
return tag.render(data.content, data);
}
}, options);
}).then(content => {
// 6. 恢复代码块
data.content = cacheObj.restoreCodeBlocks(content);
// 7. 执行 after_post_render 过滤器
return ctx.execFilter('after_post_render', data, { context: ctx });
}).asCallback(callback);
}

这个过程可以分解为七个步骤:

  1. 实例化 PostRenderEscape 对象,专门用来“保护”和“恢复”特殊标签
  2. 执行一轮 before_post_render 过滤器,允许插件对原始 Markdown 内容进行修改
  3. 保护性转义
    • escapeCodeBlocksescapeAllSwigTags 方法会用正则表达式查找所有的代码块和 Nunjucks/Swig 标签(例如 {% post_link %}{{ post.title }}
    • 它会将这些找出的内容从字符串中“挖”出来,存入 cacheObj.stored 数组,然后在原文中留下一个无害的占位符
    • 这么做的理由是为了让下一步的 Markdown 渲染器知道,这一块是 Nunjucks 语法,不要把它当做普通文本并转义
  4. 调用 ctx.render.render()。它会根据文件扩展名(.md)找到对应的渲染器插件,然后调用其 render 方法,将已经被保护起来的 Markdown 文本转换成 HTML。此时生成的 HTML 中依然包含着占位符
  5. Markdown 渲染器完成工作后,onRenderEnd 回调函数会立刻执行,开始逆向工程
    • restoreAllSwigTags 方法被调用。它会查找 HTML 中的所有占位符,并用之前存储在 cacheObj.stored 数组中的原始标签替换它们。现在我们得到了一个混合了 HTML 和 Nunjucks 标签的字符串
    • tag.render() 被调用、用于处理这个混合字符串,执行其中的 {& post_link &} 等标签,并将其替换为最终的 HTML 片段
  6. 经过 Nunjucks 渲染后,restoreCodeBlocks 被调用,以同样的方式将代码块占位符恢复成原始的代码块 HTML
  7. 所有渲染和模版执行都已完成。这最后一轮 after_post_render 过滤器让插件有机会对最终生成的 HTML 内容进行处理,例如添加 target="_blank"、图片懒加载等

3.3. Render

ctx.renderRender 类的一个实例,它扮演着渲染任务“总调度员”的角色。它不关心具体如何渲染,只负责找到正确的渲染器并把任务交给它。

/hexo/render.jsjavascript
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
class Render {
render(data, options, callback) {
// 参数处理和文件读取
if (!callback && typeof options === 'function') {
callback = options;
options = {};
}
const ctx = this.context;
let ext = '';
let promise;
if (!data)
return bluebird_1.default.reject(new TypeError('No input file or string!'));
if (data.text != null) {
promise = bluebird_1.default.resolve(data.text);
}
else if (!data.path) {
return bluebird_1.default.reject(new TypeError('No input file or string!'));
}
else {
promise = (0, hexo_fs_1.readFile)(data.path);
}
return promise.then(text => {
data.text = text;
// 1. 获取文件扩展名
ext = data.engine || getExtname(data.path);
if (!ext || !this.isRenderable(ext))
return text;
// 2. 根据扩展名查找对应的渲染器
const renderer = this.getRenderer(ext);
// 3. 执行渲染器
return Reflect.apply(renderer, ctx, [data, options]);
}).then(result => {
result = toString(result, data);
// 4. 检查并执行 onRenderEnd 回调
if (data.onRenderEnd) {
return data.onRenderEnd(result);
}
return result;
}).then(result => {
// 5. 执行 after_render 过滤器
const output = this.getOutput(ext) || ext;
return ctx.execFilter(`after_render:${output}`, result, {
context: ctx,
args: [data]
});
}).asCallback(callback);
}
}
module.exports = Render;
//# sourceMappingURL=render.js.map

也就是说,核心渲染是由具体的渲染器插件完成的。

3.3.1. hexo-renderer-marked

hexo-renderer-marked 是 Hexo 默认的 Markdown 渲染器,也是 Renderer 类调度的完美范例。它本身不是一个简单的函数,而是一个集配置、扩展和定制于一体的小系统。

hexojs/hexo-renderer-marked - GitHub

renderer.jsjavascript
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
module.exports = function(data, options) {
const { post_asset_folder, marked: markedCfg, source_dir } = this.config;
const { prependRoot, postAsset, dompurify } = markedCfg;
const { path, text } = data;

marked.defaults.extensions = null;
marked.defaults.tokenizer = null;
marked.defaults.renderer = null;
marked.defaults.hooks = null;
marked.defaults.walkTokens = null;

// exec filter to extend marked
this.execFilterSync('marked:use', marked.use, { context: this });

// exec filter to extend renderer
this.execFilterSync('marked:renderer', renderer, { context: this });

// exec filter to extend tokenizer
this.execFilterSync('marked:tokenizer', tokenizer, { context: this });

const extensions = [];
this.execFilterSync('marked:extensions', extensions, { context: this });
marked.use({ extensions });

let postPath = '';
if (path && post_asset_folder && prependRoot && postAsset) {
const Post = this.model('Post');
// Windows compatibility, Post.findOne() requires forward slash
const source = path.substring(this.source_dir.length).replace(/\\/g, '/');
const post = Post.findOne({ source });
if (post) {
const { source: postSource } = post;
postPath = join(source_dir, dirname(postSource), basename(postSource, extname(postSource)));
}
}

let sanitizer = function(html) { return html; };

if (dompurify) {
if (createDOMPurify === undefined && JSDOM === undefined) {
createDOMPurify = require('dompurify');
JSDOM = require('jsdom').JSDOM;
}
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
let param = {};
if (dompurify !== true) {
param = dompurify;
}
sanitizer = function(html) { return DOMPurify.sanitize(html, param); };
}

marked.use({
renderer,
tokenizer
});
return sanitizer(marked.parse(text, Object.assign({
// headerIds was removed in marked v8.0.0, but we still need it
headerIds: true
}, markedCfg, options, { postPath, hexo: this, _headingId: {} })));
};

该渲染器用了 marked 库。

markedjs/marked - GitHub

它的入口函数接收从 Renderer 类传来的 dataoptions,然后执行以下步骤:

  1. 为了保证每次渲染的纯粹性,它首先会清空 marked 库的默认扩展。然后,它立刻通过 this.execFilterSync 触发三个专属于 marked 的过滤器:

    • marked:use
    • marked:renderer
    • marked:tokenizer

    允许其他插件来进一步修改和扩展 marked 的行为,体现了 Hexo 插件系统的层次性。

  2. 定义一个 renderer 对象,重写 marked 库的默认渲染行为,以实现 Hexo 的特有功能:

    • heading:为标题标签自动生成 id 属性,用于页面内锚点跳转,并添加一个可点击的“链接”图标。因此我们能在 Hexo 文章标题旁看到那个小链接图标
    • link:根据 _config.yml 中的 external_link 配置、自动为外部链接添加 target="_blank"rel="noopener" 等属性
    • image:智能处理图片路径,可以根据配置自动在图片路径前加上根目录(root),并处理与文章关联的资源文件夹中的图片
    • paragraph:增加了一个小功能,可以识别特定的语法(Term<br>: Definition)并将其转换为定义列表(<dl>
    renderer.jsjavascript
    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
    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
    132
    const 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;
    }
    };
  3. 定制 tokenizer,用于影响 marked 如何解析原始的 Markdown 文本

    renderer.jsjavascript
    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
    const 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.jsjavascript
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    const 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');
    };
  4. 在准备好所有定制的 renderertokenizer 之后,插件通过 marked.use() 将它们应用到 marked 实例上。最后调用 marked.parse(),传入原始文本和所有配置,得到最终的 HTML

    • 如果用户开启了 dompurify 选项,它还会在返回结果前对 HTML 进行一次安全过滤,防止恶意的脚本注入

4. 页面的生成

还记得第二章最开始出现的那段代码吗?

/hexo/index.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
load(callback) {
return (0, load_database_1.default)(this).then(() => {
this.log.info('Start processing');
return bluebird_1.default.all([
this.source.process(),
this.theme.process()
]);
}).then(() => {
mergeCtxThemeConfig(this);
return this._generate({ cache: false });
}).asCallback(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

/hexo/index.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
_generate(options = {}) {
if (this._isGenerating)
return;
const useCache = options.cache;
this._isGenerating = true;
this.emit('generateBefore');
// Run before_generate filters
return this.execFilter('before_generate', null, { context: this })
.then(() => this._routerRefresh(this._runGenerators(), useCache)).then(() => {
this.emit('generateAfter');
// Run after_generate filters
return this.execFilter('after_generate', null, { context: this });
}).finally(() => {
this._isGenerating = false;
});
}

_runGenerators 的职责非常专一:执行所有已注册的 Generator 插件,并收集它们的返回结果。

/hexo/index.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
_runGenerators() {
this.locals.invalidate();
// 1. 准备 Generator 的输入数据(site 对象)
const siteLocals = this.locals.toObject();
// 2. 获取所有已注册的 Generator 函数列表
const generators = this.extend.generator.list();
const { log } = this;
// 3. 遍历并执行所有的 Generator
return bluebird_1.default.map(Object.keys(generators), key => {
const generator = generators[key];
log.debug('Generator: %s', (0, picocolors_1.magenta)(key));
// 4. 执行单个 Generator 函数
return Reflect.apply(generator, this, [siteLocals]);
}).reduce((result, data) => {
// 5. 将所有返回结果合并成一个数组
return data ? result.concat(data) : result;
}, []);
}

这里的 this.localsHexo 实例的一个属性。第一篇文章中,它被我们分析过的 _bindLocals 方法初始化。_bindLocals 将数据库查询函数与 posts, pages, tags, categories 等键名绑定。

this.locals.toObject() 方法的作用,就是执行所有这些绑定的函数,从数据库中查询出最新的数据,并组装成一个巨大的 site 对象。这个对象包含了:

  • site.posts: 一个 Warehouse 模型集合,包含了所有文章
  • site.pages: 包含了所有独立页面
  • site.categoriessite.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.postssite.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/)中,负责处理有源文件的页面
  • archivecategorytag 等生成器,虽然是默认安装,但它们是独立的 npm 包。它们负责处理无源页面

archive 生成器的核心代码可以分解为三个主要步骤:

  1. 生成主归档页

    Generator 首先会处理根归档页,也就是 /archives/ 目录。

    hexo-generator-archive/lib/generator.jsjavascript
    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
    'use strict';

    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);

    hexojs/hexo-pagination - GitHub

  2. 按日期对文章进行分组

    如果用户在 _config.yml 中开启了 yearlymonthly 归档,Generator 接下来会执行一个精巧的数据预处理步骤:将所有文章按年、月、日进行分组。

    hexo-generator-archive/lib/generator.jsjavascript
    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
    if (!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 月的所有文章。

  3. 生成年、月、日归档页

    最后,代码会遍历上一步中分组好的 posts 对象,为每个时间单位生成对应的归档页面。

hexo-generator-archive/lib/generator.jsjavascript
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
  const { Query } = this.model('Post');
const years = Object.keys(posts);
let year, data, month, monthData, url;

// 遍历年份
for (let i = 0, len = years.length; i < len; i++) {
year = +years[i];
data = posts[year];
url = archiveDir + year + '/';
if (!data[0].length) continue;

// 为该年份生成归档页,例如 /archives/2025/
generate(url, new Query(data[0]), { year });

if (!config.archive_generator.monthly && !config.archive_generator.daily) continue;

// 遍历月份
for (month = 1; month <= 12; month++) {
monthData = data[month];
if (!monthData.length) continue;
if (config.archive_generator.monthly) {
// 为该月份生成归档页,例如 /archives/2025/07/
generate(url + fmtNum(month) + '/', new Query(monthData), {
year,
month
});
}

if (!config.archive_generator.daily) continue;

// 同样逻辑处理按日归档
for (let day = 1; day <= 31; day++) {
const dayData = monthData.day[day];
if (!dayData || !dayData.length) continue;
generate(url + fmtNum(month) + '/' + fmtNum(day) + '/', new Query(dayData), {
year,
month,
day
});
}
}
}

return result;
};

通过嵌套循环,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)

/hexo/router.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
class Router extends events_1.EventEmitter {
constructor() {
super();
this.routes = {}; // 核心数据结构
}
list() {}
format(path) {}
get(path) {}
isModified(path) {}
set(path, data) {}
remove(path) {}
}

_routerRefresh 的核心任务便是清空并重新填充这个 this.routes 对象。

5.2. _routerRefresh

_routerRefresh 方法接收 _runGenerators 返回的蓝图数组作为输入、遍历这个数组、为每一个蓝图在 this.route 中创建一条对应的记录。

/hexo/index.jsjavascript
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
_routerRefresh(runningGenerators, useCache) {
const { route } = this;
const routeList = route.list(); // 1. 获取旧的路由列表,用于后续对比
const Locals = this._generateLocals(); // 2. 准备一个 Locals 类的构造器
Locals.prototype.cache = useCache;
// 3. 遍历所有由 Generator 生成的页面蓝图
return runningGenerators.map((generatorResult) => {
if (typeof generatorResult !== 'object' || generatorResult.path == null)
return undefined;
const path = route.format(generatorResult.path);
const { data, layout } = generatorResult;
// 4. 根据蓝图是否有 layout 属性,进行分流处理
if (!layout) {
// 4.1. 没有 layout:视为静态资源
route.set(path, data);
return path;
}
// 4.2. 有 layout:视为待渲染页面
// 这里的 createLoadThemeRoute 是用于将主题的布局文件和页面的具体数据打包成一个待执行的渲染任务、通过 route.set 存入路由系统的
return this.execFilter('template_locals', new Locals(path, data), { context: this })
.then(locals => { route.set(path, createLoadThemeRoute(generatorResult, locals, this)); })
.thenReturn(path);
}).then(newRouteList => {
// 5. 刷新操作:移除所有过时的路由
for (let i = 0, len = routeList.length; i < len; i++) {
const item = routeList[i];
if (!newRouteList.includes(item)) {
route.remove(item);
}
}
});
}

_generateLocals 方法见这里

这个方法的逻辑非常清晰,但要真正理解,我们需要回到 Router 类。

5.3. Router 类深度解析

  1. set 方法负责将内容存入路由。它的实现非常巧妙,能够区分处理不同类型的数据。

    /hexo/router.jsjavascript
    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
    set(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 方法的逻辑揭示了路由内容的两种形态:

    1. 成品 (静态资源):如果传入的 data 是 Buffer 或字符串(比如一张图片的内容),它会被直接存入 this.routes
    2. 半成品 (待渲染页面):如果传入的 data 是一个函数(由 createLoadThemeRoute 创建的那个),set 方法会用 bluebird 将其包装成一个标准的异步函数。存入路由的,是这个被包装后的函数本身

    这种设计就是 Hexo 高性能的延迟执行 (Lazy Evaluation) 策略的核心。它避免了在生成路由时就渲染所有页面,而是将渲染工作推迟到最后一刻。

  2. 如果说 set 方法是把“速冻料理包”(待执行的渲染函数)放入冰箱,那么 get 方法就是从冰箱里取出料理包并准备加热。

    get 方法本身很简单,但它返回的东西却大有文章:

    /hexo/router.jsjavascript
    1
    2
    3
    4
    5
    6
    7
    8
    9
    get(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() 方法中:

    javascript
    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
    class 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 类:

/plugins/console/generate.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
function 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;

generator.firstGenerate() 就是文件写入流程的入口。这个方法负责比对文件差异、创建任务队列,并最终调用 writeFiledeleteFile 来同步 public 目录。

6.2. firstGenerate

firstGenerate 方法做的第一件大事,不是写入,而是比对,以确定一份精准的“施工清单”。

/plugins/console/generate.jsjavascript
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
firstGenerate() {
const { concurrency } = this;
const { route, log } = this.context;
const publicDir = this.context.public_dir;
const Cache = this.context.model('Cache');
const interval = (0, pretty_hrtime_1.default)(process.hrtime(this.start));
log.info('Files loaded in %s', (0, picocolors_1.cyan)(interval));
this.start = process.hrtime();
// 确保 public 文件夹存在
return (0, hexo_fs_1.stat)(publicDir).then(stats => {
if (!stats.isDirectory()) {
throw new Error(`${(0, picocolors_1.magenta)((0, tildify_1.default)(publicDir))} is not a directory`);
}
}).catch(err => {
if (err && err.code === 'ENOENT') {
return (0, hexo_fs_1.mkdirs)(publicDir);
}
throw err;
}).then(() => {
const task = (fn, path) => () => fn.call(this, path);
const doTask = fn => fn();
const routeList = route.list(); // 1. 获取本次需要生成的文件清单
const publicFiles = Cache.filter(item => item._id.startsWith('public/')).map(item => item._id.substring(7)); // 2. 获取上次已生成的文件清单
// 3. 生成任务队列
const tasks = publicFiles.filter(path => !routeList.includes(path))
.map(path => task(this.deleteFile, path)) // 需要删除的文件
.concat(routeList.map(path => task(this.generateFile, path))); // 需要生成/更新的文件
// 4. 并发执行所有任务
return bluebird_1.default.all(bluebird_1.default.map(tasks, doTask, { concurrency: parseFloat(concurrency || 'Infinity') }));
}).then(result => {
const interval = (0, pretty_hrtime_1.default)(process.hrtime(this.start));
const count = result.filter(Boolean).length;
log.info('%d files generated in %s', count.toString(), (0, picocolors_1.cyan)(interval));
});
}

这里的逻辑非常清晰:

  1. 获取新蓝图:从 this.context.route.list() 获取我们在第四章构建的内存虚拟网站的文件列表
  2. 获取旧清单:从 Cache 模型中读取上次生成时写入 public/ 的所有文件记录
  3. 计算差异并生成任务:
    • 如果一个文件在“旧清单”里,但不在“新蓝图”里,就为它创建一个 deleteFile 任务
    • 所有“新蓝图”里的文件,都为它们创建一个 generateFile 任务
  4. 并发执行:使用 bluebird.map 并发处理所有任务,以提高生成效率

6.3. 触发延迟执行

writeFile 方法是整个流程中最关键的“临门一脚”,它负责触发第四章中我们埋下的“延迟执行”机制。

/plugins/console/generate.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
writeFile(path, force) {
const { route, log } = this.context;
const publicDir = this.context.public_dir;
const Cache = this.context.model('Cache');
// 1. 从路由中获取内容,返回的是一个 RouteStream 实例
const dataStream = this.wrapDataStream(route.get(path));
const buffers = [];
const hasher = (0, hexo_util_1.createSha1Hash)();
const finishedPromise = new bluebird_1.default((resolve, reject) => {
dataStream.once('error', reject);
dataStream.once('end', resolve);
});
// 2. 消费这个流
dataStream.on('data', chunk => {
buffers.push(chunk);
hasher.update(chunk);
});

当我们开始监听 data 事件,也就是开始从流中“拉取”数据时,RouteStream 内部的 _read() 方法就会被触发。

如果这个路由的内容是一个“待执行的渲染函数”,这个函数在此时此刻才会被执行,渲染出最终的 HTML,然后将结果推入流中,被这里的 data 事件监听器接收。

6.4. 哈希值对比

writeFile 方法的后半部分,还隐藏着 Hexo 再次生成时速度飞快的秘密——增量生成。

/plugins/console/generate.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
  return finishedPromise.then(() => {
const dest = (0, path_1.join)(publicDir, path);
const cacheId = `public/${path}`;
const cache = Cache.findById(cacheId); // 1. 查找上一次的缓存
const hash = hasher.digest('hex'); // 2. 拿到本次内容的哈希值
// 3. 如果哈希值未变,且不是强制生成,则跳过
if (!force && cache && cache.hash === hash) {
return;
}
// 4. 哈希值变了,才执行真正的写入操作
return Cache.save({
_id: cacheId,
hash
}).then(() =>
(0, hexo_fs_1.writeFile)(dest, Buffer.concat(buffers))).then(() => {
log.info('Generated: %s', (0, picocolors_1.magenta)(path));
return true;
});
});
}

在将文件内容写入磁盘之前,writeFile 会计算出内容的 SHA1 哈希值,并与缓存中的旧哈希值进行对比。如果两者相同,意味着文件内容没有变化,Hexo 就会聪明地跳过这次文件写入操作,从而极大地提升了二次生成的效率。

7. 总结

现在,让我们回到最初的起点,以 _posts/hello-world.md 的视角,快速回顾它在 hexo generate 命令下经历的完整旅程:

  1. 我们在终端敲下回车,Console 扩展接收到 generate 命令
  2. Box 引擎扫描 source 目录,发现了 hello-world.mdpost 处理器接管了它,解析其文件名和 Front-matter,将标题、日期等元数据连同原始 Markdown 内容(_content)一起,存入了数据库的 Post 模型中
  3. 在生成页面之前,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 字段
  4. _runGenerators 开始执行。post 生成器遍历数据库,找到了我们已渲染好的 hello-world.md,为它创建了一张包含最终路径、主题布局(layout: 'post')和所有页面数据的“页面蓝图”
  5. _routerRefresh 接收到这张蓝图。它没有立即渲染整个页面,而是创建了一个“待办任务”——一个包含了“使用 post 布局”和“填充 hello-world.md 内容”指令的函数,并将这个任务存入了内存中的 Router 里,键名为最终的文件路径 posts/hello-world/index.html
  6. Generater 类的 firstGenerate 方法开始工作。它对比 Routerpublic 目录的缓存,确定 posts/hello-world/index.html 是一个需要生成的文件,并为其创建了一个 writeFile 任务
  7. writeFile 任务执行时,它向 Router 请求 posts/hello-world/index.html 的内容。RouteStream 机制被触发,直到这一刻,第五步中创建的那个“待办任务”才被真正执行。它读取主题的 post 布局文件,将 hello-world.mdcontent(HTML)填充进去,生成了包含页头、页脚的完整页面 HTML
  8. 最终的 HTML 内容通过流被写入 public/posts/hello-world/index.html 文件。hello-world.md 的旅程至此结束,它已成为网站上一个可被访问的真实页面