很久之就一直想把博客改成单页的,而中间发生了不少咕咕咕(懂得都懂,不懂的我也不用再说了 🐶)的事情,别说改造了,连文都没更新。在咕了大半年之后,终于完成了大改造,将原来使用的 Hexo 替换成了个人实现的博客框架 gugu。
最初的想法是想将整个博客应用改造成单页应用,想着带来不一样的体验;其次想尝试一些新的东西、想实践一些想法,所以就去做了。
工作上一直在使用 React,所以在写个人项目的时候就想用些不一样的。正巧 Svelte 和 Vue3 都在风头上,Tailwind CSS 热度也不小,刚好都可以接触一下来练练手。
虽然最初尝试了 Svelte,照着官网教程写下来十分容易上手,结合 VSCode 相关的插件,编程体验也不错。但考虑到对恰饭的帮助,没有去过多深入,最终是选择了 Vue3。
构建工具方面对 Vite 和 Webpack5 都进行了尝试:Webpack5 因为没有使用什么新的特性,整体用下来没有什么新的感受;当使用 Vite 之后,就有很多新的感受,只能说 Vite 真香,速度的确快、配置也简单,只能再说一次很香了。
所以最终的技术栈组成是:
全文另一项重点技术的应用是 SSR 和 SSG。因为博客类应用需要 SEO、以及为了首屏更好地体验,所以不能仅仅只使用客户端渲染,故引出了 SSR;但自己又没有服务器,也不想专门搞个服务器,只想用纯静态托管的方式达到目的,故进一步引出了 SSG。加上博客本身都是静态的内容,使用 SSG 再合适不过了。
博客原本就是基于 Hexo 框架的,因此最初的想法是构建一套 Hexo 主题来达到目的。虽然完成了一套功能达到目的的主题,但心里感觉是不够痛快,不过这里还是简要记录一下实现过程中涉及的主要部分,处理好以下主要部分,基本就能基于 hexo 走通了。
半成品的主题:hexo-theme-spa。
只需保留一个 vue 文件,作为整个应用的入口组件:
<template>
<App />
</template>
<script lang="ts">
import App from '@/App.vue'; // 另外存储源码的地方
import { defineComponent } from 'vue';
export default defineComponent({
components: { App },
name: 'Index_layut',
});
</script>
hexo.extend.renderer.register('vue', 'html', async (data) => {
// 导入 webpack 或 vite 构建器
const build = require('./compile');
// 启动构建服务,同时构建 client 和 server 端两份 bundle
const { clientManifest } = await build();
// 引用上一步构建的 server 结果
const { renderHtml } = require('../source/ssr/main');
// SSR 渲染出首屏 html,内部实际上就是调用 @vue/server-renderer 的 renderToString
const htmlStr = await renderHtml(data, clientManifest);
// 将结果返回,hexo 会输出成静态 html
return htmlStr;
});
需要注册一个 generator
,枚举站点所涵盖的页面:
hexo.extend.generator.register('spa', function (locals) {
const { generator } = this.theme.config;
const { per_page: perPage } = generator;
const result = [
// 文章分页
{
path: '',
data: { __index: true },
},
...paginationUtil(locals.posts, {
perPage,
pathPattern: 'page/%d/',
data: { __index: true },
}),
{ path: 'categories/' },
// category 分页
...locals.categories.reduce((rs, category) => {
if (!category.length) return rs;
return [
...rs,
{ path: category.path },
...paginationUtil(category.posts, {
perPage,
pathPattern: category.path.replace(/\/?$/, '') + '/page/%d/',
}),
];
}, []),
// tag 分页
...locals.tags.reduce((rs, tag) => {
if (!tag.length) return rs;
return [
...rs,
{ path: tag.path },
...paginationUtil(tag.posts, {
perPage,
pathPattern: tag.path.replace(/\/?$/, '') + '/page/%d/',
}),
];
}, []),
// 归档 archives
{ path: 'archives/' },
...paginationUtil(locals.posts, {
perPage,
pathPattern: 'archives/page/%d/',
}),
// 404
{
path: '404.html',
},
].map((it) => ({
layout: ['index'],
...it,
}));
return result;
});
当页面运行在浏览器之后,路由模式转变为前端路由,页面切换后数据需要异步拉取,这里就可以借助 server_middleware
钩子:
hexo.extend.filter.register('server_middleware', function (app) {
app.use(function (req, res, next) {
if (/^\/json\/.*\.json$/i.test(req.url)) {
const key = req.url.replace(/^\/json\//i, '').replace(/\.json$/i, '');
const { renderData } = loadModule('../source/ssr/main');
const path = Buffer.from(key, 'base64').toString();
const data = renderData(path, savedLocals);
res.writeHead(200, {
'Content-type': 'application/json',
});
res.write(JSON.stringify(data));
res.end();
return;
}
next();
});
});
而构建的时候,只需遍历每个路由,将每个路由页面所需的数据写入静态的 json 文件,这样一来数据也静态化了。再使用约定好的方式将路由与 json 文件进行映射,最终前端路由切换时只需去请求对应的 json 即可,即整个站点就单页化了。
PS:hexo 运行时会将 Markdown 文件序列化,将所有数据存储于一个小型的、运行时的数据库,可通过 hexo.model('Post')
的形式获取到具体的数据,数据库的底层是基于 warehouse,支持丰富灵活的查询方式。
基于 hexo 就得遵循它的一套规则,有些细节还总是得翻看源码,写起来也不够自由,总体就感觉不痛快,所以又转换思路:从头完整地写了一个简单的博客框架。
过程就不展开了,因为核心思路与上述主题的实现是相近的,而且一些数据的处理也借鉴了 hexo,就是底层构建更换成 vite 了。
核心依旧是 SSR 渲染,而 vite 的服务端渲染其实在官网上写得十分清楚了。
数据的处理同样是两种情况:
SSG 的过程就是在开发服务的基础上,枚举所有路由去访问本地服务,将响应的 HTML 输出成一个个 html 文件。
源码地址:gugu
期间有个问题值得一提,框架的改造重新设计了博客的链接,格式从 /YYYY-MM-DD/:id.html
改成了 post/:id
,这样一来使用新框架之后,原本被搜索引擎收录的链接就都变成 404 了,这是一种不好的体验。正常的做法是需要在服务器上对迁移的链接配置 301 重定向,而我的应用托管在 github pages 上,不具备配置服务器的能力。幸运的是,meta 标签支持定义页面的重定向,只要配置如下内容,浏览器在加载文档之后就会根据配置跳转至目标链接,对搜索引擎也是比较友好的,相当于实现了客户端层面的 301 重定向。
<head>
<meta
http-equiv="Refresh"
content="0; URL=https://daief.tech/post/git-general-knowledge"
/>
</head>
最后要提的一点是 gugu
这个名字,gugu
即咕咕~
,表示作者鸽了自己大半年。