Files
ymb-site/docs/plans/2026-11-12-enterprise-refactor.md
Zhou Weidong ecfbd3b8b5 refactor: 企业级目录重构与播放/关于页体验升级
- 按 app / shared / entities / features 分层,路由懒加载并抽出布局组件
- 请求层改为可插拔拦截器,统一错误类型、超时取消与端点拆分
- 抽取播放器、骨架屏、网格等复用组件,点赞收藏接入统一交互反馈
- 关于页重做为无边框极光风格,补充 iOS / Google Play 下载入口
- 修复播放页滚轮切集吞事件、页面误滚动、后台持续播放与声音记忆问题
- 语言按本地偏好与 IP 识别,国旗改为内联 SVG
- 清理未使用的旧站点文件,补充 ESLint 配置
2026-11-12 12:32:29 +08:00

11 KiB
Raw Permalink Blame History

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: 风格。

六、风险与对策

  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 上下滑、点赞/收藏/评论、语言切换、移动端侧栏、上传弹窗。
  • 目录符合本文第二节,跨层依赖检查无违规。