type
Post
status
Published
date
May 17, 2026
slug
nba-tracker
summary
从对阵图说起,NBA Tracker 重构记
tags
推荐
文字
思考
工具
开发
建站
category
技术分享
icon
password
前情提要
上一篇写到 NBA Tracker 第一版:8800 行、16 个页面、纯 SVG 投篮图、Suspense 流式渲染。当时的目标是把功能堆起来——比分、Box Score、投篮图、季后赛对阵——能跑就行。
跑了一段时间之后,越用越觉得糙。不是哪一处特别坏,而是处处差一口气:
- 季后赛对阵图是垂直堆叠的大卡,看不出 1v8 / 4v5 的层级
- 球队页的点差走势图配色像 PPT 默认样式
- 顶部菜单的下拉框内容多了之后会溢出屏幕外,根本点不到
- 手机端的「更多」弹层撑出屏幕,按住屏幕滚动的居然是下层页面
这些都不是阻断功能的 bug,但每次打开都膈应。
直到我在 Claude Code 的 skill 库里看到一个叫 UI/UX Pro Max 的 skill——专门做大厂级别的 UI 审计和重构建议。试着让它扫了一遍站点,回来一份清单:30+ 个具体问题,从间距、字号、配色到信息架构。
于是有了这次重写。从 8800 行涨到 30000 行,46 个页面,每一处之前膈应的地方都过了一遍。
这篇记录这次 UI 改造的关键节点。代码全部开源在 github.com/fxy2026/nba-tracker。
一、对阵图:从堆叠卡片到树状结构
第一版的季后赛对阵图是按"轮"竖着堆 8 个系列的卡片,看不出 4 对阵的层级关系——你看不出 OKC 打的是太阳还是火箭,因为它们都只是排在第一轮的某个位置。
UI/UX Pro Max 给的建议:
- 用真正的树状结构,SVG 连接线把上一轮的胜者连到下一轮
- 用进度点显示系列赛打了几场(best of 7,1-0 / 2-0 / 4-0)
- 已经晋级的球队预先填到下一轮的位置——OKC 4-0 横扫之后,下一轮"OKC vs ???"直接标出来

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

页面包含逐场战果、双方场均、最大胜差、关键球员排行——把整个 best-of-7 拍扁到一个页面看。
拆掉 911 行的怪物
UI 重写需要触碰原来的
BracketTree.tsx——911 行单文件。打开就头晕:
React 组件、纯函数、桌面布局、移动布局全混在一起。任何一处微调都要在 900 多行里找位置。
UI/UX Pro Max 的建议里有一条隐含的工程要求:视觉结构应该映射到代码结构。一个 series card 就该有一个
SeriesCard.tsx;一组 SVG 连接线就该有一个 Connector.tsx。拆完变成 7 个文件:

拆分原则:
- 纯函数下沉到
lib/:无 React、无 JSX 的代码全部抽到lib/playoffs.ts,纯 TS
- 一个组件一个文件:SeriesCard / Connector / ConfHalf / BracketMobile / BracketDesktop
- 视觉零变化:CSS 类名、prop 形状一个字符都不改
同样的思路套到
game/[id]/page.tsx(1026 行 → 243 行 + 13 个 _components/)和 team/[tricode]/page.tsx(786 行 → 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 也一起做了。桌面端搞定之后,手机端也是同一个问题。
手机端「更多」的修复
手机底栏的「更多」按钮原本弹出的是个底部抽屉。我前阵子在手机上点开发现——弹层顶部撑到了状态栏,下面那点点也看不全。手指按住屏幕想往下滚,结果滚的是下层页面,弹层本身一动不动:

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

src/lib/useMoreGroups.ts 成为单一数据源:桌面 Navbar 和手机 MobileNav 都
const moreGroups = useMoreGroups();,传给同一个 <CommandPalette> 组件。修弹层本身的细节:
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 分钟前更新"


实现一个
<PageHeader> 接受可选的 updatedAt prop,下面挂一个 <UpdatedPill> 实时更新:updatedAt 接收 getScheduleAge() 的返回值(schedule cache 距今 ms 数)。挂到 17 个 schedule-derived 页面后,用户在每页标题下都能看到"X 分钟前更新"——对实时性的预期被校准。新加
<Breadcrumbs> 和 <RelatedPages> 两个共享组件,应用到 33 个详情和分析页:100% 覆盖意味着:用户在任意一个数据视图都能跳到相关的 4-6 个视图。SEO 也会受益——内部链接图密度上升。
四、首页:滚动条 + 最近浏览 + 数据流标识
UI/UX Pro Max 对首页的建议很具体:
- 顶部加 2 像素滚动进度条,跟着滚动位置走,给长页面一个进度感
- 保留访问历史:把用户最近浏览的球员 / 球队 / 比赛缩到首页中部一条横向滚动
- 第一次访问的人看不到访问历史块——空状态不显示

进度条用纯 CSS 滚动驱动动画实现:
零 JS、零 scroll listener、零 jank。Chrome 115+ 原生支持,老浏览器静默忽略——进度条不显示,但不报错。
之前类似效果需要:
每次滚动事件都触发布局抖动。CSS 自己搞定后,浏览器优化掉合成层。
最近浏览的实现:localStorage 存最近 12 条访问,每个详情页挂一个零 UI 的
<RecentVisitTracker>:首页有
<RecentlyViewed> 横向滚动条显示最近 8 条。首次访问时 localStorage 空,整个组件 return null,不显示空状态——避免空状态比展示空状态体面。
五、现代 CSS:那些以前要 JS 才能做的事
UI/UX Pro Max 的几条建议涉及现代 CSS 能力,这几年的浏览器普及度已经够了。
text-wrap: balance让标题断行时计算最佳分布,避免"最后一行就一个字"。一行 CSS,全站质感拉满。Chrome 114+ / Edge 114+ / Safari 17.4+ 已支持。
:has() 选择器之前要做"父元素响应子元素状态"必须 JS:
现在 CSS:
Chrome 105+ / Edge 105+ / Safari 15.4+ / Firefox 121+ 支持,覆盖率足够。
容器查询
同一个卡片放在三列网格里和放在单列侧栏里自动呈现不同密度。区别于 media query:媒体查询跟视口走,容器查询跟父容器宽度走。
六、PWA:装到主屏 + 离线能用
UI/UX Pro Max 对 PWA 的清单:manifest 完整化、安装提示、离线指示器、Service Worker 真离线。
Service Worker 分桶缓存
public/sw.js 按请求类型分桶缓存:
设计要点:
/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:

两个层级:
- prefetch:固定的 5 个高频入口一次性 prefetch
- prerender +
eagerness: "moderate":鼠标 hover 200ms 后浏览器在后台 prerender 任意站内非 /admin 非 /api 链接
不支持的浏览器忽略整个 script tag,零副作用。比手写
Link.prefetch 强一档。七、副作用:捡到几个数据 bug
UI 改完之后,开始有人能注意到数据层面的问题——以前 UI 太糙,没人能看清数据本身错没错。
打开
/all-time-leaders,按"生涯场均得分"排序。前几名:但 NBA 历史上能场均超过 30 分的只有两个人:乔丹 30.12、张伯伦 30.07。Luka 上赛季 33.5 是他一个赛季的数据,不是生涯。
排查代码,数据源是 NBA CDN 的
playerIndex.json,取 pts/reb/ast 三个字段:
两个隐藏坑:
playerIndex只包含现役球员。乔丹 2003 年退役不在里面,张伯伦 1973 年退役更不在
pts字段是球员上一个完整赛季的场均,不是生涯均值
stats.nba.com 的历史职业端点被 CORS 卡死,Vercel IP 也被拒,走 API 路径不通。最后的修法:手工录入静态历史榜单:
类似的"标签 vs 数据"不一致还在 5 个页面里发现:
页面 | 错误标签 | 实际数据 | 修法 |
/rookie-watch | "本赛季新秀" | 上赛季新秀(playerIndex 滞后) | 按 draftYear 过滤 + 显式说明 |
/milestones | "Career Milestones" | 用上赛季均推算的生涯总和 | 改名"生涯轨迹追踪",明确是投影 |
/awards-race ROY | "Rookie of the Year" | 所有联赛球员(老兵霸榜) | 加 rookie 过滤器 |
/by-position/country/college | 球员场均 | 上赛季均值 | 标签后加"· 上赛季"限定 |
Bug 让用户感知到问题(页面挂了)。标签错了让用户带着错误信息走。后者更难发现,影响更大。
八、时区:一个 bug 渗透到全栈
修 UI 期间发现的另一个深层问题。中国用户北京时间 5/17 早上 10 点打开网站,看到顶部写着"今天 5/16":

直接原因:NBA 官方赛程用美东时间编码。北京时间凌晨开打的比赛,在赛程数据里写的是前一天。但服务端代码强转 ET 算"今天",于是中国用户看到的"今天"是 ET 的今天(北京的昨天)。
修法:把"用户的本地时区"当成 first-class concept 沿请求链一路传:
然后
/api/games 接受 ?tz=Asia/Shanghai,扫全赛季日程,把每场比赛的 UTC 开球时间换算到用户时区,看它落在哪一天。修完之后的数据流:
修复扩散到
DateNav.tsx、GamesList.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.ts,230+ 条别名映射:/api/search 用 expandQuery() 把查询展开成多个候选词,OR 起来匹配。另外加了球队名直搜——搜"湖人"或"Lakers"返回整队现役球员:
十、词汇表 + Quiz:内容深度
/glossary 第一版只有英文。扩到 82 个词条,全部翻译成虎扑式中文("协防 / 护框者 / 退守战术 / 横扫 / 附加赛 / 双双 / 三双 / 接球投篮"),加了"阵容与战术"和"交易与名单"两个新分类:
/quiz 第一版有 3 个模式:看头像猜人 / 看数据猜人 / 猜球队。加了第 4 个:"猜历史名人"。从那 45 位 GOAT 里随机出题,给生涯均值让你 4 选 1:
看到一组
30.07 / 22.9 / 4.4 / 退役 能马上认出是张伯伦——22.9 篮板是大杀器,除了张伯伦只有比尔拉塞尔 22.5。现在的状态
代码层面:
指标 | 第一版 | 现在 |
代码行数 (TS/TSX) | 8,800 | 30,000+ |
路由 | 16 | 46 |
React 组件 | 39 | 71 |
API endpoint | 13 | 16 |
lib 模块 | ~5 | 20 |
词汇表条目(中英) | 47 | 82 |
球员搜索别名 | ~0 | 230+ |
有 RelatedPages 的页面 | 4 | 33(100%) |
有 Breadcrumbs 的页面 | 1 | 24 |
有 error.tsx 的路由 | 1 | 6 |
体验层面:
- 装到主屏 → 断网能看 → 联网无感更新
- 中英双语全覆盖,搜索支持中英文别名 + 球队名直搜
- A11y 焦点陷阱 / aria-label / SVG
role="img"/ 屏幕阅读器友好
- 44px 触控目标 + iOS 安全区 + 反穿透
- 33 个数据页底部都有"继续探索",没死胡同
- 滚动条 / "X 分钟前"标签 / 最近浏览 / Toast 反馈 / Web Vitals 监控
一些工程教训
- UI/UX skill 是认知放大器,不是替代。它告诉你"这里间距太小、那里层级不清楚",但具体怎么改是工程决策——比如对阵图改成树状之后必须拆 911 行的怪物文件,那个不是 UI 工作,是结构工作。
- 拆分的收益不在行数减少。是单元的认知负担降低——以后维护这个区域不需要把 900 行装进脑子。
- field 的名字 ≠ field 的含义。一个叫
pts的字段可能是生涯均值,可能是上赛季,也可能是当前赛季。名字从来不会告诉你它实际是什么——永远验证 schema。
- 小 bug 是冰山。手机端「更多」菜单滚不动这种小问题,往往挂着 body scroll lock 缺失、max-height 没设、overscrollBehavior 没配置、两套实现两份翻译漂移一整套问题。
- 本地时区是 first-class concept。函数签名上写出来,不要让"哪个 timezone"成为隐含的、由调用者随机决定的参数。
- CSS 已经能做你以为只能 JS 做的事。
:has()、容器查询、滚动驱动动画、text-wrap: balance都是这两年的新能力。别再写 scroll listener 算进度条了。
- 标签错比 bug 还可怕。Bug 是用户能感知到问题。标签错是用户带着错误信息走。
现在试试
线上地址:nba.xpy.me
代码(完全开源):github.com/fxy2026/nba-tracker
试试这些:
- /all-time-leaders 切换"生涯总得分" → 看到詹姆斯 42184 / 贾巴尔 38387 / 卡尔马龙 36928
- /glossary 切中文 → 82 个篮球术语全中文
- /search 输入"字母哥"或"乐邦" → 立即命中
- 任意比赛/球员/球队详情页底部 → "继续探索"卡片
- 手机访问 → 顶部"安装到主屏"按钮 → 加到主屏
- 装好后断网刷新 → 仍能看到首页 shell + 顶部红条提示离线
代码 fork 之后可以一键部署到自己的 Vercel,env vars 留空也能用(NBA CDN 数据完全免费)。
季后赛还在打。
- 作者:FXY
- 链接:https://www.xpy.me/article/nba-tracker
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。
相关文章




