Skip to content

个人主页5.0升级全记录

2023年9月28日

我大概是从2007年起开始写博客的,过去这么多年中,也有更新非常频繁的时间,但总体来看最近几年的确是写得越来越少了。随着公众号、短视频的兴起,大家也不再关注博客,而我也一度不怎么更新,甚至想不起来要写一写文字。

但是最近两年,我又突然发现,虽然写内容的平台到处都是,但只有博客是真正属于自己的,那种永远不会背叛你的安全感是其他平台无法比拟的。

再回头看看自己的博客,发现已经很久没有更新过了,而前一版整理的AMP版本,现在回头看也算是掉坑里了。刚好看到勾股写的博客站迁移至 VitePress 的备忘,于是就打算自己也再整理一下,顺便做一个升级,拍个脑袋就叫“个人主页5.0”。

以下为过程中的一些记录。

首页截图

换程序

之前使用的是Hexo,是一个典型的“静态博客”,即所有内容都在构建时生成,托管时只有静态的HTML和对应的资源文件。这次打算换成VitePress,也同样是“静态”的,因此核心概念和大致用法上比较类似。按照VitePress的文档,首先安装好依赖,然后将文章(.md文件)放到docs目录下即可。

由于之前使用Hexo时就在路由中加了一级/article,本次迁移过程中也需要建立一个对应的子目录,因此将文章目录由source/_posts修改为docs/article即可。

与之对应的,构建脚本也需要做修改,以前用hexo serverhexo generate,现在换成vitepress dev docsvitepress build docs。需要注意的是,VitePress是一个“极为先进”的项目,所以需要在package.json中加入"type": "module",否则会报错。

图片的引用路径同样需要修改,按照VitePress的文档,图片最好放在docs目录下的子目录,例如docs/assets,然后在文章中使用相对路径引用即可。但最终我还是将图片放到了docs/public/assets目录下,具体原因见后文。

VitePress的主题是通过docs/.vitepress/theme目录下的文件来定义的,其中index.js是主要的入口文件。我们可以自定义一个新的主题来使用,但如果这样做的话默认主题的一些功能就会丢失,例如暗黑模式、顶部导航、404页面等,于是我选择了直接修改默认主题,即从代码仓库中将整个默认主题拷贝过来,然后针对个别地方进行修改。

基本上经过简单的替换以后,文章详情页就可以正常访问了,但是还有一些页面需要处理,例如首页、文章列表页以及一些固定的页面(如关于)。

导航&固定页面

固定页面的实现是最简单的,只需要在docs目录下新建一个.md文件即可,例如docs/about.md,就能通过/about.html访问。

VitePress中顶部导航是通过docs/.vitepress/config.js中的themeConfig.nav配置来实现的:

js
themeConfig: {
    logo: '/logo.png',
    nav: [
        { text: '首页', link: '/' },
        { text: '作品', link: '/works.html'},
        { text: '关于', link: '/about.html' },
    ],
    ...
}
themeConfig: {
    logo: '/logo.png',
    nav: [
        { text: '首页', link: '/' },
        { text: '作品', link: '/works.html'},
        { text: '关于', link: '/about.html' },
    ],
    ...
}

首页

首页和固定页面的概念是一样的,只需要在docs目录下新建一个index.md文件即可,就能通过/访问。但是如果直接在这个文件中写文字内容的话,它的表现和普通文章是一样的,因此这里需要更多的定制化内容。

到这里终于可以体会到VitePress的高明之处了:所有的.md文件都会被转换为.vue组件,然后通过Vue的工具链进行编译,因此我们可以在.md文件中使用Vue的语法,例如<script><style>等,基本上和写一个.vue组件没有太大差异。

也正是因为有这样的机制在,使得我们可以在首页中使用一些Vue生态中可用的开源项目,例如为了编写样式,我引入了TailwindCSS,引入方法和在Vue 3项目中几乎没有区别,值得注意的有几个点:

第一,通常Vue项目中我们会在main.js或者app.js中引入一个公共CSS文件,而VitePress中需要在docs/.vitepress/theme/index.js中引入(例如tailwind.css),其内容如下:

css
@tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind base;
@tailwind components;
@tailwind utilities;

第二,tailwind.config.js中配置的content选项需要修改,因为VitePress中的.md文件也需要处理,因此需要在content中加入.md后续。除此之外,因为.vitepress目录以.开头,默认会被忽略,需要单独写出来。

为了适配VitePress的暗黑模式,这里可以顺便设置darkMode: class,这样就可以使用TailwindCSS中的dark:前缀为暗黑模式匹配样式。

js
export default {
    content: [
        './docs/**/*.{vue,ts,js,md}',
        './docs/.vitepress/**/*.{vue,ts,js,md}',
    ],
    darkMode: 'class',
}
export default {
    content: [
        './docs/**/*.{vue,ts,js,md}',
        './docs/.vitepress/**/*.{vue,ts,js,md}',
    ],
    darkMode: 'class',
}

具体到页面内容,就比较无聊了,使用Grid布局放了几个大块块,然后适当做了一点响应式布局的调整就完成了。

文章分类页

文章分类页

有了固定页的经验,按道理分类页也很简单,只需要在docs目录下新建对应分类的.md文件即可,例如docs/article/web.md,就能通过/article/web.html访问。但这样做有两个问题:

  1. 分类是写死的,一旦有新增或者变化就需要手工调整
  2. 无法处理分页的情况

此时就可以使用VitePress的“动态路由”功能,具体而言,是这么做:

  1. 建立一个带有占位符的.md文件,例如docs/article/[category]-[page].md,其中[category][page]都是占位符
  2. 建立一个同名的.paths.js文件,例如docs/article/[category]-[page].paths.js,它的作用是输出[category][page]占位符的所有可能值,例如web-1 web-2 tech-1 tech-2等等

这样,就可以通过/article/web-1.html article/web-2.html等访问到对应的分类页了。为了额外提供一个“所有”分类,在处理[category]的值时加入一个all作为分类,这样就可以通过/article/all-1.html访问到所有文章的第一页了。

具体的写法大致如下:

javascript
// [category]-[page].paths.js

// Recursively get all files in a directory
function getFiles(dir: string): MdFile[] {
  const files = readdirSync(dir);
  let fileList: MdFile[] = [];

  files.forEach((file) => {
    const filePath = join(dir, file);
    const stats = statSync(filePath);

    if (stats.isDirectory()) {
      fileList = fileList.concat(getFiles(filePath));
    } else if (filePath.endsWith('.md') && !filePath.endsWith('/index.md') && !/\.\/docs\/[^/]+\.md/.test(filePath)) {
      const fileContent = readFileSync(filePath, 'utf8');
      const { data } = matter(fileContent);
      const category = data.category || (Array.isArray(data.categories) && data.categories[0]);
      fileList.push({ filePath, category });
    }
  });

  return fileList;
}

// Get all markdown files
const data = getFiles('./docs');

// Group files by category
const categories: { [key: string]: MdFile[] } = {};
data.forEach((file) => {
  const category = file.category || 'all';
  if (!categories[category]) {
    categories[category] = [];
  }
  categories[category].push(file);

  if (category !== 'all') {
    if (!categories.all) {
      categories.all = [];
    }
    categories.all.push(file);
  }
});

// Calculate page count for each category
const pageParams: PageParams[] = [];
Object.entries(categories).forEach(([category, files]) => {
  const pageCount = Math.ceil(files.length / PAGE_SIZE);
  for (let i = 1; i <= pageCount; i++) {
    pageParams.push({ params: { category, page: i } });
  }
});

export default {
  paths() {
    return pageParams;
  },
};
// [category]-[page].paths.js

// Recursively get all files in a directory
function getFiles(dir: string): MdFile[] {
  const files = readdirSync(dir);
  let fileList: MdFile[] = [];

  files.forEach((file) => {
    const filePath = join(dir, file);
    const stats = statSync(filePath);

    if (stats.isDirectory()) {
      fileList = fileList.concat(getFiles(filePath));
    } else if (filePath.endsWith('.md') && !filePath.endsWith('/index.md') && !/\.\/docs\/[^/]+\.md/.test(filePath)) {
      const fileContent = readFileSync(filePath, 'utf8');
      const { data } = matter(fileContent);
      const category = data.category || (Array.isArray(data.categories) && data.categories[0]);
      fileList.push({ filePath, category });
    }
  });

  return fileList;
}

// Get all markdown files
const data = getFiles('./docs');

// Group files by category
const categories: { [key: string]: MdFile[] } = {};
data.forEach((file) => {
  const category = file.category || 'all';
  if (!categories[category]) {
    categories[category] = [];
  }
  categories[category].push(file);

  if (category !== 'all') {
    if (!categories.all) {
      categories.all = [];
    }
    categories.all.push(file);
  }
});

// Calculate page count for each category
const pageParams: PageParams[] = [];
Object.entries(categories).forEach(([category, files]) => {
  const pageCount = Math.ceil(files.length / PAGE_SIZE);
  for (let i = 1; i <= pageCount; i++) {
    pageParams.push({ params: { category, page: i } });
  }
});

export default {
  paths() {
    return pageParams;
  },
};

写本文的时候在想为什么这里没有使用createContentLoader来写,暂时没有想清楚,可能尝试过碰到了问题,记不太清了,回头再试一下。

按照官方文档,在[category]-[page].md中,可以使用createContentLoader来获取文章数据,这一段逻辑被封装到一个单独的文件posts.data.js中:

javascript
export default createContentLoader('article/**/*.md', {
    excerpt: '<!-- more -->',
    includeSrc: true,
    transform(raw): Post[] {
        return raw
            .filter(({ url }) => url && !/\[.+\]/.test(url))
            .map(({ src, url, frontmatter, excerpt }) => ({
                title: frontmatter.title,
                url,
                date: formatDate(frontmatter.date),
                category: frontmatter.category || frontmatter.categories?.[0] || 'all',
                excerpt,
                cover: getCover(src),
            }))
            .sort((a, b) => b.date.time - a.date.time)
    }
});
export default createContentLoader('article/**/*.md', {
    excerpt: '<!-- more -->',
    includeSrc: true,
    transform(raw): Post[] {
        return raw
            .filter(({ url }) => url && !/\[.+\]/.test(url))
            .map(({ src, url, frontmatter, excerpt }) => ({
                title: frontmatter.title,
                url,
                date: formatDate(frontmatter.date),
                category: frontmatter.category || frontmatter.categories?.[0] || 'all',
                excerpt,
                cover: getCover(src),
            }))
            .sort((a, b) => b.date.time - a.date.time)
    }
});

其中getCover()是一个从原文中解析封面图片的方法。

这里有一个困扰了很久的点,就是图片路径的问题。按照官方文档,推荐将图片放在docs/assets中,在.md文件中通过相对路径进行引用,如果这样做的话,这个getCover()获取到的将是构建前的图片原始地址,而构建之后,图片地址会发生变化,使得封面无法显示。因此最终还是将图片放在了docs/public/assets中。

渲染的时候根据根据params中的categorypage来筛选文章,最后将结果渲染出来即可:

javascript
import { data as posts } from '../.vitepress/theme/posts.data'

const { params } = useData()

const PAGE_SIZE = 12
const PAGE = +params.value.page
const CATEGORY = params.value.category

const categoryPosts = computed(() => {
    return posts.filter(post => CATEGORY === 'all' || post.category === CATEGORY);
});

const currentPosts = computed(() => {
    const start = (PAGE - 1) * PAGE_SIZE
    const end = start + PAGE_SIZE
    return categoryPosts.value.slice(start, end)
});
import { data as posts } from '../.vitepress/theme/posts.data'

const { params } = useData()

const PAGE_SIZE = 12
const PAGE = +params.value.page
const CATEGORY = params.value.category

const categoryPosts = computed(() => {
    return posts.filter(post => CATEGORY === 'all' || post.category === CATEGORY);
});

const currentPosts = computed(() => {
    const start = (PAGE - 1) * PAGE_SIZE
    const end = start + PAGE_SIZE
    return categoryPosts.value.slice(start, end)
});

在界面上还要放一些分类选择、页面选择的组件,比较常规,不过多记录。

影集页面

影集详情页

影集模块中的详情页主要用来展示照片,这里不想使用文章中把图片顺序铺排下来的方式,而是希望有一个自定义的布局,于是扩展了一个单独的GalaryLayout来展示。

首先编写好布局组件,就是非常常规的Vue组件,使用useData()方法获取页面对应的数据,然后进行展示即可。为了方便,在docs/.vitepress/config.js中的transformPageData()hook中加了一个解析页面图片的功能:

javascript
transformPageData(pageData, ctx) {
    if (pageData.frontmatter.category === 'galary') {
        // @ts-ignore
        pageData.images = getImages(readFileSync(join(process.cwd(), 'docs', pageData.relativePath), 'utf8'));
    }

    return pageData;
},
transformPageData(pageData, ctx) {
    if (pageData.frontmatter.category === 'galary') {
        // @ts-ignore
        pageData.images = getImages(readFileSync(join(process.cwd(), 'docs', pageData.relativePath), 'utf8'));
    }

    return pageData;
},

这个页面还有待进一步优化,图片的查看方式、整体视觉风格都还不够好。

敏感信息擦除

因为有一些文章中有部分文字不适宜对外发布,但作为个人的文字记录,又不想删掉,于是之前就做了一个敏感信息的标记([*敏感文字*]),在渲染的时候会擦除标记中的文字。这次迁移的时候发现VitePress不容易做这样的处理,因为它没有给出在渲染前进行原文修改的hook,只能修改渲染后的HTML代码。

于是修改后的代码如下:

javascript
export function eraseSensitiveText(text?: string): string {
    if (!text) return '';
    return text.replace(/\[<em>.*?<\/em>\]/g, '[***]');
}
export function eraseSensitiveText(text?: string): string {
    if (!text) return '';
    return text.replace(/\[<em>.*?<\/em>\]/g, '[***]');
}

docs/.vitepress/config.js中使用trnasformHtml()hook来调用:

javascript
transformHtml(code, id, ctx) {
    return eraseSensitiveText(code);
},
transformHtml(code, id, ctx) {
    return eraseSensitiveText(code);
},

写成transformHtml: eraseSensitiveText的形式也可以,但个人习惯保留回调函数的完整参数信息。

评论

写作的时候Copilot提示说“评论是博客不可缺少的功能”,想了下也有几分道理,我的确是这么想的。

之前使用的评论服务是Disqus,考虑到它已经被墙,且开始提供越来越多与评论无关的功能,这次打算顺手迁移一下。

因为站点本身是静态的,不具备数据库和后端逻辑,因此评论数据需要托管到一个另外的地方。这里大致有几种路线:

  1. 基于Github issue的评论系统
  2. 基于LeanCloud之类的BaaS的评论系统
  3. 基于自托管的评论系统

在方案选择上纠结了很久。首先个人觉得使用issue作为评论数据托管的地方,多多少少有一些滥用的嫌疑,而且Github的API现在也有频率限制,因此放弃这个路线。然后是LeanCloud之类的数据托管,有一些很私人的考虑,不太想再使用国内的服务,而国外的类似产品又不多(也可能是没有去穷尽)。

最后看到了Artalk这个产品,属于自托管的评论系统。看了一下前端界面还是挺符合需求的,但它的后端是用Go写的。我希望这个服务能部署到Cloudflare workers中去,因为刚好它还有D1(SQLite)服务,这样就可以完美解决后端程序和数据库的问题,既不用花钱,也不用自己运维。

于是就翻了一下Artalk的文档和源码,自己写了一个接口完全一致的后端,部署到了Cloudflare workers中。当然,因为我只有自己使用,有一部分特性用不着,所以只实现了最核心的评论相关的特性,其它部分都未做实现。

Cloudfalre有一篇官方教程,讲的就是如何为静态站点写一个评论功能,作为入门参考非常合适。https://developers.cloudflare.com/d1/tutorials/build-a-comments-api/

前端部分的使用和文档基本一致,需要注意的是VitePress生成的是一个SPA应用,即点击站内链接时页面不会刷新,这将导致评论内容也不更新。因此需要手工调用update()方法传入新的配置并调用reload()重新拉取评论。

javascript
const router = useRouter()

router.onAfterRouteChanged = async () => {
    initArtalk();
}

let Artalk, artalk;

const initArtalk = () => {

    const conf = {
        el: '#artalk-container',
        pageKey: location.href.replace(/\?.*/, '').replace(/#.*/, '').replace(/http:\/\/localhost:\d+/, 'https://www.toobug.net'),
        pageTitle: document.title,
        server: location.host.includes('localhost') ? 'http://localhost:8787' : 'https://comments.toobug.net',
        site: 'TooBug',
        nestMax: 2,
        emoticons: false,
    }

    if (!artalk) {
        artalk = Artalk.init(conf);
    } else {
        artalk.update(conf);
        artalk.reload();
    }

};
const router = useRouter()

router.onAfterRouteChanged = async () => {
    initArtalk();
}

let Artalk, artalk;

const initArtalk = () => {

    const conf = {
        el: '#artalk-container',
        pageKey: location.href.replace(/\?.*/, '').replace(/#.*/, '').replace(/http:\/\/localhost:\d+/, 'https://www.toobug.net'),
        pageTitle: document.title,
        server: location.host.includes('localhost') ? 'http://localhost:8787' : 'https://comments.toobug.net',
        site: 'TooBug',
        nestMax: 2,
        emoticons: false,
    }

    if (!artalk) {
        artalk = Artalk.init(conf);
    } else {
        artalk.update(conf);
        artalk.reload();
    }

};

在这个过程中还发现了一个Bug,Artalk在调用update()方法时会重复为按钮绑定事件,从而导致点击时出现重复评论的情况,目前给官方报了issue(https://github.com/ArtalkJS/Artalk/issues/592),期待解决。而我自己先加了一个初始化的标识放在dataset上,使用pnpm patch打了一个补丁作为临时解决方案:

javascript
function submitBtn(editor) {
  editor.refreshSendBtnText();
  const $submitBtn = editor.getUI().$submitBtn;
  const isInit = $submitBtn.dataset.init === "1";
  if (!isInit) {
    $submitBtn.addEventListener("click", () => editor.submit());
    $submitBtn.dataset.init = "1";
  }
}
function submitBtn(editor) {
  editor.refreshSendBtnText();
  const $submitBtn = editor.getUI().$submitBtn;
  const isInit = $submitBtn.dataset.init === "1";
  if (!isInit) {
    $submitBtn.addEventListener("click", () => editor.submit());
    $submitBtn.dataset.init = "1";
  }
}

在评论的通知部分,Artalk原版后端应该是邮件通知的,这部分我没去做实现,只是简单接了一个Telegram机器人,这样当别人添加新的评论的时候我就能收到机器人的推送通知。

其他

RSS

RSS基本上照抄Vue博客的代码,源码位于https://github.com/vuejs/blog/blob/main/.vitepress/genFeed.ts。唯一的改动是我没有输入全部文章,因此加了一个.slice()方法来限制输出的文章数量。

GA & favicon

GA和favicon之类的都很简单,只需要在<head>中添加几行代码即可,对应到VitePress是在docs/.vitepress/config.js中添加配置:

javascript
head: [
    ['link', { rel: 'icon', href: '/logo.png' }],
    ['script', { async: '', src: 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXX' }],
    ['script', {}, `window.dataLayer = window.dataLayer || [];function gtag(){dataLayer.push(arguments);}gtag('js', new Date());gtag('config', 'G-XXXXXXXX');`],
],
head: [
    ['link', { rel: 'icon', href: '/logo.png' }],
    ['script', { async: '', src: 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXX' }],
    ['script', {}, `window.dataLayer = window.dataLayer || [];function gtag(){dataLayer.push(arguments);}gtag('js', new Date());gtag('config', 'G-XXXXXXXX');`],
],

首页彩蛋

首页渐变背景

在正式上线之前,临时为首页的模块加了一点点效果:鼠标hover到某个模块上时,会产生一个渐变的背景,并且每一次hover看到的颜色都是随机的。利益于Tailwind的封装,整体代码比较简洁:

html
<section class="hover:bg-gradient-to-br" :class="getGradient()" @mouseenter="refreshGradient">
...
</section>

<script setup>
const gradientList = {
    light: [
        ['from-amber-200', 'to-yellow-400'],
        ['from-amber-500', 'to-pink-500'],
        ['from-violet-200', 'to-pink-200'],
        ['from-blue-200', 'to-cyan-200'],
        ['from-teal-200', 'to-teal-500'],
    ],
    dark: [
        ['from-purple-500', 'to-purple-900'],
        ['from-blue-800', 'to-indigo-900'],
        ['from-emerald-500', 'to-emerald-900'],
        ['from-slate-500', 'to-slate-800'],
        ['from-violet-600', 'to-indigo-800'],
    ]
};

const isDark = ref(false);
const checkDark = () => {
    isDark.value = document.documentElement.classList.contains('dark');
}

const refreshGradient = () => {
    isDark.value = true;
    isDark.value = false;
    checkDark();
}

const themeList = computed(() => {
    const listName = isDark.value ? 'dark' : 'light';
    return gradientList[listName];
});

const getGradient = () => {
    const index = Math.floor(Math.random() * themeList.value.length);
    return themeList.value[index].join(' ')
};

onMounted(checkDark)
</script>
<section class="hover:bg-gradient-to-br" :class="getGradient()" @mouseenter="refreshGradient">
...
</section>

<script setup>
const gradientList = {
    light: [
        ['from-amber-200', 'to-yellow-400'],
        ['from-amber-500', 'to-pink-500'],
        ['from-violet-200', 'to-pink-200'],
        ['from-blue-200', 'to-cyan-200'],
        ['from-teal-200', 'to-teal-500'],
    ],
    dark: [
        ['from-purple-500', 'to-purple-900'],
        ['from-blue-800', 'to-indigo-900'],
        ['from-emerald-500', 'to-emerald-900'],
        ['from-slate-500', 'to-slate-800'],
        ['from-violet-600', 'to-indigo-800'],
    ]
};

const isDark = ref(false);
const checkDark = () => {
    isDark.value = document.documentElement.classList.contains('dark');
}

const refreshGradient = () => {
    isDark.value = true;
    isDark.value = false;
    checkDark();
}

const themeList = computed(() => {
    const listName = isDark.value ? 'dark' : 'light';
    return gradientList[listName];
});

const getGradient = () => {
    const index = Math.floor(Math.random() * themeList.value.length);
    return themeList.value[index].join(' ')
};

onMounted(checkDark)
</script>

整体思路是当鼠标hover的时候重置isDark的值,然后就会依次更新themeListgetGradient()的值,从而实现更换渐变背景的效果。

小结

翻了一下提交记录,第一次安装vitepress依赖的时候是8月28日,不知不觉居然折腾了一个月了,还是花费了不少时间的。从最终结果上来看,基本上达到了我的预期,希望后续可以在这个站点上做更多的记录。

这里也遗留了很多后续需要去处理的问题:

  • 整体视觉风格的优化,包括TailwindCSS和VitePress默认主题的颜色不一致的问题处理以及暗黑模式下一些细节的优化
  • 影集模块的功能和视觉优化
  • 首页动画的微调和优化
  • 评论系统的完善,包括邮件通知、回复等功能
  • 移动端一些样式优化和交互优化
  • 构建性能的优化(目前需要3分钟)

希望以后有时间可以慢慢去完善。

题外话

通过这次迁移,对VitePress的认知也更深刻了。总体上来看,它的设计非常优雅,能够使用的特性并不太多,但是有足够的灵活性来满足各种各样的需求。而且因为它的最终产物是SPA,使用体验极好,相对Astro之类的框架来说,最终的使用体验是更好的。因此如果VitePress在后续的扩展和迭代中保持目前设计上的优异表现,我相信完全可以产生一个更大的生态圈。