我们上篇文章分析了hexo-cli的执行原理,那么本篇文章,将分析一下hexo这个依赖中做的事情。将尝试分析一下hexo的内部处理机制。

整体流程

在详细分析之前,我们可以先来看一下整体的执行流程,我们以hexo g命令为例,我们可以在命令行中加上--debug参数,例hexo g --debug,里面可以打印出一些开发时候埋入的关键log信息。如下所示:

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
$ npx hexo g --debug
# 创建 db.json存储相关博客数据信息
07:59:56.062 DEBUG Writing database to /Users/huidizhu/Personal/blog/blog/db.json
07:59:56.071 DEBUG Hexo version: 5.4.0
07:59:56.071 DEBUG Working directory: ~/Personal/blog/blog/
# 配置加载(根目录 config、主题 config)
07:59:56.156 DEBUG Config loaded: ~/Personal/blog/blog/_config.yml
07:59:56.156 INFO Validating config
07:59:56.165 DEBUG Second Theme Config loaded: ~/Personal/blog/blog/_config.icarus.yml
Inferno is in development mode.
# 插件加载,包含用到的插件
07:59:56.367 DEBUG Plugin loaded: hexo-generator-archive
07:59:56.367 DEBUG Plugin loaded: hexo-generator-category
...
# [hexo-theme-icarus]/scripts/index.js 执行
INFO =======================================
██╗ ██████╗ █████╗ ██████╗ ██╗ ██╗███████╗
██║██╔════╝██╔══██╗██╔══██╗██║ ██║██╔════╝
██║██║ ███████║██████╔╝██║ ██║███████╗
██║██║ ██╔══██║██╔══██╗██║ ██║╚════██║
██║╚██████╗██║ ██║██║ ██║╚██████╔╝███████║
╚═╝ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝
=============================================
INFO === Checking package dependencies ===
INFO === Checking theme configurations ===
INFO === Registering Hexo extensions ===
07:59:56.828 DEBUG Script loaded: node_modules/hexo-theme-icarus/scripts/index.js
# db
07:59:56.832 DEBUG Loading database.
# 文件预处理(source、theme)放到 db的 Cache中
07:59:56.954 INFO Start processing
07:59:56.999 DEBUG Processed: _posts/1. hexo博客构建.md
07:59:56.999 DEBUG Processed: _posts/11. 如何实现一个轮播图.md
07:59:56.999 DEBUG Processed: _posts/12. background属性和img标签.md
07:59:56.999 DEBUG Processed: _posts/10. Express+MySQL实现登陆界面.md
...
07:59:57.014 DEBUG Processed: source/img/avatar.png
07:59:57.014 DEBUG Processed: source/img/favicon.svg
07:59:57.014 DEBUG Processed: source/img/logo.svg
...
# md后缀文章渲染
07:59:58.303 DEBUG Rendering post: /Users/huidizhu/Personal/blog/blog/source/_posts/32. hexo源码分析(一).md
07:59:58.303 DEBUG Rendering post: /Users/huidizhu/Personal/blog/blog/source/_posts/33. hexo源码分析(二).md
# Generator过程
07:59:58.367 DEBUG Generator: page
07:59:58.367 DEBUG Generator: post
07:59:58.367 DEBUG Generator: archive
07:59:58.367 DEBUG Generator: category
07:59:58.367 DEBUG Generator: index
07:59:58.401 INFO Files loaded in 1.57 s
# Render过程
07:59:58.407 INFO Deleted: 2020/05/07/29. 浏览器重绘和requestAnimationFrame/index.html
07:59:58.407 INFO Deleted: 2019/08/03/28. vue-cli源码学习2.x/index.html
...
07:59:58.417 INFO Deleted: 2017/10/24/5. 如何实现一个导航栏/index.html
07:59:58.424 DEBUG Rendering HTML post: hexo源码分析(二)/index.html
...
07:59:58.545 DEBUG Rendering HTML archive: archives/index.html
07:59:58.549 DEBUG Rendering HTML archive: archives/page/2/index.html
...
07:59:58.574 DEBUG Rendering HTML category: categories/前端/page/2/index.html
07:59:58.579 DEBUG Rendering HTML category: categories/hexo/index.html
...
07:59:59.323 INFO 96 files generated in 922 ms
07:59:59.344 DEBUG Database saved

可以看到整体的大致流程就是上面的这些,然后了解了这个内容之后就更方便我们找对应的逻辑实现了。

Hexo类

我们承接上文hexo源码分析(一)中的逻辑介绍,里面对Hexo进行了实例化,并执行了hexo.init()。因此我们就从这里开始进行分析。

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
class Hexo extends EventEmitter {
constructor(base = process.cwd(), args = {}) {
super();

// 这里定义了一些关键的路径,比如项目的路径,theme路径,plugin路径等等
// 因为 Hexo实例贯穿整个执行逻辑,因此这里定义的一些路径信息方便后面使用
this.base_dir = base + sep;
this.public_dir = join(base, 'public') + sep;
this.source_dir = join(base, 'source') + sep;
// hexo插件一般命名规范都是 hexo-...结构,后面在读取插件的时候会正则判断
this.plugin_dir = join(base, 'node_modules') + sep;
this.script_dir = join(base, 'scripts') + sep;
this.scaffold_dir = join(base, 'scaffolds') + sep;
// 这里的 theme路径有两种可能
// 1. [project-name]/theme (优先级高)
// 2. [project-name]/node_modules/hexo-theme-[name]
this.theme_dir = join(base, 'themes', defaultConfig.theme) + sep;
this.theme_script_dir = join(this.theme_dir, 'scripts') + sep;

// ...
// 这里定义了一些比较关键的 extend方法,里面全部都是通过 register来注册进来的
// 这里的对应逻辑可在 hexo/lib/extend中查看,都是暴露出来的注册类
this.extend = {
// hexo-cli命令行注册的一些命令,在hexo/lib/plugins/console/index.js中注册进来
console: new Console(),
deployer: new Deployer(),
// filter可以理解为拦截器,在 hexo执行过程中,会看到很多 execFilter(type, data, options)这种的逻辑,就是在某些环节调用对应的拦截器逻辑
// 可在 hexo/lib/plugins/filter找对应的拦截逻辑
filter: new Filter(),
generator: new Generator(),
helper: new Helper(),
injector: new Injector(),
migrator: new Migrator(),
// 定义了文件预处理的逻辑,比如 md后缀文件使用 hexo-front-matter来分离顶部数据和文章内容
processor: new Processor(),
// 也是一个比较关键的逻辑,提供给文件渲染,比如 markdown -> html, yml -> json 等等,模板渲染逻辑都通过这里注册
renderer: new Renderer(),
tag: new Tag()
};

// ...
// 这里 hexo存储数据的方式是在 [project-name]/db.json 中,里面记录了整个静态网站的数据信息,比如文章内容,资源文件路径等等
// hexo会有在更新的时候会去判断文件的 hash来判断文件是否修改,只针对修改过的文件进行渲染生成
this.database = new Database({
version: dbVersion,
path: join(dbPath, 'db.json')
});

// 引入 models里所有的关键操作,比如 Cache
registerModels(this);

// 这里定义了一些 db.json的查询方式
this._bindLocals();
}

// ...
init() {
// debug模式下会打印出来,在执行命令的时候后面跟上 --debug即可进入该模式,方便查看整个执行流程,便于定位问题
// 具体实现可查看 hexo-log
this.log.debug('Hexo version: %s', magenta(this.version));
this.log.debug('Working directory: %s', magenta(tildify(this.base_dir)));

// Load internal plugins
// hexo/lib/plugins中,基本都是使用 hexo/lib/extend 中实例的对象来进行注册
require('../plugins/console')(this);
require('../plugins/filter')(this);
require('../plugins/generator')(this);
require('../plugins/helper')(this);
require('../plugins/injector')(this);
require('../plugins/processor')(this);
require('../plugins/renderer')(this);
require('../plugins/tag')(this);

// Load config
return Promise.each([
'update_package', // Update package.json
'load_config', // Load config
'load_theme_config', // Load alternate theme config
// 所有的 hexo插件依赖就是在这里加载进来的,例 hexo-renderer-ejs
'load_plugins' // Load external plugins & scripts
], name => require(`./${name}`)(this)).then(() => this.execFilter('after_init', null, {context: this})).then(() => {
// Ready to go!
this.emit('ready');
});
}
}

先来看这一段代码,里面的部分逻辑添加上了一些我的理解注释,这一部分就是Hexo内部的启动逻辑。里面的逻辑还是很多的,后面会找一些比较关键的核心流程来进行分析一下。

_config.yml 和 _config.[theme-name].yml

1
2
3
4
5
return Promise.each([
'load_config', // Load config
'load_theme_config', // Load alternate theme config
]
// ...

_config.yml_config.[theme-name].yml起到配置的作用,是在init过程中引入的,最终会被转换成json结构的配置,然后作为hexo中的一个变量贯穿整个流程。

_config.yml 根目录配置

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
// hexo/lib/hexo/load_config.js
// 整体将 _config.yml的配置放到 hexo.config中
module.exports = async ctx => {
if (!ctx.env.init) return;

const baseDir = ctx.base_dir;
let configPath = ctx.config_path;

const path = await exists(configPath) ? configPath : await findConfigPath(configPath);
if (!path) return;
configPath = path;

// ctx.render将 _config.yml文件转换成 json结构的数据
// 用了 js-yaml这个依赖,详情可见 hexo/lib/plugins/renderer/index.js里面注册了 render的常见后缀
let config = await ctx.render.render({ path });
if (!config || typeof config !== 'object') return;

ctx.log.debug('Config loaded: %s', magenta(tildify(configPath)));

ctx.config = deepMerge(ctx.config, config);

// ...

// 找 theme的路径,因为有两种引入theme的方式,一种放在根目录中,另一种作为 npm依赖引入进来
if (!config.theme) return;

const theme = config.theme.toString();
config.theme = theme;

const themeDirFromThemes = join(baseDir, 'themes', theme) + sep; // base_dir/themes/[config.theme]/
const themeDirFromNodeModules = join(ctx.plugin_dir, 'hexo-theme-' + theme) + sep; // base_dir/node_modules/hexo-theme-[config.theme]/

// themeDirFromThemes has higher priority than themeDirFromNodeModules
let ignored = [];
if (await exists(themeDirFromThemes)) {
ctx.theme_dir = themeDirFromThemes;
ignored = ['**/themes/*/node_modules/**', '**/themes/*/.git/**'];
} else if (await exists(themeDirFromNodeModules)) {
ctx.theme_dir = themeDirFromNodeModules;
ignored = ['**/node_modules/hexo-theme-*/node_modules/**', '**/node_modules/hexo-theme-*/.git/**'];
}
ctx.theme_script_dir = join(ctx.theme_dir, 'scripts') + sep;
ctx.theme = new Theme(ctx, { ignored });
};

上面的代码一些关键的地方做了注释,做的比较核心的两个事情是:

  • yml后缀的文件转换成 json结构
  • 然后根绝_config.yml中 theme的配置找到对应的 theme路径,为后面load_theme_config.js的执行做下铺垫。

关于 yml转换成 json结构的设计我会在后面的文章分析 render的时候会分析中间如何实现的。

_config.[theme-name].yml

这个和上面的类似,你可以去hexo/lib/hexo/load_theme_config.js中查看对应的实现。作为一个变量贯穿整个hexo的运行逻辑。

npm依赖插件加载

1
2
3
4
5
return Promise.each([
// 所有的 hexo插件依赖就是在这里加载进来的,例 hexo-renderer-ejs
'load_plugins' // Load external plugins & scripts
],
// ...

这一部分是插件加载的逻辑,我以 icarus主题为例,来看一下都加载了哪些插件,同样是使用--debug参数来查看加载的插件。

1
2
3
4
5
6
7
8
9
07:59:56.367 DEBUG Plugin loaded: hexo-generator-archive
07:59:56.367 DEBUG Plugin loaded: hexo-generator-category
07:59:56.367 DEBUG Plugin loaded: hexo-generator-index
07:59:56.367 DEBUG Plugin loaded: hexo-generator-tag
07:59:56.367 DEBUG Plugin loaded: hexo-renderer-inferno
07:59:56.455 DEBUG Plugin loaded: hexo-server
07:59:56.471 DEBUG Plugin loaded: hexo-renderer-ejs
07:59:56.471 DEBUG Plugin loaded: hexo-renderer-marked
07:59:56.582 DEBUG Plugin loaded: hexo-renderer-stylus

我们来看这些插件,比如hexo-generator-archivehexo-generator-categoryhexo-generator-indexhexo-generator-tag比如这些都是对应的页面实现,下面的hexo-renderer-infernohexo-renderer-ejs这些是对应的模板渲染插件,里面会注册对应的 render方法。

1
2
3
4
5
6
// hexo/lib/hexo/load_plugins.js
module.exports = ctx => {
if (!ctx.env.init || ctx.env.safe) return;

return loadModules(ctx).then(() => loadScripts(ctx));
};

然后这里先是加载了所有的插件,然后执行对应定义的 script文件。

插件加载

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
function loadModules(ctx) {
return Promise.map([ctx.base_dir, ctx.theme_dir], basedir => loadModuleList(ctx, basedir))
.then(([hexoModuleList, themeModuleList]) => {
return Object.entries(Object.assign(themeModuleList, hexoModuleList));
})
.map(([name, path]) => {
// Load plugins
// 类似 .../node_modules/hexo-generator-archive/index.js
return ctx.loadPlugin(path).then(() => {
ctx.log.debug('Plugin loaded: %s', magenta(name));
}).catch(err => {
ctx.log.error({err}, 'Plugin load failed: %s', magenta(name));
});
});
}

function loadModuleList(ctx, basedir) {
const packagePath = join(basedir, 'package.json');

// Make sure package.json exists
return exists(packagePath).then(exist => {
if (!exist) return [];

// Read package.json and find dependencies
return readFile(packagePath).then(content => {
const json = JSON.parse(content);
const deps = Object.keys(json.dependencies || {});
const devDeps = Object.keys(json.devDependencies || {});

return basedir === ctx.base_dir ? deps.concat(devDeps) : deps;
});
}).filter(name => {
// Ignore plugins whose name is not started with "hexo-"
if (!/^hexo-|^@[^/]+\/hexo-/.test(name)) return false;

// Ignore plugin whose name is started with "hexo-theme"
if (/^hexo-theme-|^@[^/]+\/hexo-theme-/.test(name)) return false;

// Ignore typescript definition file that is started with "@types/"
if (name.startsWith('@types/')) return false;

// Make sure the plugin exists
const path = ctx.resolvePlugin(name, basedir);
return exists(path);
}).then(modules => {
return Object.fromEntries(modules.map(name => [name, ctx.resolvePlugin(name, basedir)]));
});
}

可以看到里面的加载插件逻辑是根据ctx.base_dirctx.theme_dir的两个路径,然后去对应的两个路径下面的package.json文件找依赖项,正则匹配到对应的以hexo-开头的命名方式。然后调用ctx.loadPlugin(path)加载对应的插件(依赖)。

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
// hexo/index.js
// 这里插件的书写要按照一定的格式,然后根据加载插件往里提供的方法执行
loadPlugin(path, callback) {
return readFile(path).then(script => {
// Based on: https://github.com/joyent/node/blob/v0.10.33/src/node.js#L516
const module = new Module(path);
module.filename = path;
module.paths = Module._nodeModulePaths(path);

function req(path) {
return module.require(path);
}

req.resolve = request => Module._resolveFilename(request, module);

req.main = require.main;
req.extensions = Module._extensions;
req.cache = Module._cache;

script = `(function(exports, require, module, __filename, __dirname, hexo){${script}\n});`;

// http://nodejs.cn/api/vm.html#vm_vm_runinthiscontext_code_options可见 runInThisContext的使用
// 和 eval作用很像都是执行代码,但是区别是能 更安全? 创建沙箱环境?
const fn = runInThisContext(script, path);

return fn(module.exports, req, module, path, dirname(path), this);
}).asCallback(callback);
}

我们回到loadPlugin的实现,里面又一个很有意思的实现,使用了NodeJS的vm模块,const fn = runInThisContext(script, path)这部分实际上是创建了一个沙箱,因为引入的插件是外部开发的,因此需要做一层隔离,因此这里使用runInThisContext来做这件事情,防止获取到hexo内部的变量。

关于沙箱暂时了解不深,就不再展开分析,后续有时间会研究一下。

script执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function loadScripts(ctx) {
const baseDirLength = ctx.base_dir.length;

return Promise.filter([
ctx.theme_script_dir,
ctx.script_dir
], scriptDir => { // Ignore the directory if it does not exist
return scriptDir ? exists(scriptDir) : false;
}).map(scriptDir => listDir(scriptDir).map(name => {
const path = join(scriptDir, name);

return ctx.loadPlugin(path).then(() => {
ctx.log.debug('Script loaded: %s', displayPath(path, baseDirLength));
}).catch(err => {
ctx.log.error({err}, 'Script load failed: %s', displayPath(path, baseDirLength));
});
}));
}

和插件加载的逻辑很相似,路径是theme_script_dirscript_dir,然后我们就可以在里面定义一些我们主题想定义的逻辑,比如npm依赖版本检查等等逻辑。

开篇提出的问题

其实代码读到这里我们已经可以看到里面的插件加载机制了,其实已经可以解决我们在hexo源码分析(一)中开头提出的问题了。插件的加载逻辑是会分别读取根目录下的和主题文件夹下面的npm依赖,然后加载进来。从代码上看似乎并无问题,那么问题出在哪里了呢?

由于我拉的代码是github上最新的代码,是否是版本的问题呢,我们来看一看博客中node_modules中的hexo对应实现。

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
function loadModules(ctx) {
return loadModuleList(ctx).map(name => {
const path = ctx.resolvePlugin(name);

// Load plugins
return ctx.loadPlugin(path).then(() => {
ctx.log.debug('Plugin loaded: %s', magenta(name));
}).catch(err => {
ctx.log.error({err}, 'Plugin load failed: %s', magenta(name));
});
});
}

function loadModuleList(ctx) {
const packagePath = join(ctx.base_dir, 'package.json');

// Make sure package.json exists
return exists(packagePath).then(exist => {
if (!exist) return [];

// Read package.json and find dependencies
// ...
});
})
}

问题很明显了,两处的代码不一样,目前5.4.0这个版本你的hexo就是上述实现,其中对插件的加载是只读取了根目录下面的package.json并没有读取主题npm依赖下的文件。我们再看一下hexo最近的提交记录,发现在十几天前这个bug被修复了feat: load hexo plugin in the theme’s package.json,但是还没有新发布版本。

至此开篇引出的问题已经解决,可惜错失了一个fix的机会。

小结

虽然问题至此已经发现,但是我们对于hexo的分析还没有分析完毕,我们将继续梳理hexo的主干流程,并尝试分析hexo的设计思想。