本文将利用Hexo的标签插件Viz.js, 实现服务端渲染 .

这里的 是指这个,

Gaabba->bccb->cc->addd->b
我是一个有向图

目标

我们想实现这样的功能, 在Markdown里面描述图的边和点的关系, 然后在运行hexo generate命令时, 把这个关系可视化出来. 图的描述可以使用DOT语言, 具体语法可以看文档.

也就是说, 我们在Markdown里写下这样的内容:

1
2
3
4
5
6
7
8
我放个图G在这里

digraph G {
a -> b;
b -> c;
d -> b;
c -> a;
}

最终在页面里显示

我放个图G在这里

Gaabba->bccb->cc->addd->b
G

我们的目标就是把这个DOT语言描述的图渲染出来.

实现方法

标签插件

在建立博客静态页面时, 把DOT语言段用一个图片替换, 可以使用Hexo提供的标签插件. 只需要注册一个处理函数就行.

大概像这样:

1
2
3
4
hexo.extend.tag.register('myTag', function (args) {
// ...
return /*替换内容(一般是html标签)*/;
});

把这个js文件放到博客根目录下的scripts/tag文件夹里. 在建立静态页面时, Markdown文件里面的{% myTag %}就会被替换成处理函数返回的内容. 处理函数可以带参数args, 它是一个字符串数组. 运行hexo generate时, 函数外部会传入标签的参数, 也就是{% myTag arg1 arg2 arg3 %}中标签名后面的东西. 此时args就是['arg1', 'arg2', 'arg3'], 参数是严格按照空格分割的, 若参数包含空格可以用引号包起来.

但这样还不行, 因为它仅仅只能替换标签所在的内容, 还不能够实现替换DOT语言段. 我们可以使用结束标签, 只需给register函数传入一个对象, 像这样,

1
2
3
4
hexo.extend.tag.register('myTag', function (args, content) {
// ...
return /*替换内容(一般是html标签)*/;
}, {ends: true});

这样, hexo就会把

1
2
3
{% myTag %}
标签内容...
{% endmyTag %}

替换为返回内容. 并且, 外部还会传入一个字符串content, 就是开始标签和结束标签之间的所有内容, 即标签内容.... register函数的第二个参数需要传入一个对象, 其属性作为选项, 其类型是{ends: boolean; async: boolean}.

ends就表示是否使用结束标签, 而async表示是否开启非同步模式, 就是这个处理函数是否应该异步执行, 开启处理函数应该返回一个Promise.

很好, 接下来我们只要解析传入的参数content的内容就好了. 这里我们使用Viz.js来解析和渲染.

服务端渲染

我们想实现在服务端渲染(也就是在建立博客静态页面时就解析和渲染完成, 而不用等到浏览器打开页面时才解析), 但是, Viz.js是只能在前端(浏览器)渲染的.

虽然完全可以把Viz.js放到前端, 但我想深入学习一下Hexo, 就作为一个练习吧.

怎么办呢?

一个粗暴的办法就是, 在服务端实现一个浏览器, 然后让Viz.js跑在这个浏览器上, 再用结果替换标签所在内容. 难道我们真的要实现一个浏览器? 幸运的是, 我们不需要从头造轮子, 可以借助他人的轮子. 在Node.js里, 有个jsdom库, 它是一个各种Web标准的实现, 相当于一个后端的浏览器. 我们正需要他.

安装好后, 在标签注册代码导入jsdomviz.js, 并且在处理函数里添加相关解析和渲染的代码. 我们将这个graphviz标签的处理函数注册为了异步执行的, 这是因为viz.js解析和渲染是异步执行的, viz.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
const { htmlTag } = require('hexo-util');
const { JSDOM } = require('jsdom');
const Viz = require('viz.js/viz');
const { Module, render } = require('viz.js/full.render');

// 设置为global的属性是要让viz.js能够调用到.
global.window = new JSDOM().window;
global.DOMParser = window.DOMParser;
global.btoa = window.btoa;
global.encodeURIComponent = window.encodeURIComponent;
let viz = new Viz({Module, render});

hexo.extend.tag.register('graphviz', function (args, content) {
let viz_config = {
engine: 'dot',
images: [],
images_path: '/',
};
return viz.renderSVGElement(content, viz_config)
.then((element) => {
return htmlTag(
'div', {}, element.outerHTML, false
);
})
.catch((error) => {
viz = new Viz({Module, render});
console.error(error);
return htmlTag(
'div', {}, error, false
);
});
}, {ends: true, async: true});

这里我们用到了hexo-util提供的函数htmlTag, 用来创建一个HTML标签. 具体用法htmlTag(标签名, 属性对象, 内部HTML, 是否转义内部HTML文本). 详见htmlTag

使用viz对象来解析和渲染. viz.renderSVGElement函数接受两个参数, 一个是要解析的DOT字符串和选项对象. 关于渲染选项, 我们只需要设置engine即可, 即以什么方式和什么布局渲染图, 其他基本用不上, 感兴趣的可以看Render Options. 这个渲染函数返回的是一个Promise. 因此, 我们要指定resolve函数, 给then方法传入处理函数, 参数是element, 接受渲染函数渲染完成的SVG元素. 然后, 给这个SVG元素套一层div, 返回这个HTML. 至此, 已经差不多实现我们的目标了.

另外, 我们又给catch方法传入错误处理函数. 如果解析和渲染出错了(一般是DOT的语法错误), 我们就重新创建viz对象, 并且显示错误信息.

重新创建对象是因为语法错误有时会造成DOT解析器进入一个它无法恢复的错误状态, 导致之后的解析永远报错1.

模块化

为了让它更像个插件, 我们还应该增加一些功能, 比如通过_config.yml配置以及给标签添加一些有用的参数。

对于前者, 我们想配置默认的渲染引擎和SVG元素块的CSS. 那么, 我们在配置中写下

1
2
3
graphviz:
engine: dot
css: 'graphviz' # css类名, 对应的样式类写在某个css文件中.

它应该配置好默认的渲染引擎, 给SVG元素块添加对应的样式类.

对于后者, 标签参数应该有这个图的标题或描述和渲染引擎. 图的标题或描述将显示在SVG元素的下方, 就像普通图片一样有个图片描述什么的. 有时候我们并不想用默认的渲染引擎, 应该提供选择.

根据这些想法, 我们对处理函数做出修改.

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
const { htmlTag, deepMerge } = require('hexo-util');
// ...

function GetUUID() {
let d = new Date().getTime();
let uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
let r = (d + Math.random() * 16) % 16 | 0;
d = Math.floor(d / 16);
return (c == 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
return uuid;
}

function GetRenderId() {
return 'graphviz-' + GetUUID();
}

hexo.extend.tag.register('graphviz', function (args, content) {
if (!hexo.config.graphviz) return; // 如果hexo配置中不存在graphviz数组, 则不渲染
let caption = undefined; // 图的标题或描述

let viz_config = deepMerge({
engine: 'dot',
images: [],
images_path: '/',
}, hexo.config.graphviz); // 与默认设置合并
// 处理标签参数
args.forEach((arg) => {
if (arg.startsWith('engine')) {
viz_config.engine = arg.split(':')[1];
} else {
caption = arg;
}
});
// 分配唯一id
let render_id = (typeof caption === 'undefined') ? GetRenderId() : caption;

return viz.renderSVGElement(content, viz_config)
.then((element) => {
console.info(this.title + '::' + ((typeof caption === 'undefined') ? render_id : caption) + ' Rendered');
let html = element.outerHTML;
// 如果提供标题或描述, 在SVG元素块的末尾添加它.
if (typeof caption !== 'undefined') {
html += htmlTag('div', {class: 'image-caption'}, caption, false);
}
return htmlTag(
'div',
(typeof viz_config.css !== 'undefined') ? // 是否提供SVG元素块的样式类
{id: render_id, class: viz_config.css} : // 设置该元素块的属性
{id: render_id, style: 'margin-bottom:20px;text-align:center;'}, // 默认样式
html, false
);
})
.catch((error) => {
viz = new Viz({Module, render});
console.error(error);
return htmlTag(
'div',
{id: render_id, style: 'margin-bottom:20px;text-align:center;color:red;'}, // 红色居中字体
error, false
);
});
}, {ends: true, async: true});

主体框架仍然不变, 只是添加了一些细节, 具体看注释. 其中GetRenderId返回一个唯一的id用于标识SVG元素块, 作为它们的id属性.

最终效果

至此, 我们实现了我们最初的目标, 看一下效果.

Gcluster_1process #2cluster_0process #1a0a0a1a1a0->a1a2a2a1->a2b3b3a1->b3a3a3a2->a3a3->a0endenda3->endb0b0b1b1b0->b1b2b2b1->b2b2->a3b2->b3b3->endstartstartstart->a0start->b0
这是viz-js.com默认示例

它的使用方法很简单, 在md文件中插入如下标签即可.

1
2
3
{% graphviz [描述] engine:["circo"|"dot"|"fdp"|"neato"|"osage"|"twopi"] %}
...
{% endgraphviz %}

完整代码

最后上完整代码.

scripts/tag
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
'use strict'

const { htmlTag, deepMerge } = require('hexo-util');
const { JSDOM } = require('jsdom');
const Viz = require('viz.js/viz');
const { Module, render } = require('viz.js/full.render');

global.window = new JSDOM().window;
global.DOMParser = window.DOMParser;
global.btoa = window.btoa;
global.encodeURIComponent = window.encodeURIComponent;
let viz = new Viz({Module, render});

function GetUUID() {
let d = new Date().getTime();
let uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
let r = (d + Math.random() * 16) % 16 | 0;
d = Math.floor(d / 16);
return (c == 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
return uuid;
}
function GetRenderId() {
return 'graphviz-' + GetUUID();
}

/**
* Usage: {% graphviz [caption] engine:["circo"|"dot"|"fdp"|"neato"|"osage"|"twopi"] %}
*/
hexo.extend.tag.register('graphviz', function (args, content) {
if (!hexo.config.graphviz) return;
let caption = undefined;

let viz_config = deepMerge({
engine: 'dot',
images: [],
images_path: '/',
}, hexo.config.graphviz);

args.forEach((arg) => {
if (arg.startsWith('engine')) {
viz_config.engine = arg.split(':')[1];
} else {
caption = arg;
}
});

let render_id = (typeof caption === 'undefined') ? GetRenderId() : caption;

return viz.renderSVGElement(content, viz_config)
.then((element) => {
console.info(this.title + '::' + ((typeof caption === 'undefined') ? render_id : caption) + ' Rendered');

let html = element.outerHTML;
if (typeof caption !== 'undefined') {
html += htmlTag('div', {class: 'image-caption'}, caption, false);
}

return htmlTag(
'div',
(typeof viz_config.css !== 'undefined') ?
{id: render_id, class: viz_config.css} :
{id: render_id, style: 'margin-bottom:20px;text-align:center;'},
html, false
);
})
.catch((error) => {
viz = new Viz({Module, render});

console.error(error);

return htmlTag(
'div',
{id: render_id, style: 'margin-bottom:20px;text-align:center;color:red;'},
error, false
);
});
}, {ends: true, async: true});


  1. Viz.js Caveats: Rendering Graphs With User Input↩︎