木匣子

Web/Game/Programming/Life etc.

所码即所得主页

0x00 Self-printing Homepage

很久很久以前注册 mutoo.im 域名的时候,给自己弄了一个很简陋的主页。如果用 Wayback Machine 往前翻,可以找到这一记录(找到的最早的快照是 2013 年初的):

当时的想法是:简单、另类,最好可以用代码来表达想法。但是因为刚弄的网站没什么东西可以表达的,就直接丢了个链接。这一丢就丢了将近五六年。前两个月再看到它的时候,觉得是时候重新装修一翻了。于是有了这个项目:Self-printing Homepage

Self-printing Homepage

咋一看,除了内容长一点,颜色不一样之外。这有啥区别呢?如果查看之前简陋版的网页源码,内容大概是这样的:

<span class="main">&lt;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">&gt;<a href="http://web.archive.org/web/20130131230136/http://blog.mutoo.im/" title="点击进入木匣子">Mutoo's Blog</a>&lt;/a&gt;</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/parse5fb55/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,
    …
}

根据 typename 我们可以知道它是一个链接标签,于是可以把它渲染到页面上,并带上链接功能:

/**
 *
 * @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 伪元素实现了与我类似的想法,参见这里