--- 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 组件,直接嵌入一个页面作为说说页 前端效果不再多说,后端管理页面效果 ![Memo 管理页面](https://blog-img.shinya.click/2025/8551751fe98e55c4159d28b9ff5b9473.png) ### 后端 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 管理

Memos 管理

已发布
新 Memo
``` 从 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 --- ``` 完事收工