Technical Documentation: czy0729/Bangumi
ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference
Overview (`README.MD`)
Bangumi
一个基于 react-native 的 Bangumi 第三方客户端
Bangumi 是一个用于管理追番进度(不限于动漫、音乐、日剧、游戏、AVG),发现和讨论番组的网站
iOS 和安卓都做了兼容,并最大限度保持一致
目前已开发不少于 100 个页面,基于移动端重新设计布局,几乎涵盖网站所有页面
本人并没有原生开发经验,用于练手 react-native,希望通过这个平台去学习更多
更多涉及到本项目的说明,可前往 语雀 查看
下载
安卓 APK 下载 | AltStore 侧载 | AltStore 直接添加源 | iOS 上当前能使用的方法 | Expo 主页
Google Play 被举报下架,暂搁置
iOS 付费开发者账号已过期,期间没能过审,因涉及到社区、版权敏感、备案等问题,不考虑再送审了
功能
基本功能(网站原有功能)
带链接的支持网页预览(只能看不能操作)
扩展功能
- 其他站点(bilibili、豆瓣)有限同步到 bgm.tv 功能
- 封面图片和大部分脱敏静态数据覆盖到自费 CDN 中间层,能更稳定快速地展示数据(尽管网页版遇到很慢或者挂掉客户端也能正常获取数据)
- 照片墙、猜你喜欢、AI 推荐、关联系列、优质目录、评分月刊、分类排行
- 词云:条目、人物、帖子
当季番剧精准到单集网络播放源、源头自定义管理,找番剧、书籍、游戏- 番剧截屏、游戏截图预览
- 多规则超展开屏蔽、用户拉黑功能
- 本地文件夹条目管理(支持文件夹罗马音刮削)
- 小圣杯(角色交易空气游戏,客户端独立设计 UI 和交互)
预览
以下为 2025 年 6 月份,一些 UI 相对不重复的页面截屏。图片无法正常加载也可以前往 语雀 查看。
版本
在线
基于 @storybook/react-native 6.5 生成的 iframe.html,尽量少入侵原项目的基础上,补全了一个完整的单页面流程(当然这样是不考虑性能问题的,不可能与正常开发的,例如使用 Next.js 的相提并论)。
关键代码实现:navigate | window popstate
因现在 expo 在网页上面已经很成熟,若以后有空再考虑使用同一套方案补全网页端。
SPA 应用预览(网页收藏管理条目相关功能不定期开发中,已支持基本的信息展示,移动端最佳)
开发心得
从 [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 源头,可能会在跑环境时提示不允许代理。
npm config set registry https://registry.npmjs.org国内环境,首次下载包有可能会异常地慢。因用了很多旧的依赖包,里面的包版本依赖混乱,为了不出错请使用 yarn 安装。
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 前请先阅读
安卓是 expo 下的 bare workflow,已经脱离了 expo 的环境很多年了,有条件请先尝试 iOS 环境开发,因为后面诸多问题,比如特定的 jdk 版本、子库的一次微小的版本发布,都会导致就是启动不起来。
本 repo 默认提交 iOS 环境的
package.json,若你想开发安卓请先用./packages/android/package.json替换掉./package.json,/patches复制到根目录,再安装包。手机 USB 连接后允许调试授权,运行
yarn dev会自动编译一个版本到手机,若后面报错 metro 无法正常启动,可以运行yarn metro不打包直接打开调试进程。若需要打包 apk 前请先使用自己的环境生成
my-release-key.keystore放到./android/app/my-release-key.keystore。./android/gradle_yours.properties重命名为gradle.properties,并根据上面生成的 keystore 自行修改***的内容。可能还需要你手动创建local.properties编写sdk.dir=/Yours/Android/sdk
MYAPP_RELEASE_STORE_FILE=my-release-key.keystore
MYAPP_RELEASE_KEY_ALIAS=***
MYAPP_RELEASE_STORE_PASSWORD=***
MYAPP_RELEASE_KEY_PASSWORD=***打包命令为
yarn build。打包完成后可以使用
yarn openoryarn open:mac直达 apk 输出目录。
Overview (`packages/README.md`)
packages 多环境管理
本项目通过 yarn env [ios | android | web] 在多个开发环境之间切换,脚本见 packages/env.js。提交代码前请切回 ios 环境。
目录结构
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 <目标环境> 执行三件事:
- 备份当前环境:将根目录的
package.json、node_modules备份到packages/{当前环境}/ - 恢复目标环境:将
packages/{目标环境}/的package.json、node_modules恢复到根目录 - 更新平台配置:同步
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(推荐)
全程在网页操作,不需要安装任何东西。
部署通用代理
- 打开 Deno Deploy,用 GitHub 登录
- 点击 New Project → Playground
- 删除默认代码,复制粘贴
deno/index.ts的全部内容 - 顶部 Project Name 填一个名字(如
bgm-proxy),点击 Deploy - 部署完成后得到一个
https://bgm-proxy.deno.dev的地址
部署图片代理(可选)
重复以上步骤,粘贴 deno/lain.ts 的内容,取另一个名字(如 bgm-lain)。
部署到 Cloudflare Workers(需自定义域名)
如果你有备案域名,也可以用 Cloudflare:
- 打开 Cloudflare Dashboard → Workers & Pages
- 点击 Create Worker
- 删除默认代码,复制粘贴
cf/index.js(通用代理)或cf/lain.js(图片代理) - 点击 Deploy
- 在 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 封锁。
架构
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. 编译
npm run build:rust
# 或
bash android/rust/build-android.sh脚本会自动检查 OpenSSL 源码是否存在:
- 已存在 → 直接编译
- 不存在 → 询问是否自动下载,或手动下载到
android/rust/openssl/build/openssl-4.0.1/
手动下载地址:
https://github.com/openssl/openssl/releases/download/openssl-4.0.1/openssl-4.0.1.tar.gz目录结构:
android/rust/openssl/
build/
openssl-4.0.1/ ← 源码 (自动下载或手动放置)
install/ ← 编译产物 (自动生成)构建流程
- 检查/安装 Rust +
aarch64-linux-androidtarget + cargo-ndk - 设置 Android NDK 环境变量
- 交叉编译 OpenSSL (带 ECH 支持,静态链接,已编译则跳过)
cargo ndk编译libechproxy.so- 复制到
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
目录结构
├── 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(expojsx: react-jsx),多余的 React 导入会触发noUnusedLocals的 TS6133
React Native 规范
触摸事件必须在同步代码中提取数据:RN 触摸事件(
onTouchMove等)的合成事件对象会被重用回收,在requestAnimationFrame/setTimeout/Promise等异步回调中访问e.nativeEvent.touches会得到null。先同步提取,再进异步回调:typescriptconst 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)样式对象内属性按「布局 → 尺寸 → 间距 → 文字 → 填充 → 修饰」从上到下书写,即先写决定盒子如何被放置与占位的属性,再写内容(文字),然后是容器的视觉填充,最后写裁切阴影等修饰,一组内固定先后:
- 布局参与:
flex/alignItems/justifyContent - 定位:
position→zIndex→top/right/bottom/left(偏移仅 absolute 时出现,紧跟定位) - 尺寸:
width→height - 间距:
padding(全 → 轴向 → 单边)→margin(全 → 轴向 → 单边) - 文字排版:
fontFamily→fontSize→lineHeight→color→textAlign(内容在前,容器 paint 在后) - 背景:
backgroundColor - 边框与圆角:
borderWidth→borderColor→borderStyle→borderRadius - 修饰收尾:
overflow→transform→..._.shadow
typescriptexport 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:typescriptconst { 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
目录结构
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),用memocover/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 派生数据
两个机制的冲突:
observer()追踪渲染期(含useMemo回调执行期间)的所有可观察值读取,store 变化时通过自身订阅触发组件重渲染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:
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导出名称统一为
Propsstyle / 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 参数能从上层的PropsPick 就不重复定义(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警告,应改用_.creatememoStyles()不是 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,用手写renderHookharness(RNTL 版本校验不可用,方案见 testing.md 的「hook 测试规范」)
.Claude Docs Screen (`.claude/docs/screen.md`)
页面(Screen)规范
全局风格规则(observer 包裹、
import type、样式数组stl()等)见 code-style.md,本文不重复。
目录结构
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 等使用处。
import { GetRouteParams, WithNavigation } from '@types'
import Store from './store'
export type Ctx = WithNavigation<{
$: InstanceType<typeof Store>
}>
export type Params = GetRouteParams<RouteXxx>hooks.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
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
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 继承链
State → Computed → Fetch → Action → ScreenXxx每个文件只做一件事:state.ts 声明 observable 状态,computed.ts 做派生,fetch.ts 请求数据,action.ts 处理用户操作,index.ts 定义 init()/unmount()。
子组件访问 Store
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 派生,不重复声明:
// grid/list/types.ts
import type { Props as GridProps } from '../types'
export type Props = Pick<GridProps, 'title'>交叉写入的自有 key 单独加注释:
// 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 类型:
// 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」之类的类型级说明注释:
import type { SubjectId } from '@types'
export type Props = {
/** 条目 Id */
subjectId: SubjectId
/** 已看集数 */
epStatus: string | number
}3. 格式
首个字段的注释可紧贴 {,后续字段的注释与上一个字段之间必须空一行(与全局声明注释规则一致)。
页面风格要求
- headerRight 等回调用
useCallback包裹:
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 通则的合法例外:
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— 网格布局选中的 subjectIdfilter— 各 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 继承链
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 行红线时,按领域切成子目录中的中间类文件,继承链接力,对外导出不变:
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 的
@storesmock 通过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,按需参考,自行判断是否适用。
结构
describe('运行时结构符合类型定义', () => { // 放在最前
it('函数名 返回 TypeName', () => { // 用 expect.any() 验证每项字段类型
...
})
})
describe('函数名', () => { // 具体的值验证
it('总数验证', ...) // toHaveLength(n)
it('首项关键字段', ...) // toMatchObject({ ... })
it('边界情况', ...) // Re: 前缀、空值、特殊字符
it('异常/边缘值', ...) // userId 为 0、HTML 实体编码
})常见做法
- 类型校验优先:第一个 describe 用
expect.any(String)/expect.any(Boolean)验证每项结果是否匹配 TS 类型定义,数据类型不符时第一时间暴露 - HTML 存外部文件:真实页面 HTML 放在
__tests__/html/下,通过fs.readFileSync加载,该目录建议加入.gitignore - 使用 cHelper 方法测试:测 cheerio 解析函数推荐用项目封装的
cParse/cMap/cFind/cText/cData/cHtml/cHasClass,而非直接用 cheerio,保证与生产代码行为一致 toMatchObject部分匹配:用toMatchObject只检查关心的字段,避免因cHtml对中文做 HTML 实体编码(如测试→测试)导致全量匹配失败- 内容含中文用
toContain:cHtml返回 HTML 实体编码后的字符串,精确匹配可考虑使用toContain配合部分内容 - 每类边缘场景一个 it:Re: 前缀拆分、
/分隔符、icon.jpg默认头像、&HTML 实体等各自独立成 it [类型定义问题]标注类型与实际不符:如果函数运行时返回值结构与 TS 类型定义不一致(如 label 项缺少name/avatar/userId但类型标记为 required),可在注释中用[类型定义问题]标注@utils在 jest 中被 jest.setup.js 虚拟 mock:mock 只覆盖部分导出,若解析函数用到@utils中 mock 未提供的工具,需先在 jest.setup.js 补充对应实现(与真实实现保持一致),否则运行时会是 undefinedcText/cEach/cPagination等解析工具直接复用真实实现:为避免 mock 与生产代码分叉,jest.setup.js 的@utilsmock 通过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 ---
- Files Extracted: 14
- Estimated Token Budget: ~12886 tokens
- Canonical Reference: https://codewiki.google/github.com/czy0729/Bangumi