项目:react-travel | 版本:v1.1 | 生成日期:2026-10-06 | 依据:源码分析
react-travel 的性能策略可以概括为四条结构性原则,它们不是调参,而是写入代码形态的约束:能用缓存的不查库(ISR 分级:首页 60s、内容页 3600s)、必须查库的少查(React cache 共享查询、单往返 update 自读、Promise.all 并行、投影裁剪)、必须传的变小(houseToCardRoute 裁剪富文本、HOUSE_CARD_SELECT 排除 BigInt、列表 imgs take 1)、必须等的不卡(路由 loading.tsx 骨架、客户端 hasSearched 区分"未开始"与"加载中")。本篇按"渲染与缓存链路 → 搜索双链路 → 数据访问 → 网络与资源 → 前端交互 → 优化建议"的顺序走读,所有论断均给出源码位置。
性能分级全景表(路由级):
| 路由 | 渲染模式 | 缓存 | 首屏 DB 查询 | 判定依据(源码位置) |
|---|---|---|---|---|
/ | 静态 + ISR | revalidate = 60 | 60s 内 0 次;过期后台重建 | app/page.tsx 第 9 行 |
/destinations /topics /reviews | 静态 + ISR | revalidate = 3600 | 1h 内 0 次 | 各自 page.tsx 的 export const revalidate = 3600 |
/detail/[id] | 动态 RSC | 无(刻意) | 1 次(cache 共享后) | app/detail/[touristRouteId]/page.tsx 注释"不设 revalidate——浏览量自增需实时" |
/search/[keywords] | 动态 RSC | 无(刻意) | 2 次(列表 + 计数并行) | app/search/[keywords]/page.tsx 注释"搜索词多变,不设 revalidate" |
/about /help /signin /register | 纯客户端静态 | 构建期静态化 | 0 | 各 page.tsx 无数据获取 |
| 受保护交互页(购物车/订单等) | 客户端动态 | 无 | 0(SSR 阶段);数据走 API | 各 AuthGuard 薄壳 page.tsx |
/sitemap.xml | ISR | revalidate = 3600 | 1h 一次房源列表 | app/sitemap.ts 第 25 行 |
首页是全站缓存设计的样板,值得完整走读一遍时间线。T0 构建期:next build 执行 app/page.tsx,getHomeCollections() 三查询并行(爆款 showCount desc / 新品 id desc / 国内游 price desc,各 9 条),产出首版静态 HTML 与 RSC payload。T0 到 T0+60s:所有请求命中 ISR 缓存,零 DB 查询、零 React 渲染——这是首页承载突发流量的底气。T0+60s 后的第一个请求:立即返回旧 HTML(用户无感),同时后台触发重新渲染(stale-while-revalidate 语义),新版本就位后原子替换。60s 这个数值对"爆款推荐按 showCount 排序"的业务是精准的折中:足够短让新爆款及时上榜,足够长让缓存承担绝大部分读压力。
hydration 双链路是 ISR 的配套设计。SSR HTML 里已含三个集合的完整卡片 DOM(AntdRegistry 已在服务端抽取 antd 样式,无样式闪烁),initialCollections 序列化进 RSC payload;浏览器注水时 client/views/home/HomePage.tsx 检测到该 prop 直接渲染(hasInitial 语义,与详情页 initialProduct、搜索页 injectedActive 同族),不再发起任何数据请求——从 HTML 到可交互的完整路径里只有静态资源请求,没有 API 调用。降级分支同样被性能考虑覆盖:RSC 直取失败时页面返回不带 props 的 <HomePage />,recommendedFetched ref 守卫(防 StrictMode 双跑)保证回退请求只发一次,客户端 store 拉 /api/productCollections——页面慢一拍但绝不空转。这个"服务端优先、客户端兜底"的双链路在性能上的意义是:正常路径的请求数被压到理论最小值,异常路径的代价被限制为一次额外 API 调用。
ISR 的运维侧代价也要记入账本(《06-部署运行》§4.3):缓存存于进程与文件系统,重启清空,发布后首个访问会触发后台重建(期间返回构建期版本);因此发布后应主动 curl 首页与三个内容页预热,避免首批用户撞上"构建期空态库"的旧产物。目的地/专题/游记三页的 3600s 粒度则反映其内容低频变更的运营现实——这三页的数据没有"实时性"诉求(目的地/专题是运营编辑的静态内容,游记按天更新),一小时级延迟完全可接受。
搜索页没有用 ISR(词空间无限、注释明说不设 revalidate),它的性能设计集中在"RSC 首屏 + 客户端接管"的双态数据源上。首态(注入态):app/search/[keywords]/page.tsx 把 resolveKeyword(slug) 翻译后的关键词交给 Promise.all([searchHouses, countHouses])——两个查询并行,共用 buildWhere(列表与计数同源,不存在重复构造 where 的 CPU 浪费),结果经 houseToCardRoute 裁剪后以三个 props 注入。客户端 Searchpage.tsx 的 injectedActive 判定(第 59-63 行)在"URL 词 === 注入词 && 用户未交互"时直接渲染注入数据:showLoading = injectedActive ? false : loading、showSearched = injectedActive ? true : hasSearched——首屏连 loading 态都被跳过,SSR HTML 即完整结果页。次态(接管态):用户翻页/排序/筛选触发 setClientMode(true),数据源切到 useProductSearchStore,此后每次交互一次 API 调用(pageSize 固定 10),响应解析 x-pagination 头获得分页元数据——分页信息走响应头而非 body,让列表接口的 body 保持纯数组(省一层包装对象,也让裸数组语义与 imooc 形状兼容)。
slugMap 在这条链路里的性能贡献常被忽视:URL 只出现 19 个英文 slug(client/lib/slugMap.ts),导航分类链接(Header 8 个 tab、Footer、首页快捷词)全部是静态可预测的 /search/<slug>——这让 Next 的 Link 预取(prefetch)可以稳定工作,用户悬停导航 tab 时目标搜索页的 RSC 骨架已开始拉取;而中文关键词经 toSlug 的 encodeURIComponent 分支进入 URL 时仍是合法路径段,两向兼容不牺牲可缓存性。safeDecode 的"解码失败原样返回"兜底则保证畸形 URL(如 /search/100%)不会把性能问题变成 500 崩溃。
分页与排序的客户端细节也值得记录:searchSeq 序号守卫让快速连点排序时只有最后一次响应落地(无用请求照发,但 UI 不重渲染过期数据);useProductSearchStore 的 hasSearched 位把"尚未开始"与"加载中"区分开(注释:"避免首帧闪一下空状态");空关键词直接置空结果并结束 loading(第 53-56 行注释记载了旧实现"early return 但 loading 已置 true 导致永远转圈"的修复)。这些都是花小钱防大抖动的交互性能细节。
服务层有四处教科书级的查询优化。其一,列表投影常量:HOUSE_CARD_SELECT(lib/services/house.ts 第 8-21 行)注释直陈动机:"只取前端卡片真正用到的列,避免把 publishTime/startTime/endTime(BigInt) 捞出来再靠 serialize() 深拷贝转换(列表接口 QPS 最高,省一次转换 + 网络传输)"——双重收益:网络传输列数从 19+ 降到 11,且序列化免掉(serialize 是递归深拷贝,是全仓最热的 CPU 路径之一)。其二,图片 take 1:列表所有 include imgs 的地方(HOUSE_CARD_SELECT、CART_INCLUDE、listOrders/listFavorites/listViewHistory 的 include、listTopics 的 house 投影)一律 take: 1,种子数据每线路 4 图,列表场景只取封面——购物车域注释点破:"避免把每套房全量 imgs(含 createTime)都捞出来"。其三,单往返自读:getHouseDetail 用 house.update({ showCount: { increment: 1 } }, select) 把"自增 + 读取"合成一次 DB 往返(第 165-169 行注释:"update 自带返回更新后的行,'浏览量自增 + 详情读取'合并为一次查询")。其四,显式并行:getHomeCollections 三查询并行、搜索列表与计数并行、listFavorites/listViewHistory 的 list 与 total 并行(Promise.all)——串行改并行的延迟收益在 DB 往返 1-5ms 的内网环境约等于减少一半等待。
React cache 共享(详情页):const getDetailVM = cache(async (id) => ...)(app/detail/[touristRouteId]/page.tsx 第 18-24 行)让 Page 与 generateMetadata 共享同一次查询——React 的 request-scope memoization 保证同一请求内只执行一次,注释点明三重收益:"浏览量只自增一次、DB 只查一次",且 metadata 侧 catch 后回落通用标题不崩溃。配套的 layout.tsx 注释从反面记录了不用它的后果:"layout 的 generateMetadata 与 page 不共享 cache,会造成每请求两次查询且绕过浏览量去重"。序列化的源头规避:lib/services/destination.ts/topic.ts 的 select 刻意不取 createTime,注释:"避免 RSC 边界传递 Date 对象"——比"查出来再 serialize"更彻底的做法是不查。内存去重的容量管理:bumpKeys Map 上限 5 万条整体清空(lib/services/house.ts 第 149-156 行),rate-limit 桶上限 1 万条过期清扫 + 插入序淘汰——两处内存结构都设了防爆栅栏,性能保护自身不成为内存泄漏源。
图片策略是"本地 webp + 原生 img + 懒加载"的务实组合。全部线路图/头像统一为 public/travel-img/<unsplash-id>.webp(约 74 张,scripts/download-images.mts 按 w=800&q=80&fm=webp 参数预生成——图片在构建前就被压缩为 800 宽的 webp,而非运行时按设备动态出图),DB 存相对路径(VarChar 500),无图时 VM 层兜底 /logo192.png(lib/vm.ts 第 7 行 FALLBACK_IMG)。视图侧全部使用原生 <img loading="lazy">(DetailPage 画廊第 151/161 行、TourCard 第 75 行),首屏外图片滚动到视口才加载;首页轮播三张背景图走 CSS background,属首屏必需不懒加载。刻意不用 next/image:images.disableStaticImages: true 的注释写明这是为对齐 CRA 语义让旧组件零改动——代价是放弃了 Next 的自动 srcset/AVIF/尺寸优化;以当前"预压缩 webp + 800px 展示宽"的组合,图片单张约 60-150KB,该取舍在桌面为主的场景下损失有限,移动端弱网场景是《13-技术债务与演进建议》记录的潜在优化点。
其余静态资源:webpack 把 png/jpg/gif/svg/ico 按 asset/resource 产出为 static/media/[name].[hash:8][ext](文件名含 hash → 可长缓存);public/ 下的 favicon/logo192/travel-img 由 Next 直接服务,部署建议(《06-部署运行》§6.2)让 Nginx 对 /travel-img/* 回源缓存——静态图完全不必经过 Node 进程。分享图 app/opengraph-image.tsx 用 next/og 的 ImageResponse 在请求时用 Satori 渲染 1200×630 PNG(渐变背景 + 文字排版),因为无外部图片依赖且内容固定,Next 会对其做生成缓存,成本一次性。第三方脚本只有 Umami 统计一处,defer 加载(app/layout.tsx 第 82-86 行)不阻塞解析;/api/commons/citys 的外部 fetch 带 next: { revalidate: 86400 }(app/api/commons/citys/route.ts 第 11 行注释"城市数据变动极少,缓存 1 天")——外部调用也被 Next 的 fetch 缓存层兜住,每进程每天至多一次真实外呼。
三个方向的状态管理设计直接服务渲染性能。订阅粒度:7 个 Zustand store 全部用 selector 订阅(useCartStore((s) => s.items) 而非整个 store),useIsFavorited hook 特意"返回 boolean,引用稳定,避免数组依赖导致的重渲染"(useFavoriteStore.ts 第 82-85 行注释)——列表页几十张卡片只在自己收藏态变化时重渲染。单一收藏源替代逐卡请求:登录后 SessionLoader 一次性拉 /api/favorites/ids,几十张卡片共享一个 id 集合,替代了旧实现"每张卡片各发一个 isFavorited 请求"的 N+1 网络模式(TourCard 第 42-44 行注释记载了这次修复)。乐观更新:收藏 toggle 与评论点赞都是"UI 先行、请求跟进、失败回滚",用户感知延迟为零;pending 字典合并同房源并发点击防抖。序列化体积意识贯彻到 RSC props:houseToCardRoute 的注释算过账——剥离 features/fees/notes 三个富文本大字段(每条数 KB 的 HTML)"减小 RSC 边界序列化与 SSR HTML 体积",搜索首页 10 条结果意味着 SSR 传输立省数十 KB。
请求时序方面的细节:useHotKeywords 的 300ms 防抖 + 卸载时 seqRef++ 丢弃在途响应;installAuthInterceptor 的 installed 守卫防 HMR 重复注册拦截器(重复注册会导致每个请求带多个 Authorization 头与双倍拦截开销);SessionLoader 用 hydrated && token 的组合依赖避免未水合时抢先发请求。React 层面无 memo/useMemo 滥用(组件树浅、antd 组件自身优化足够),filtersRef 模式(Searchpage.tsx 第 74-75 行)用 ref 读取最新筛选值避免 stale closure 引发的多余重查——这些是"少即是多"的范例。
以下为源码走读后识别的可行优化,均不改变架构,标注预期收益与主要成本:
<Image> + 已有 remotePatterns(需同时按《08-安全与权限设计》§7.3 收紧),获得自动 srcset 与按设备出图;travel-img 本地路径可直接受益于内置优化器。移动端 LCP 预计明显改善。unstable_cache/"use cache" 缓存详情 VM(tag 按线路 id),浏览量自增移到客户端 POST /api/touristRoutes/:id/view 轻端点。热门详情页从每次查询变成缓存命中。 ⚠️ 待确认:Next 15.1 版本下 use cache 的稳定性,必要时用 unstable_cache。name/tripType/departureCity 建 pg_trgm GIN 索引(《04-数据模型》§5 已标注待确认项);showCount 排序同理考虑表达式索引。useReportWebVitals,一个客户端组件的成本)以验证效果。