# 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 已提示超限。 ## 二、目标目录结构 ```text 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. 统一错误类型 ```js // 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. 拦截器管线 ```js // 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. 端点按域拆分 ```text 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` 使用方式: ```jsx } emptyText={locale.empty} onRetry={request.refetch} > ``` ## 五、分阶段实施 | 阶段 | 内容 | 验收 | | --- | --- | --- | | 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:` 风格。 ## 六、风险与对策 1. **视觉回归**:P4/P5 只调整结构,CSS 类名与覆盖顺序保持不变;阶段结束对照截图。 2. **Shorts 滑动性能**:`ShortsFeedPage` 的 IntersectionObserver、scroll-snap、键盘/滚轮逻辑整体迁移,不重写。 3. **ID 精度**:所有 episode/drama ID 继续保持 `String()`,禁止 `Number()`;在 `entities/drama/model.js` 注释固化。 4. **Context 拆分**:拆 locale 与 ui 后,确认切语言仍能刷新全站文案、搜索输入不再触发页面重渲染。 5. **接口兼容**:P1/P2 期间 `appApi` 门面保留,页面可逐步迁移,避免一次性大爆炸。 ## 七、验收标准 - `pnpm build` 通过,主包因路由懒加载下降(当前 907KB)。 - `pnpm lint` 0 error。 - 路由回归:`/`、`/shorts`、`/watch/:id`、`/search`、`/history`、`/subscriptions`、`/library`、`/about`。 - 交互回归:上下滑切集、Shorts 上下滑、点赞/收藏/评论、语言切换、移动端侧栏、上传弹窗。 - 目录符合本文第二节,跨层依赖检查无违规。