- 按 app / shared / entities / features 分层,路由懒加载并抽出布局组件 - 请求层改为可插拔拦截器,统一错误类型、超时取消与端点拆分 - 抽取播放器、骨架屏、网格等复用组件,点赞收藏接入统一交互反馈 - 关于页重做为无边框极光风格,补充 iOS / Google Play 下载入口 - 修复播放页滚轮切集吞事件、页面误滚动、后台持续播放与声音记忆问题 - 语言按本地偏好与 IP 识别,国旗改为内联 SVG - 清理未使用的旧站点文件,补充 ESLint 配置
11 KiB
11 KiB
YMB Site 企业级重构方案
目标:在不改变现有视觉与交互的前提下,把「演示工程」重构成可按业务域扩展的前端工程。 约束:纯重构,功能与文案不变;每个阶段结束必须
pnpm build+pnpm lint通过;剧集 ID 保持字符串(19 位长整型)。
一、现状诊断
1. 目录没有业务边界
当前 src/ 按技术类型平铺:pages/、components/、services/、hooks/、lib/、data/。
- 首页、Shorts、播放页、搜索页共享同一批
services与components,无法知道改一个页面会影响谁。 - 页面与接口调用直接耦合:
HomePage、SearchPage、ListingPage各自写一遍normalizePage(...).list.map(normalizeDrama)。 data/catalog.js是演示兜底数据,和真实 API 层混在同一层级。
2. 请求层只有 24 行,没有封装
src/services/httpClient.js 现状:
- header、超时、JSON 解析、业务码判断全部内联在一个
request()里; - 错误统一
throw new Error(payload.message),丢失 HTTP 状态、业务码、请求路径,超时/断网/业务失败无法区分; - 没有拦截器链,后续加 token、埋点、错误上报只能继续往里堆 if;
appApi.js用字符串拼 query(?pageIndex=1&pageSize=20),16 个端点挤在一个对象里。
3. 数据与状态层薄弱
useApi只有active标志,没有AbortController、没有refetch、没有重试;错误只有一个message字符串。- 点赞/收藏在
ShortsFeedPage和WatchPage各写了一遍(含 try/catch + console.error),没有 mutation 抽象。 store.jsx把 locale / 搜索词 / 侧栏状态塞进一个 Context,搜索框每次输入都会让整棵树重渲染。normalizers.js同时承担媒体 URL 解析、分页解包、播放进度模型,职责混杂。
4. 组件没有抽离
| 现状 | 问题 |
|---|---|
layouts/AppShell.jsx 134 行 |
顶栏 + 搜索 + 创建菜单 + 侧栏 + 底部导航 + 上传弹窗状态全在一个组件 |
pages/ShortsFeedPage.jsx 237 行 |
ShortSlide、点赞/收藏、分页游标、滚动监听、键盘事件混在一起 |
pages/WatchPage.jsx 235 行 |
播放源、滑动手势、剧集列表、统计、评论、分享混在一起 |
| 各页面 | loading / error / empty 三态重复手写 |
components/Skeletons.jsx 104 行 |
6 个骨架屏塞在一个文件 |
5. 样式与路由
src/styles.css(旧版全站样式,含@font-face)、enterprise.css、youtube-overrides.css三层叠加,没有 tokens 与层级约定。src/app/App.jsx单行全量 import,无懒加载;构建主 chunk 907KB,Vite 已提示超限。
二、目标目录结构
src/
├── app/ # 应用装配层
│ ├── App.jsx # 只做 Provider + Router 组装
│ ├── router.jsx # 路由表(React.lazy + Suspense)
│ └── providers/
│ ├── AppProviders.jsx
│ ├── LocaleProvider.jsx # 语言 + SEO
│ └── UiProvider.jsx # 侧栏 / 搜索词 / toast
├── shared/ # 与业务无关的通用能力
│ ├── api/
│ │ ├── config.js # baseURL、超时、平台常量
│ │ ├── errors.js # ApiError / TimeoutError / NetworkError
│ │ ├── client.js # fetch 核心 + 拦截器管线
│ │ ├── interceptors.js # 设备头 / JSON / 业务码 / 错误归一
│ │ └── index.js
│ ├── hooks/
│ │ ├── useRequest.js # 查询:取消、重试、refetch
│ │ ├── useMutation.js # 写操作:pending、错误、乐观更新
│ │ └── useToast.js
│ ├── components/
│ │ ├── AsyncBoundary.jsx # 统一 loading / error / empty / retry
│ │ ├── VideoGrid.jsx
│ │ ├── EmptyState.jsx
│ │ ├── ErrorState.jsx
│ │ ├── Toast.jsx
│ │ └── skeletons/index.jsx
│ ├── lib/ # format / seo / media
│ └── styles/ # tokens / base / layout / 组件样式
├── entities/ # 领域模型与数据转换
│ └── drama/
│ ├── model.js # normalizeDrama / normalizeProgress
│ └── api.js # 剧集、分集、播放、统计
├── features/ # 业务域(页面 + 域内组件 + 域内 hook)
│ ├── home/ HomePage.jsx / CategoryBar.jsx / useHomeFeed.js
│ ├── shorts/ ShortsFeedPage.jsx / ShortSlide.jsx / useShortsFeed.js
│ ├── watch/ WatchPage.jsx / EpisodeRail.jsx / useEpisodePlayback.js
│ ├── search/ SearchPage.jsx / useSearchResults.js
│ ├── history/ HistoryPage.jsx
│ ├── listing/ ListingPage.jsx
│ └── about/ AboutPage.jsx + i18n/about.js
├── layouts/
│ ├── AppShell.jsx # 只做骨架拼装
│ └── components/
│ ├── TopBar.jsx
│ ├── SearchBox.jsx
│ ├── CreateMenu.jsx
│ ├── Sidebar.jsx
│ └── BottomNav.jsx
├── i18n/
│ ├── index.js # 聚合 + getLocale
│ └── locales/{id,en,zh}.js
└── main.jsx
依赖方向固定为:features → entities → shared,layouts → shared;禁止 shared 反向引用 features,禁止页面直接调用 fetch。
三、请求层设计(重点)
1. 统一错误类型
// shared/api/errors.js
export class ApiError extends Error {
constructor(message, { status = 0, code = null, path = '', cause } = {}) { ... }
}
export class TimeoutError extends ApiError {}
export class NetworkError extends ApiError {}
页面只判断 error instanceof TimeoutError / error.status,不再解析字符串。
2. 拦截器管线
// shared/api/client.js
const client = createHttpClient({ baseURL, timeout: 12000 });
client.useRequest((config) => ({
...config,
headers: { 'X-Device-Id': getDeviceId(), 'X-Client-Platform': 'web', ...config.headers },
}));
client.useResponse((response) => response); // 预留:解包、埋点
client.useError((error) => { /* 归一化 + 401 处理入口 */ throw error; });
client.get('/app/home/feed');
client.post('/app/likes/toggle', { targetType, targetId });
- 请求拦截器:设备 ID、平台头、Content-Type、语言头(预留)。
- 响应拦截器:
code === 0/200时解包data;否则抛ApiError(code, message)。 - 错误拦截器:
AbortError→ 取消(静默);超时 →TimeoutError;fetch异常 →NetworkError;HTTP 非 2xx →ApiError(status)。 - 所有查询共用
AbortSignal,组件卸载即取消,避免切页后 setState。
3. 端点按域拆分
entities/drama/api.js getDrama / getEpisodes / getPlayback / getStats
features/home/api.js getHomeFeed / getCategories / getCategoryDramas
features/search/api.js search
features/history/api.js getRecent / clearHistory / reportProgress
features/shared? likes / favorites / comments → shared/api/engagement.js
src/services/appApi.js 在迁移期保留为兼容门面,阶段 6 删除。
四、组件抽离清单
| 抽离项 | 来源 | 去向 |
|---|---|---|
TopBar / SearchBox / CreateMenu / Sidebar / BottomNav |
AppShell.jsx |
layouts/components/ |
ShortSlide |
ShortsFeedPage.jsx 内的私有组件 |
features/shorts/components/ShortSlide.jsx |
EpisodeRail |
WatchPage.jsx 右侧剧集列表 |
features/watch/components/EpisodeRail.jsx |
AsyncBoundary |
4 个页面重复的三态 | shared/components/AsyncBoundary.jsx |
VideoGrid |
HomePage / ListingPage / SearchPage 重复网格 |
shared/components/VideoGrid.jsx |
EmptyState / ErrorState |
各页面 api-note / search-empty |
shared/components/ |
Toast |
Shorts / Watch 各自实现 | shared/components/Toast.jsx + useToast |
| 骨架屏 | Skeletons.jsx |
shared/components/skeletons/(按页面一个文件 + index 聚合) |
| 点赞/收藏交互 | Shorts 与 Watch 重复 | shared/hooks/useEngagement.js |
AsyncBoundary 使用方式:
<AsyncBoundary
loading={request.loading}
error={request.error}
empty={!items.length}
skeleton={<VideoGridSkeleton />}
emptyText={locale.empty}
onRetry={request.refetch}
>
<VideoGrid items={items} />
</AsyncBoundary>
五、分阶段实施
| 阶段 | 内容 | 验收 |
|---|---|---|
| P0 | 记录基线:pnpm build / pnpm lint 结果,页面清单截图 |
基线存档 |
| P1 | shared/api:错误类型、client、拦截器、config;appApi 改为基于 client |
build + lint,接口行为不变 |
| P2 | entities/drama:模型归一化、分页、媒体 URL 收敛;页面改用 |
首页 / 列表 / 搜索数据一致 |
| P3 | useRequest / useMutation / useToast / useEngagement;替换 useApi 与重复逻辑 |
点赞、收藏、评论、上传弹窗回归 |
| P4 | 拆 AppShell 为 TopBar/SearchBox/CreateMenu/Sidebar/BottomNav;路由懒加载 |
侧栏收起、移动端抽屉、搜索跳转回归 |
| P5 | 页面迁入 features/,抽 ShortSlide、EpisodeRail、VideoGrid、AsyncBoundary、骨架屏 |
首页 / Shorts / 播放 / 搜索 / 历史 / 关于全量回归 |
| P6 | 样式分层(tokens/base/legacy 分离),删除旧兼容层与 services/appApi.js |
视觉 diff 无变化,build + lint 通过 |
每个阶段独立提交,提交信息遵循仓库现有 feat: / refactor: 风格。
六、风险与对策
- 视觉回归:P4/P5 只调整结构,CSS 类名与覆盖顺序保持不变;阶段结束对照截图。
- Shorts 滑动性能:
ShortsFeedPage的 IntersectionObserver、scroll-snap、键盘/滚轮逻辑整体迁移,不重写。 - ID 精度:所有 episode/drama ID 继续保持
String(),禁止Number();在entities/drama/model.js注释固化。 - Context 拆分:拆 locale 与 ui 后,确认切语言仍能刷新全站文案、搜索输入不再触发页面重渲染。
- 接口兼容:P1/P2 期间
appApi门面保留,页面可逐步迁移,避免一次性大爆炸。
七、验收标准
pnpm build通过,主包因路由懒加载下降(当前 907KB)。pnpm lint0 error。- 路由回归:
/、/shorts、/watch/:id、/search、/history、/subscriptions、/library、/about。 - 交互回归:上下滑切集、Shorts 上下滑、点赞/收藏/评论、语言切换、移动端侧栏、上传弹窗。
- 目录符合本文第二节,跨层依赖检查无违规。