---
title: 在 VitePress 中实现一个动态说说功能
tags: ["折腾","VitePress","Memos","CloudFlare"]
lang: zh
published: 2025-01-29T21:58:00+08:00
abbrlink: fiddling/vitepress-memos-component
description: "在构建动态博客时,添加说说功能能显著提升用户体验。相比静态博客的繁琐流程,这种功能允许用户随时随地分享短小的想法,降低了发文的心理负担。通过利用 CloudFlare Workers 实现后端逻辑,并结合 KV 存储,开发者能够轻松管理说说内容。前端则通过 VitePress 框架和 Vue 组件的嵌入,快捷地展示这些动态信息,为博客增添了生动的交互性。"
---
### 前言
很多动态博客中都有一个说说的功能,本质就是一种特殊的博文,借助动态博客的实时性,可以做到随写随发
静态博客由于是在本地或服务器上静态编译成 html 后再部署,实时性比较差。写一篇博文长篇大论自然可以在电脑前,走 git 推送部署也不算麻烦,但是发一篇说说还要打开电脑,心智负担就有些重了,手机上操作 git 也比较麻烦,不是很优雅,干脆一想就不发算了
于是实现了一套说说系统的前后端,效果就是本博客的 [碎碎念](/memos)。后端使用 CloudFlare Workers 实现,存储当然也就近存储在大善人的 KV 里,简单写了个管理页面。博客框架是 VitePress,前端也就做成了个 Vue 组件,直接嵌入一个页面作为说说页
前端效果不再多说,后端管理页面效果

### 后端 CloudFlare Workers + KV
#### 基本概述
后端包含以下功能:
- 支持说说的增删改(基本功能)
- 页面和所有写接口都有鉴权,足够安全
- Markdown 格式实时预览(by marked)
KV 中存储一个 `index` key,value 是一个 uid 的数组,作为全部说说的索引。其他所有说说都存储在以 `uid` 为 key 的条目中,value 格式如
```js
{
"uid":"唯一 id",
"createTime":"发布时间",
"content":"说说内容",
}
```
#### 实现
首先要创建一个 CloudFlare 的 KV Space,专门存储说说相关的 KV 对。位置在`账户首页 - 存储和数据库 - KV`,点击创建,名字不太重要,记住就行了,我这里简单命名为 `memos`
接着就是创建 CloudFlare Workers,用于逻辑处理。位置在`账户首页 - 计算(Workers)- Workers 和 Pages`,点击创建,名字依然不太重要,我简单命名为 `memos-api`。创建完成后,点击 Workers 名称进入 Workers 详情,在`设置 - 绑定`中添加一个绑定关系,选择绑定 `KV 命名空间`,变量名称为 `KV`,KV 命名空间选择刚刚创建的 KV Space 名称,我的是 `memos`。这样绑定完成后,就可以在代码中直接使用 `env.KV` 操作 `memos` 这个 KV 空间了。最后点击顶栏右侧的 `编辑代码` 按钮
下面就是 Code Time!
首先创建一个 `index.html`,用来存放管理页面的 html、css 和 js
```html
Memos 管理
```
从 JS 代码中即可看出,后端包含如下两个端点
- `POST /api/auth`:页面鉴权
- `GET /api/memos`:获取说说详情,支持分页
- `POST /api/memos`: 发布新说说
- `PUT /api/memos/{uid}`: 更新说说
- `DELETE /api/memos/{uid}`:删除说说
随后编辑 `worker.js` 实现这些端点即可
```js
import html from './index.html';
const CORRECT_PASSWORD = 'CORRECT_PASSWORD'; // 设置你的密码 // [!code highlight]
const CALLBACK_URL = 'https://CALLBACK_URL'; // 设置回调 URL // [!code highlight]
const ALLOWED_ORIGINS = ['https://example.com']; // 允许请求的域名 // [!code highlight]
// 生成随机 UID
function generateUID() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < 22; i++) {
const randomIndex = Math.floor(Math.random() * chars.length);
result += chars[randomIndex];
}
return result;
}
// CORS 处理
function handleCORS(request) {
const origin = request.headers.get('Origin');
const allowedOrigin = ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0];
const corsHeaders = {
'Access-Control-Allow-Origin': allowedOrigin,
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
};
return corsHeaders;
}
function getCurrentTimeInISOFormat() {
const now = new Date();
// 获取各个部分
const year = now.getUTCFullYear();
const month = String(now.getUTCMonth() + 1).padStart(2, '0'); // 月份从零开始
const day = String(now.getUTCDate()).padStart(2, '0');
const hours = String(now.getUTCHours()).padStart(2, '0');
const minutes = String(now.getUTCMinutes()).padStart(2, '0');
const seconds = String(now.getUTCSeconds()).padStart(2, '0');
// 组装成 ISO 8601 格式字符串
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}Z`;
}
async function handleRequest(request, env) {
const url = new URL(request.url);
function validateAuth(request) {
const auth = request.headers.get('Authorization');
return auth === CORRECT_PASSWORD;
}
async function shouldNotify(uid) {
const indexStr = await env.KV.get('index');
if (!indexStr) return false;
const index = JSON.parse(indexStr);
return index.indexOf(uid) < 10;
}
async function executeCallback() {
try {
await fetch(CALLBACK_URL);
} catch (error) {
console.error('Callback failed:', error);
}
}
const corsHeaders = handleCORS(request);
// 处理 CORS 预检请求
if (request.method === 'OPTIONS') {
return new Response(null, {
headers: handleCORS(request),
});
}
// 管理页面
if (url.pathname === '/manage') {
return new Response(html, {
headers: { 'Content-Type': 'text/html' },
});
}
// 验证密码
if (url.pathname === '/api/auth' && request.method === 'POST') {
const { password } = await request.json();
return new Response(
JSON.stringify({ success: password === CORRECT_PASSWORD }),
{
headers: {
'Content-Type': 'application/json',
...corsHeaders
},
}
);
}
// API 路由处理
if (url.pathname.startsWith('/api/memos')) {
// 获取说说列表
if (request.method === 'GET') {
const offset = parseInt(url.searchParams.get('offset')) || 0;
const limit = parseInt(url.searchParams.get('limit')) || 10;
const indexStr = await env.KV.get('index');
const index = indexStr ? JSON.parse(indexStr) : [];
const pageUids = index.slice(offset, offset + limit);
const posts = await Promise.all(
pageUids.map(uid => env.KV.get(uid).then(JSON.parse))
);
return new Response(JSON.stringify({
offset,
limit,
data: posts,
total: index.length,
hasMore: (offset + limit) < index.length,
}), {
headers: {
'Content-Type': 'application/json',
...corsHeaders
},
});
}
// 需要验证的操作
if (!validateAuth(request)) {
return new Response('Unauthorized', {
status: 401,
headers: corsHeaders
});
}
// 发布新说说
if (request.method === 'POST') {
const { content } = await request.json();
if (!content || !content.trim()) {
return new Response('Content cannot be empty', {
status: 400,
headers: corsHeaders
});
}
const indexStr = await env.KV.get('index');
const index = indexStr ? JSON.parse(indexStr) : [];
let uid = generateUID();
while (true) {
if (!index.includes(uid)) {
break;
}
uid = generateUID();
}
const post = {
uid,
createTime: getCurrentTimeInISOFormat(),
content: content.trim()
};
index.unshift(uid);
await Promise.all([
env.KV.put('index', JSON.stringify(index)),
env.KV.put(uid, JSON.stringify(post))
]);
await executeCallback();
return new Response(JSON.stringify(post), {
headers: {
'Content-Type': 'application/json',
...corsHeaders
},
});
}
// 编辑说说
if (request.method === 'PUT') {
const uid = url.pathname.split('/').pop();
const { content } = await request.json();
if (!content || !content.trim()) {
return new Response('Content cannot be empty', {
status: 400,
headers: corsHeaders
});
}
const postStr = await env.KV.get(uid);
if (!postStr) {
return new Response('Post not found', {
status: 404,
headers: corsHeaders
});
}
const post = JSON.parse(postStr);
post.content = content.trim();
await env.KV.put(uid, JSON.stringify(post));
// 检查是否需要回调
if (await shouldNotify(uid)) {
await executeCallback();
}
return new Response(JSON.stringify(post), {
headers: {
'Content-Type': 'application/json',
...corsHeaders
},
});
}
// 删除说说
if (request.method === 'DELETE') {
const uid = url.pathname.split('/').pop();
const indexStr = await env.KV.get('index');
if (!indexStr) {
return new Response('Post not found', {
status: 404,
headers: corsHeaders
});
}
const needCallback = await shouldNotify(uid);
const index = JSON.parse(indexStr);
const newIndex = index.filter(id => id !== uid);
await Promise.all([
env.KV.put('index', JSON.stringify(newIndex)),
env.KV.delete(uid)
]);
if (needCallback) {
await executeCallback();
}
return new Response(JSON.stringify({ success: true }), {
headers: {
'Content-Type': 'application/json',
...corsHeaders
},
});
}
}
return new Response('Not Found', {
status: 404,
headers: corsHeaders
});
}
export default {
async fetch(request, env) {
try {
return handleRequest(request, env);
} catch (error) {
return new Response(`Internal Server Error: ${error.message}`, {
status: 500,
headers: handleCORS(request)
});
}
},
};
```
最顶上三个常量需要配置:
- `CORRECT_PASSWORD`,页面密码
- `CALLBACK_URL`,发布新说说或更新/删除说说后触发的回调地址
- `ALLOWED_ORIGINS`,跨域处理,允许访问的域名列表,至少两个:你的博客域名和管理页面域名
配置完成后点击发布
由于墙的原因,默认的 `workers.dev` 域名很难访问,最好为 worker 配置一个新的域名。在 `memos 详情页面 - 设置 - 域和路由`,添加一个自定义域,填入一个在 Cloudflare 上托管的域名即可。注意这个域名也要添加到 `worker.js` 的 `ALLOWED_ORIGINS` 中
完成后就可以使用这个管理页面了,管理页面的 URL 为 `https://{你的域名}/manage`,进入页面需要输入密码,then enjoy!
### 前端
Thanks to VitePress,我们可以很方便地通过 Vue 组件的方式,编写说说前端并嵌入博客
首先安装 markedjs 依赖,pnpm 可使用如下命令:
```shell
pnpm add marked
```
在你的博客的主题配置文件(通常为 `docs/.vitepress/theme/index.ts`,文件路径和拓展名也许会有区别)的同级目录下,新建一个 `components` 文件夹(已有则无需新建),在其中新建 `memos.vue`
```js
```
注意将 `{你的域名}` 替换为 CloudFlare Worker 的域名
眼尖的同学可能注意到了,这个组件初始化加载的内容不是通过请求 Worker 接口获取到的,而是从一个 json 文件获取的(`import memosRaw from '../../../../memos.json'`)。只有点击加载更多,才会通过 Worker 接口获取更多内容。这是为什么呢?
- 从体验上来说,进入说说页面时,如果初始数据从接口获取,那么这时页面在获取到数据之前会空白一会儿,体验不佳
- 从省钱的角度上来说,CloudFlare Worker 免费版是限制请求次数的,初始化数据静态获取可以极大地降低请求次数
这个 `memos.json`,则是在项目编译时,从接口获取到的前十条说说。这也就是为什么,Worker 代码中会添加一个 `CALLBACK_URL`,这个 URL 是在你发布新说说,或者删改前十条说说时重新触发编译使用的,具体 URL 可以根据你的部署平台自行搜索。如果完全动态获取说说内容的话,这里可以不用设置这么一个回调
下面的代码用于在编译时生成 `memos.json`,在主题配置文件(通常为 `docs/.vitepress/theme/index.ts`,文件路径和拓展名也许会有区别)的同级目录下,新建一个 `utils` 文件夹(已有则无需新建),在其中新建 `memos.js`
```js
import https from 'https';
import { promises as fs } from 'fs';
const url = 'https://{你的域名}/api/memos?limit=10';// [!code highlight]
const requestOptions = {
headers: {
'Accept-Encoding': '',
}
};
// 发出 GET 请求
https.get(url, requestOptions, (resp) => {
let data = [];
// 逐步接收数据
resp.on('data', (chunk) => {
data.push(chunk);
});
// 完成接收数据
resp.on('end', async () => {
try {
// 将 Buffer 数组合并为一个 Buffer
const buffer = Buffer.concat(data);
const decodedData = buffer.toString('utf-8'); // 假设返回的数据是 UTF-8 编码
// 保存 JSON 数据到文件
await fs.writeFile('memos.json', decodedData);
console.log('JSON 数据已保存到 data.json');
} catch (e) {
console.error('解析 JSON 时出错:', e);
}
});
}).on('error', (err) => {
console.error('获取数据时出错:', err);
});
```
接着编辑博客根目录下的 `package.json`,在 dev 和 build 的命令前都添加 `node docs/.vitepress/theme/utils/memos.js`。这里的添加位置可能因人而异,以我为例:
```json
{
...
"scripts": {
"dev": "node docs/.vitepress/theme/utils/memos.js && vitepress dev docs",
"build": "node docs/.vitepress/theme/utils/memos.js && vitepress build docs",
"serve": "vitepress serve docs"
},
...
}
```
这样在 dev 阶段和 build 阶段都会首先调用 `memos.js`,在博客根目录下生成 `memos.json`。注意根据目录层级调整 `memos.vue` 中 import 的路径
这样组件和数据都准备好了,下面这个组件注册为全局组件
在主题配置文件(通常为 `docs/.vitepress/theme/index.ts`,文件路径和拓展名也许会有区别)中引入这个组件,并注册
```js
...
import Memos from './components/memos.vue'
...
export default {
...
enhanceApp({ app }) {
...
app.component('Memos', Memos);// [!code highlight]
}
} satisfies Theme
```
这样在博客的任何地方,都可以通过 `` 直接引入这个组件了
最后就是创建一个单页,专门用于放置这个组件
> 什么?你说你从来没有在 vitepress 中使用过单页?
>
> 这样,你先在根目录下新建一个 pages 文件夹,再在 VitePress 核心配置文件中(注意不是主题配置文件,通常为 docs/.vitepress/config.ts,文件路径和拓展名也许会有区别)中新增一个 rewrites 规则 `'pages/:file.md': ':file.md'`,这样 pages 下的内容都可以直接通过 `/文件名` 访问了。关于 rewrites,见 [官方文档](https://vitepress.dev/guide/routing#route-rewrites)
pages 文件夹下新建 balabala.md,内容为
```markdown
---
title: 碎碎念
hidden: true
comment: false
sidebar: false
aside: false
readingTime: false
showMeta: false
---
```
完事收工