Bangumi Documentation

Electron: An unofficial ui first app client for Android and iOS, built with React Native. 一个无广告、以爱好为驱动、不以盈利为目的、专门做 ACG 的类似豆瓣的追番记录,bgm.tv...

6,129 stars TypeScript TypeScript 14 files · ~12,964 tokens #android#android-app#bangumi#design#expo#ios#ios-app#mobx
RAW Doc

Technical Documentation: czy0729/Bangumi

ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference

Overview (`README.MD`)

Bangumi

一个基于 react-native 的 Bangumi 第三方客户端

Bangumi 是一个用于管理追番进度(不限于动漫、音乐、日剧、游戏、AVG),发现和讨论番组的网站

iOS 和安卓都做了兼容,并最大限度保持一致

目前已开发不少于 100 个页面,基于移动端重新设计布局,几乎涵盖网站所有页面

本人并没有原生开发经验,用于练手 react-native,希望通过这个平台去学习更多

更多涉及到本项目的说明,可前往 语雀 查看

ENGLISH

下载

安卓 APK 下载 | AltStore 侧载 | AltStore 直接添加源 | iOS 上当前能使用的方法 | Expo 主页

  • Google Play 被举报下架,暂搁置

  • iOS 付费开发者账号已过期,期间没能过审,因涉及到社区、版权敏感、备案等问题,不考虑再送审了

功能

基本功能(网站原有功能)

带链接的支持网页预览(只能看不能操作)

扩展功能

预览

以下为 2025 年 6 月份,一些 UI 相对不重复的页面截屏。图片无法正常加载也可以前往 语雀 查看。

版本

当前版本图文说明

所有更新 CHANGELOG

在线

基于 @storybook/react-native 6.5 生成的 iframe.html,尽量少入侵原项目的基础上,补全了一个完整的单页面流程(当然这样是不考虑性能问题的,不可能与正常开发的,例如使用 Next.js 的相提并论)。

关键代码实现:navigate | window popstate

因现在 expo 在网页上面已经很成熟,若以后有空再考虑使用同一套方案补全网页端。

SPA 应用预览(网页收藏管理条目相关功能不定期开发中,已支持基本的信息展示,移动端最佳)

Storybook 组件面板预览

开发心得

从 [email protected] 就开始持续开发,当年并没有今天如此多简单而且优秀的方案和第三方库,比如 react hooks、react-native-reanimated、expo libraries,甚至连 Pressable、color scheme 这些概念都没有。至于 TypeScript 是最近两三年才开始慢慢补充的,所以很多最初的设计并没考虑过类型问题。最近还涌现了很多一站式解决方案,比如 expo-router,ones 都能在客户端和网页端上直出高性能构建。现在代码上会存在大量老旧的又或者与你现在了解到的 react-native 很多雷同的实现,都已经很难通过使用新的方案迭代过来。

使用 react-native 开发客户端,想做到流畅是需要下很大的功夫的,写法没问题的话一样流畅够用。其实跟开发网页应用没多大区别,无非就是多了一些概念而且还需要转变思路。几年开发下来,关键影响到流畅度,无非就几个点:

  • 尽可能不要渲染过大的图片(是所有里面最影响流畅性的)
  • 尽量减少同时请求数、慢请求(包括图片的请求)
  • 尽量缓存计算过的结果
  • 对不可见区域延迟渲染(不像浏览器有现成的)
  • 多写函数式编程、异步逻辑,少用 this、循环
  • 如果可以,减少嵌套层数(网页影响更大)

本地开发

关于环境

详细

本人一直在 mac 环境下开发,iOS 使用 expo 客户端调试,安卓跑真机 usb 调试。

由于 4 年的迭代,不管是对于本项目,还是最初使用的 expo 来说,都已经大相径庭。iOS 目前只能跑在 expo 上,而安卓只能跑在 metro 上。

在 iOS 上想充分使用 expo 客户端简化调试流程,所以不会引入 expo 不支持的第三方库。而安卓上面想要最高的性能,所以后期已经从 expo 上面 eject 出来了,用上了很多第三方的性能优化库替代了原来的组件。

若你想在本地调试,不要在根目录安装包,而请先到 ./packages/[目标环境]/ 下使用 yarn 安装包,然后使用命令 yarn env [xxx] 来切换到目标开发环境,最后请参考这个时候根目录的 package.json 中的命令开发。通常预置的本地调试命令是 yarn dev。多环境切换与补丁管理的详细说明见 packages/README.md。

遇到跑不起来,不要太过折腾,推荐在运行本项目之前,就应该使用官方提供的工作流预先把所有环境需要的库都安装好了。window 和 mac 的环境差异还是比较大的,可以查阅 Expo 官方文档。

关于 NPM

详细

不要使用 taobao 源头,可能会在跑环境时提示不允许代理。

bash
npm config set registry https://registry.npmjs.org

国内环境,首次下载包有可能会异常地慢。因用了很多旧的依赖包,里面的包版本依赖混乱,为了不出错请使用 yarn 安装。

bash
git clone https://github.com/czy0729/Bangumi
cd ./Bangumi
yarn

安卓真机 usb 调试遇到的问题

详细

不能安装请尝试在命令前添加 sudo。

安卓系统请打开开发者调试模式,需要至少进行一次打开 Android Studio 的同时连接 usb 允许添加调试。

调试请保证给应用安装权限,允许悬浮窗权限。

遇到 spawnSync adb ENOENT,参考 stackoverflow。

第一次就遇到打包失败,看看是不是遇到 read timeout 等问题,表示需要科学上网。明明前一天还能打包成功,后一天就失败,也可能是依赖包无法获取的问题,因为 gradlew 依赖包只有 24 小时的缓存时间,尝试使用 --offline 命令。

常见错误,包括 mac M1

详细

已在 mac M1 上调试成功,最近购入了 M1,尝试实用新环境,iOS expo 运行一路顺风,但是打包 apk 遇到很多问题,总结如下:

  • 一定要使用正确的 JAVA jdk,不是 jre!

  • 打包前一定要安装 watchman,不然会遇到监听文件过多错误

  • 打包遇到失败,请进入 android 目录下执行 ./gradlew clean 后再重新打包

  • 打包失败,看见错误提示上面有 node_modules 包的名字,请尝试把这个包下载补全

  • 若出现各种无法预料的问题,请一定要尝试重启机器

免责

  • 本项目所有数据信息均来自各种网站,不提供任何形式的媒体下载、直接播放和修改功能

  • 本项目承诺不保存任何第三方用户信息

  • 本项目代码仅供学习交流,不得用于商业用途,若侵权请联系

致谢

[Bangumi-OnAir] ekibun 的单集播放数据源

[bangumi-mosaic-tile] weizhenye 的用户统计瓷砖库

[bangumi-data] 番组数据索引

[bangumi-api] 官方接口

投食

开发迭代不易,觉得客户端好用的,有能力的可以投喂一下,也可以给个星星


Overview (`android/README.MD`)

开发安卓或打包 apk 前请先阅读

  1. 安卓是 expo 下的 bare workflow,已经脱离了 expo 的环境很多年了,有条件请先尝试 iOS 环境开发,因为后面诸多问题,比如特定的 jdk 版本、子库的一次微小的版本发布,都会导致就是启动不起来。

  2. 本 repo 默认提交 iOS 环境的 package.json,若你想开发安卓请先用 ./packages/android/package.json 替换掉 ./package.json,/patches 复制到根目录,再安装包。

  3. 手机 USB 连接后允许调试授权,运行 yarn dev 会自动编译一个版本到手机,若后面报错 metro 无法正常启动,可以运行 yarn metro 不打包直接打开调试进程。

  4. 若需要打包 apk 前请先使用自己的环境生成 my-release-key.keystore 放到 ./android/app/my-release-key.keystore。

  5. ./android/gradle_yours.properties 重命名为 gradle.properties,并根据上面生成的 keystore 自行修改 *** 的内容。可能还需要你手动创建 local.properties 编写 sdk.dir=/Yours/Android/sdk

text
MYAPP_RELEASE_STORE_FILE=my-release-key.keystore
MYAPP_RELEASE_KEY_ALIAS=***
MYAPP_RELEASE_STORE_PASSWORD=***
MYAPP_RELEASE_KEY_PASSWORD=***
  1. 打包命令为 yarn build。

  2. 打包完成后可以使用 yarn open or yarn open:mac 直达 apk 输出目录。


Overview (`packages/README.md`)

packages 多环境管理

本项目通过 yarn env [ios | android | web] 在多个开发环境之间切换,脚本见 packages/env.js。提交代码前请切回 ios 环境。

目录结构

text
packages/
├── env.js          # 环境切换脚本
├── patches/        # ★ 统一补丁管理目录(唯一实体补丁文件)
├── ios/            # iOS 环境(提交基准)
├── android/        # Android 环境
├── web/            # Web 环境
└── README.md       # 本文件

每个环境目录独立维护以下内容:

内容 说明
package.json 该环境专属依赖版本
node_modules/ 该环境安装的完整依赖
patches/ 指向 packages/patches/ 的补丁符号链接(该环境的补丁子集)

补丁统一管理

所有补丁的实体文件统一存放在 packages/patches/,各环境目录与根目录 patches/ 里只有指向它的符号链接,按各环境需要的补丁子集决定包含哪些符号链接:

目录 补丁子集
根 patches/ 当前环境的补丁集合
packages/android/patches/ 共享 3 + pager-view/smb/cameraroll/android-widget
packages/ios/patches/ 共享 3(realtimeblurview/render-html/tab-view)
packages/web/patches/ realtimeblurview/render-html + @storybook/preview-web

补丁只维护一份,修改 packages/patches/ 下文件即对所有环境生效,无需逐份复制。

切换机制

yarn env <目标环境> 执行三件事:

  1. 备份当前环境:将根目录的 package.json、node_modules 备份到 packages/{当前环境}/
  2. 恢复目标环境:将 packages/{目标环境}/ 的 package.json、node_modules 恢复到根目录
  3. 更新平台配置:同步 babel.config.js 与 app.json

patches/ 不做物理拷贝:切换时 env.js 的 syncPatches 会清空目标目录,并按源目录的补丁子集重建指向 packages/patches/ 的符号链接,因此各环境的补丁子集得以保留、且统一指向同一份实体文件。

补丁管理约定

  • 补丁文件命名遵循 patch-package 规范:{包名}+{版本}.patch
  • 新增/修改补丁:先在 packages/patches/ 创建/修改实体文件,再在需要该补丁的环境 patches/(及根 patches/)下创建符号链接
  • 各环境只保留该环境下实际安装且版本匹配的补丁,避免跨环境版本不匹配导致应用失败
  • npx patch-package <包名> 会写入当前环境根目录 patches/ 的符号链接目标(即 packages/patches/),自动归档到统一目录

注意事项

  • 根目录 patches/ 是指向 packages/patches/ 的符号链接集合,不要复制成实体文件
  • 新增某包补丁时,需先在 packages/patches/ 建好实体文件再软链(patch-package 无法写入不存在的符号链接目标)
  • 非 mac 环境切换前请关闭编辑器,避免 node_modules 被占用
  • 提交 git 前务必切回 ios 环境

Overview (`workers/README.MD`)

Workers

🤖 本文档由 AI 生成,实际情况请自行判断。

Bangumi 的反向代理 Worker,推荐部署到 Deno Deploy(国内可访问)。

❗ Cloudflare Workers 自带的 *.workers.dev 域名国内无法访问,如果你有备案的自定义域名可以用 CF,否则请用 Deno。


文件说明

文件 用途
deno/index.ts 通用代理 — 转发 API、网页等请求,解决跨域
deno/lain.ts 图片代理 — 缓存 lain.bgm.tv 图片,节省流量
cf/index.js Cloudflare 版通用代理(需自定义域名)
cf/lain.js Cloudflare 版图片代理(需自定义域名)

部署到 Deno Deploy(推荐)

全程在网页操作,不需要安装任何东西。

部署通用代理

  1. 打开 Deno Deploy,用 GitHub 登录
  2. 点击 New Project → Playground
  3. 删除默认代码,复制粘贴 deno/index.ts 的全部内容
  4. 顶部 Project Name 填一个名字(如 bgm-proxy),点击 Deploy
  5. 部署完成后得到一个 https://bgm-proxy.deno.dev 的地址

部署图片代理(可选)

重复以上步骤,粘贴 deno/lain.ts 的内容,取另一个名字(如 bgm-lain)。


部署到 Cloudflare Workers(需自定义域名)

如果你有备案域名,也可以用 Cloudflare:

  1. 打开 Cloudflare Dashboard → Workers & Pages
  2. 点击 Create Worker
  3. 删除默认代码,复制粘贴 cf/index.js(通用代理)或 cf/lain.js(图片代理)
  4. 点击 Deploy
  5. 在 Worker 详情页 → Triggers → Custom Domains 绑定你的域名

在 Bangumi 中配置

设置 → 网络 → 代理,连接模式选**「镜像 / 反代」**。

填写地址

App 中有 3 个输入框,对应不同的用途:

输入框标题 默认上游 填写你的 Worker 地址
主站域名 bgm.tv https://bgm-proxy.deno.dev
API 域名 api.bgm.tv 不填则和主站共用同一个
图片域名 lain.bgm.tv https://bgm-lain.deno.dev
  • 主站域名必填,登录、浏览等绝大部分功能都依赖它
  • API 域名通常可以不填,会跟随主站域名
  • 图片域名不填也不影响使用,只是图片不经过代理

填写后立即生效,如遇到问题请重启 App 或重新登录。

直接转发

如果用的是本项目提供的 Worker 脚本,保持「使用了自建 Worker?」关闭即可。这个开关是给 Nginx 等其他方案用的。


加个密码(可选)

部署后任何人都能使用你的 Worker。如果需要加密码,在 Deno Dashboard / Cloudflare Dashboard 的 Environment Variables 中添加:

变量名 workerSecret workerLainSecret
对应文件 index.ts / index.js lain.ts / lain.js
添加位置 环境变量 WORKER_SECRET 环境变量 LAIN_SECRET
App 填写 设置 → 密钥 设置 → 图片密钥

不设置则所有人都能用,按需决定。

调试

App 中设置页面底部有日志控制台,可以实时查看请求走了哪个代理、是否成功,方便排错。


Overview (`android/rust/README.md`)

ECH Proxy - Android Native Module

本地 HTTPS CONNECT 代理,通过 ECH (Encrypted Client Hello) + DoH 绕过 SNI 封锁。

架构

text
JS (enableEchProxy)
  → Java EchProxyModule (NativeModule)
    → JNI EchProxyNative
      → Rust libechproxy.so
        ├── 本地 HTTP CONNECT 代理
        ├── ECH TLS 后端 (OpenSSL 4.0.1 + ech.h)
        ├── MITM: 自签 CA → per-host 证书 → rustls
        └── DoH DNS 解析

依赖

依赖 版本 说明
Rust stable 交叉编译 aarch64-linux-android
cargo-ndk latest Android NDK 集成
OpenSSL 4.0.1 需要 ECH 支持 (openssl/ech.h)
Android NDK 27.1.12297006 在 build-android.sh 中配置

构建

1. 编译

bash
npm run build:rust
# 或
bash android/rust/build-android.sh

脚本会自动检查 OpenSSL 源码是否存在:

  • 已存在 → 直接编译
  • 不存在 → 询问是否自动下载,或手动下载到 android/rust/openssl/build/openssl-4.0.1/

手动下载地址:

text
https://github.com/openssl/openssl/releases/download/openssl-4.0.1/openssl-4.0.1.tar.gz

目录结构:

text
android/rust/openssl/
  build/
    openssl-4.0.1/    ← 源码 (自动下载或手动放置)
  install/            ← 编译产物 (自动生成)

构建流程

  1. 检查/安装 Rust + aarch64-linux-android target + cargo-ndk
  2. 设置 Android NDK 环境变量
  3. 交叉编译 OpenSSL (带 ECH 支持,静态链接,已编译则跳过)
  4. cargo ndk 编译 libechproxy.so
  5. 复制到 android/app/src/main/jniLibs/arm64-v8a/

构建产物

产物 路径
libechproxy.so android/app/src/main/jniLibs/arm64-v8a/
OpenSSL 静态库 android/rust/openssl/install/ (gitignored)
Cargo target android/rust/target/ (gitignored)

源码

文件 作用
src/lib.rs 代理主逻辑、JNI 入口、ECH 后端、MITM、DNS
ech_helper.c OpenSSL ECH GREASE retry-config 获取
build.rs 编译期探测 openssl/ech.h
Cargo.toml Rust 依赖配置

注意事项

  • 首次构建时脚本会询问是否自动下载 OpenSSL 源码,也可手动下载
  • 仅支持 Android arm64-v8a
  • OpenSSL 必须有 ECH 支持 (ech.h),否则编译为 no_ech 版本,ECH 功能不可用
  • libechproxy.so 和 openssl/ 目录均被 .gitignore 忽略,需要本地编译
  • 构建需要 macOS (当前 build-android.sh 使用 darwin-x86_64 工具链)

.Claude Docs Api (`.claude/docs/api.md`)

API 层

两种请求方式

  • fetchAPI() — JSON API 调用,自动附加 Authorization header,支持重试
  • fetchHTML() — HTML 页面抓取,携带 cookies,支持代理,cheerio 解析

HTML 解析引擎

  • 生产代码一律经 src/utils/thirdParty/html(cheerio / cParse / cFind / cMap / cText 等)使用,禁止直接 import 引擎包
  • 底层引擎固定为 cheerio/slim(1.0,纯 htmlparser2),engines/slim.ts 构造单例适配层,由 resolveEngine() 懒加载
  • 旧引擎 legacy(cheerio-without-node-native 0.20)仅测试对照用,生产代码不引用(不进 bundle);双引擎基线稳定后删除
  • 根 package.json dependencies 里的 htmlparser2@^3.9.0 是给 legacy 引擎占提升位的:占住根 node_modules 的 htmlparser2 槽位(3.x),cheerio 1.x 需要的 htmlparser2 v9 会被嵌套安装到自身目录下,两引擎互不干扰;不要"清理"掉它,也不要恢复顶层 resolutions 的 htmlparser2(会降级 cheerio 1.x 的依赖导致解析失败)
  • formhash 提取统一用 getFormhash(html)(正则实现,引擎无关),不要再写 cheerio 选择器

API 常量位置

  • REST API: src/constants/api/index.ts(API_SUBJECT(), API_USER_INFO() 等)
  • HTML 页面: src/constants/html/
  • CDN: src/constants/cdn/
  • 第三方: MAL、AniDB、Pixiv、Anitabi

.Claude Docs Architecture (`.claude/docs/architecture.md`)

项目架构

技术栈

  • 框架: React Native + Expo SDK 54
  • 状态管理: MobX 6 + mobx-react 9
  • 导航: React Navigation 6(native-stack + bottom-tabs)
  • 样式: StyleSheet + 自定义 memoStyles() 缓存
  • HTTP: 自封装 fetchAPI() + fetchHTML()(cheerio 解析 HTML)
  • 构建: EAS (Expo Application Services) + Fastlane + GitHub Actions

目录结构

text
├── src/                    # 主源码
│   ├── App.tsx             # 统一入口(平台差异用 ANDROID 常量分流, 不再拆分 App.android.tsx)
│   ├── config.ts           # 开发标志、代理、初始路由
│   ├── components/         # ~80+ 可复用 UI 组件
│   ├── screens/            # 所有页面模块(~100+)
│   ├── stores/             # MobX domain stores(18个)
│   ├── navigations/        # React Navigation 配置
│   ├── constants/          # 常量: index.ts 为 barrel; 子模块 api/, app/, assets/, cdn/, data/, device/, env/, events/, host/, html/, i18n/, init/, model/, site/, text/, tinygrail/ (运行时管线已迁至 utils/cdn)
│   ├── styles/             # 主题、颜色、布局工具 (index.ts 汇总; colors.ts 单文件; layout.ts 布局/尺寸; device.ts 设备检测; tools/ 工具类: container/margin/border/effect/legacy)
│   ├── types/              # TypeScript 类型定义
│   ├── utils/              # ~35+ 工具模块
│   └── assets/             # 图片、字体、JSON 数据
├── android/                # Android 原生工程
├── ios/                    # iOS 原生工程
├── packages/               # 多环境构建目录(android/ios/ipa/web,含各环境独立 package.json、node_modules、patches)
├── web/                    # IPA 构建、更新日志
├── patches/                # patch-package 补丁(当前环境的运行时镜像,由 packages/env.js 切换时自动同步)
├── [deprecated]/           # 废弃代码
└── test/                   # 测试数据

多环境切换(yarn env)

  • 通过 yarn env [ios | android | ipa | web] 切换开发环境,脚本见 packages/env.js
  • 各环境独立维护 packages/{env}/package.json、node_modules、patches/
  • 切换时:备份当前环境配置到 packages/{当前env}/,恢复目标环境配置到根目录
  • patches 同步:备份时根目录 patches/ 同步到 packages/{当前env}/patches/,恢复时 packages/{目标env}/patches/ 同步到根目录 patches/。因此根目录 patches/ 始终是当前环境的补丁镜像,补丁只需在各环境目录维护一处

路径别名

别名 路径
@components src/components/
@screens src/screens/
@stores src/stores/
@utils src/utils/
@constants src/constants/
@styles src/styles/
@types src/types/
@assets src/assets/
@src src/

导航结构

  • Root Stack: createNativeStackNavigator() — 所有页面作为 Stack.Screen
  • Bottom Tab: 6 个主 tab — Discovery / Timeline / Home / Rakuen / User / Tinygrail
  • 懒加载: 非 tab 页面使用 React.lazy() 代码分割
  • 配置: src/screens/index.ts 导出 ~100+ 页面组件

构建/部署

  • EAS: eas.json — development / preview / production profiles
  • Fastlane: Android 商店元数据
  • CI: .github/workflows/build-upstream-ipa.yml — IPA 构建
  • 发布: npm run pub(eas update → production channel)

.Claude Docs Code Style (`.claude/docs/code-style.md`)

代码规范

  • 一个文件没必要不超过 400 行,超了就拆
  • 嵌套没必要不要超过 4 层

提交描述规范

  • 格式为 - [功能块] xxx(功能块如 文档 / 组件 / 重构 / 依赖 / 词云 等,取自最近提交惯例)
  • 尽量简洁,用自然语言概括改动本身,不关心代码层面细节

注释规范

  • 文件头注释框:元信息之后空一行写文件职责描述,描述紧贴 */ 收尾,可多行(续行与首行对齐)。新建文件一律带上;存量文件未带的不必回填,编辑该文件时顺手补即可。先例见 src/utils/thirdParty/protobuf/index.ts:
    typescript
    /*
     * @Author: czy0729
     * @Date: 2023-12-07 21:42:04
     * @Last Modified by: czy0729
     * @Last Modified time: 2026-08-30 07:30:00
     *
     * native 端入口: 资源加载 (assets.ts) + 缓存去重 (cache.ts) + 解码 (decoder.ts)
     */
  • /** */ 块注释上面如果有内容(非空白行),必须空一行隔开
  • 类型定义中,每个属性的 /** */ 注释前也需要空一行,仅首个属性(上方为 {)可紧贴;联合类型逐成员注释同理,成员的 /** */ 注释上方必须空一行(成员较多或注释较长时,优先用类型头部 /** */ 破折号列表描述各成员)
  • hooks 返回对象时,每个 key 都必须加 /** */ 注释(返回 as const 或条件提前返回的对象同样适用)
  • 函数参数说明写在函数头 JSDoc 的 @param name 说明 中(不带类型标注,类型由 TS 提供),不在签名内联给参数写 /** */。先例见 constants/api/tinygrail.ts:
    typescript
    /**
     * 创建冰山委托
     *
     * @param monoId 条目 Id
     */
    export const iceberg = (monoId: MonoId) => {}

类型规范

  • 不允许使用 interface,统一使用 type 定义类型
  • 优先查找并复用项目中已有的类型定义,避免重复定义
  • 类型定义应放在文件顶部导入语句之后
  • 内联类型仅用于简单、一次性使用的场景
  • 必须使用 import type:TS 类型直接用 import type 引入
  • 样式与路由类型优先从 @types 引入,不要直接从 react-native 引:
    • ViewStyle / TextStyle = @types 中的宽口径别名(StyleProp<RNViewStyle> 等),RN 原生窄型仅在 reanimated 的 AnimatedStyle<T> 等需要具体对象型的场景使用(可别名引入,如 ViewStyle as RNViewStyle)
    • 组件 style?: ViewStyle 用 WithViewStyles<> 组合;带导航参数的组件用 WithNavigation<>
    • children 一律用 React 的 PropsWithChildren<>
  • 遗留类型 Fn 与 AnyObject 已从 @types 移除(2026-09 清理):禁止再使用,回调一律写具体签名(如 () => void、(res?: unknown) => void、复用 TouchableHandlePress),动态数据袋用 Record<string, any> 等内联结构
  • 云快照(@utils/kv 读写的 OTA 数据)类型一律用 ResultData<T>(@utils/kv/type,等价于 T & { ts?: number }),不手写 T & { ts?: number }:
    • 读取写 get<ResultData<X>>(key) / gets<ResultData<X>>(keys)
    • store 保存快照时,先在页面 types.ts 用 ResultData<...> 定义快照类型(如 type OtaChannel = ResultData>),state 再引用
    • ts 由 ResultData 提供,业务类型里不再重复声明;上传用 update(key, omit(data, [...瞬态字段])) 剔除 _loaded 等本地字段

代码风格(全局)

  • 所有组件统一用 observer() 包裹,不使用 useObserver 或 ob
  • 性能优化推荐依赖 MobX observer() 自动追踪,默认不手写 React.memo / useMemo
    • store 派生数据每次渲染直接计算即可,useMemo 缓存 store 派生值可能滞留旧数据(原因见 component.md「observer 组件内不要用 useMemo 缓存 store 派生数据」)
    • 例外:滚动密集页面的区块内容组件(见 screen.md「页面区块双组件模式」)使用项目封装的 memo(props, DEFAULT_PROPS, COMPONENT_MAIN) + props 驱动;此类组件内部的 useMemo 若服务于稳定引用可保留
  • useCallback 用于稳定引用
  • use* 函数禁止在条件提前返回之后调用:if (...) return null 之后不要再调用任何 use* 函数(含纯函数),避免日后函数内引入真实 hook 时触发 Rules of Hooks 崩溃。提前返回应放在所有 use* 调用之后
  • memoStyles() 不是 use*,不受上一条约束:_.memoStyles() 返回的是普通函数,调用它只是取一份 StyleSheet;组件内有提前返回时应放在提前返回之后,避免无渲染的分支也生成样式(见 component.md「样式规范」)。不要为了「与 use* 对齐」把它提到提前返回之前,审查时也不要按 use* 报此问题
  • 每个组件/页面通过 ds.ts 导出 COMPONENT 常量标识自身,子组件用 rc(PARENT, 'Name') 生成
  • Store 访问:页面通过 hooks 解构 { id, $ },子组件通过 useStore<Ctx>(COMPONENT) 获取
  • StoreContext.Provider 在页面 index.tsx 中提供,子组件通过 context 消费
  • JSX 文件不要写 import React from 'react':项目使用 automatic JSX runtime(expo jsx: react-jsx),多余的 React 导入会触发 noUnusedLocals 的 TS6133

React Native 规范

  • 触摸事件必须在同步代码中提取数据:RN 触摸事件(onTouchMove 等)的合成事件对象会被重用回收,在 requestAnimationFrame / setTimeout / Promise 等异步回调中访问 e.nativeEvent.touches 会得到 null。先同步提取,再进异步回调:

    typescript
    const handleTouchMove = useCallback((e: any) => {
      const touch = e.nativeEvent?.touches?.[0]
      if (!touch) return
      const { pageX, pageY } = touch  // 同步提取
    
      requestAnimationFrame(() => {
        // 使用已提取的 pageX, pageY(此处再读 e.nativeEvent 已被回收)
      })
    }, [])
  • 页面底色是 colorPlain(浅色为纯白),不是 colorBg:Page 在 iOS 用 _.container.plain,浅色页面即白底。因此浅色下卡内分隔靠「描边 + 白底」,不要在白底上挖 colorBg 大灰井或叠白卡片 + 阴影(两者都不可见);暗色相反,靠「level1 卡片 + colorBg 深井」分层,阴影无效可省略

  • 覆盖组件默认样式必须用同名具体键,不要用简写:RN 样式数组按下标合并,padding 简写与组件内部的 paddingHorizontal / paddingVertical 是不同 key,且 Yoga 中具体边优先于简写——padding: 0 压不住自带的 paddingHorizontal: _.wind。要覆盖就写同名键(如 paddingHorizontal: 0)

  • 样式对象内属性按「布局 → 尺寸 → 间距 → 文字 → 填充 → 修饰」从上到下书写,即先写决定盒子如何被放置与占位的属性,再写内容(文字),然后是容器的视觉填充,最后写裁切阴影等修饰,一组内固定先后:

    1. 布局参与:flex / alignItems / justifyContent
    2. 定位:position → zIndex → top / right / bottom / left(偏移仅 absolute 时出现,紧跟定位)
    3. 尺寸:width → height
    4. 间距:padding(全 → 轴向 → 单边)→ margin(全 → 轴向 → 单边)
    5. 文字排版:fontFamily → fontSize → lineHeight → color → textAlign(内容在前,容器 paint 在后)
    6. 背景:backgroundColor
    7. 边框与圆角:borderWidth → borderColor → borderStyle → borderRadius
    8. 修饰收尾:overflow → transform → ..._.shadow
    typescript
    export const memoStyles = _.memoStyles(() => ({
      dot: {
        position: 'absolute',   // 定位
        zIndex: 1,
        top: 6,
        right: 6,
        width: 6,               // 尺寸
        height: 6,
        backgroundColor: _.colorSuccess,  // 填充
        borderRadius: 6,        // 圆角
        overflow: 'hidden'      // 修饰收尾
      },
      form: {
        paddingVertical: 16,    // 间距: 先 padding 后 margin
        paddingRight: 8,
        paddingLeft: 16,
        marginBottom: _.sm,
        backgroundColor: _.colorBg,
        borderWidth: 1,
        borderColor: _.colorBorder,
        borderRadius: _.radiusSm,
        overflow: 'hidden'
      },
      input: {
        width: '100%',          // 尺寸
        paddingVertical: 8,     // 间距
        paddingHorizontal: 12,
        fontFamily: _.fontBoldFamily,  // 文字在背景前
        color: _.colorDesc,
        ..._.fontSize14,
        backgroundColor: _.select(_.colorPlain, _._colorDarkModeLevel2),  // 背景
        borderWidth: _.select(1, 0),
        borderColor: _.colorBorder,
        borderRadius: _.radiusXs,
        overflow: 'hidden'
      }
    }))

模块级缓存规范

  • Map / Set 缓存需要上限时,统一使用 @utils/cache 的 ensureCacheLimit(cache, maxSize),在每次 set / add 之后调用即可(FIFO 淘汰最早条目,单次只淘汰 1 条);禁止自建「超过上限就删除」的私有工具函数或 LRU 类
  • 数组缓存需要上限时,统一使用 @utils/cache 的 ensureArrayLimit(list, maxLength),原地 splice 从尾部裁剪并返回同一引用;MobX observable 数组需在 runInAction 内调用
  • decode / 映射 / 去重等纯函数级缓存优先做成「模块级 Map + ensureCacheLimit」,缓存 key 要包含会影响结果的全部输入(如屏蔽词列表需加入内容指纹,修改后缓存自动失效)

MobX 规范

  • MobX store 属性若作为 hooks 依赖,必须先解构:systemStore.setting.xxx 需要进入 useMemo / useEffect / useCallback 的依赖数组时,先在组件顶层解构为局部变量再使用,不要在依赖数组中内联 systemStore.setting.xxx:
    typescript
    const { s2t: s2tEnabled } = systemStore.setting
    const labels = useMemo(() => ..., [action, s2tEnabled])
  • 解构命名需避开已导入的符号:如 s2t 是导入的转换函数,设置项解构时应重命名为 s2tEnabled,避免遮蔽
  • 禁止在 computed / derivation 中原地修改 observable 数组:.sort()、.reverse() 等会 mutates in-place,必须先 .slice() 或 [...arr] 拷贝后再操作,例如 arr.slice().sort(...)
  • 同理避免 .splice()、.push()、.pop() 等原地修改 observable 数组的操作
  • 如果 .filter() / .map() 的源是 observable 数组,其返回值已经是新数组,可安全使用 .sort();但如果源是其他 computed 返回值,需先 .slice()
  • computedFn 统一从 @utils/computed-fn 导入,禁止直接从 mobx-utils 导入。项目自定义的 computedFn 内部通过 Proxy 自动补齐可选参数,避免原版 DeepMap 因 arguments.length 不一致而崩溃(DeepMap should be used with functions with a consistent length)

.Claude Docs Component (`.claude/docs/component.md`)

可复用组件(components/)规范

页面内私有组件规范见 screen.md

目录结构

text
component-name/
├── index.tsx       # 主组件(函数组件 + observer()),只编排 hook 返回值 + 渲染
├── types.ts        # 所有类型定义(Props + hook 参数 + 事件类型 + 内部类型别名)
├── styles.ts       # StyleSheet + memoStyles()
├── hooks.ts        # 组件专属 hooks
├── utils.ts        # 工具函数(纯函数,供逻辑测试)
└── ds.ts           # 常量(调试常量 / 模块级常量)

关键模式

  • 所有组件为函数组件,包裹 observer()
  • Props 类型使用 WithViewStyles<>、WithNavigation<> 组合
  • 样式用 _.memoStyles() 缓存(theme-aware)
  • 样式合并用 stl() 工具函数
  • 组件包裹 <Component id='...'> 调试壳

observer 与 memo 的选择

组件级包裹方式取决于组件是否直接读取可观察值(store / 主题):

  • 读取可观察值(如 systemStore.setting.xxx、_ 主题值)→ 包裹 observer()。observer 内部是「React.memo 浅比较 + 可观察值追踪」,能响应 store 变化,是 memo 的超集。
  • 纯转发 props、不读任何 store → 包裹 memo()。不读取可观察值时 observer 退化为纯 memo 行为,memo 更轻(不引入 mobx 追踪依赖)。

判断标准:组件体(含调用的派生计算)是否出现 systemStore.*、_、userStore.* 等可观察值读取。例如:

  • cover/cover-image 只把 props 转发给 Image/Squircle(各自是 observer),用 memo
  • cover/disc 读取 _.radiusSm,用 observer

要点:

  • memo 浅比较对字符串/数字按值比较(稳定生效),对对象按引用比较——传内联对象会让任何 memo 失效,样式应传 styles.xxx 稳定引用
  • 若组件未来可能直接读 store,直接先用 observer 亦无性能代价

失焦门控(Tab 保活下的常驻副作用)

底栏 tab 与页面内二级 Tab 都会保活,组件不能只靠卸载来停止副作用:

  • 需要「页面聚焦 && 应用在前台 && 所在 Pager 场景聚焦」时用 useActive()(@utils/hooks):blur 时同步置 false,能在 freezeOnBlur 冻结前完成最后一次渲染;不要改用 useIsFocused(blur 延迟到切页动画后,冻结的树内 setState 不生效)
  • 无限循环动画:useEffect 内以 active 为条件启停(停时 cancelAnimation 再归位),不能只依赖卸载清理
  • 轮询 / 定时器:以 active 作为启动条件;多实例共享的全局定时器沿用引用计数清理
  • 页面内 TabView 传 keepDistance={1}:|i - index| <= 1 的场景保活,更远的场景卸载内容(外层页容器必须保留,Pager 子节点数量恒定);跨级跳转期间区间自动扩展到目标页
  • Android 的 nested-scroll 页面给 NestedScrollParallaxHeader 传 lazyDistance={1},未激活页用占位 View 保号(lazy-load-view 的 placeholder)

observer 组件内不要用 useMemo 缓存 store 派生数据

两个机制的冲突:

  1. observer() 追踪渲染期(含 useMemo 回调执行期间)的所有可观察值读取,store 变化时通过自身订阅触发组件重渲染
  2. useMemo 按 deps 引用浅比较决定重算——而 deps 只能写组件 props / 局部变量

结果:把「调用 store 方法的派生计算」包进 useMemo 后,store 更新(如点赞乐观更新)→ observer 正常触发重渲染 → 但 deps 未变 → 返回缓存里的旧数据,UI 停留在过期状态。且被追踪的可观察源藏在调用链深处(如 rakuenStore.likesList() 内部读取),deps 无法枚举完整。

规则:

  • ❌ 不写:
    tsx
    const likesList = useMemo(() => rakuenStore.likesList(topicId, id) || [], [topicId, id])
  • ✅ 正确:store 派生数据每次渲染直接计算,失效交给 observer 细粒度追踪(这也是全局规范「不使用 useMemo」的同一原因)
    tsx
    const likesList = rakuenStore.likesList(topicId, id) || []
  • 派生计算本身很廉价,真正的开销在子树渲染——全局依赖 observer() 自动优化即可;仅在 screen.md「页面区块双组件模式」等例外场景才引入 memo
  • 纯事件回调(useCallback)不受此限制,正常使用

复合组件(子组件独立文件夹)

当组件拆分出多个子组件时(如 hold-menu、scroll-view/mask),每个子组件独立成文件夹,文件夹内 index.tsx 用 export default 导出组件,私有样式放该文件夹的 styles.ts,专属逻辑抽成 useXxx.ts 自定义 hooks:

text
hold-menu/
├── index.tsx           # 入口, 导出默认组件与外部类型
├── context.ts / ds.ts / types.ts / utils.ts   # 共享模块
├── provider/
│   ├── index.tsx           # default export
│   ├── useMenuController.ts
│   └── styles.ts
├── hold-item/
│   ├── index.tsx           # default export
│   ├── useItemAnimation.ts
│   ├── useItemGesture.ts
│   └── styles.ts
├── menu/
│   ├── index.tsx           # default export
│   ├── useMenuAnimation.ts
│   ├── styles.ts
│   └── menu-item/          # 更深层的私有子组件同理
│       └── index.tsx       # default export
└── backdrop/
    ├── index.tsx           # default export
    └── styles.ts
  • 子组件一律 export default,hooks 用具名导出
  • 组件直接以真实名称声明 function X(...),末尾 export default memo(X) 包裹导出,不要多包一层(不做 function XComponent + const X = memo(XComponent) 的中间命名);仅对外入口组件用 observer() 包裹(如 HoldMenuProvider)
  • 例外:子组件若直接读取可观察值(_ 主题值、store 字段),按「observer 与 memo 的选择」规则应改用 observer()——如 scroll-view/mask 读取 _.isPad/_.wind/_._wind 故用 observer(Mask)
  • 子组件内不写 r(COMPONENT) 调试调用;ds.ts 在无使用时不导出 COMPONENT
  • 子组件 Props 类型提取到各自文件夹 ./types.ts,每个 key 带 /** ... */ 注释,回调字段放 type 末尾
  • 模块级常量放该文件夹的 ds.ts:非调试用途的常量(如 GRADIENT_DIRECTION)也放 ds.ts,避免散落在组件体内每次渲染新建对象
  • 跨组件引用优先 @components/* 别名,避免深层相对路径(../../../)
  • 共享常量/类型/工具仍放组件根目录(ds.ts/types.ts/utils.ts)

类型定义规范

  • 使用 type 而不是 interface 定义 Props

  • 导出名称统一为 Props

  • style / children 等样式与路由类型优先从 @types 引入(见 code-style.md 类型规范):WithViewStyles<> 组合 style、WithNavigation<> 组合导航、ViewStyle/TextStyle 用宽口径别名、children 用 React 的 PropsWithChildren<>

  • 所有类型一律放 ./types.ts:Props、hook 参数类型(useXxxOptions)、事件类型、内部类型别名都收进 types.ts,不写在 hooks.ts / 组件体内

  • 优先 Pick 上层类型:下层组件 Props / hook 参数能从上层的 Props Pick 就不重复定义(Pick);只有语义或必选性不同(如上层可选、本层必填)才保留本地定义

  • 共享类型别名抽到最上层:跨层复用的字面量类型抽具名别名(如 MaskColors = readonly [string, string, string])放根 types.ts,逐层 import type 复用,不重复字面量

  • 每个类型字段带 /** ... */ 注释,回调字段放 type 末尾

  • 示例:

    typescript
    // types.ts
    import type { PropsWithChildren } from 'react'
    import type { WithViewStyles } from '@types'
    
    export type Props = PropsWithChildren<WithViewStyles<{}>>
    
    /** 遮罩渐变色 [左, 中, 右] */
    export type MaskColors = readonly [string, string, string]
    
    /** 水平渐隐遮罩参数 */
    export type UseHorizontalMaskOptions = Pick<Props, 'horizontal' | 'showMask'>

hooks 规范

  • 逻辑优先抽到 hooks.ts 自定义 hooks,组件体只做「hook 返回值组合 + 渲染」
  • 含 JSX 的 hook 文件用 .tsx 扩展名(如 tabs-v2/hooks.tsx 的 useRenderTabBar),纯逻辑 hooks 用 .ts
  • 一类状态机 / 一组回调封装一个 hook(如 useScrollLock),参数与返回值类型放 ./types.ts(useXxxOptions)
  • 对外暴露的回调一律 useCallback 稳定引用,按真实依赖声明 deps
  • 渲染期不直读 ref.current:ref 在渲染后才赋值,渲染期直读会得到 null。需要传给下层组件使用的值应暴露成「调用时懒读取 ref」的稳定函数(如 useScrollViewRef 返回 scrollTo 而非裸 ref)

样式规范

  • 无主题依赖的样式用 _.create() 创建,导出名称统一为 styles,消费端 import { styles } from './styles'

  • 依赖主题(样式值使用 _.select())才用 _.memoStyles() 创建,导出名称统一为 memoStyles

  • 若 memoStyles 内没有任何 computed theme 值(如 _.select),会触发 bangumi/require-computed-in-memo-styles 警告,应改用 _.create

  • memoStyles() 不是 Hook,不受 Rules of Hooks 约束(use* 必须保持在早退之前);若有早退分支(如无条件直接 return 场景),应放到早退之后,避免无渲染时也生成样式。例外:早退分支自身要读样式(如 return <Flex style={styles.loading} />)时,取样式需留在早退之前

  • 组件内部样式放在组件目录下的 styles.ts 中

  • 共享样式放在上层目录的 styles.ts 中

  • 样式数组必须用 stl() 包裹:

    tsx
    // ❌ 错误
    <Text style={[_.mt.sm, style]}>
    
    // ✅ 正确
    <Text style={stl(_.mt.sm, style)}>

禁止用 ts 抑制注释掩盖类型错误

  • 不使用 // @ts-ignore / @ts-expect-error 掩盖可修复的类型错误,从根因解决
  • 例:LinearGradient.colors 期望可变的 string[],传入 readonly 元组报错时应 [...colors] 展开为可变数组,而不是加抑制注释

动画 / 静态 API 组件

原 ant-design 系组件(现位于 src/components/<name>)遵循的模式:

  • 动画核心组件独立成目录(如 modal-view),负责 Portal、遮罩、进出场动画(withTiming/withSpring),对外 UI 组件在之上组合(见 modal/);动画逻辑抽 hooks.ts(如 useModalAnimation):shared values + scheduleOnRN 桥接 onAnimationEnd 回调、稳定 useCallback 引用,JSX 拆 Mask/Content 子组件组合(镜像 collapsible/hooks.ts)
  • 需要静态 API 的组件用 api.tsx 提供命令式入口,通过 Portal.add 挂载(见 action-sheet/api.tsx 的 AntmActionSheet)
  • 纯逻辑抽到 utils.ts(如 normalPercent、getUpdatedIndex、getPosition),测试写 __tests__/utils.test.ts;hook 测试写 __tests__/hooks.test.ts,用手写 renderHook harness(RNTL 版本校验不可用,方案见 testing.md 的「hook 测试规范」)

.Claude Docs Screen (`.claude/docs/screen.md`)

页面(Screen)规范

全局风格规则(observer 包裹、import type、样式数组 stl() 等)见 code-style.md,本文不重复。

目录结构

text
screen-name/
├── index.tsx           # 页面组件
├── types.ts            # Ctx / Params 类型
├── hooks.ts            # useXxxPage() 初始化 hook
├── ds.ts               # COMPONENT 名称常量
├── store/
│   ├── index.ts        # ScreenXxx 类(extends Action),定义 init() / unmount()
│   ├── state.ts        # State extends Store<typeof STATE>,observable(STATE)
│   ├── computed.ts     # Computed extends State,@computed get
│   ├── fetch.ts        # Fetch extends Computed,异步请求
│   ├── action.ts       # Action extends Fetch,业务逻辑
│   └── ds.ts           # NAMESPACE / RESET_STATE / EXCLUDE_STATE / STATE
├── header/             # 页面头部组件(独立目录,不内联在 index.tsx)
│   ├── index.tsx
│   ├── ds.ts
│   └── styles.ts
├── component/          # 页面私有子组件(访问 Store)
│   └── xxx/
│       ├── index.tsx
│       ├── ds.ts
│       └── styles.ts
└── components/         # 页面内纯 UI 小组件(不访问 Store)
    └── yyy/
        ├── index.tsx
        ├── types.ts
        └── styles.ts   # 可选,无样式时省略

types.ts

页面内跨文件复用的类型(数据模型、定位参数、编辑表单项等)统一放页面 types.ts,store 与组件从各自目录 import type 引用,不要内联在 store/action.ts 等使用处。

ts
import { GetRouteParams, WithNavigation } from '@types'
import Store from './store'

export type Ctx = WithNavigation<{
  $: InstanceType<typeof Store>
}>
export type Params = GetRouteParams<RouteXxx>

hooks.ts

ts
import { useInitStore } from '@stores'
import { usePageLifecycle } from '@utils/hooks'
import Store from './store'
import { Ctx } from './types'

export function useXxxPage(props: NavigationProps) {
  const context = useInitStore<Ctx['$']>(props, store)
  const { id, $ } = context
  usePageLifecycle(
    {
      onEnterComplete() {
        $.init()
      },
      onLeaveComplete() {
        $.unmount()
      }
    },
    id
  )
  return context
}

index.tsx

ts
import { observer } from 'mobx-react'
import { Component, StoreContext } from '@stores'
import { useXxxPage } from './hooks'

/** 页面描述 */
function ScreenXxx(props: NavigationProps) {
  const { id, $ } = useXxxPage(props)
  return (
    <Component id='screen-xxx'>
      <StoreContext.Provider value={id}>
        <Page />
        <Header />
      </StoreContext.Provider>
    </Component>
  )
}

export default observer(ScreenXxx)

store/ds.ts

ts
import { COMPONENT } from '../ds'

export const NAMESPACE = `Screen${COMPONENT}`
export const RESET_STATE = { ... }
export const EXCLUDE_STATE = { ... }  // 瞬态,不持久化
export const STATE = { ...RESET_STATE, ...EXCLUDE_STATE, _loaded: false as Loaded }

Store 继承链

text
State → Computed → Fetch → Action → ScreenXxx

每个文件只做一件事:state.ts 声明 observable 状态,computed.ts 做派生,fetch.ts 请求数据,action.ts 处理用户操作,index.ts 定义 init()/unmount()。

子组件访问 Store

ts
import { observer } from 'mobx-react'
import { rc } from '@utils/dev'
import { useStore } from '@stores'
import { COMPONENT as PARENT } from '../ds'
import { Ctx } from '../types'

export const COMPONENT = rc(PARENT, 'Info')

/** 组件描述 */
function Info() {
  const { $ } = useStore<Ctx>(COMPONENT)
  return (
    // JSX
  )
}

export default observer(Info)

页面和子组件统一使用命名函数声明 + 底部 export default observer(Xxx) 导出。

子组件 Props 类型规范

子组件 types.ts 的 Props 按以下优先级编写:

1. 能从上层 Pick 则 Pick

结构与父级类型一致或为其子集时,用 Pick 派生,不重复声明:

ts
// grid/list/types.ts
import type { Props as GridProps } from '../types'

export type Props = Pick<GridProps, 'title'>

交叉写入的自有 key 单独加注释:

ts
// empty/types.ts
import type { Props as ListProps } from '../list/types'

export type Props = Pick<ListProps, 'title'> & {
  /** 当前列表条目数 */
  length: number
}

上级没有独立 Props 类型时,最常见的场景是接收上级展平的数据模型条目(List 里 {...item} 透传),此时从共享数据模型 Omit / Pick 派生,不手写一份字段相同的镜像类型;回调签名优先复用组件库已有的 prop 类型:

ts
// item/types.ts —— 上级 List 直接展开 OriginItem 条目
import type { InputProps } from '@components'
import type { Keys, OriginItem } from '../../types'

export type Props = Omit<OriginItem, 'desc'> & {
  /** 源头类型 */
  type?: Keys

  /** 输入框聚焦时滚动到可视区 */
  onScrollIntoViewIfNeeded?: InputProps['onScrollIntoViewIfNeeded']
}
  • 数据模型缺失实际数据携带的字段时(如 iconSquare),把字段补进数据模型,而不是在子组件 Props 里凭空手写
  • 上级自身没有可 Pick 的类型(页面无 props、上级 Props 只有一个回调字段)时,才按第 2 条手写

2. 不能 Pick 则保留手写,key 必须有字段注释

父级为可选字段 / 子级要求必传、或子级接收展平标量等无法 Pick 时,保留手写类型;每个自写 key 都要有 /** */ 字段注释。不要写「为何不 Pick」之类的类型级说明注释:

ts
import type { SubjectId } from '@types'

export type Props = {
  /** 条目 Id */
  subjectId: SubjectId

  /** 已看集数 */
  epStatus: string | number
}

3. 格式

首个字段的注释可紧贴 {,后续字段的注释与上一个字段之间必须空一行(与全局声明注释规则一致)。

页面风格要求

  • headerRight 等回调用 useCallback 包裹:
tsx
const handleHeaderRight = useCallback(
  () => <IconHeader name='md-info-outline' onPress={handlePress} />,
  []
)

return <Header title='标题' hm={HM} headerRight={handleHeaderRight} />
  • 埋点 t() 置后:先执行核心业务逻辑(如 alert、导航),后执行 t() 埋点调用

页面区块(component/xxx/)双组件模式

滚动密集页面(如条目页)的区块采用 Wrap + 内容组件两层结构,配合 React.lazy 分包,是 observer 通则的合法例外:

text
component/ep/
├── index.tsx     # EpWrap: function + useStore + observer;读 show* 守卫显隐、组装数据、注册 ref
├── ep.lazy.ts    # export default lazy(() => import('./ep')) —— 代码分割
├── ep.tsx        # 内容组件: memo(function Ep(...) {...}, DEFAULT_PROPS, COMPONENT_MAIN)
└── ds.ts         # COMPONENT_MAIN = rc(COMPONENT)、DEFAULT_PROPS(含 FROZEN_FN 兜底)
  • Wrap 是 store 连接点:早退(模块隐藏)、派生选择逻辑都在这层
  • 内容组件保持 props 驱动 + memo 浅比较,不接 store;由上层保证 props 引用稳定
  • 区块锚点统一用 component/block-anchor(``),不要手写 useCallback + <View ref collapsable> 样板
  • 新增区块遵循此模式;不要把内容组件改成 observer 直连 store(会破坏浅比较优化)

敏感标签过滤(条目页第三方标签)

条目页底部四个第三方标签块(component/tags/block/,path = Anime / Game / Manga / Wenku)在 userStore.isExtremeLimit 为真时,统一走 isSensitiveTag() 过滤:

  • 判定实现在 src/utils/subject/sensitive-tag.ts,三档匹配:子串包含(绝大多数词)、整串相等(肉 / 变态 / 正太 / 触手 / 强制,子串会误伤 肉鸽 / 生肉 / 变态王子与不笑猫 等)、ASCII 单词边界(ntr / sm / 3p,会误伤 LapinTrack / PLAYISM / d3p)
  • 四个块一视同仁,不要再按 path 区分;path 只用于跳转
  • 「四块」指条目页标签区实际渲染的 4 个 ``(component/tags/tags-list/index.tsx),与 src/utils/subject/ 下的 9 个频道目录无关:找ADV / 本子 / 三次元 / 音乐频道不渲染第三方标签块,它们用各自 ds 的 TAGS 做筛选 chip;但词表来源覆盖全部频道目录
  • 词表增删后必须跑 src/utils/subject/__tests__/sensitive-tag.test.ts:它以 typerank 五类全量标签(4776 条)为语料,断言命中集合与人工确认清单完全一致(多一个即误伤,少一个即漏词)
  • 题材义标签(BL / 百合 / 耽美 / 后宫 / 纯爱 / 一般向)与资源义标签(生肉)一律不遮

页面内组件规范(components/)

纯 UI 小组件放 components/,不访问 Store(这是它与 component/ 下子组件的唯一区别)。目录结构、类型定义(PropsWithChildren<WithViewStyles<{}>>)、样式(stl())、命名函数 + export default observer() 写法均同 component.md。


.Claude Docs Screens (`.claude/docs/screens.md`)

页面索引

新增或修改页面时,同步更新此文件。

Discovery

adv, anime, anitama, award, bi-weekly, blog, browser, calendar, catalog, catalog-detail, channel, character, dollars, game, hentai, like, manga, music, nsfw, pic, rank, recommend, search, series, staff, tags, users, vib, wenku, wiki, word-cloud, yearbook

Home

catalogs, characters, episodes, info, link, mono, overview, persons, preview, rating, subject, tag, typerank, v2, voices, wiki, works

v2(首页)

COMPONENT: Home,NAMESPACE: ScreenHomeV2

用户登录后的主页面,展示在看/在玩的收藏条目,支持 5 个分类 tab:全部/动画/书籍/三次元/游戏。

Store 关键状态:

  • top: SubjectId[] — 置顶条目
  • item: Record<SubjectId, { expand, doing }> — 每个条目的展开/进行中状态
  • page — 当前 tab 页码
  • grid — 网格布局选中的 subjectId
  • filter — 各 tab 的筛选文本
  • visible, modal, progress — 弹窗/进度等瞬态

Store 主要方法:

  • init() — 加载缓存 → 延迟 4s 拉取最新数据
  • fetchSubject() — 单条目拉取(6h 缓存)
  • fetchSubjectsQueue() — 批量队列拉取
  • doWatchedNextEp() — 标记下一集已看
  • doUpdateSubjectWatched() — 批量更新看到第几集
  • doSaveCalenderEvent() / doExportCalenderEventICS() — 日历提醒
  • itemToggleExpand() / itemToggleTop() — 展开/置顶

子组件:

  • tab/ — Tab 切换(支持 2+ tab 用 Tab 组件,1 tab 直接渲染)
  • list/ — 列表布局(每条 item 可展开显示章节按钮)
  • grid/ — 网格布局(含 info 子组件:eps/cover/title/toolbar 等)
  • item/ — 条目项(cover/eps/count/title/toolbar/onair/progress 等拆分)
  • filter/ — 筛选栏
  • modal/ — 收藏管理弹窗
  • tips/ — 提示条
  • heatmaps/ — 热力图
  • time/ — 时间显示
  • is-top/ — 置顶标识
  • empty/ — 空状态

Login

assist, token, v2

Rakuen

blog, board, group, history, mine, notify, reviews, search, setting, topic, ugc-agree, v2

Timeline

say, v2

Tinygrail

advance, advance-ask, advance-auction, advance-auction2, advance-bid, advance-sacrifice, advance-state, bid, chara-assets, clipboard, deal, ico, ico-deal, items, logs, lottery-rank, new-bangumi, overview, relation, rich, sacrifice, search, star, temples, top-week, trade, transaction, tree, tree-rich, valhall, wiki

User

actions, backup, blogs, catalogs, dev, friends, milestone, origin-setting, pm, qiafan, server-status, setting, smb, sponsor, timeline, user-setting, v2, zone

WebView

bilibili-sync, douban-sync, information, log, playground, share, tips, versions, web-browser, webhook


.Claude Docs Store (`.claude/docs/store.md`)

MobX Store 规范

Store 继承链

text
State → Computed → Fetch → Action
  • State(extends @utils/store)— observable state、init()、save()、readStorage()
  • Computed(extends State)— @computed 派生数据
  • Fetch(extends Computed)— API 请求 → setState() + save()
  • Action(extends Fetch)— 业务逻辑

Store 文件拆分

每个 store 目录包含:index.ts、state.ts、computed.ts、fetch.ts、action.ts、common.ts(解析器)、init.ts(初始状态)、types.ts

大文件二次拆分(链式中间类)

单文件超过 400 行红线时,按领域切成子目录中的中间类文件,继承链接力,对外导出不变:

text
store/
├── computed.ts          # 聚合入口: export default class Computed extends Derived {}
├── computed/
│   ├── base.ts          # extends State
│   ├── meta.ts          # extends Base
│   └── ...
├── fetch.ts             # 核心请求, extends fetch/extend
└── fetch/
    ├── oss.ts           # extends Computed
    └── ...

规则:

  • 中间类文件只做「方法搬家」,函数体逐字不变;被调用者所在类必须位于调用者祖先方向(链序 = 依赖序)
  • 每个新文件自带裁剪后的 import 子集,基类导入指向上一环(import Oss from './oss')
  • 纯函数工具拆为目录 + index.ts 再导出(如 store/utils/),消费方 from './utils' 零改动

已应用:home/subject/store(computed/ fetch/ action/ utils/ 四组)、home/v2/store(computed/ action/ utils/ 三组)、discovery/word-cloud/store(computed/ 一组:base/meta/comment/collection)

18 个 Domain Store

Store 职责
calendarStore 放送日历
collectionStore 用户收藏
discoveryStore 发现/搜索
monoStore 人物/制作人员
otaStore OTA 数据
rakuenStore 超展开(论坛)
searchStore 搜索
smbStore SMB 网络文件
subjectStore 条目(动画/书籍/游戏)
systemStore 系统设置
tagStore 标签
themeStore(别名 _) 主题/样式
timelineStore 时间线
tinygrailStore 小圣杯(股票游戏)
uiStore UI 状态
userStore 用户认证
usersStore 其他用户信息

全局初始化:src/stores/global.ts → GlobalStores.init()


.Claude Docs Testing (`.claude/docs/testing.md`)

测试规范

  • it() / test() 描述使用中文,格式为简洁的行为描述
  • describe() 为被测函数/模块名,it() 描述预期行为,如 it('空字符串返回空数组')
  • 测试注释使用中文,保持格式一致
  • 主动发掘隐藏问题,用 [问题] 前缀标注,如 it('[问题] 锁永远不释放时 Promise 永远挂起')
  • 发现问题时测试应该失败,而不是通过——这样用户看到失败才会去修复代码。写期望的正确行为,而非当前的错误行为
  • 常见需验证的问题:
    • 内存泄漏:setInterval/setTimeout 无清理机制、持续累积的订阅/监听
    • 竞态条件:并发调用共享状态、Promise 乱序完成
    • Promise 永远挂起:缺少 reject/resolve 路径、错误时等待者未通知
    • 异常处理:async executor 反模式、空 catch 吞掉错误
    • 边界情况:switch 无 default、空值/undefined 未处理
  • 不要在测试文件里局部 mock @utils/dev:jest.setup.js 已按 resolved 路径全局注册完整 mock(含全部导出与 logger 七个方法),相对路径导入同样生效;需要断言日志时直接 import { logger } from '@utils/dev' 后对 jest.fn 断言

hook 测试规范

RNTL 的 renderHook 因 ensure-peer-deps 严格校验 react-test-renderer 与 react 版本完全一致而不可用(版本漂移即抛错,依赖安装由用户自理)。hook 测试直接用 react-test-renderer 手写最小 harness(参考 src/components/touchable/__tests__/hooks.test.ts):

  • jest.requireActual('react-test-renderer') 引入(该包无 TS 类型,requireActual 返回 any 规避)
  • 顶部设 globalThis.IS_REACT_ACT_ENVIRONMENT = true,否则 act 内状态更新不生效
  • 所有触发 setState 的调用(含 afterEach 里 jest.runAllTimers() 执行到的解锁回调)都要包在 act 内
  • 依赖 store 的 hook:在 jest.setup.js 的 @stores mock 通过 global.__mockStoreState__ 可变 cell 暴露字段(如 uiStore.isScrolling),测试里直接改 cell 控制分支
  • 屏蔽 react-test-renderer is deprecated 官方告警用定向 jest.spyOn(console, 'error') 过滤,保留其余错误输出

cheerio HTML 解析测试规范

HTML 解析引擎对照 (engine-battery)

  • 生产引擎固定为 next(cheerio 1.0 slim,静态 import);legacy(0.20)仅测试对照用,生产代码不引用、不进 bundle
  • 双引擎对照方式:命令行 HTML_ENGINE=legacy npx jest,battery 测试在模块顶层 __setEngineForTest(legacyEngine()) 注入(结果收集在模块顶层执行,注入不能放 beforeAll),afterAll 传 null 恢复
  • src/utils/thirdParty/html/__tests__/engine-battery.test.ts 对 24 个真实页面 fixture + 内联边界用例跑生产在用的全部选择器/方法组合,与 battery-baseline/<engine>.json 逐字节对照;REGEN_FIXTURES=1 重新生成基线
  • 修改引擎适配层或 src/utils/thirdParty/html/ 解析逻辑后,必须跑 battery 并确认两引擎基线零 diff;新引擎行为确需变化时先改适配层归一(如实体大小写归一在 engines/slim.ts),归一不了才 REGEN 基线并记录理由
  • 注意:文档级 $(cheerio(html) 的返回值)没有 .find 等实例方法,cFind($, ...) 会走 catch 返回 $el 本身,.length 是函数 arity——测试中 cFind/cList 必须作用于元素实例(与生产一致)

以下模式来自 src/stores/user/__tests__/common.test.ts,按需参考,自行判断是否适用。

结构

text
describe('运行时结构符合类型定义', () => {   // 放在最前
  it('函数名 返回 TypeName', () => {          // 用 expect.any() 验证每项字段类型
    ...
  })
})

describe('函数名', () => {                    // 具体的值验证
  it('总数验证', ...)                         // toHaveLength(n)
  it('首项关键字段', ...)                      // toMatchObject({ ... })
  it('边界情况', ...)                          // Re: 前缀、空值、特殊字符
  it('异常/边缘值', ...)                       // userId 为 0、HTML 实体编码
})

常见做法

  1. 类型校验优先:第一个 describe 用 expect.any(String) / expect.any(Boolean) 验证每项结果是否匹配 TS 类型定义,数据类型不符时第一时间暴露
  2. HTML 存外部文件:真实页面 HTML 放在 __tests__/html/ 下,通过 fs.readFileSync 加载,该目录建议加入 .gitignore
  3. 使用 cHelper 方法测试:测 cheerio 解析函数推荐用项目封装的 cParse / cMap / cFind / cText / cData / cHtml / cHasClass,而非直接用 cheerio,保证与生产代码行为一致
  4. toMatchObject 部分匹配:用 toMatchObject 只检查关心的字段,避免因cHtml对中文做 HTML 实体编码(如测试→&#x6D4B;&#x8BD5;)导致全量匹配失败
  5. 内容含中文用 toContain:cHtml 返回 HTML 实体编码后的字符串,精确匹配可考虑使用 toContain 配合部分内容
  6. 每类边缘场景一个 it:Re: 前缀拆分、/ 分隔符、icon.jpg 默认头像、&amp; HTML 实体等各自独立成 it
  7. [类型定义问题] 标注类型与实际不符:如果函数运行时返回值结构与 TS 类型定义不一致(如 label 项缺少 name/avatar/userId 但类型标记为 required),可在注释中用 [类型定义问题] 标注
  8. @utils 在 jest 中被 jest.setup.js 虚拟 mock:mock 只覆盖部分导出,若解析函数用到 @utils 中 mock 未提供的工具,需先在 jest.setup.js 补充对应实现(与真实实现保持一致),否则运行时会是 undefined
  9. cText / cEach / cPagination 等解析工具直接复用真实实现:为避免 mock 与生产代码分叉,jest.setup.js 的 @utils mock 通过 require(__dirname + '/src/utils/thirdParty/html/parse') 直接复用 parse.ts 中抽离的解析函数,不要重复实现

src/styles 测试规范

参考 src/styles/__tests__/。不要断言字面量(改源码就要同步改测试,纯噪音),只测不变量/派生关系:亮暗配色成对、Raw 能从源色解析、工具类引用统一 token(mt.sm.marginTop === sm)、几何自洽(wind === floor((width - contentWidth)/2))、tabsHeaderHeight === headerHeight + tabsHeight、lineHeight === floor(fontSize * lineHeightRatio)、默认导出 _ 覆盖全部命名导出。

注意点:

  • layout/utils 需 mock 环境:顶部 jest.mock('@constants/device') 补全 PAD/RATIO/STORYBOOK_* 等(全局 mock 只有 WEB),expo-constants 须带 __esModule: true;测 IOS 分支再 mock @constants/constants
  • jest 不支持 expect(a, msg) 双参,用 expect(cond).toBe(true)
  • @styles(无子路径)不在 moduleNameMapper,index.test.ts 用相对路径 ../index

.Claude Docs Token Budget (`.claude/docs/token-budget.md`)

Token 预算规则

禁止读取的路径(文件过大,浪费 tokens)

  • src/assets/json/ — 所有 JSON 数据文件(mono.json、vib.json、substrings/、typerank/)
  • src/assets/ — 图片、字体等二进制资源(39M+)
  • dist/、node_modules/、metro-cache/
  • web/test/*.json — 测试数据
  • 任何超过 50KB 的 .json 文件

替代方案

  • src/ 源码与 package.json、tsconfig.json 等配置文件可自由读取
  • 用 grep/find 查找 JSON key 或资源在代码中的引用
  • 了解数据结构先看类型定义、import 语句、消费它的 store,而非直接读数据文件
  • 如确需了解 JSON 结构,只用 Read 工具的 limit 参数读前 20 行

--- METRICS ---