Hexo-Theme-NexT主题自定义设置:论theme_config与next.yml方法的差异及取舍

上一篇blog中我提到我使用了NexT主题的Data Files功能实现中提到的Hexo-Way,它其实是通过Overriding Theme Config功能来实现配置文件中设置值的覆盖。但这两天在修改侧边栏属性的时候碰到一个坑。这个坑让我发现了theme_confignext.yml这两种方法的差异,以及考虑了一下在使用时如何取舍。

Hexo文档里的Overriding Theme Config示例

关于Overriding Theme Config功能,Hexo的文档里是这么说的:
overriding_theme_config

看起来很直接对不对,只要在网站设置文件hexo/_config.yml文件里设置theme_config,只需要在其下写入主题设置中你需要修改的设置,你不需要修改的部分不用写入,就可以覆盖掉主题设置文件theme/[selected_theme]/config.yml中的相应设置内容,从而不用修改后者就可以实现对主题的个性化设置。

然而,这个“只需要写入要修改的设置,不想修改的部分不用写入”的说法并不是很清楚,因为这个例子只呈现到主题的一级选项,它没有告诉你,如果主题设置选项有多级内容,其中次级选项有需要修改的,有保持不变的,这时保持不变的次级选项需不需要写入theme_config中?

如果你直觉地认为保持不变的次级选项不需要写入theme_config,那你就踩坑里了。

Hexo的Overriding Theme Config功能误用一例:NexT主题的sidebar中”display: post”等设置的丢失

theme/next/_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
sidebar:
# Sidebar Position, available value: left | right (only for Pisces | Gemini).
position: left
#position: right

# Manual define the sidebar width.
# If commented, will be default for:
# Muse | Mist: 320
# Pisces | Gemini: 240
#width: 300

# Sidebar Display, available value (only for Muse | Mist):
# - post expand on posts automatically. Default.
# - always expand for all pages automatically
# - hide expand only when click on the sidebar toggle icon.
# - remove Totally remove sidebar including sidebar toggle.
display: post
#display: always
#display: hide
#display: remove

# Sidebar offset from top menubar in pixels (only for Pisces | Gemini).
offset: 12

# Back to top in sidebar (only for Pisces | Gemini).
b2t: false

# Scroll percent label in b2t button.
scrollpercent: false

# Enable sidebar on narrow view (only for Muse | Mist).
onmobile: false

其中最后一个onmobile设置是控制是否在手机、平板等水平宽度较窄的屏幕中显示侧边栏(只控制Muse、Mist风格)。

这里我希望把这个设置改为onmobile: true。按照直觉的理解,既然我只改动sibebar下的onmobile属性,sidebar下的其他选项应该不用写到theme_conf中,于是我把如下内容加到hexo/_confiy.yml中的theme_conf:的下方:

1
2
sidebar:
onmobile: true

hexo ghexo s之后我发现,预览网站中文章详情页的侧边栏不自动显示了,必须要手动点击侧边栏切换按键才能显示,也就是说,侧边栏设置相当于突然变成了display: hide模式。

疑惑之下,我找到文章详情页加载时显示侧边栏的代码,在theme/next/source/js/src/post-details.js中判断在文章详情页加载完成时是否初始显示侧边栏的代码是:

1
2
3
4
5
6
7
8
9
10
11
// Expand sidebar on post detail page by default, when post has a toc.
var $tocContent = $('.post-toc-content');
var isSidebarCouldDisplay = CONFIG.sidebar.display === 'post' ||
CONFIG.sidebar.display === 'always';
var hasTOC = $tocContent.length > 0 && $tocContent.html().trim().length > 0;
if (isSidebarCouldDisplay && hasTOC) {
CONFIG.motion.enable ?
(NexT.motion.middleWares.sidebar = function () {
NexT.utils.displaySidebar();
}) : NexT.utils.displaySidebar();
}

可以看到,NexT主题的设置是,必须在最终的主题设置里显式指定sidebardisplay: postdisplay: always其中之一,才会在文章详情页(或文章所有页面)初始加载时自动显示出侧边栏。

而在/theme/next/layout/_layout.swig中包含侧边栏的界面代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<main id="main" class="main">
<div class="main-inner">
<div class="content-wrap">
<div id="content" class="content">
{% block content %}{% endblock %}
</div>
{% include '_third-party/duoshuo-hot-articles.swig' %}
{% include '_partials/comments.swig' %}
</div>
{% if theme.sidebar.display !== 'remove' %}
{% block sidebar %}{% endblock %}
{% endif %}
</div>
</main>

可以看到,如果显式指定display: remove,则根本不会产生侧边栏。

排除了以上情况之后,就可以得到,如果不指定sidebardisplay属性,代码其实是会把它当成display: hide来处理。

这是一个要注意的地方,即NexT配置文件中所说的“默认采用display: post”,是指在theme/next/_config.yml文件中默认指定了display: post,但并不意味着display: post可以直接被去掉,实际上如果直接去掉而不指定display的值,会变成display: hide的效果。

也就是说,在sidebar显示的问题上,display: post是明面上的默认选项,但是display: hide才是那个藏在代码背后的“真·缺省选项”。

因此出现我所说的这个情况,原因就不是被指定了display: hide,而是当theme/next/source/js/src/post-details.js中的上述代码被执行时,CONFIG.sidebar.display这个属性已经丢失了,变成了undefined状态,自然不会触发NexT.utils.displaySidebar()的操作。

在Chrome的开发者工具中监测变量的结果印证了这个推测:

theme_config_sidebar_only_onmobile

可以看到CONFIG.sidebar设置只剩下{onmobile: true}孤零零的一项。出现上图的结果,说明代码在产生CONFIG.sidebar设置的过程中,把theme/next/_configy.yml中默认的sidebar的次级选项抛弃了,直接用hexo/_config.yml文件所指定的只有onmobile: truesidebar替换了前者。

为什么会这样?

问题根源:theme_config和next.yml的差异——加载时机及加载方式

NexT主题的next.yml加载代码传递出两者等效的错觉

NexT主题使用Data Files功能修改设置的实现,有如下代码(next/scripts/merge-configs.js中):

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
/* global hexo */

'use strict';

var merge = require('./merge');

hexo.on('generateBefore', function() {
if (hexo.locals.get) {
var data = hexo.locals.get('data');

/**
* Merge configs from _data/next.yml into hexo.theme.config.
* If `override`, configs in next.yml will rewrite configs in hexo.theme.config.
* If next.yml not exists, merge all `theme_config.*` into hexo.theme.config.
*/
if (data && data.next) {
if (data.next.override) {
hexo.theme.config = data.next;
} else {
merge(hexo.config, data.next);
merge(hexo.theme.config, data.next);
}
} else {
merge(hexo.theme.config, hexo.config.theme_config);
}

// Custom languages support. Introduced in NexT v6.3.0.
if (data && data.languages) {
var lang = this.config.language;
var i18n = this.theme.i18n;

var mergeLang = function(lang) {
i18n.set(lang, merge(i18n.get([lang]), data.languages[lang]));
};

if (Array.isArray(lang)) {
for (var i = 0; i < lang.length; i++) {
mergeLang(lang[i]);
}
} else {
mergeLang(lang);
}
}
}
});

表面上看起来next.yml + override: false方案和theme_next方案,对于主题设置来说其实都是一样的,两个方案之间是二选一:

  1. next.yml,就调用merge(hexo.theme.config, data.next),不再理会theme_config
  2. 没有next.yml,才使用theme_config,调用merge(hexo.theme.config, hexo.config.theme_config)

如果以上说法是真的,那么用next.yml + override: false方案和theme_config方案的效果应该是一样的。

然而,显而易见,这个代码所传递出的这种逻辑是错的。我不知道NexT主题的开发者们是没理解对,还是无可奈何只能这样做个样子。为什么说无可奈何,下文马上分解。

加载时间决定——theme_config会笑到最后

上述的next.yml加载代码的实现方式,是将自身函数注册Hexo所提供的generateBefore事件的监控函数,从而当hexo generate命令被执行时,让next.yml加载代码在静态文件生成前得到执行机会,可以对主题设置进行修改。这个事件的触发位置,在Hexo的主模块代码(lib/hexo/index.js)的Hexo生成函数Hexo.prototype._generate中:

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
Hexo.prototype._generate = function(options) {
if (this._isGenerating) return;

options = options || {};
this._isGenerating = true;

const config = this.config;
const generators = this.extend.generator.list();
const route = this.route;
const keys = Object.keys(generators);
const self = this;
const routeList = route.list();
const log = this.log;
const theme = this.theme;
const newRouteList = [];
let siteLocals = {};

this.emit('generateBefore');

function Locals(path, locals) {
this.page = typeof locals === 'object' ? locals : {};
if (this.page.path == null) this.page.path = path;

this.path = path;
this.url = `${config.url}/${path}`;
}

Locals.prototype.config = config;
Locals.prototype.theme = _.assign({}, config, theme.config, config.theme_config); //关键,theme_config在最后进入合并!
Locals.prototype._ = _;
Locals.prototype.layout = 'layout';
Locals.prototype.cache = options.cache;
Locals.prototype.env = this.env;
Locals.prototype.view_dir = pathFn.join(this.theme_dir, 'layout') + sep;
...

从这个代码中可以看到,Hexo程序发布generateBefore事件,触发作为监视器的next.yml加载代码。假设这时是next.yml + override: false,则调用两次merge(),将next.yml中的设置内容加到hexo.confighexo.theme.config里去。

可是随后,我们马上看到Hexo程序调用了_.assign函数,把theme_config加进来了:

1
Locals.prototype.theme = _.assign({}, config, theme.config, config.theme_config); //关键,theme_config在最后进入合并!

这里可以看出来:

  1. theme_config设置只要存在于网站配置文件里,它就是阴魂不散的,因为它本来是Hexo的标准功能,因此就算NexT主题不主动加载它,Hexo最后也会自己加载它。
  2. theme_config设置的加载时机是在next.yml的后面,也就是说后加入的theme_config完全可以覆盖替换掉next.yml修改过的设置。

加载方式差异——theme_config对次级设定的直接替换

更要命的是,theme_config加进来用的是_.assign()函数。

NexT主题的代码中将next.yml加入设置,使用的是他们自己实现的merge()函数,这是一个简化版的_.merge()函数。而Hexotheme_config加进来用的是_.assign()函数。这两个函数的原型是取自Loadsh,根据Lodash - difference between .extend() / .assign() and .merge()中的讨论,它们的主要联系与区别如下:

assign_merge_diff

可以看到,根据_.assign()_.merge()的区别,在最后theme_config设置与主题设置的合并中,使用_.assign()只对顶层对象进行合并,对次级对象中的同名对象之前不进行递归的合并,而是通通全替换。因此原来主题设置中已有的同名上级选项中的次级选项,如果在theme_config设置中对同名上级选项中的其他次级选项再赋值,而未对不需要修改的选项原样赋值,则原主题设置中这些不需要修改的选项会被抛弃掉。于是同样一个设置,写在next.yml还是写在theme_config,就会出现两种不同的结果:

1
2
_.merge({}, { sidebar: { display: 'post' } }, { sidebar: { onmobile: true } })=>{ sidebar: { display: 'post', onmobile: true } }
_.assign({}, { sidebar: { display: 'post' } }, { sidebar: { onmobile: true } })=>{ sidebar: { onmobile: true } }

这就是文章前面所提到的theme_config设置导致CONFIG.sidebar中只有{onmobile: true}的原因。

因此,当使用next.yml+override: false方案时,对主题设置中的次级项目也进行合并而不是替换,而在设置override: true时则直接完全替换主题设置。无论是选哪一个,如果有theme_config加入,这些设置都要冒着被打乱的风险。因此对NexT主题来说,使用next.yml和使用theme_config设置这两种方法是互斥的,只能选择一种。

利弊权衡:什么时候使用theme_config,什么时候使用next.yml?

既然只能选择一种方案,那么如何选择?从上面的例子及原理分析,似乎NexT主题使用next.yml时用的_.merge()方法更“科学”,但实际上没有那么简单。我们从_.assign()_.merge()在处理上的差异上出发,再总结出两种方法的特点:

  1. 采用_.merge()的方式,对主题设置中的次级设定也进行合并而不是替换。因此,当修改设置的目的是增加对原来少量未定义的次级设定的赋值,或者对原来少量已定义的次级设定进行重新赋值时next.yml + override: false只需要写入需修改的次级项目即可,不用把不需修改的次级设定都原样罗列出来;theme_config则要把不需修改的次级设定都原样罗列出来,否则会使这些设定丢失;next.yml + override: true则完全没有必要使用。
  2. 采用_.assign()的方式,可以严格地把次级设定全覆盖掉,包括把原来设置中已定义激活的次级设定去激活,使之变成未定义。因此,当需要把原来主题中的一些已定义赋值的次级设定去除,使之变成未定义状态时next.yml + override: false无法实现这个功能(在该方式发布时就有网友指出了这一点);theme_config则利用_.assign()的特性很自然完成;next.yml + override: true也可以实现,但是如果只是为了屏蔽掉一个次级设定,就需要把全部配置文件重复搬到next.yml中,开销太大。
  3. next.yml + override: true是连顶级设置选项也直接全替换,因此,当需要把原来主题设定做很大的改动,包括要把其中的一些已定义赋值的顶级设定去除,使之变成未定义状态时,则在不更改主题设置文件的限制下,next.yml + override: true是唯一的选择。

因此,三者的适用场景也就很明确了:

  1. next.yml+override: false方式:其实是功能最弱的,适用于只需要增加对原来少量未定义的次级设定的赋值,或者对原来少量已定义的次级设定进行重新赋值时。无法把已定义赋值的次级设定去除。
  2. theme_config方式:也适用于第1个场景(就是可能麻烦一点,要把不需要修改的次级设定也列出),但除此之外还可以适用于当需要把原来主题中的一些已定义赋值的次级设定去除,使之变成未定义状态时。
  3. next.yml+override: true方式:就可以实现的功能而言最强大,可以任意摆布顶级和次级设定,但是需要钜细靡遗地拷贝所有设定到next.yml中,出错的风险也更高,只要有必要时才启用。

折腾了一番,我还是选择继续使用theme_config方式,只是以后写设置的时候就要小心了。