Skip to content

基于 VitePress 开发博客主题

January 1, 2024深圳 宝安图书馆15 min read
Vaquita Porpoises
A public domain image. Paula Olson. commons.wikimedia.org.

前两天完成了这个博客的最后一个功能 (Atom Feed)。本来打算在 2023 年最后一天写一篇总结,但刚好有朋友邀约去爬大南山[1],所以推迟到新年的第一天。对于爬山我的态度很明确,不愿爬,不怕爬,必要时不得不爬:

View from the Top
请欣赏二〇二三年最后一天深圳市南山区最美丽的风景

言归正传,这篇总结主要列举基于 VitePress 做一个博客主题需要解决的问题。我还没系统地学过 Vite、Vue 和 TypeScript,所以注定有些代码实现不是最佳实践。目前写文章是我的第一需要,也许等到下个阶段我会抽出时间来重构。

开发环境

自定义 VitePress 主题

VitePress 的官方文档相当详细,新手直接按顺序阅读 Guide 部分就能上手,需要查阅接口信息时往往可以通过搜索进入 Reference 部分。

用代码扩展 VitePress 时需要注意两个概念:“构建时 (Build-Time)”和“运行时 (Runtime)”。这两个概念最根本的区别是其执行环境:

  • 构建时:本地 Node.js 提供的运行、构建环境
  • 运行时:构建打包后的代码运行于浏览器环境

更细致的区别是由 VitePress 的生命周期决定的,但目前官方文档没有详细介绍其生命周期。不过有时候理解错了也能通过报错来区分出是哪个阶段的问题。

自定义 CSS

⚠️ 在 <style module></style>(即打开 module 模式)中,嵌套的 CSS 似乎不兼容 Safari 浏览器,但只要不写嵌套的 CSS 代码就没问题。

页面布局

VitePress 的 Markdown 文件有几个类型,通过 frontmatter 中的 layout 指定,比如 layout: homelayout: page

自定义布局主要参考这几个部分:

当你用 Vue 组件自行实现了某个页面,可以根据 "Registering Global Components" 将其注册为全局组件。这样就可以在 Markdown 页面(区别于作为文章的 Markdown)中使用这个页面组件,避免在自定义 Layout 时混杂太多不同页面的实现。详情见:Octobug/blog/.vitepress/theme/pages

SSR 兼容性

构建生成的静态网站中,如果有页面存在动态内容(比如显示时间),在浏览器 console 会报如下错误:

Error

Hydration completed but contains mismatches.

这种情况可以使用 <ClientOnly><NonSSRFriendlyComponent /></ClientOnly> 将动态部分包裹起来:SSR Compatibility - <ClientOnly>

常见的博客功能

首页、归档、分类与标签

  • 首页:按时间线倒序显示文章列表并分页
  • 归档:按倒序列出每个年份的文章列表
  • 分类:在分类页按照分类过滤文章,每篇文章只有一个分类归属
  • 标签:在标签页按照标签过滤文章,每篇文章可以有若干个标签

这几个页面都使用 Build-Time Data Loading - Data from Local Files 来加载文章列表,免去自己用文件读写(如使用 isaacs/node-glob)获取文章列表的麻烦。

Markdown 文章

文章目录结构

文章有时会包含图片文件,这些图片要集中放在一起(比如放在 assets/ 目录中)还是各自和所属文章放在一起?经过一番纠结之后,我选择了后者。因为这样在引用图片时可以用最短的相对路径,同时图片文件也更好管理。每篇 Markdown 文章对应一个目录,与其相关的文件都放在同一个目录里:

sh
$ tree posts
posts
├── an-https-issue-on-adas
   ├── README.md
   ├── spinner-dolphin.gif
   └── spinner-dolphin.jpg
├── better-not-mess-around-with-iptables
   ├── README.md
   └── bcy0094.jpg
├── building-a-vitepress-blog-theme
   ├── README.md
   ├── feeder-screenshot.jpg
   ├── vaquita.jpg
   └── view-from-the-top-of-nanshan.jpg
├── non-original-content-copyright-issues
   ├── README.md
   ├── aigc-best.jpg
   ├── aigc-worst.jpg
   └── baiji-qiqi.jpg
└── pilot
    ├── README.md
    └── pilot-whale.jpg

文章要素

每篇文章的标题下方都加了这三个要素:

  1. 日期(星期几)
  2. 地点(乡镇级区划信息):记录文字是在什么地方写下的,回忆会更加具象化 :)
    • 由于写文章的地方不会非常多变,所以最后决定不使用中国的行政区划信息库。
    • 而且,万一以后有机会出国写一篇呢?
  3. 阅读时长(文章长度)

在 VitePress 中,文章标题和正文是一个 <Content /> 整体,不可拆分。要在文章标题下方插入上面这行“三要素”有几个方案:

  1. 在每篇 Markdown 文章中插入全局注册的 Vue 组件:这个方案对于后续写文章来说过于繁琐。
  2. 使用 frontmatter title,而不使用 Markdown 一级标题:VitePress 为文章建立索引时将段落和其前面最近的一个标题归入同个 section,如果不使用 Markdown 一级标题,会导致第一个标题前面的内容不被索引而搜索不到。
  3. 使用 JavaScript 操作 DOM 元素:先将 Vue 组件放在 Layout 的 doc slots 里面,再用 JS 把渲染后的 DOM 元素搬运到标题之下。这个方案过于 hack 过于丑陋。
  4. 通过 VitePress 的 markdown-it 接口写类插件代码markdown-it API 很复杂,但它是最适合做这件事的:
    1. 将需要插入 Markdown 文章中间的 Vue 组件注册为全局组件;
    2. 改写 md.renderer.rules[token.type] 渲染函数,将 Vue 组件插入到 markdown-it 的渲染结果中;
    3. VitePress 会进一步处理 Markdown 渲染后的 HTML,此时插入的 Vue 组件才会被编译:Using Vue in Markdown

例如:

ts
md.renderer.rules.heading_close = (tokens, idx, options, _env, self) => {
  let result = self.renderToken(tokens, idx, options);
  if (tokens[idx].markup === "#") {
    result += "\n\n<PostElements />\n\n";
  }
  return result;
};

详情见:Octobug/blog/.vitepress/theme/mdit.ts

上一页/下一页

VitePress 本身有“上一页/下一页”的功能,但需要将文章列表数据喂给 Sidebar 才会触发这个页面组件。然而在 .vitepress/config.ts 中无法使用 Build-Time Data Loading - createContentLoader 接口加载文章列表:vuejs/vitepress/discussions - can I use createContentLoader in config.js?

也就是说需要自行读写文件把文章列表数据喂给 sidebar,这就有点得不偿失。所以我选择自行实现“上一页/下一页”组件,为了保持样式一致,这个组件基本上是从 VitePress 源代码中 copy 的:Octobug/blog/.vitepress/theme/components/PrevNext.vue

markdown-it 插件

markdown-it/markdown-it 非常强大,插件使用方便,且插件生态也足够丰富。但它的开发 API 有些复杂,文档对新手很不友好。

如何在 VitePress 中使用 markdown-it 插件:Markdown Extensions - Advanced Configuration

已使用插件列表:

处理图片文件大小

对于部署在 GitHub Pages、Netlify 这些免费托管平台上的静态网站来说,图片文件如果太大十分影响用户体验。每次手动用 Photoshop 之类的软件处理图片大小都让我觉得很繁琐。macOS 系统用户可以用自带的 sips 命令来处理,相较于使用 GUI 软件处理要快捷非常多。比如将一张图的长边分辨率转成 1080 px,且保留图片比例:

sh
sips -Z 1080 origin.jpg -o resized.jpg

作为意外收获,sips 压缩图片大小的效果特别好。在生成同样大小的图片时,sips 保留原图视觉效果的能力甚至比 Photoshop 还强。

文章评论

giscus 是基于 GitHub Discussions 实现的评论系统,它也提供了 Vue 组件,集成到 VitePress 中还算方便。

giscus 是用 <iframe> 实现整个组件的加载,切换主题时通过额外的 HTTP 请求加载 CSS 文件,所以在 VitePress 切换日/夜间模式时触发 giscus 切换主题会有明显的延时,导致整个评论区域的颜色出现切换“闪烁”。我的解决方案是干脆重新加载整个评论组件:

接入 giscus 时我发现 Vue 组件版本在浏览器 console 有多余的输出,所以向开发者提了个问,目前这个问题已经被解决:giscus/giscus-component/discussions - How to suppress this information output by giscus?

中文搜索

VitePress 自带全文搜索:Search - Local Search,这个搜索是通过 lucaong/minisearch 实现的。

不幸的是,minisearch 默认不支持中文分词,所以中文搜索效果很差。要实现比较好的中文搜索需自行实现 minisearch 的 tokenize 函数:lucaong/minisearch/issues - how to correctly search for phone numbers

lucaong/minisearch/issues - Excuse me, how to support other language search, such as Chinese search, thank you 中有人推荐使用 yanyiwu/nodejiebaIntl.Segmenter 做中文分词。其中 nodejieba 似乎不支持浏览器端运行,目前没找到在 VitePress 中使用它的方案。而 Intl.Segmenter 还没有被 Firefox 支持,且移动端的分词效果也一般。

Intl.Segmenter 是目前找到的唯一可行的方案,所以最终还是采用了它。详情见:Octobug/blog/.vitepress/theme/search.ts

其他

订阅流

在这之前,我以为文字订阅流都是 RSS 标准,在计划做这个功能时才知道还有个标准叫 The Atom Syndication Format。它们的主要区别见:Difference Between RSS and ATOM

如今除了写博客的人,应该已经很少人使用 RSS 了。我问了几个非计算机专业的朋友,他们甚至听都没听说过。但既然我是在做一个博客主题,理所应当把这个经典功能加上。

这个效果我觉得还行:

Feeder screenshot
Feeder 阅读器截屏

Google Analytics

Google Analytics 的功能菜单非常乱,我到现在都不理解为什么一个平台可以设计得这么难用。但它提供的访问数据统计很有价值:Site Config - Example: Using Google Analytics


以上大部分功能是在最近两个月内抽空零散实现的。在时间上这么分散的原因是,很多功能一开始我根本不知道要做成什么样,等到基本构思清楚了才开始动手。

这个博客还有少数计划中的页面没上线,不过它们不属于典型的博客功能,所以对别人来说可能没有参考价值。

Cover

Vaquita Porpoise

封面图是一只小头鼠海豚 (Vaquita)[2] 妈妈领着她的幼崽[3]。小头鼠海豚目前在 IUCN 红色名录中处于极度濒危级别,2022 年仅剩 18 个有记录的成年个体[4]

References

  1. 大南山 (深圳). zh.wikipedia.org. ↩︎

  2. Vaquita. en.wikipedia.org. ↩︎

  3. Endangered Vaquita Porpoise Not Doomed to Extinction by Inbreeding Depression. fisheries.noaa.gov. ↩︎

  4. Phocoena sinus (Vaquita). iucnredlist.org. ↩︎