From 70752275737f04a26ea39e789b06d1eb0a90565f Mon Sep 17 00:00:00 2001 From: SuziJay Date: Mon, 3 Aug 2026 14:00:30 +0800 Subject: [PATCH] =?UTF-8?q?perf(blog):=20=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E5=8C=96=E6=96=87=E7=AB=A0=E6=B8=B2=E6=9F=93=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E9=A6=96=E5=B1=8F=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 文章渲染:将 Markdown、高亮和 slug 生成收口服务端,以交互岛保留复制、折叠及上传兼容行为。 - 首屏资源:按需加载设置与预览,延后视频预载,以 CSS 动画和缓存策略减少初始脚本与资源开销。 - 数据与文档:复用持久化标签 slug,流式加载评论,并记录性能基线与回归门禁。 - 验证:npm run lint(通过);npm run typecheck(通过);npm test(20 文件、72 测试通过);npm run build(通过);git diff --check(通过)。 - 行为边界:未修改数据库结构、既有 URL、Markdown 显示效果、图片存储协议或发布状态。 --- README.md | 2 +- docs/architecture.md | 2 + docs/performance.md | 56 ++++++++++++ next.config.ts | 18 ++++ src/app/api/posts/[slug]/update/route.ts | 3 +- src/app/api/posts/create/route.ts | 3 +- src/app/api/upload-image/route.ts | 17 ++-- src/app/globals.css | 41 +++++++++ src/app/post/[slug]/page.tsx | 54 +++++++++--- src/app/template.tsx | 17 +--- src/components/layout/navbar.tsx | 22 ++++- src/components/layout/site-loader.tsx | 15 ++-- .../home/components/cinematic-hero.tsx | 29 ++++++- src/features/posts/code-block-language.ts | 19 ++++ .../posts/components/code-block-shell.tsx | 42 +++------ .../components/markdown-copy-controller.tsx | 56 ++++++++++++ .../posts/components/markdown-renderer.tsx | 86 ++++++------------- src/features/posts/components/post-card.tsx | 21 ++--- src/features/posts/components/post-form.tsx | 53 ++++++++---- src/features/posts/editor/editor.tsx | 11 ++- src/features/posts/editor/rich-editor.tsx | 7 +- .../posts/editor/source-markdown-editor.tsx | 8 +- .../posts/hooks/use-post-cover-upload.ts | 7 +- src/features/posts/image-upload.ts | 2 + src/features/posts/model.ts | 33 +++++++ src/features/tags/actions.ts | 2 +- src/lib/markdown.ts | 14 --- src/server/repositories/posts.ts | 3 +- src/server/slug.ts | 33 +++++++ tests/code-block-shell.test.ts | 2 +- tests/markdown-renderer.test.tsx | 15 ++++ tests/markdown-server-renderer.test.tsx | 68 +++++++++++++++ tests/markdown.test.ts | 5 -- tests/post-model.test.ts | 44 ++++++++-- tests/post-repository.test.ts | 1 + tests/slug.test.ts | 62 +++++++++++++ 36 files changed, 665 insertions(+), 208 deletions(-) create mode 100644 docs/performance.md create mode 100644 src/features/posts/code-block-language.ts create mode 100644 src/features/posts/components/markdown-copy-controller.tsx create mode 100644 src/server/slug.ts create mode 100644 tests/markdown-server-renderer.test.tsx create mode 100644 tests/slug.test.ts diff --git a/README.md b/README.md index 9b8c057..14fdd28 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ ### 🛡️ 坚实的技术架构 - **全栈类型安全**: TypeScript + Supabase 生成类型,确保前后端数据链路安全。 - **企业级认证**: 完整的用户认证流程,支持头像上传(自动压缩与清理)、资料管理。 -- **高性能**: Next.js 服务端组件 (RSC) + 骨架屏 (Skeleton) 加载策略,秒级响应。 +- **高性能**: Next.js 服务端组件 (RSC)、服务端 Markdown、流式评论与按需资源加载;基线和回归标准见 [页面加载性能与服务端边界](docs/performance.md)。 - **SEO 友好**: 自动生成 Sitemap、Robots 配置与 JSON-LD 结构化数据,搜索引擎完美抓取。 ## 🛠️ 技术栈 (Tech Stack) diff --git a/docs/architecture.md b/docs/architecture.md index dc92ee7..906ede6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -98,4 +98,6 @@ git diff --check npm run build ``` +页面加载、Markdown 服务端化、slug 服务端边界和客户端分块的详细约束见 [页面加载性能与服务端边界](performance.md)。 + 数据库改动还必须遵循 [审计与整改计划](audit-remediation-plan.md) 中的 Supabase 验证要求。 diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 0000000..8e392b5 --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,56 @@ +# 页面加载性能与服务端边界 + +本文记录 2026-08-03 完成的页面加载性能优化、关键架构约束和后续回归标准。生产构建体积会随依赖版本和业务代码变化,本文数据用于保留同口径基线。 + +## 优化结果 + +| 指标 | 优化前 | 优化后 | 变化 | +| --- | ---: | ---: | ---: | +| 首页 JavaScript(gzip) | 353,499 B | 315,067 B | -10.9% | +| 文章详情客户端引用(raw) | 8,533,843 B | 574,479 B | -93.3% | +| Markdown 服务端化后的文章客户端引用(raw) | 6,969,562 B | 574,479 B | -91.8% | + +公开文章不再向浏览器发送约 1.46 MB 的 Markdown 解析与高亮实现,也不再发送原先约 6.39 MB raw / 1.98 MB gzip 的拼音字典分块。优化后的生产客户端资源中不得出现 `pinyin` 实现。 + +## 实现边界 + +### 公开页面 + +- Markdown 正文、GFM 表格、代码高亮、标题锚点和 HTML 清理在服务端完成,初始 HTML 必须包含可阅读正文。 +- 客户端只保留代码复制、展开/收起和文章选区复制等必要交互,不能让整个 Markdown 树跨越 Client Component 边界。 +- 评论查询通过 `Suspense` 流式加载,不阻塞文章正文和侧栏输出。 +- 文章详情直接使用数据库保存的标签名称与 slug 构造链接,不在请求期间重复执行拼音转换。 + +### Slug 与图片上传 + +- `pinyin` 只能由 `src/server/slug.ts` 引入,该模块必须声明 `server-only`。 +- 文章创建、更新和标签管理由服务端生成最终 slug,客户端表单只提交用户显式填写的别名或空值。 +- 图片上传优先使用显式 `articleSlug`;未填写时由上传 API 根据 `articleTitle` 在服务端生成目录 slug;无法生成时回退到 `blog/YYYY/MM`。 +- 不得修改既有文章和标签 slug,也不得因性能优化改变公开 URL。 + +### 首屏资源 + +- 页面转场、站点加载器和精选文章描边使用 CSS 动画,并遵循 `prefers-reduced-motion`。 +- 设置弹窗和文章预览按需加载,可在用户悬停或聚焦入口时预取。 +- 首页视频仅预加载当前首要资源,其余视频在首帧缓冲完成并进入空闲时段后加载。 +- `/videos` 与 `/images` 静态资源使用可重验证的长期缓存响应头。 + +## 回归标准 + +提交涉及路由、Server/Client 边界或资源加载的改动时,至少执行: + +```bash +npm run lint +npm run typecheck +npm test +npm run build +git diff --check +``` + +生产构建后还需检查: + +1. `.next/static/chunks` 中不存在拼音词典或 `pinyin` 客户端实现。 +2. 文章初始响应包含正文、表格和高亮代码,不依赖客户端解析后才可阅读。 +3. 首页、文章列表、搜索、标签和文章详情没有页面级横向溢出或控制台错误。 +4. 桌面端与移动端的明暗主题、代码复制、代码展开/收起和标签跳转保持正常。 +5. 后台文章保存、标签管理、封面上传和编辑器图片上传继续由服务端生成安全 slug。 diff --git a/next.config.ts b/next.config.ts index c74fc77..471dfe1 100644 --- a/next.config.ts +++ b/next.config.ts @@ -13,6 +13,15 @@ const supabaseHostname = getSupabaseHostname(); const nextConfig: NextConfig = { async headers() { return [ + { + source: "/videos/:asset*", + headers: [ + { + key: "Cache-Control", + value: "public, max-age=86400, stale-while-revalidate=604800", + }, + ], + }, { source: "/videos/about/:asset*", headers: [ @@ -22,6 +31,15 @@ const nextConfig: NextConfig = { }, ], }, + { + source: "/images/:asset*", + headers: [ + { + key: "Cache-Control", + value: "public, max-age=86400, stale-while-revalidate=604800", + }, + ], + }, ]; }, images: { diff --git a/src/app/api/posts/[slug]/update/route.ts b/src/app/api/posts/[slug]/update/route.ts index 035c3b4..d70e684 100644 --- a/src/app/api/posts/[slug]/update/route.ts +++ b/src/app/api/posts/[slug]/update/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse, type NextRequest } from 'next/server' -import { generatePostSlug, getPostExcerpt } from '@/lib/markdown' +import { getPostExcerpt } from '@/lib/markdown' +import { generatePostSlug } from '@/server/slug' import { AccessError, requireAdmin } from '@/lib/server-auth' import { getValidationMessage, postPayloadSchema } from '@/lib/validation' import { invalidatePublishedPosts } from '@/server/cache' diff --git a/src/app/api/posts/create/route.ts b/src/app/api/posts/create/route.ts index 3f2025b..f931c9e 100644 --- a/src/app/api/posts/create/route.ts +++ b/src/app/api/posts/create/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse, type NextRequest } from 'next/server' -import { generatePostSlug, getPostExcerpt } from '@/lib/markdown' +import { getPostExcerpt } from '@/lib/markdown' +import { generatePostSlug } from '@/server/slug' import { AccessError, requireAdmin } from '@/lib/server-auth' import { getValidationMessage, postPayloadSchema } from '@/lib/validation' import { invalidatePublishedPosts } from '@/server/cache' diff --git a/src/app/api/upload-image/route.ts b/src/app/api/upload-image/route.ts index 6c46c78..3e83420 100644 --- a/src/app/api/upload-image/route.ts +++ b/src/app/api/upload-image/route.ts @@ -8,6 +8,7 @@ import { ImageStorageError, storeBlogImage, } from "@/server/image-storage" +import { resolveArticleUploadSlug } from "@/server/slug" export const runtime = "nodejs" @@ -46,15 +47,15 @@ export async function POST(request: NextRequest) { return apiError("未选择图片文件", 400, "IMAGE_REQUIRED") } + const folder = formData.get("folder") + const articleSlug = formData.get("articleSlug") + const articleTitle = formData.get("articleTitle") const stored = await storeBlogImage(supabase, value, { - folder: - typeof formData.get("folder") === "string" - ? String(formData.get("folder")) - : null, - articleSlug: - typeof formData.get("articleSlug") === "string" - ? String(formData.get("articleSlug")) - : null, + folder: typeof folder === "string" ? folder : null, + articleSlug: resolveArticleUploadSlug({ + articleSlug: typeof articleSlug === "string" ? articleSlug : null, + articleTitle: typeof articleTitle === "string" ? articleTitle : null, + }), }) return NextResponse.json(stored) diff --git a/src/app/globals.css b/src/app/globals.css index 91905f1..6ad56a4 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -61,6 +61,47 @@ html, body { font-family: var(--font-instrument-serif), Georgia, serif; } +.page-enter-transition { + animation: page-enter 0.4s cubic-bezier(0.2, 0.01, 0, 1) both; +} + +@keyframes page-enter { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.featured-border-draw { + stroke-dasharray: 1; + stroke-dashoffset: 1; + opacity: 0; + animation: featured-border-draw 1.5s ease-in-out forwards; +} + +@keyframes featured-border-draw { + to { + stroke-dashoffset: 0; + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .page-enter-transition { + animation: none; + } + + .featured-border-draw { + animation: none; + stroke-dashoffset: 0; + opacity: 1; + } +} + /* Liquid Glass Effect */ .liquid-glass { background: rgba(255, 255, 255, 0.04); diff --git a/src/app/post/[slug]/page.tsx b/src/app/post/[slug]/page.tsx index c4e7122..4428dce 100644 --- a/src/app/post/[slug]/page.tsx +++ b/src/app/post/[slug]/page.tsx @@ -2,7 +2,7 @@ import { notFound } from 'next/navigation' import { Metadata } from 'next' import Link from 'next/link' import { ArrowLeft, Calendar, Tag, Eye, Clock, User } from 'lucide-react' -import { calculateReadingTime, formatDateString, generatePostSlug } from '@/lib/markdown' +import { calculateReadingTime, formatDateString } from '@/lib/markdown' import { Button } from '@/components/ui/button' import { ViewCounter } from '@/features/posts/components/view-counter' import { CommentSection } from '@/features/posts/components/comment-section' @@ -19,6 +19,29 @@ import { PostPageBackground } from '@/features/posts/components/post-page-backgr import { BackToTop } from '@/components/ui/back-to-top' import { GoToComments } from '@/components/ui/go-to-comments' +import { Suspense } from 'react' + +async function PostComments({ + postId, + commentsPromise, +}: { + postId: string + commentsPromise: ReturnType +}) { + const comments = await commentsPromise + return +} + +function PostCommentsFallback() { + return ( +
+ ) +} export async function generateMetadata({ params, @@ -68,13 +91,14 @@ export default async function PostPage({ const post = await getVisiblePost(decodedSlug) if (!post) notFound() - const [author, comments] = await Promise.all([ - post.author_id ? getPublicProfile(post.author_id) : Promise.resolve(null), - getComments(post.id), - ]) + const commentsPromise = getComments(post.id) + const authorPromise = post.author_id + ? getPublicProfile(post.author_id) + : Promise.resolve(null) + const author = await authorPromise const content = post.content || '' - const tags = post.tags + const tags = post.tagLinks const readingTime = calculateReadingTime(content) const formattedDate = formatDateString(post.created_at) @@ -143,10 +167,10 @@ export default async function PostPage({ {tags.length > 0 && (
- {tags.map((tag: string) => { - const styles = getTagStyles(tag) + {tags.map((tag) => { + const styles = getTagStyles(tag.name) return ( - +
- {tag} + {tag.name} ) @@ -225,10 +249,12 @@ export default async function PostPage({
- + }> + +
{/* Sidebar */} diff --git a/src/app/template.tsx b/src/app/template.tsx index 14d7911..bf194f9 100644 --- a/src/app/template.tsx +++ b/src/app/template.tsx @@ -1,20 +1,7 @@ -"use client" - -import { motion, useReducedMotion } from "framer-motion" - export default function Template({ children }: { children: React.ReactNode }) { - const shouldReduceMotion = useReducedMotion() - return ( - +
{children} - +
) } diff --git a/src/components/layout/navbar.tsx b/src/components/layout/navbar.tsx index 490d433..cf216a1 100644 --- a/src/components/layout/navbar.tsx +++ b/src/components/layout/navbar.tsx @@ -2,6 +2,7 @@ import { useState, type FormEvent } from "react"; import Link from "next/link"; +import dynamic from "next/dynamic"; import { usePathname, useRouter } from "next/navigation"; import { Plus, User, Search, Settings, LayoutDashboard, FileText, Menu, ChevronUp, ChevronDown } from "lucide-react"; import { type User as SupabaseUser } from "@supabase/supabase-js"; @@ -29,9 +30,20 @@ import { SignOutButton } from "@/features/auth/components/sign-out-button"; import { LoginModal } from "@/features/auth/components/login-modal"; import { Logo } from "@/components/ui/logo"; -import { SettingsModal } from "@/features/settings/components/settings-modal"; import { SafeImage } from "@/components/ui/safe-image"; +const SettingsModal = dynamic( + () => + import("@/features/settings/components/settings-modal").then( + (module) => module.SettingsModal, + ), + { ssr: false }, +); + +function preloadSettingsModal() { + void import("@/features/settings/components/settings-modal"); +} + const navItems = [ { href: "/", label: "首页" }, { href: "/post", label: "文章" }, @@ -176,6 +188,8 @@ export function Navbar({ user: initialUser }: { user?: SupabaseUser | null }) { setIsSheetOpen(false); setIsSettingsOpen(true); }} + onMouseEnter={preloadSettingsModal} + onFocus={preloadSettingsModal} className="px-4 py-3 text-sm font-medium text-neutral-500 hover:text-black dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl transition-all flex items-center gap-3 w-full text-left" > @@ -298,6 +312,8 @@ export function Navbar({ user: initialUser }: { user?: SupabaseUser | null }) { )} setIsSettingsOpen(true)} + onMouseEnter={preloadSettingsModal} + onFocus={preloadSettingsModal} className="cursor-pointer flex items-center w-full" > @@ -361,7 +377,9 @@ export function Navbar({ user: initialUser }: { user?: SupabaseUser | null }) { )} /> {/* Settings Modal */} - + {isSettingsOpen ? ( + + ) : null} ); } diff --git a/src/components/layout/site-loader.tsx b/src/components/layout/site-loader.tsx index 430f2b2..1913234 100644 --- a/src/components/layout/site-loader.tsx +++ b/src/components/layout/site-loader.tsx @@ -1,8 +1,8 @@ "use client" import { useEffect, useState } from "react" -import { motion } from "framer-motion" import { BrandedLoaderVisual } from "@/components/layout/branded-loader" +import { cn } from "@/lib/utils" const MIN_VISIBLE_MS = 650 const MAX_VISIBLE_MS = 5000 @@ -58,15 +58,16 @@ export function SiteLoader() { if (phase === "hidden") return null return ( - - + ) } diff --git a/src/features/home/components/cinematic-hero.tsx b/src/features/home/components/cinematic-hero.tsx index a529157..7d28fd8 100644 --- a/src/features/home/components/cinematic-hero.tsx +++ b/src/features/home/components/cinematic-hero.tsx @@ -189,7 +189,8 @@ function GroupVideoLoop({ > {videos.map((vid, idx) => { const isPreloadAllowed = - allowBackgroundPreload || (isPrimaryGroup && idx === 0); + (idx === 0 && (isPrimaryGroup || isActiveGroup)) || + (allowBackgroundPreload && (isActiveGroup || idx === 0)); return (