我重写了一个 NBA 数据网站 — 8800 行到 30000 行做了什么

0 阅读17分钟

NBA Tracker v2

一次时区 bug,让我把整个项目重写了一遍。从 8800 行到 30000 行,46 个页面,10 张架构图。

🎯 本文你会看到

  • UI 改造的全流程:对阵图怎么从堆叠卡片变成树状结构,顶部菜单为什么换成 Command Palette,详情页加了什么细节
  • 拆分 911 行单文件组件的实战:BracketTree.tsx 怎么拆成 7 个文件,从"打开就头晕"变成"单元认知负担可控"
  • 现代 Web 平台真实使用案例:Speculation Rules、:has() 选择器、滚动驱动动画、容器查询——以前要 JS 才能做的事,现在 CSS 一行搞定
  • Service Worker 分桶缓存:装到主屏的 PWA + 断网能用的真离线
  • 数据准确性陷阱:为什么我的"NBA 历史排行榜"第一名居然是 Luka Dončić 而不是乔丹
  • 几个工程教训:field 的名字 ≠ field 的含义,小 bug 是冰山

📦 技术栈:Next.js 16 · React 19 · Tailwind 4 · TypeScript 5 · Service Worker · Puppeteer (用于自动化 demo 录制)

🔗 线上演示:nba.xpy.me · 完整源码:github.com/fxy2026/nba…


起因

上一篇写到 NBA Tracker 第一版:8800 行、16 个页面、纯 SVG 投篮图、Suspense 流式渲染。当时的目标是把功能堆起来——比分、Box Score、投篮图、季后赛对阵——能跑就行。

跑了一段时间之后,越用越觉得糙。不是哪一处特别坏,而是处处差一口气:

  • 季后赛对阵图是垂直堆叠的大卡,看不出 1v8 / 4v5 的层级
  • 球队页的点差走势图配色像 PPT 默认样式
  • 顶部菜单的下拉框内容多了之后会溢出屏幕外,根本点不到
  • 手机端的「更多」弹层撑出屏幕,按住屏幕滚动的居然是下层页面

这些都不是阻断功能的 bug,但每次打开都膈应。

直到我在 Claude Code 的 skill 库里看到一个叫 UI/UX Pro Max 的 skill——专门做大厂级别的 UI 审计和重构建议。试着让它扫了一遍站点,回来一份清单:30+ 个具体问题,从间距、字号、配色到信息架构。

于是有了这次重写。


一、对阵图:从堆叠卡片到树状结构

第一版的季后赛对阵图是按"轮"竖着堆 8 个系列的卡片,看不出 4 对阵的层级关系——你看不出 OKC 打的是太阳还是火箭,因为它们都只是排在第一轮的某个位置。

UI/UX Pro Max 给的建议:

  • 用真正的树状结构,SVG 连接线把上一轮的胜者连到下一轮
  • 进度点显示系列赛打了几场(best of 7,1-0 / 2-0 / 4-0)
  • 已经晋级的球队预先填到下一轮的位置——OKC 4-0 横扫之后,下一轮"OKC vs ???"直接标出来
graph TB
    R1A1["首轮 1v8<br/>OKC vs PHX"]
    R1A2["首轮 4v5<br/>HOU vs LAL"]
    R1B1["首轮 2v7<br/>DEN vs LAC"]
    R1B2["首轮 3v6<br/>MEM vs MIN"]

    R2A["半决赛<br/>OKC vs ???"]
    R2B["半决赛<br/>??? vs ???"]

    CF["西部决赛"]

    R1A1 -->|"OKC 4-0"| R2A
    R1A2 -.->|"进行中"| R2A
    R1B1 -.-> R2B
    R1B2 -.-> R2B
    R2A --> CF
    R2B --> CF

    style R1A1 fill:#d4edda,stroke:#28a745,color:#1a1a2e
    style R2A fill:#fff3cd,stroke:#ffc107,color:#1a1a2e
    style CF fill:#cce5ff,stroke:#0d6efd,color:#1a1a2e

每个系列卡片可以点进去,跳到独立的系列赛详情页(这是新加的路由):

系列赛详情页

页面包含逐场战果、双方场均、最大胜差、关键球员排行——把整个 best-of-7 拍扁到一个页面看。

拆掉 911 行的怪物

UI 重写需要触碰原来的 BracketTree.tsx——911 行单文件。打开就头晕:

graph TB
    Old["BracketTree.tsx<br/>911 行 · 单文件"]
    Old --- F1["纯函数 ×6<br/>parseGameId<br/>projectFutureSeries<br/>winnerOf<br/>isOnChampionPath<br/>..."]
    Old --- C1["组件 ×7<br/>TeamRow<br/>SeriesCard<br/>CandidatesRow<br/>ProgressDots<br/>Connector<br/>RoundLabel<br/>ConfHalf"]
    Old --- C2["布局 ×2<br/>桌面 SVG 树状<br/>移动按分区堆叠"]

    style Old fill:#f8d7da,stroke:#dc3545,color:#1a1a2e
    style F1 fill:#fff3cd,stroke:#ffc107,color:#1a1a2e
    style C1 fill:#fff3cd,stroke:#ffc107,color:#1a1a2e
    style C2 fill:#fff3cd,stroke:#ffc107,color:#1a1a2e

React 组件、纯函数、桌面布局、移动布局全混在一起。任何一处微调都要在 900 多行里找位置。

UI/UX Pro Max 的建议里有一条隐含的工程要求:视觉结构应该映射到代码结构。一个 series card 就该有一个 SeriesCard.tsx;一组 SVG 连接线就该有一个 Connector.tsx

拆完变成 7 个文件:

graph TB
    Root["BracketTree.tsx<br/>163 行 · 组合入口"]

    Root --> Mobile["BracketMobile.tsx<br/>83 行"]
    Root --> Desktop["BracketDesktop.tsx<br/>100 行"]

    Mobile --> Card["SeriesCard.tsx<br/>220 行"]
    Desktop --> Card
    Desktop --> Conn["Connector.tsx<br/>50 行"]
    Desktop --> Half["ConfHalf.tsx<br/>184 行"]

    Card --> Lib["lib/playoffs.ts<br/>198 行<br/>纯函数 + 类型"]
    Half --> Lib

    style Root fill:#cce5ff,stroke:#0d6efd,color:#1a1a2e
    style Lib fill:#d4edda,stroke:#28a745,color:#1a1a2e
    style Mobile fill:#fff3cd,stroke:#ffc107,color:#1a1a2e
    style Desktop fill:#fff3cd,stroke:#ffc107,color:#1a1a2e
    style Card fill:#fff3cd,stroke:#ffc107,color:#1a1a2e
    style Conn fill:#fff3cd,stroke:#ffc107,color:#1a1a2e
    style Half fill:#fff3cd,stroke:#ffc107,color:#1a1a2e

拆分原则

  • 纯函数下沉到 lib/:无 React、无 JSX 的代码全部抽到 lib/playoffs.ts,纯 TS
  • 一个组件一个文件:SeriesCard / Connector / ConfHalf / BracketMobile / BracketDesktop
  • 视觉零变化:CSS 类名、prop 形状一个字符都不改

同样的思路套到 game/[id]/page.tsx1026 行 → 243 行 + 13 个 _components/)和 team/[tricode]/page.tsx786 行 → 295 行 + 6 个 _components/)。

🎯 Next.js 的 _components/ 约定:下划线开头的文件夹不会被识别为路由。只在某个页面用到的子组件就该和它一起住,不污染全局 src/components/ 命名空间。

拆分的收益不是行数减少——是单元的认知负担降低。以后维护这个区域不需要把 900 行装进脑子。


二、顶部菜单:从下拉框到 Command Palette

第一版的顶部「更多」按钮是个 hover 出来的下拉框。问题是数据多了之后,菜单太长——浏览器视口不够高的时候,下面那截直接被屏幕底部截断,点都点不到

UI/UX Pro Max 的建议:改成 Command Palette 风格的居中模态。原因:

  • 不依赖锚点定位,永远居中显示
  • 内容多了自带滚动条,不会被截断
  • 可以加搜索框(输入"战力"立刻定位到"战力榜")
  • 键盘导航(↑↓ Enter Esc)天然友好

实现要点:用 createPortal 把模态挂到 document.body,逃离 Navbar 的 containing block;搜索过滤用 O(1) Map 而不是每次 indexOf。焦点陷阱(Tab/Shift+Tab 在模态里循环)和打开时 body scroll lock 也一起做了。

桌面端搞定之后,手机端也是同一个问题。

手机端「更多」的修复

手机底栏的「更多」按钮原本弹出的是个底部抽屉。在手机上点开发现——弹层顶部撑到了状态栏,下面那点点也看不全。手指按住屏幕想往下滚,结果滚的是下层页面,弹层本身一动不动:

graph TB
    BUG["弹层结构<br/>position: absolute<br/>bottom: 56px<br/>没有 max-height<br/>没有 overflow-y-auto"]
    BUG --> P1["内容 4 个 section ×~10 项<br/>总高度 > 屏幕高度"]
    BUG --> P2["body 没设 overflow: hidden"]
    P1 --> R1["内容顶部溢出屏幕<br/>被 Navbar 挡住<br/>下面那点点也看不全"]
    P2 --> R2["touch 事件穿透到下层<br/>滚的是 body<br/>不是弹层"]
    R1 --> X["用户体验:抓狂"]
    R2 --> X

    style BUG fill:#f8d7da,stroke:#dc3545,color:#1a1a2e
    style X fill:#f8d7da,stroke:#dc3545,color:#1a1a2e
    style P1 fill:#fff3cd,stroke:#ffc107,color:#1a1a2e
    style P2 fill:#fff3cd,stroke:#ffc107,color:#1a1a2e

更深的问题是两套实现两套翻译:桌面 Navbar 的「更多」菜单已经全中文翻译完了,手机的 MobileNav 重新写了一套英文硬编码版本。维护两份必然漂移。

修法:抽到共享 hook,桌面手机都用同一个 CommandPalette:

graph TB
    F0["从底部抽屉<br/>切换为<br/>居中模态(CommandPalette)"]
    F0 --> F1["桌面和手机<br/>共享 useMoreGroups hook<br/>单一翻译源"]
    F0 --> F2["弹层用 createPortal<br/>逃离 z-index 容器块"]
    F0 --> F3["焦点陷阱 + body scroll lock<br/>从 CommandPalette 继承"]
    F0 --> F4["内置搜索框<br/>键盘导航 ↑↓ Enter Esc"]

    F1 --> OK["✓ 全中文<br/>✓ 不被 Navbar 挡<br/>✓ 滚动正常<br/>✓ 可搜索"]
    F2 --> OK
    F3 --> OK
    F4 --> OK

    style F0 fill:#cce5ff,stroke:#0d6efd,color:#1a1a2e
    style OK fill:#d4edda,stroke:#28a745,color:#1a1a2e

src/lib/useMoreGroups.ts 成为单一数据源:

export function useMoreGroups(): PaletteGroup[] {
  const { t, locale } = useLocale();
  const isZh = locale === "zh";

  return useMemo<PaletteGroup[]>(() => [
    {
      title: isZh ? "联赛排序" : "League Order",
      eyebrow: isZh ? "排名" : "Standings",
      color: "#FFD700",
      items: [
        { href: "/conference-race", label: isZh ? "分区赛" : "Conference Race", icon: Trophy },
        { href: "/divisions", label: isZh ? "六分区" : "Divisions", icon: MapIcon },
        { href: "/power-rankings", label: isZh ? "战力榜" : "Power Rankings", icon: Crown },
        // ...
      ],
    },
    // ... 4 个分类共 35 个链接
  ], [t, isZh]);
}

桌面 Navbar 和手机 MobileNav 都 const moreGroups = useMoreGroups();,传给同一个 <CommandPalette> 组件。

修弹层本身的细节:

// Body scroll lock — iOS Safari 会高高兴兴地穿透弹层滚下层页面
useEffect(() => {
  if (!moreOpen) return;
  const prev = document.body.style.overflow;
  document.body.style.overflow = "hidden";
  return () => { document.body.style.overflow = prev; };
}, [moreOpen]);

几个细节:

  • 100dvh 而不是 100vh:动态视口高度,会考虑浏览器地址栏
  • env(safe-area-inset-bottom):iOS 刘海屏的底部安全区
  • overscrollBehavior: contain:阻止滚动链——弹层滚到底再继续滑不会传到 body
  • touchAction: pan-y:允许垂直拖动,禁止水平滑动

副作用是删了 88 行重复代码。


三、详情页布局:信息层级和留白

球员页 / 球队页 / 比赛页是站点流量最高的三个详情页。UI/UX Pro Max 审完给了几个共性的反馈:

  • 顶部要有 breadcrumbs:用户进来之前是从哪里来的,不要让他用浏览器回退
  • 每个数据 section 要有"路标":左侧 1px 高 3px 宽的小竖条 + 9px 字号的眉头("/ Box Score" 这种),不抢主信息但能让眼睛分清楚
  • 底部要有"继续探索"卡片:5-6 个相关链接,让用户不会撞墙
  • 数据新鲜度要可见:每页标题下显示"X 分钟前更新"

Breadcrumbs + UpdatedPill

RelatedPages 卡片网格

实现一个 <PageHeader> 接受可选的 updatedAt prop,下面挂一个 <UpdatedPill> 实时更新:

interface PageHeaderProps {
  eyebrow?: string;
  icon?: LucideIcon;
  title: string;
  subtitle?: string;
  action?: React.ReactNode;
  updatedAt?: number | null;  // ms since data was fetched
}

updatedAt 接收 getScheduleAge() 的返回值(schedule cache 距今 ms 数)。挂到 17 个 schedule-derived 页面后,用户在每页标题下都能看到"X 分钟前更新"——对实时性的预期被校准。

新加 <Breadcrumbs><RelatedPages> 两个共享组件,应用到 33 个详情和分析页

// 详情页顶部
<Breadcrumbs items={[
  { label: round.full, href: "/" },
  { label: `${team1.tricode} vs ${team2.tricode}` },
]} />

// 详情页底部
<RelatedPages
  eyebrow={isZh ? "继续探索" : "Keep exploring"}
  pages={[
    { href: `/team/${home}`, label: "球队主页", icon: Users },
    { href: `/h2h?t1=${home}&t2=${away}`, label: "历史交锋", icon: GitCompareArrows },
    // ... 5-6 个上下文相关链接
  ]}
/>

100% 覆盖意味着:用户在任意一个数据视图都能跳到相关的 4-6 个视图。SEO 也会受益——内部链接图密度上升。


四、首页:滚动条 + 最近浏览 + 数据流标识

UI/UX Pro Max 对首页的建议很具体:

  • 顶部加 2 像素滚动进度条,跟着滚动位置走,给长页面一个进度感
  • 保留访问历史:把用户最近浏览的球员 / 球队 / 比赛缩到首页中部一条横向滚动
  • 第一次访问的人看不到访问历史块——空状态不显示

滚动驱动进度条 + UpdatedPill

进度条用纯 CSS 滚动驱动动画实现:

.scroll-progress-rail::after {
  content: "";
  display: block;
  height: 100%;
  background: var(--gradient-accent);
  transform-origin: 0 50%;
  animation: scroll-progress-grow linear;
  animation-timeline: scroll(root);  /* ← 关键 */
}
@keyframes scroll-progress-grow {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

零 JS、零 scroll listener、零 jank。Chrome 115+ 原生支持,老浏览器静默忽略——进度条不显示,但不报错。

之前类似效果需要:

window.addEventListener("scroll", () => {
  const progress = window.scrollY / (document.body.scrollHeight - window.innerHeight);
  bar.style.transform = `scaleX(${progress})`;
}, { passive: true });

每次滚动事件都触发布局抖动。CSS 自己搞定后,浏览器优化掉合成层。

最近浏览的实现:localStorage 存最近 12 条访问,每个详情页挂一个零 UI 的 <RecentVisitTracker>

// src/lib/recentlyViewed.ts
export function recordVisit(kind: RecentKind, id: string, label: string): void {
  const items = read();
  const filtered = items.filter((it) => !(it.kind === kind && it.id === id));
  const next: RecentItem = { kind, id, label, ts: Date.now() };
  const sameKind = filtered.filter((it) => it.kind === kind).slice(0, 11);
  const otherKinds = filtered.filter((it) => it.kind !== kind);
  write([next, ...sameKind, ...otherKinds].sort((a, b) => b.ts - a.ts));
}

首页有 <RecentlyViewed> 横向滚动条显示最近 8 条。首次访问时 localStorage 空,整个组件 return null,不显示空状态——避免空状态比展示空状态体面

最近浏览卡片


五、现代 CSS:那些以前要 JS 才能做的事

UI/UX Pro Max 的几条建议涉及现代 CSS 能力,这几年的浏览器普及度已经够了。

text-wrap: balance

h1, h2, h3 { text-wrap: balance; }
p { text-wrap: pretty; }

让标题断行时计算最佳分布,避免"最后一行就一个字"。一行 CSS,全站质感拉满。Chrome 114+ / Edge 114+ / Safari 17.4+ 已支持。

:has() 选择器

之前要做"父元素响应子元素状态"必须 JS:

<div className={isChildHovered ? "active" : ""} onMouseEnter={...}>
  <Link onMouseEnter={() => setIsChildHovered(true)}>...</Link>
</div>

现在 CSS:

/* 卡片含 Link/Button 在 hover 时整张卡片提亮 */
.glass-tile:has(a:hover),
.glass-tile:has(button:hover) {
  border-color: var(--border-strong);
  box-shadow: var(--shadow-elevated);
}

/* 卡片内任意子元素 focus-visible 时显示焦点环 — 键盘用户友好 */
.glass-tile:has(:focus-visible) {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

Chrome 105+ / Edge 105+ / Safari 15.4+ / Firefox 121+ 支持,覆盖率足够。

容器查询

.cq-grid > * { container-type: inline-size; }

@container (max-width: 320px) {
  .glass-tile.cq-adapt { padding: 12px; }
  .glass-tile.cq-adapt > .cq-row {
    flex-direction: column;
    gap: 8px;
  }
}

同一个卡片放在三列网格里和放在单列侧栏里自动呈现不同密度。区别于 media query:媒体查询跟视口走,容器查询跟父容器宽度走


六、PWA:装到主屏 + 离线能用

UI/UX Pro Max 对 PWA 的清单:manifest 完整化、安装提示、离线指示器、Service Worker 真离线。

Service Worker 分桶缓存

public/sw.js 按请求类型分桶缓存:

flowchart TB
    Req[/fetch event/] --> Method{"GET?"}
    Method -->|No| Pass1["passthrough"]
    Method -->|Yes| API{"路径 /api/*?"}
    API -->|Yes| Pass2["passthrough<br/>直播比分必须新鲜"]
    API -->|No| Mode{"request.mode"}

    Mode -->|"navigate (HTML)"| Nav["network-first<br/>失败 → 缓存 → / shell"]
    Mode -->|其他| Bucket{"URL 类型"}

    Bucket -->|"/_next/static/<br/>字体 + 图标"| Static["cache-first<br/>内容哈希永不过期"]
    Bucket -->|"cdn.nba.com<br/>头像 + logo"| Image["stale-while-revalidate<br/>立刻返回缓存<br/>后台刷新"]
    Bucket -->|其他| Pass3["passthrough"]

    style Pass2 fill:#fff3cd,stroke:#ffc107,color:#1a1a2e
    style Nav fill:#cce5ff,stroke:#0d6efd,color:#1a1a2e
    style Static fill:#d4edda,stroke:#28a745,color:#1a1a2e
    style Image fill:#e0d4f5,stroke:#A855F7,color:#1a1a2e

代码:

self.addEventListener("fetch", (event) => {
  const req = event.request;
  if (req.method !== "GET") return;

  const url = new URL(req.url);
  if (url.pathname.startsWith("/api/")) return;  // 实时数据不拦截

  if (req.mode === "navigate") {
    event.respondWith(
      fetch(req)
        .then((res) => {
          if (res.ok) caches.open(CACHE_PAGES).then((c) => c.put(req, res.clone()));
          return res;
        })
        .catch(() => caches.match(req).then((c) => c || caches.match("/")))
    );
    return;
  }

  // _next/static 走 cache-first,cdn.nba.com 走 SWR...
});

设计要点:

  • /api/* 永远 passthrough:直播比分不能缓存
  • HTML network-first + 离线 shell fallback:在线时永远拿新数据;断网时拿缓存或 / 首页
  • _next/static/ cache-first:Next 构建的资源 URL 带内容哈希,可以无限期缓存
  • 图片 stale-while-revalidate:先显示缓存,后台刷新
  • 版本化缓存CACHE_VERSION = "v1",每次 breaking change bump。activate 时清掉旧版本,避免新部署后老 chunk 僵尸服务

iOS Safari 特殊处理:那个浏览器永远不会触发 beforeinstallprompt。所以 InstallPrompt 组件做 UA 检测,遇到 iOS 不显示 Install 按钮,改为弹引导:"点击 Safari 分享按钮 → 添加到主屏幕"。

Speculation Rules:预测性导航

Chrome 122+ 加了新 API,能告诉浏览器哪些 URL 值得预先 prefetch 或者 prerender:

sequenceDiagram
    autonumber
    participant U as 用户
    participant P as 当前页面
    participant B as 浏览器
    participant N as 下一页(后台)

    U->>P: 打开首页
    Note over P: 嵌入<br/>SpeculationRules<br/>script
    B->>B: 立即 prefetch<br/>列表 URL<br/>(/standings /stats...)

    U->>P: 鼠标停留在<br/>"湖人队"链接上
    Note over B: hover ~200ms
    B->>N: 后台 prerender<br/>/team/LAL<br/>完整 RSC stream

    U->>P: 点击链接
    Note over P,N: 切换瞬时<br/>页面已渲染好
    P-->>U: ✓ 0ms 切换
const rules = {
  prefetch: [
    { source: "list", urls: ["/", "/standings", "/stats", "/search", "/calendar"] },
  ],
  prerender: [
    {
      source: "document",
      where: {
        and: [
          { href_matches: "/*" },
          { not: { href_matches: "/admin*" } },
          { not: { href_matches: "/api/*" } },
        ],
      },
      eagerness: "moderate",  // hover ~200ms 时 prerender
    },
  ],
};

return <script type="speculationrules" dangerouslySetInnerHTML={{ __html: JSON.stringify(rules) }} />;

两个层级:

  • prefetch:固定的 5 个高频入口一次性 prefetch
  • prerender + eagerness: "moderate":鼠标 hover 200ms 后浏览器在后台 prerender 任意站内非 /admin 非 /api 链接

不支持的浏览器忽略整个 script tag,零副作用。比手写 Link.prefetch 强一档。


七、数据准确性陷阱:playerIndex 在骗你

UI 改完之后,开始有人能注意到数据层面的问题——以前 UI 太糙,没人能看清数据本身错没错。

打开 /all-time-leaders,按"生涯场均得分"排序。前几名:

1. Luka Dončić    33.5 PPG
2. SGA            31.1
3. Anthony Edwards 28.8

但 NBA 历史上能场均超过 30 分的只有两个人:乔丹 30.12、张伯伦 30.07。Luka 上赛季 33.5 是他一个赛季的数据,不是生涯。

排查代码,数据源是 NBA CDN 的 playerIndex.json,取 pts/reb/ast 三个字段:

graph LR
    PI["NBA playerIndex.json<br/>字段: pts / reb / ast"]
    PI -->|"字段名暗示"| EXPECT["以为是<br/>'生涯均值'<br/>'所有球员'"]
    PI -->|"实际含义"| REAL["实际是<br/>'上赛季均值'<br/>'仅现役球员'"]

    EXPECT -.->|"这种期待<br/>导致"| BUG["all-time-leaders 显示<br/>Luka 33.5 PPG 第一<br/>乔丹张伯伦都不存在"]

    style EXPECT fill:#f8d7da,stroke:#dc3545,color:#1a1a2e
    style REAL fill:#d4edda,stroke:#28a745,color:#1a1a2e
    style BUG fill:#fff3cd,stroke:#ffc107,color:#1a1a2e

两个隐藏坑:

  • playerIndex 只包含现役球员。乔丹 2003 年退役不在里面,张伯伦 1973 年退役更不在
  • pts 字段是球员上一个完整赛季的场均,不是生涯均值

stats.nba.com 的历史职业端点被 CORS 卡死,Vercel IP 也被拒,走 API 路径不通。最后的修法:手工录入静态历史榜单

// src/lib/allTimeLeaders.ts
export const ALL_TIME_LEADERS: AllTimeLeader[] = [
  { personId: 0, name: "Michael Jordan", fromYear: 1984, toYear: 2003, active: false, team: "CHI",
    ppg: 30.12, rpg: 6.2, apg: 5.3, spg: 2.3, bpg: 0.8,
    totalPts: 32292, totalReb: 6672, totalAst: 5633, totalStl: 2514 },
  { personId: 0, name: "Wilt Chamberlain", fromYear: 1959, toYear: 1973, active: false, team: "LAL",
    ppg: 30.07, rpg: 22.9, apg: 4.4,
    totalPts: 31419, totalReb: 23924, totalAst: 4643 },
  // ... 共 45 条
];

真历史排行

类似的"标签 vs 数据"不一致还在 5 个页面里发现:

页面错误标签实际数据修法
/rookie-watch"本赛季新秀"上赛季新秀按 draftYear 过滤 + 显式说明
/milestones"Career Milestones"用上赛季均推算的生涯总和改名"生涯轨迹追踪",明确是投影
/awards-race ROY"Rookie of the Year"所有联赛球员(老兵霸榜)加 rookie 过滤器
/by-position/country/college球员场均上赛季均值标签后加"· 上赛季"限定

⚠️ Bug 让用户感知到问题(页面挂了)。标签错了让用户带着错误信息走。后者更难发现,影响更大。


八、时区:一个 bug 渗透到全栈

修 UI 期间发现的另一个深层问题。中国用户北京时间 5/17 早上 10 点打开网站,看到顶部写着"今天 5/16":

sequenceDiagram
    autonumber
    participant U as 浏览器<br/>(北京 5/17)
    participant S as Vercel Server
    participant A as NBA CDN

    U->>S: GET /
    Note over S: new Date() → UTC
    Note over S: formatDate() 强转 ET<br/>得到 "2026-05-16"
    S->>A: 拉取赛程
    A-->>S: 赛程数据<br/>用 ET 日期编码
    Note over A: "5/16" 那场实际是<br/>北京 5/17 早上的比赛
    S-->>U: initialDate = "2026-05-16"
    Note over U: 显示"今天 5/16"
    Note over U: 用户:"今天明明是 5/17!"

直接原因:NBA 官方赛程用美东时间编码。北京时间凌晨开打的比赛,在赛程数据里写的是前一天。但服务端代码强转 ET 算"今天",于是中国用户看到的"今天"是 ET 的今天(北京的昨天)。

修法:把"用户的本地时区"当成 first-class concept 沿请求链一路传:

// src/lib/timezone.ts
export function localTz(): string {
  try {
    return Intl.DateTimeFormat().resolvedOptions().timeZone || "America/New_York";
  } catch {
    return "America/New_York";
  }
}

export function dateInTz(d: Date, tz: string = localTz()): string {
  return new Intl.DateTimeFormat("en-CA", {
    timeZone: tz,
    year: "numeric", month: "2-digit", day: "2-digit",
  }).format(d);
}

然后 /api/games 接受 ?tz=Asia/Shanghai,扫全赛季日程,把每场比赛的 UTC 开球时间换算到用户时区,看它落在哪一天。修完之后的数据流:

sequenceDiagram
    autonumber
    participant U as 浏览器<br/>(北京 5/17)
    participant C as HomeClient
    participant API as /api/games

    U->>C: 首次挂载
    Note over C: Intl.DateTimeFormat()<br/>.resolvedOptions().timeZone<br/>→ "Asia/Shanghai"
    C->>API: GET ?date=2026-05-17<br/>&tz=Asia/Shanghai
    Note over API: 扫全赛季<br/>把每场 UTC 开球时间<br/>换算到上海时区<br/>取落在 5/17 的
    API-->>C: 北京 5/17 当天的<br/>所有比赛
    C-->>U: 显示"今天 5/17"<br/>+ 6 场比赛

修复扩散到 DateNav.tsxGamesList.tsx/api/calendar/app/calendar/page.tsx 等多处。两个分离的概念不能混:

  • "NBA 的今天":ET today。用于 live scoreboard endpoint
  • "用户的今天":local tz today。用于显示哪一格高亮、/api/games?date= 该取哪天的比赛

九、搜索:用 230+ 别名兜底中文外号

第一版搜"字母哥"返回 0 结果。

UI 上"搜索"是用户最高频的入口之一,但只能搜英文罗马名是个硬伤——中文外号、英文外号、传奇球员的中英文称谓、球队名都应该能搜到。

新加 src/lib/playerAliases.ts230+ 条别名映射

export const PLAYER_ALIASES: Record<string, string> = {
  // 中文外号
  "字母哥": "antetokounmpo",
  "大胡子": "harden",
  "司机": "nowitzki",
  "蚂蚁": "anthony edwards",
  "胖虎": "williamson",
  "乐邦": "lebron james",
  "詹皇": "lebron james",
  // 英文外号
  "king james": "lebron james",
  "greek freak": "antetokounmpo",
  "chef curry": "curry",
  // 传奇
  "乔丹": "michael jordan",
  "篮球之神": "michael jordan",
  "曼巴": "bryant",
  "黑曼巴": "bryant",
  // ... 230+ 条
};

export function expandQuery(qLower: string): string[] {
  const expansions = new Set<string>();
  expansions.add(qLower);
  const exact = PLAYER_ALIASES[qLower];
  if (exact) expansions.add(exact);
  for (const key of Object.keys(PLAYER_ALIASES)) {
    if (qLower.includes(key)) expansions.add(PLAYER_ALIASES[key]);
  }
  return [...expansions];
}

/api/searchexpandQuery() 把查询展开成多个候选词,OR 起来匹配。另外加了球队名直搜——搜"湖人"或"Lakers"返回整队现役球员:

const matchedTeams = new Set<string>();
for (const [tri, meta] of Object.entries(TEAM_META)) {
  const haystack = `${meta.city} ${meta.name} ${tri}`.toLowerCase();
  if (queries.some((qq) => haystack.includes(qq))) matchedTeams.add(tri);
}

搜索"字母哥"返回 Giannis


十、词汇表 + Quiz:内容深度

/glossary 第一版只有英文。扩到 82 个词条,全部翻译成虎扑式中文("协防 / 护框者 / 退守战术 / 横扫 / 附加赛 / 双双 / 三双 / 接球投篮"),加了"阵容与战术"和"交易与名单"两个新分类:

中文词汇表

/quiz 第一版有 3 个模式:看头像猜人 / 看数据猜人 / 猜球队。加了第 4 个:"猜历史名人"。从那 45 位 GOAT 里随机出题,给生涯均值让你 4 选 1:

Legend Quiz 模式

看到一组 30.07 / 22.9 / 4.4 / 退役 能马上认出是张伯伦——22.9 篮板是大杀器,除了张伯伦只有比尔拉塞尔 22.5。


数据对比:从第一版到现在

指标第一版现在
代码行数 (TS/TSX)8,80030,000+
路由1646
React 组件3971
API endpoint1316
lib 模块~520
词汇表条目(中英)4782
球员搜索别名~0230+
有 RelatedPages 的页面433(100%)
有 Breadcrumbs 的页面124
有 error.tsx 的路由16

体验层面:

  • 装到主屏 → 断网能看 → 联网无感更新
  • 中英双语全覆盖,搜索支持中英文别名 + 球队名直搜
  • A11y 焦点陷阱 / aria-label / SVG role="img" / 屏幕阅读器友好
  • 44px 触控目标 + iOS 安全区 + 反穿透
  • 33 个数据页底部都有"继续探索",没死胡同
  • 滚动条 / "X 分钟前"标签 / 最近浏览 / Toast 反馈 / Web Vitals 监控

八条工程教训

写到最后,挑几条印象最深的:

  1. field 的名字 ≠ field 的含义。一个叫 pts 的字段可能是生涯均值,可能是上赛季,也可能是当前赛季。名字从来不会告诉你它实际是什么——永远验证 schema。
  2. 小 bug 是冰山。手机端「更多」菜单滚不动这种小问题,往往挂着 body scroll lock 缺失、max-height 没设、overscrollBehavior 没配置、两套实现两份翻译漂移一整套问题
  3. 本地时区是 first-class concept。函数签名上写出来,不要让"哪个 timezone"成为隐含的、由调用者随机决定的参数。
  4. 拆分的最大收益不是行数减少。是单元的认知负担降低——以后维护这个区域不需要把 900 行装进脑子。
  5. CSS 已经能做你以为只能 JS 做的事:has()、容器查询、滚动驱动动画、text-wrap: balance 都是这两年的新能力。别再写 scroll listener 算进度条了。
  6. Service Worker 不可怕。坚持几个原则:/api/* 不拦截 / HTML network-first / hashed static 永久缓存 / 版本化 purge。
  7. PWA 是免费的护城河。一份代码同时是网站 + iOS app + Android app + 桌面 PWA。
  8. 标签错比 bug 还可怕。Bug 是用户能感知到问题。标签错是用户带着错误信息走。

现在试试

线上地址:nba.xpy.me

代码(完全开源):github.com/fxy2026/nba…

试试这些:

  • /all-time-leaders 切换"生涯总得分" → 看到詹姆斯 42184 / 贾巴尔 38387 / 卡尔马龙 36928
  • /glossary 切中文 → 82 个篮球术语全中文
  • /search 输入"字母哥"或"乐邦" → 立即命中
  • 任意比赛/球员/球队详情页底部 → "继续探索"卡片
  • 手机访问 → 顶部"安装到主屏"按钮 → 加到主屏
  • 装好后断网刷新 → 仍能看到首页 shell + 顶部红条提示离线

🙏 如果觉得有用

  • GitHub 给个 Stargithub.com/fxy2026/nba… — Star 是最实在的反馈
  • 💬 评论区聊聊:你在用哪些"现代 CSS 替代以前 JS 的"方案?关心哪些篮球数据分析?
  • 🔄 转发给同温层:尤其是手搓副项目的同学
  • 📖 完整代码:MIT 协议,fork 后可一键部署到 Vercel,env vars 留空也能跑(NBA CDN 数据完全免费)

写副项目的乐趣在于做一个自己每天用的东西。NBA 季后赛还在打,欢迎来试用。

下一篇可能写:怎么用 Puppeteer + ffmpeg 给项目自动生成 demo GIF——也是这次顺手做的工具,刚好覆盖了 Akamai 反爬、Next/Image 优化、CDN 加速这些坑。