所码即所得主页
¶0x00 Self-printing Homepage
很久很久以前注册 mutoo.im 域名的时候,给自己弄了一个很简陋的主页。如果用 Wayback Machine 往前翻,可以找到的最早的快照是 2013 年初的这一记录。
当时的想法是:简单、另类,最好可以用代码来表达想法。但是因为刚弄的网站没什么东西可以表达的,就直接丢了个链接。这一丢就丢了将近五六年。前两个月再看到它的时候,觉得是时候重新装修一翻了。于是有了这个项目:Self-printing Homepage。
咋一看,除了内容长一点,颜色不一样之外。这有啥区别呢?如果查看之前简陋版的网页源码,内容大概是这样的:
<span class="main"><a <span class="att">href</span>=<span class="value">"http://blog.mutoo.im/"</span> </span><span class="att">title</span><span class="main">=</span><span class="value">"点击进入木匣子"</span><span class="main">><a href="http://web.archive.org/web/20130131230136/http://blog.mutoo.im/" title="点击进入木匣子">Mutoo's Blog</a></a></span>
以上是纯手工打造的 html 源码,html tags 夹杂着 html entities,就为了显示上面那么一段超链接。如果我想多放点内容,估计要累死。
于是我就想,要不然写个工具来自动把要展示的 html 生成成上面的格式吧。本质上就是使用一个 html 语法解析器,然后把生成的 token 配上 span
tag 输出成网页,不能再简单!
¶0x01 Parsers
那么问题就来了!是自己造 html 解析器呢,还是直接使用开源库?要知道 html 是一种非常诡异的语言,虽然它有很简单的定义,例如签标的开闭规则:<tag attr1="value1" attr2="value2"></tag>
。但它还支持各种非标准规则,甚至你可以不去封闭一个标签,浏览器也会想办法去解释它,好让网页能渲染出来。例如 Google 曾经为了节省流量,极度精简自己的主页。
所以我决定找个开源的解析来用一用。那么该使用哪个开源库呢?我从 AST Explorer 上检索了一下,锁定了两个 html 解析器,inikulin/parse5 与 fb55/htmlparser2。以下是对它们的考查:
¶parse5
Parse5 能很好地将 html5 文档解析为 DOM(文档对象模型),但是解析后的 DOM 缺失了一些与源码对应的信息,例如空格、换行等。需要自己另外根据 sourceCodeLocationInfo
来处理。
¶htmlparse2
而 htmlparse2 很完美的保留了被 parse5 过滤掉的源码信息。我们需要在渲染的时候用到这些空格和换行,真正实现所码即所得。所以这个库比较适合我的需求。
¶0x02 Input
我希望主页能像一张名片一样,只放一些必要的信息。而且考虑到现代浏览器能够正确解析不那么正确的 html,所以为了美观我甚至去掉了像是 <head>
以及 <body>
的标签。
<!DOCTYPE html>
<html lang="zh">
<meta charset="utf-8">
<title>Lingjia's Homepage</title>
<meta name="author" content="Lingjia">
<meta name="description" content="A geek, web developer, game programmer">
<meta name="keywords" content="web, game, programming, blog">
<link rel="stylesheet" type="text/css" href="bundle.css">
<script async type="text/javascript" src="bundle.js"></script>
<style>[cloak]{display:none;}</style>
<card cloak>
<!-- find me here -->
<a href="//blog.mutoo.im" title="点击进入木匣子" tabIndex="0">木匣子</a>
<!-- find me there -->
<ul class="socials">
<li>LinkedIn: /in/mutoo</li>
<li>Twitter: @tmutoo</li>
<li>CodePen: /mutoo</li>
<li>GitHub: /mutoo</li>
</ul>
<!-- to be continued -->
<footer>© 2010-2019</footer>
</card>
</html>
另外,我还把功能性的样式(一些交互效果)和脚本(Google Tag Manager)都藏到了 bundle.css 和 bundle.js 里。
¶0x03 Render
有了 DOM 树结构之后,我们只需要写个递归遍历这棵树,然后把 HTML 生成出来就行了。以下是一个 DOM 结点的信息:
{
"type": "tag",
"name": "a",
"attribs": {
"href": "//blog.mutoo.im",
"tabIndex": "0",
"title": "点击进入木匣子",
},
"children": […],
"next": {…},
"parent": {…},
"prev": {…},
"startIndex": 468,
…
}
根据 type
和 name
我们可以知道它是一个链接标签,于是可以把它渲染到页面上,并带上链接功能:
/**
*
* @param container - To keep the rendered output
* @param dom - The dom tree
*/
export default function renderer(container, dom) {
if (dom instanceof Array) {
return dom.forEach((d) => {
renderer(container, d);
});
}
let append = appendTo(container);
switch (dom.type) {
/* ... */
case 'tag':
// <tag
append(lt());
append(tag(dom.name));
// key1="value1" key2="value2"
for (let attr in dom.attribs) {
if (dom.attribs.hasOwnProperty(attr)) {
map(append)(flatten([space(), attribute(attr, dom.attribs[attr])]));
}
}
// >
append(gt());
// make children in the a-tag clickable
if (dom.name === 'a') {
let a = compose(append, setAttributes(dom.attribs), addClass('link'), node)('a');
renderer(a, dom.children);
}
/* ... */
// make the </tag> a group
let group = compose(append, spanWithClass('no-break'))('');
map(appendTo(group))([lt(), slash(), tag(dom.name), gt()]);
break;
/* ... */
}
}
这里我用函数式风格封装了大量的结点创建的工作,省得重复编写 createElement()
以及将结点传来传去。例如 lt()
是由几个可以复用的柯里化函数实现的:
let node = (tag) => document.createElement(tag);
let addClass = curry((className, node) => {
node.classList.add(className);
return node;
});
let setText = curry((text, node) => {
node.innerText = text;
return node;
});
let setAttribute = curry((attr, value, node) => {
node.setAttribute(attr, value);
return node;
});
let spanWithClass = curry((className, text) => {
return compose(setText(text), addClass(className), node)('span');
});
let lt = () => spanWithClass('angle-bracket')('<');
同理可以实现其它的符号:
let gt = () => spanWithClass('angle-bracket')('>');
let slash = () => spanWithClass('angle-bracket')('/');
let eq = () => spanWithClass('eq')('=');
let quote = () => spanWithClass('quote')('"');
let space = () => textNode(' ');
这和面向过程的写法有何不同呢?为什么要写这么多工具函数?函数式编程的好处是,这些工具函数可以像乐高一样随意组合,来实现不同的功能:
let tagWithClassAndText = curry((tag, className, text) => {
return compose(setText(text), addClass(className), node)(tag);
});
还可以柯里化出不同功能的辅助函数,简化代码:
let spanWithClass = tagWithClassAndText('span')
let comment = spanWithClass('comment');
console.log(comment('this is a comment'));
// <span class="comment">this is a comment</span>
¶0x04 Summary
有了解析器和渲染器,剩下的工作就交给 bundle.js 了:
- 使用
fetch(window.location.href)
将当前页面加载到字符串中; - 使用 Parser 分析成 DOM 树;
- 使用 Renderer 渲染到页面;
- 添加一些额外的页面功能。
这也是我第一次使用函数式思维进行编程,真的是一下就被圈粉了。最后,我将该项目放到了 github 上开源了,有兴趣的小伙伴可以去围观:Self-printing Homepage。后续会再写一篇介绍如何将该页面发布到 github pages 的文章,敬请期待。
P.S. 有意思的是,有人用 css 的 ::before/::after
伪元素实现了与我类似的想法,参见这里。