+++ title = "网页即列表!" subtitle = "用 Clojure 构建静态网站" slug = "webpage-as-lists" tags = [ "编程", "Web", "Lisp", "Clojure",] categories = "代码炼金术" date = 2026-02-13T22:30:17 draft = false toc = true toot = "https://akk.eltr.ac/objects/7aba1b00-c788-4130-8768-c199511c4ed6" url = "/posts/webpage-as-lists/" +++ 购入 `eltr.ac` 域名之后,我打算把「个人身份」和「作品」分开,也就是用 `eltr.ac` 域名代表我在互联网上的身份标识,用 `geedea.pro`(也就是这个博客的域名)代表「极客死亡计划」这个作品。是的,我并不打算把我的博客当作我网络身份的载体,更多地是视作独立的作品。目前我的联邦宇宙实例用的是 `eltr.ac` 域名,我的 Matrix(一个去中心化的即时通信协议)服务器也是用这个域名,电子邮箱也是这个域名,所以就有了: - `@eltrac@eltr.ac`(我的联邦宇宙账号) - `@eltrac:eltr.ac`(我的 Matrix 账号) - `hi@eltr.ac`(我的电子邮箱地址) 不过,要是有有心人尝试访问 `www.eltr.ac`,只能看到一个很简陋的页面。既然要把这个域名用作身份标识,那在万维网层也要有一个足够有个性的页面吧,就算仅仅是当作导航也好。 ## 编程语言的选择 一开始我用 [Hugo](https://gohugo.io/) 做了一个简陋的网站,还参考 [slashpages.net](https://slashpages.net/) 添加了一些足够「个人化」的页面,但总觉得不满意。仔细想想,这竟然已经是我的第三个 Hugo 网站了!第一个是这个博客,第二个是这个博客的[后花园](https://jar.geedea.pro/)。尽管我很喜欢 Hugo,这个用 Go 语言开发的静态网站生成器也是全世界最快的生成器,但也不能用 Hugo 解决所有的网站构建需求吧?况且,我只需要几个页面,用非常擅长处理大量 Markdown 文件的工具来构建网站,属实有点像「用大炮打蚊子」。再者,我也对 Go HTML Template 感到厌烦了。 简单的、不常修改的静态页面,要是用我熟悉的 Vue.js(或者…… React)来做,也有过度工程之嫌。貌似手写 HTML 就能胜任,不过要是真这么做,那我还不如直接加入 [Neocities](https://neocities.org/) 呢。之前被迫写了太多 Java 代码,也不可避免地接触到了很多 XML(Java 的项目管理工具 Maven,配置文件就是用 XML 写的;还有老旧的 MyBatis,编写数据表和 Java Bean 的隐射也要写 `mapper.xml`),继而对 HTML 也感到厌烦了。*自动补全固然好用,但难道就没有更优雅的解决方案了吗!* 前端开发者应该都熟悉「DOM 树」的概念,HTML 所代表的就是一种树形结构,根节点是 `` 标签,`` 和 `` 组成了第一级的子节点,然后继续往下生长。这样想就很简单了,现代编程语言里能够代表树的数据类型多了去了,只要用某种语言先编写好 DOM 树的结构,再转换成 HTML 字符串就好了。那么,能够表示 DOM 树的最简单的结构是什么呢? 广义表就是个例子,这是数据结构里的概念,更接地气的名字是数组、元组和列表。比如,用 JavaScript 就可以这样写: ```JavaScript const html = [ { name: "head", innerHTML: [ { name: "meta", attribute: { rel: "stylesheet", href: "/main.css" } } ]}, { name: "body", innerHTML: [ ... ] } ] ``` 停停停,用 JavaScript 数组和对象写 HTML,还不如直接手写呢。有没有办法全部简化成数组(列表),不使用对象呢?比如这样: ```JavaScript const html = ["html", ["head", [ ... ]], ["body", [ ["p", [ "class", "paragraph font-sans", "id", "paragraph" ], "Hello World!" ], // ... ]] ] ``` *你等一下,你再继续写下去就是在抄袭别人的 Lisp 传教文了。* 是的,既然我们已经知道嵌套的列表实际上就是树的结构,那为什么不可以直接用列表来写 DOM 树呢?既然我们已经把编写 HTML 页面变成了对列表的处理,那四舍五入就得到了「列表处理」,List Processing,所以,答案就是 Lisp! 老实说,除了我本来就对这门编程界的拉丁语感兴趣之外,促使我学习一门 Lisp 方言的最大推动力就是:*用它来写网页的方式简直是太优雅,太天才了!* 这下谁还需要依赖 Tree-sitter 和编辑器插件自动闭合 HTML 标签?还有那可恶的 JSX,也可以说再见了。~~现在只需要闭合一大堆没完没了的括号就好了。~~ ## 技术栈的选择 我选择的 Lisp 方言是 [Clojure](https://clojure.org/),至于为什么选它,原因是…… 呃,[矩阵](https://matrix.org/)上有一只[狐狸](https://blog.southfox.me/)诱拐我进了这个兔子洞。无论如何,我就先从 Clojure 开始用着吧。要使用 Clojure 编写程序,第一步是—— *安装 Java?!* 没错,Clojure 是运行在 JVM(Java 虚拟机)上的,这下再讨厌 Java 都逃不掉了,不过也还好,不需要亲手碰肮脏的 Java 代码,还能享受偷窃 Java 库来用的快感。Minecraft 玩家们的电脑里应该都有 Java,所以可以跳过这一步了。 最常用的 Clojure 开发工具是 [Leiningen](https://leiningen.org/),用来创建项目、管理依赖、运行和编译项目等等。要将 Clojure 的数据结构转换为 HTML,可以使用 [Hiccup](https://github.com/weavejester/hiccup) 这个库。既然我们都用 Lisp 写 HTML 了,那为什么不用 Lisp 写 CSS 呢?于是我很快发现了 [Garden](https://github.com/noprompt/garden) 这个库,用于把 Clojure 转译为 CSS。 现在准备就绪了,开始创建项目吧! 顺带一提,我准备把这个新网站命名为 `capricorn`(摩羯座),至于原因,仅仅是因为它是十二星座里首字母和 Clojure 相同的一个。 ```shell lein new app capricorn cd capricorn nvim project.clj ``` 编辑 `project.clj`,把需要的依赖添加进去,也就是刚才提到的 Hiccup 和 Garden。 ```clojure :dependencies [ [org.clojure/clojure "1.12.2"] [hiccup "2.0.0"] [garden "1.3.10"]] ``` 哦对了,如果你还不了解 Lisp 的语法,让我花点时间来给你解释一下:列表的第一个元素是函数名,剩下的都是参数,比如 `(println "Hello World")` 就是打印 `Hello World` 字符串。在 Clojure 里,列表有多种类型,除了最基本的作为程序结构的 `()`,Clojure 还用其他的括号表示不同的数据容器:`'()` 是列表,`[]` 是向量,`{}` 是映射(也就是键值对),`#{}` 是集合。Clojure 里还有一种特殊的数据类型叫作「关键字」,写成这样 `:keyword`,我喜欢把它理解为不需要提前定义的枚举类型。 好了,现在你可以理解 Clojure 程序了,我们继续吧。 ## 手搓静态网站生成器 静态网站生成器(Static Site Generator,SSG)这个东西可谓是遍地开花,[Cryogen](https://cryogenweb.org/) 就是用 Clojure 写的一个 SSG。不过,由于我的需求不是额外制作一个博客,不需要将 Markdown 文件或 Org-Mode 文件构建为 HTML 的功能,我只需要把 Clojure 的数据结构编译成 HTML 字符串,然后写入一个文件即可。听起来不难,所以动手自己写吧! 用 Hiccup 编写 HTML 结构非常简单,以下是一个例子: ```clojure (require '[hiccup2.core :as h]) (str (h/html [:span {:class "foo"} "bar"])) ``` 看起来有点难懂,没事,拆开来就好了: 1. `require` 显然是引入,我们这里把 Hiccup 的核心库以 `h` 这个名字引入了。 2. `str` 是 Clojure 标准库里的函数,顾名思义,就是用来把其他数据类型转换为字符串的。 3. `h/html` 中的 `h` 是我们刚才引入的 Hiccup 核心库,`html` 是这个库提供的函数,用于把 Clojure 的数据结构转换为 HTML 表示;这里接收一个向量类型的数据,也就是 `[]` 包裹的列表。 4. `[:span ...]` 显然是 `` 标签的表示,这个向量由三部分组成: 1. `:span` 是我们刚才提到的关键词类型,这里作为 HTML 标签名。 2. `{:class "foo"}` 是一个映射,`:class` 关键词是键(key),`"foo"` 字面量是值(value),这个映射会被解析为 `class="foo"`;我们还可以继续写更多的属性,比如 `{:class "foo" :id "bar" :width 200}`,奇数个元素是键,紧跟其后的偶数个元素就是对应的值。 3. `"bar"` 是这个 `` 标签的内容,也可以替换成向量,也就是继续嵌套 HTML 结构。 总而言之,这段 Clojure 代码构建了形如 `bar` 的 HTML 结构,并将它转换为字符串输出。Hiccup 还提供了非常好用的语法糖,如果要编写 ``,可以写成 `[:span.foo#bar]`。 尽管看起来有点奇怪,但你很快就会意识到这样写 HTML 有多爽了。 我们先来解决一个关键的问题,静态网站生成器除了把某种形式的数据转换为 HTML 字符串,还应该把得到的结果写入 `.html` 文件。接下来我们要研究一下如何用 Clojure 进行 I/O 操作,文档里写的是使用 `spit` 函数将内容写入文件,然后…… *等等,就没了?!* 看起来是这样的,在 Clojure 里,你不需要创建 `Writer`,也不需要创建什么 `File` 对象,也不用额外引入一个 I/O 库[^2],你只需要使用 `spit` 函数,把内容「吐」出来。这个函数接收两个参数,第一个参数是文件路径名,第二个参数是要写入的内容。所以,我们可以编写这样的 Clojure 程序: ```clojure (defn -main [& args] (spit "public/index.html" (str (h/html [:p "Hello World"])))) ``` 接下来,运行 `lein run`,然后你就会得到一个内容为 `

Hello World

` 的 `index.html` 文件。[^1] 正式开发之前,我们大概是需要一个跑在本地的 Web 服务器的。如果是 JavaScript 技术栈,可以用 `npm run dev` 或者 `pnpm dev` 这样的命令。这样一个有些原始的项目应该怎么办呢? ```shell cd public python -m http.server 8080 open http://localhost:8080 ``` *There you go.* 如果不介意没有热重载,每次更改之后需要手动运行程序然后手动刷新浏览器的话,就已经可以上路了。 ## 模板!组件! 我们先把头拆出来吧。 ```clojure (def head [:head [:title "Eltrac"] [:meta {:charset "utf-8"}] [:meta {:name "viewport" :content "width=device-width,initial-scale=1"}]]) ``` `def` 是 Clojure 里定义符号(变量)的方式;如果要定义函数,就用 `defn`。由于我这个小网站只有几个页面,不在乎标题的话,`` 可以直接复用,所以就不需要定义成函数了。 再把刚才的 `Hello World` 放到 `` 里。由于不同页面的内容肯定是不一样的,所以把 `body` 定义为函数比较合适。 ```clojure (defn body [content] [:body [:main content]]) ``` 然后拼接在一起,放在 `` 里,最后再转成字符串写入文件。 ```clojure (defn -main [& args] (spit "public/index.html" (str (h/html (h/raw "") [:html head (body [:p "Hello World!"])])))) ``` 其中 `h/raw` 函数可以防止 Hiccup 自动将 `<` 和 `>` 转义,用来声明 `DOCTYPE` 是必要的。最后就得到了: ```HTML Eltrac

Hello World!

``` 显然,完全不需要什么 `defineProps()`,也不用额外学习怎么在父子组件之间传递数据。在 Clojure 和 Hiccup 的世界里,页面组件就是函数。怎么给函数传参数,就怎么给组件传参数。我们直接用 Clojure 处理向量,最后再把向量们汇总在一起,用 Hiccup 处理为 HTML,再转成字符串输出到文件里。这样一来,我们就能用 Lisp 编写 HTML 页面了。 ## Build website with Style 接下来要给网站添加 CSS,使用之前添加的 Garden 库。这个库的语法和 Hiccup 非常相似,最关键的函数是 `css` 函数,和前面的 `html` 函数一样,它接收向量作为参数,输出的是 CSS 字符串。 我一直是行内 CSS 的信奉者,少一个 `.css` 文件能减少服务器需要传送的文件数量,并且不会被缓存,用户能够在网站更新后立刻看到改动。只要 CSS 文件不是太大,我都建议使用行内 CSS,也就是 `