Skip to content

perf: speed up public post loading - #24

Merged
one-ea merged 2 commits into
mainfrom
dev
Apr 16, 2026
Merged

perf: speed up public post loading#24
one-ea merged 2 commits into
mainfrom
dev

Conversation

@one-ea

@one-ea one-ea commented Apr 16, 2026

Copy link
Copy Markdown
Owner

Summary

  • remove the public post list N+1 tag query pattern across D1, Turso, and PostgreSQL adapters by loading post tags in batches
  • add short-lived public API caching for post list/detail/category requests and cache rendered article HTML by slug + updatedAt to improve repeat navigation without serving stale content
  • tighten public API cache headers for /api/posts and /api/posts/:slug so Cloudflare can serve article list/detail responses faster

@coderabbitai

coderabbitai Bot commented Apr 16, 2026

Copy link
Copy Markdown
📝 Walkthrough

Summary by CodeRabbit

  • 新功能

    • 添加GitHub Actions自动部署工作流至Cloudflare Pages
    • 实现API响应缓存机制,减少重复请求
    • 增加代码块语言支持(C/C++等)
  • 性能优化

    • 优化数据库查询逻辑,改进文章列表加载效率
    • 改进文章内容渲染缓存机制
    • 为API端点添加缓存控制头
  • 文档

    • 更新部署文档,说明自动部署工作流、配置要求和防呆说明

Walkthrough

新增 Cloudflare 部署工作流(支持手动与自动触发),并在前端实现请求与渲染缓存机制、后端添加 API 缓存头和优化数据库批量查询逻辑,同时补充部署文档和脚本。

Changes

Cohort / File(s) Summary
部署工作流与文档
.github/workflows/deploy-cloudflare.yml, README.md
新增 GitHub Actions Cloudflare 部署工作流,支持手动触发与条件自动触发;更新部署说明文档,包含密钥配置、可选参数和防呆说明。
部署脚本与包配置
scripts/deploy-cloudflare.mjs, package.json, server/package.json
优化部署脚本的目录处理和前置检查;更新 deploy:client 脚本添加 Wrangler 参数;新增本地测试数据库种子脚本。
客户端请求缓存
client/src/lib/api.ts
实现 fetchJsonWithCache 缓存层,支持按路径 TTL 管理和并发去重,应用于 fetchPostsfetchPostfetchCategories 三个 API 接口。
客户端 Markdown 增强
client/src/lib/markdown.ts
添加 C/C++ 语言支持,优化代码块语言标签显示逻辑,确保未识别的语言仍有默认显示并进行 HTML 转义。
客户端渲染优化
client/src/pages/post.tsx
替换基于 setTimeout 的延迟渲染为模块级 Map 缓存,基于 slug 与 updatedAt 衍生键值实现同步缓存查询。
服务器 API 缓存策略
server/src/index.ts
/api/posts/api/posts/:slug 添加 Cache-Control 响应头(max-age=60, s-maxage=300)。
服务器数据库优化
server/src/storage/db/d1.ts, server/src/storage/db/postgres.ts, server/src/storage/db/turso.ts
消除 N+1 查询问题,用单次批量 getPostTagsMap() 替换按文章逐个标签查询,适用于 getPublishedPosts()getAllPosts()

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Cache as 请求缓存层
    participant InflightMap as 并发去重
    participant Server as API Server
    
    rect rgba(100, 200, 100, 0.5)
    Note over Client,Server: 缓存命中流程
    Client->>Cache: fetchJsonWithCache(path, ttl)
    activate Cache
    Cache->>Cache: 检查 publicCache[path]
    alt 缓存有效 (未过期)
        Cache->>Client: 返回缓存数据
    else 缓存不存在或已过期
        Cache->>InflightMap: 检查 inflightRequests[path]
        alt 请求已在进行中
            InflightMap->>Client: 返回相同 Promise
        else 无进行中请求
            Cache->>Server: fetch(path)
            activate Server
            Server-->>Cache: response
            deactivate Server
            Cache->>Cache: 解析 JSON 并存储<br/>(expiresAt = now + ttl)
            Cache->>InflightMap: 移除 inflightRequests[path]
            Cache->>Client: 返回数据
        end
    end
    deactivate Cache
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

frontend, backend

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed PR 标题完全符合 Conventional Commits 格式,清晰表达了主要改动(性能优化)的核心内容。
Description check ✅ Passed PR 描述与代码变更高度相关,详细说明了性能优化的三个主要方面,与所有修改文件的意图相符。

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch dev

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot added frontend 前端 (React/Vite) 相关变更 backend 后端 (Hono Workers) 相关变更 labels Apr 16, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
scripts/deploy-cloudflare.mjs (1)

135-145: ⚠️ Potential issue | 🔴 Critical

修复 Pages 部署目录与 cwd 不一致导致的发布失败。

Line 139 仍传入 "client/dist",但 Line 145 已设置 cwd: clientRoot。这会把目标目录解析成 client/client/dist,在多数环境下会直接导致 wrangler pages deploy 找不到构建产物。

建议修复
   runStep("部署 Cloudflare Pages 前端", "npx", [
     "wrangler",
     "pages",
     "deploy",
-    "client/dist",
+    "dist",
     "--project-name",
     options.pagesProject,
     "--branch",
     options.branch,
     "--commit-dirty=true",
   ], { cwd: clientRoot });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/deploy-cloudflare.mjs` around lines 135 - 145, 部署命令传入的目标目录与设置的 cwd
不一致导致查找失败:在 runStep 调用中把传给 wrangler pages deploy 的路径从 "client/dist" 修改为相对于 cwd:
either "dist" (since cwd is clientRoot) 或直接传入 clientRoot 的绝对 dist 路径 (e.g.
path.join(clientRoot, "dist")), 确保 runStep 的 args 中的目标目录与 { cwd: clientRoot }
对齐(参考 runStep(...) 调用及其 wrangler, pages, deploy 参数)。
🧹 Nitpick comments (1)
server/src/storage/db/d1.ts (1)

725-739: searchPosts 仍保留 N+1 查询模式,建议后续优化。

此处仍使用 Promise.all + getPostTags(post.id) 的方式。考虑到搜索结果通常有 limit 限制(默认 20),影响可控,但如果后续需要进一步优化性能,可复用 getPostTagsMap

♻️ 可选优化方案
   async searchPosts(query: string, limit = 20): Promise<PostSummary[]> {
     // ... 查询逻辑 ...
     
+    const tagMap = await this.getPostTagsMap(rows.map((post) => post.id));

-    return Promise.all(
-      rows.map(async (post) => ({
+    return rows.map((post) => ({
         id: post.id,
         slug: post.slug,
         title: post.title,
         excerpt: post.excerpt || "",
         coverColor: post.coverColor || "",
         createdAt: post.createdAt,
-        tags: await this.getPostTags(post.id),
+        tags: tagMap.get(post.id) || [],
         pinned: post.pinned,
         publishAt: post.publishAt,
         seriesSlug: post.seriesSlug || null,
         category: post.category || "",
-      }))
-    );
+    }));
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/src/storage/db/d1.ts` around lines 725 - 739, 当前实现对每个 post 调用
getPostTags(post.id) 导致 N+1 查询;改为先收集所有 post.id,调用已有的
getPostTagsMap(postIds)(或实现一个批量方法),得到一个以 postId 为键的 tags 映射,然后在原来的 rows.map
映射中直接从 tagsMap[post.id] 读取标签(回退到 [] 或空数组),保留原有字段(id, slug, title, excerpt,
coverColor, createdAt, pinned, publishAt, seriesSlug, category)以消除 Promise.all +
每项异步查询的 N+1 问题并提高性能。
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/src/lib/api.ts`:
- Around line 6-12: publicCache is unbounded and can leak memory; add a pruning
strategy and expire logic: implement a pruneCache(maxEntries: number) (or simple
LRU) that removes oldest or expired PublicCacheEntry items from publicCache,
call pruneCache() after you insert into publicCache inside fetchJsonWithCache,
and ensure entries are removed when expiresAt < Date.now(); also consider
bounding inflightRequests by deleting its key when the request completes to
avoid retaining promises.

---

Outside diff comments:
In `@scripts/deploy-cloudflare.mjs`:
- Around line 135-145: 部署命令传入的目标目录与设置的 cwd 不一致导致查找失败:在 runStep 调用中把传给 wrangler
pages deploy 的路径从 "client/dist" 修改为相对于 cwd: either "dist" (since cwd is
clientRoot) 或直接传入 clientRoot 的绝对 dist 路径 (e.g. path.join(clientRoot, "dist")),
确保 runStep 的 args 中的目标目录与 { cwd: clientRoot } 对齐(参考 runStep(...) 调用及其 wrangler,
pages, deploy 参数)。

---

Nitpick comments:
In `@server/src/storage/db/d1.ts`:
- Around line 725-739: 当前实现对每个 post 调用 getPostTags(post.id) 导致 N+1 查询;改为先收集所有
post.id,调用已有的 getPostTagsMap(postIds)(或实现一个批量方法),得到一个以 postId 为键的 tags 映射,然后在原来的
rows.map 映射中直接从 tagsMap[post.id] 读取标签(回退到 [] 或空数组),保留原有字段(id, slug, title,
excerpt, coverColor, createdAt, pinned, publishAt, seriesSlug, category)以消除
Promise.all + 每项异步查询的 N+1 问题并提高性能。
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 49b049d7-0ffe-408b-99c6-c8f564faa3d5

📥 Commits

Reviewing files that changed from the base of the PR and between a5aea08 and dfccb43.

📒 Files selected for processing (13)
  • .github/workflows/deploy-cloudflare.yml
  • README.md
  • client/src/lib/api.ts
  • client/src/lib/markdown.ts
  • client/src/pages/post.tsx
  • package.json
  • scripts/deploy-cloudflare.mjs
  • server/package.json
  • server/src/index.ts
  • server/src/seeds/seed_test_posts.sql
  • server/src/storage/db/d1.ts
  • server/src/storage/db/postgres.ts
  • server/src/storage/db/turso.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 120000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: ESLint 安全扫描
  • GitHub Check: Analyze (javascript-typescript)
  • GitHub Check: ESLint 安全扫描
🧰 Additional context used
📓 Path-based instructions (3)
client/src/pages/**

⚙️ CodeRabbit configuration file

client/src/pages/**: 页面级组件。审查时请关注: 1. 数据加载和错误处理是否完善 2. SEO 相关(页面标题、meta 标签) 3. 导航和路由是否正确

Files:

  • client/src/pages/post.tsx
server/src/index.ts

⚙️ CodeRabbit configuration file

server/src/index.ts: Hono Workers API 路由总入口。审查时请关注: 1. JWT 认证中间件是否正确保护管理接口 2. CORS 配置是否安全 3. 请求参数验证

Files:

  • server/src/index.ts
server/src/storage/**

⚙️ CodeRabbit configuration file

server/src/storage/**: 存储适配器层(数据库 + 对象存储)。审查时请关注: 1. 接口实现是否完整(IDatabase / IObjectStorage) 2. SQL 注入防护(参数化查询) 3. 多后端(D1/Turso/PostgreSQL)行为一致性 4. 错误处理和边界情况

Files:

  • server/src/storage/db/d1.ts
  • server/src/storage/db/postgres.ts
  • server/src/storage/db/turso.ts
🔇 Additional comments (19)
server/package.json (1)

12-12: 本地测试数据脚本配置正确,可直接使用。

--local + src/seeds/seed_test_posts.sql 的组合与当前仓库结构一致,便于隔离本地测试数据,不会影响远程迁移流程。

package.json (2)

15-15: deploy:client 的进程退出码透传实现是加分项。

当前写法能在子进程失败时正确让 CI/命令行失败退出,避免“假成功”部署。


19-20: workspace 级别的本地测试种子脚本转发设计合理。

根脚本到 monolith-server 的转发清晰,便于统一从仓库根目录执行本地初始化。

.github/workflows/deploy-cloudflare.yml (1)

71-83: 工作流参数拼装与脚本解析契合度高。

--skip-migrate / --skip-server / --skip-client / --api-base 的条件拼装方式与部署脚本当前解析逻辑一致,CI 与本地脚本行为保持统一。

README.md (1)

147-177: 部署文档补充完整,防呆说明实用。

新增的 Actions 触发方式、必需 Secrets 与本地测试种子约束说明都很清晰,能明显降低部署和数据初始化误操作风险。

server/src/storage/db/d1.ts (2)

57-74: 批量标签查询实现正确,有效消除 N+1 问题。

getPostTagsMap 方法通过单次 inArray 查询获取所有文章标签,逻辑清晰。空数组提前返回的处理也很好。


186-200: getPublishedPosts 批量优化实现良好。

使用 getPostTagsMap 替代 Promise.all 循环查询,显著减少数据库往返次数。

server/src/storage/db/postgres.ts (2)

174-191: PostgreSQL 适配器批量标签查询实现与 D1 保持一致。

实现模式与其他适配器统一,确保多后端行为一致性。


221-235: getPublishedPosts 批量优化实现正确。

client/src/lib/markdown.ts (2)

10-11: 新增 C/C++ 语法高亮支持。

注册了完整的 C/C++ 语言别名(c, h, cpp, c++, cc, cxx, hpp),覆盖常见用法。

Also applies to: 28-34


85-85: 代码块语言标签显示逻辑改进。

引入 displayLanguage 确保即使 Highlight.js 不识别该语言,也能在 UI 上显示用户指定的语言名称,用户体验更好。

Also applies to: 116-119

server/src/storage/db/turso.ts (2)

41-58: Turso 适配器批量标签查询实现与其他适配器保持一致。

三个数据库适配器(D1、PostgreSQL、Turso)的 getPostTagsMap 实现完全一致,确保多后端行为统一。


204-218: 批量标签加载优化实现正确。

server/src/index.ts (2)

97-97: 文章列表 API 缓存头设置合理。

max-age=60, s-maxage=300, stale-while-revalidate=600 的组合对于公开列表是合适的,平衡了性能和数据新鲜度。


115-152: 注意:缓存与浏览量统计的权衡。

该路由同时设置了缓存头(Line 120)并递增浏览量(Line 124)。当 CDN 缓存命中时,后端不会收到请求,浏览量统计会偏低。

这是一个合理的性能与精确度的权衡,但建议在文档中说明此行为。如果需要更精确的统计,可以考虑:

  1. 前端单独调用浏览量统计 API
  2. 使用 Cloudflare Analytics 或类似服务

当前实现在性能优先的场景下是可接受的。

client/src/lib/api.ts (2)

14-41: 请求去重与缓存实现整体正确。

  • inflightRequests 防止并发重复请求,设计合理
  • finally 确保 inflight 清理,即使出错也能正确处理
  • 缓存命中时直接返回,避免不必要的网络请求

68-74: fetchPostsfetchPost 使用缓存。

60 秒 TTL 与服务端 max-age=60 保持一致,客户端和 CDN 缓存策略协调良好。

client/src/pages/post.tsx (2)

22-29: 渲染缓存设计合理,使用 slug:updatedAt 作为缓存键确保内容一致性。

当文章更新时,updatedAt 变化会生成新的缓存键,避免显示过期内容。

api.ts 类似,此缓存也会无限增长。考虑到用户在单次会话中浏览的文章数量通常有限,影响较小,但建议保持与 api.ts 相同的清理策略。


90-113: Markdown 渲染缓存逻辑正确。

移除了之前的 setTimeout(..., 10) 延迟渲染,改为同步缓存查找,代码更清晰:

  • 缓存命中时直接使用,无需重新计算
  • 缓存未命中时计算并存储

渲染逻辑从异步变为同步,减少了不必要的 UI 闪烁。

Comment thread client/src/lib/api.ts
Comment on lines +6 to +12
type PublicCacheEntry<T> = {
expiresAt: number;
value: T;
};

const publicCache = new Map<string, PublicCacheEntry<unknown>>();
const inflightRequests = new Map<string, Promise<unknown>>();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

内存泄漏风险:缓存无上限且无清理机制。

publicCache 会随着访问不同文章而持续增长,在长时间运行的 SPA 中可能导致内存问题。

建议添加简单的 LRU 策略或定期清理过期条目:

🛡️ 可选方案:添加缓存大小限制
 const publicCache = new Map<string, PublicCacheEntry<unknown>>();
 const inflightRequests = new Map<string, Promise<unknown>>();
+const MAX_CACHE_SIZE = 100;
+
+function pruneCache() {
+  if (publicCache.size > MAX_CACHE_SIZE) {
+    const now = Date.now();
+    // 删除过期条目
+    for (const [key, entry] of publicCache) {
+      if (entry.expiresAt <= now) {
+        publicCache.delete(key);
+      }
+    }
+    // 如果仍超限,删除最早的条目
+    while (publicCache.size > MAX_CACHE_SIZE) {
+      const firstKey = publicCache.keys().next().value;
+      if (firstKey) publicCache.delete(firstKey);
+    }
+  }
+}

然后在 fetchJsonWithCache 中缓存后调用 pruneCache()

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
type PublicCacheEntry<T> = {
expiresAt: number;
value: T;
};
const publicCache = new Map<string, PublicCacheEntry<unknown>>();
const inflightRequests = new Map<string, Promise<unknown>>();
type PublicCacheEntry<T> = {
expiresAt: number;
value: T;
};
const publicCache = new Map<string, PublicCacheEntry<unknown>>();
const inflightRequests = new Map<string, Promise<unknown>>();
const MAX_CACHE_SIZE = 100;
function pruneCache() {
if (publicCache.size > MAX_CACHE_SIZE) {
const now = Date.now();
// 删除过期条目
for (const [key, entry] of publicCache) {
if (entry.expiresAt <= now) {
publicCache.delete(key);
}
}
// 如果仍超限,删除最早的条目
while (publicCache.size > MAX_CACHE_SIZE) {
const firstKey = publicCache.keys().next().value;
if (firstKey) publicCache.delete(firstKey);
}
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/lib/api.ts` around lines 6 - 12, publicCache is unbounded and can
leak memory; add a pruning strategy and expire logic: implement a
pruneCache(maxEntries: number) (or simple LRU) that removes oldest or expired
PublicCacheEntry items from publicCache, call pruneCache() after you insert into
publicCache inside fetchJsonWithCache, and ensure entries are removed when
expiresAt < Date.now(); also consider bounding inflightRequests by deleting its
key when the request completes to avoid retaining promises.

@one-ea
one-ea merged commit 5a54e94 into main Apr 16, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend 后端 (Hono Workers) 相关变更 frontend 前端 (React/Vite) 相关变更

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant