Files
reader/docs/frontend-react-migration.md
T
hectorqinandClaude Opus 5.5 b8e5102669 feat(web): 我的 opens every row as a page; preferences on one page
我的 mixed sheets (书架显示, 系统设置, OPDS nested in the shelf sheet) with pages
(外观与主题, 影音浏览, 播放), and the personal preferences were four separate rows.

- 偏好设置 (#/me/settings) holds 外观与主题, 书架, 影音浏览 and 播放 as sections of
  one page, with a jump bar and ?section=; every control applies at once (影音浏览
  loses its save button and unsaved-changes prompt). The shelf's gear still opens
  the same fields (ShelfSettingsFields) as a sheet shortcut.
- 系统设置 becomes three admin pages of 我的 › 管理: 用户管理, 注册与邀请, 服务配置
  (#/me/admin/*, lazily loaded).
- 连接外部阅读器 (OPDS) is a page (#/me/opds) under 账号, also reached from the
  书架 section and the shelf sheet; back returns to whoever opened it.
- Old /me/theme|browse|playback and /media/:channel/settings[/:panel] redirect to
  the matching section; /me/account keeps its page.
- Drops MediaSettingsPage and the CSS only it used; docs follow the new paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:12:40 +00:00

8.7 KiB
Raw Blame History

前端 React 架构说明

本文保留原“前端 React 迁移”文件名以避免已有链接断开,内容描述当前实现约定。生产 Web 入口已经使用 React 19;后续页面和组件应遵循本文及架构与数据边界中的边界,不再向旧的 Screen 调度链添加代码。

当前技术栈

能力 当前实现 责任
UI 运行时 React 19 + React DOM 页面、组件和生命周期
路由 React Router 7 createHashRouter Hash URL、嵌套路由、Outlet、参数、错误边界和导航
服务端状态 @tanstack/react-query 查询缓存、去重、mutation、失效和预取
客户端共享状态 Zustand 认证、设置、同步和播放状态
基础组件 项目自有组件(ui/toolkit.tsx、ui/sheet.tsx、ui/action-menu.tsx、ui/dialog.tsx、ui/pager.tsx) 按钮、表单行、提示、弹层、「⋯」菜单和分页;Mantine 已移除
全局提示 ui/notifications.ts(Notyf 封装)、ui/floating-notice.tsx 全局消息与单个操作的状态提示
主题 shared/theme/ + styles/theme-tokens.css V3 语义颜色令牌;字号、圆角等按角色命名的令牌
图标 lucide-react 与项目图标字体(ui/icon.tsx) 图标组件
构建 Vite + TypeScript H5 与 Android WebView 共用:相对资源(base: './')、固定入口 assets/client.js、单一样式表,少用的屏幕拆成懒加载块

继续使用 Hash 路由是因为同一份产物同时由 H5 服务和 Android WebView 加载;两个宿主都不能为任意路径提供客户端回退。新的链接应使用 #/... 形式。

启动与运行时

web/src/main.tsx 创建 AppRuntime,然后调用 createRoot() 渲染 AppProviders 和 RouterProvider。web/src/app/runtime.ts 只负责组装共享资源,不选择页面:

  • 根据环境选择 Web 或 Android 平台,创建 ReaderApi、MediaApi、MediaPlayer、PlaybackService、SyncEngine、OfflineStore、SettingsStore 和 Query Client;
  • 恢复本地会话、服务地址、离线作用域和同步状态;
  • 暴露 flush、登录状态、阅读器注册和设置更新等宿主能力。

AppProviders 只提供 RuntimeContext 和 QueryClientProvider。Provider 生命周期负责启动/停止播放服务、连接会话与同步监听,并在页面隐藏或离开时刷新进度、离线数据和播放器状态,重新联网时续期会话。全局提示不需要 Provider:ui/notifications.ts 按需创建唯一的提示宿主。

目录边界

当前 Web 代码按以下实际目录组织:

web/src/
  app/                         # AppShell、AppNav、Provider、运行时和 React Router(含 lazy-pages)
  features/
    account/pages/             # 我的(设置入口)
    auth/pages/                # 登录
    home/pages/                # 首页
    library/pages/             # 书库浏览和文件管理
    media/                     # 影音 API、页面、组件、查询、变更、服务、状态和样式
    reader/pages/              # 阅读路由页面
    search/pages/              # 书籍与影音的全局搜索
    shelf/pages/,queries/      # 书架
    sources/components/,hooks/,pages/  # 书源、插件管理及扩展页面
  shared/query/,stores/,theme/,ui/    # 跨领域查询、状态、主题和交互 hook(如 use-swipe-tabs)
  api/, core/, formats/, net/  # API、平台、格式和网络基础能力
  render/                      # 分页、PDF、文本、朗读等渲染服务
  store/                       # 离线、出版物、设备设置存储
  ui/                          # 阅读器命令式舞台及可复用组件(toolkit、sheet、action-menu 等)
  styles/                      # 应用和阅读器全局样式

页面组件只组合页面展示、查询和交互。API 请求通过领域 API、query 或 mutation 访问;跨页面状态放在 Zustand store;播放器、主题和阅读器等长生命周期行为放在 service。旧的 web/src/media、旧 Screen 页面、旧手写生产路由和 mountUI 已从生产入口移除。web/src/app.ts 与 app/legacy-compat.ts 仅保留 Android 诊断和旧测试所需的兼容形状,不负责生产页面调度。

路由与兼容

路由定义集中在 web/src/app/router/routes.tsx,根级 AppShell 先经过 AuthBoundary,认证后由 AuthenticatedShell 渲染页面 Outlet 和唯一的导航 AppNav(手机底部标签栏、宽屏侧栏;阅读器和播放器隐藏导航,二级页面在手机上用返回箭头代替标签栏)。MediaLayout 现在只是 /media 与 /me 两组嵌套路由的 Outlet,主题和导航都由 AuthenticatedShell 统一负责。

阅读器和管理员屏幕(媒体库管理、扫描与刮削、条目管理子页、书源编辑、凭据、扩展页)通过 web/src/app/router/lazy-pages.tsx 拆成懒加载块:每个页面包成带 Suspense 的同名组件,路由表照常引用。新增少用的屏幕时加到这里,不要在页面中临时 import()。

保留的兼容入口是有意的:

  • /settings 重定向到 /shelf,兼容旧书架设置深链接;用户设置在「我的」(/me;全部个人偏好在 /me/settings 一页,旧 /me/:panel 跳到对应分区);
  • 影音设置页已移除:/media/:channel/settings 与 /media/:channel/settings/:panel 重定向到「我的」,媒体库管理(.../settings/libraries)和扫描与刮削(.../settings/tasks)保留;
  • /sources/search 仍打开书源中心;
  • /sources/:sourceId/:pageId 与新的 /sources/:sourceId/pages/:pageId 都可打开扩展页;
  • /media、/media/music 和 /media/audiobook 重定向到对应频道默认页,未知路径回到 /media/video。

兼容路由只保证旧链接仍能进入正确的 React 页面;新代码应直接使用当前路由树中的命名空间和嵌套路径。

阅读器托管边界

features/reader/pages/ReaderPage.tsx 是 React 与阅读器命令式实现之间的边界。页面取得书籍后,在 effect 中创建 ReaderScreen,把 screen.element 放入 route host,并通过 runtime.registerReader() 注册进度刷新回调。清理 effect 时先注销回调,再调用 screen.dispose() 并清空 host。

ReaderScreen 内部仍负责章节加载、Shadow DOM、分页量测、手势、朗读和阅读工具;React 只托管它的创建、更新和销毁,不介入书籍正文的布局量测。ReaderScreen 的 chrome 使用 React 组件,正文舞台保持命令式 DOM,以确保 getBoundingClientRect() 和章节分页的节点生命周期稳定。

播放器和主题生命周期

播放器是应用级资源。AppProviders 把 runtime.player.element 挂到 document.body,调用 runtime.playback.start() 建立 PlaybackService 对 MediaPlayer 的监听;路由切换只卸载控制界面,不销毁播放会话。AuthenticatedShell 根据当前 URL 控制播放器可见性,并处理播放器发出的 open-controls 事件,将用户带到带有 item 和 part 查询参数的播放器路由。Provider 卸载时停止监听并移除播放器节点。

应用主题由 AuthenticatedShell 创建 ThemeController(web/src/shared/theme/theme.ts),作用域来自 runtime.mediaApi.preferenceScope()。控制器只在 <html> 上写 data-theme(warm、blue、green)与 data-mode(light、dark),组件只读 web/src/styles/theme-tokens.css 的 --color-* 令牌(--reader-* 和影音别名映射到它们);响应系统深浅色、同页保存和存储变化,退出登录时清理属性和监听器。阅读器的黑白灰主题只写在阅读器容器上。主题偏好通过 readThemePreference、saveThemePreference、resolveAppTheme(React 中用 shared/theme/use-theme.ts 的 useThemeState)读写,不直接访问存储。

后续改动约定

  • 新页面放入对应的 features/<domain>/pages,通过 routes.tsx 接入;少用的屏幕经 lazy-pages.tsx 懒加载;
  • 弹层一律用 ui/sheet.tsx 的 Sheet/ConfirmSheet,「⋯」菜单一律用 ui/action-menu.tsx,不引入第三方组件库;
  • 新的颜色、间距、字号、圆角、时长写成令牌,不写字面量(见 ui.md §2);
  • 领域组件、查询、变更、服务和样式留在各自 feature 内,跨领域依赖经过 shared 或 app 边界;
  • 需要长生命周期的资源必须由 Provider 或明确的 service 管理,并在清理阶段释放;
  • 阅读器正文继续通过 ReaderScreen 托管,不把命令式舞台改写为普通 React 子树;
  • 新链接使用 Hash 路由和当前嵌套路由,不新增旧 Screen 或手写 pathname 分支。