From 676a6c0e1390afa24fba06bc306cbcb129c6eaae Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 17 May 2026 20:12:39 +0800 Subject: [PATCH] =?UTF-8?q?docs(wiki):=20=E6=96=B0=E5=A2=9E=E5=B0=8F?= =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E8=B4=A8=E9=87=8F=E8=A7=84=E8=8C=83=E6=B8=85?= =?UTF-8?q?=E5=8D=95=20=E2=80=94=208=E7=B1=BB=E8=A7=84=E5=88=99+=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E5=89=8D=E6=A3=80=E6=9F=A5+PR=20Review=E4=BE=9D?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽象两轮审计中的 20+ 问题为可复用规范: - 并发与请求层:限流器/长轮询独立通道/reLaunch去重 - 导航与路由:页栈保护/分包预加载/生命周期防重入 - 组件与渲染:禁止render body组件/双重ScrollView/图片懒加载 - 数据与内存:数组上限/去重索引一致/Storage清理 - 认证与会话:模块缓存清理/Token刷新安全 - 审计与交付:提交前必检/PR Review检查点/里程碑审计 --- wiki/index.md | 3 +- wiki/miniprogram-quality-checklist.md | 410 ++++++++++++++++++++++++++ 2 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 wiki/miniprogram-quality-checklist.md diff --git a/wiki/index.md b/wiki/index.md index 3bb427d..173fd8f 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -4,7 +4,7 @@ ## 关键数字 -> 最后更新: 2026-05-17 | 数据截止: feat/media-library-banner 分支(小程序并发饥饿修复 + P1/P2 稳定性加固) +> 最后更新: 2026-05-17 | 数据截止: feat/media-library-banner 分支(二轮审计修复 + 质量规范清单) | 指标 | 值 | |------|-----| @@ -119,6 +119,7 @@ ### 患者端 - [[miniprogram]] — **微信小程序** · Taro 4.2 · 微信登录 · 手机绑定 · 健康数据查看 +- [[miniprogram-quality-checklist]] — **小程序质量规范清单** · 8 类规则 · 提交前检查 · PR Review 依据 ### 基础设施 - [[infrastructure]] — 连接信息 · 环境变量 · 一键启动 (**单一真相源**) diff --git a/wiki/miniprogram-quality-checklist.md b/wiki/miniprogram-quality-checklist.md new file mode 100644 index 0000000..59a8cf6 --- /dev/null +++ b/wiki/miniprogram-quality-checklist.md @@ -0,0 +1,410 @@ +# 小程序质量规范清单 + +> **最后更新:** 2026-05-17 | 来源:两轮深度审计(并发饥饿修复 + 二轮精细审查),8 CRITICAL/HIGH + 12 MEDIUM/LOW 问题抽象 +> +> **用途:** 新页面/新模块开发时的自检清单,PR Review 的检查依据,新项目启动时的基线规范。每条规则都来自真实 bug 修复,不是理论推导。 + +--- + +## 目录 + +1. [[#1 并发与请求层]] — 限流器、长轮询、Token 刷新 +2. [[#2 导航与路由]] — 页栈保护、reLaunch 去重、分包预加载 +3. [[#3 组件与渲染]] — render body 定义、ScrollView 嵌套、懒加载 +4. [[#4 数据与内存]] — 数组上限、去重索引一致性、Storage 清理 +5. [[#5 认证与会话]] — 模块级缓存清理、logout 完整性 +6. [[#6 样式与布局]] — CSS 变量主题、长者模式、设计 token +7. [[#7 错误处理与日志]] — 静默吞错、生产日志、用户提示 +8. [[#8 审计与交付]] — 提交前检查、Feature DoD + +--- + +## 1 并发与请求层 + +### 规则 1.1 — 全局并发限制器 + +> **根因:** 微信小程序 `wx.request` 并发上限为 10,超出排队。无限制地发起请求会导致请求饥饿。 +> **案例:** ConcurrencyLimiter(8) 时长轮询占满 8 个槽位 25-30s,所有新请求排队等待直到超时。 + +- [ ] 所有 HTTP 请求必须经过全局 `ConcurrencyLimiter` +- [ ] 并发上限 ≤ 12(微信限制 10,留 2 个给框架内部请求) +- [ ] Token 刷新必须直接调用 `Taro.request()` 绕过限流器(否则 401 重试时死锁) + +```typescript +// ✅ 正确:Token 刷新绕过限流器 +async function doRefresh(): Promise { + const res = await Taro.request({ url: refreshTokenUrl, method: 'POST', ... }); + // ... +} + +// ❌ 错误:Token 刷新走限流器,所有槽位被占时死锁 +async function doRefresh(): Promise { + return api.post('/auth/refresh', { refresh_token }); // 死锁! +} +``` + +### 规则 1.2 — 长轮询独立通道 + +> **根因:** 长轮询请求 hang 25-30s,占用并发槽位,与普通 API 请求竞争。 +> **案例:** 咨询页长轮询 + Tab 切换加载 = 开发者工具卡死。 + +- [ ] 长轮询必须使用 `requestUnlimited`(绕过限流器的独立通道) +- [ ] 长轮询必须有连续失败上限(默认 10 次),达到后停止轮询 +- [ ] 长轮询必须有 generation counter 模式,组件卸载/参数变化时旧轮询自动失效 +- [ ] 成功轮询间隔 ≥ 3 秒(不能 `delay=0` 立即递归,否则 CPU 飙升) + +```typescript +// ✅ 正确:独立通道 + generation counter +useLongPolling({ + pollFn: () => requestUnlimited('GET', path, undefined, 30000), + onData: (data) => { /* ... */ }, + maxFailures: 10, +}); + +// ❌ 错误:长轮询走限流器 +useLongPolling({ + pollFn: () => api.get('/messages/poll'), // 占用槽位 30s! +}); +``` + +### 规则 1.3 — reLaunch 去重 + +> **根因:** 401 时多个并发请求同时触发 `Taro.reLaunch('/pages/login/index')`,微信框架崩溃。 +> **案例:** 3 个请求同时 401 → 3 次 reLaunch → 页面栈混乱。 + +- [ ] `reLaunch` 必须全局去重(Promise 锁 + 2s 冷却期) +- [ ] 401 重试上限为 1 次,避免无限循环 + +```typescript +// ✅ 正确:去重 +let reLaunchPromise: Promise | null = null; +function safeReLaunch(url: string): void { + if (reLaunchPromise) return; + reLaunchPromise = Taro.reLaunch({ url }) + .then(() => {}, (err) => { console.warn('[request] reLaunch failed:', err); }) + .then(() => { setTimeout(() => { reLaunchPromise = null; }, 2000); }); +} +``` + +### 规则 1.4 — 请求层错误处理 + +- [ ] `catch` 块不允许完全静默(至少 `console.warn`) +- [ ] Token 刷新函数必须有死锁风险注释(提醒不要改用 `request()`) +- [ ] 网络超时/异常必须有用户友好提示(不显示原始 error message) + +--- + +## 2 导航与路由 + +### 规则 2.1 — 页栈溢出保护 + +> **根因:** 微信 `navigateTo` 页栈上限 10 层,超出后报错 `navigateTo:fail`。 +> **案例:** 患者列表 → 详情 → 报告 → 化验 → 趋势 → 咨询 → ... → 第 10 层崩溃。 + +- [ ] 所有 `Taro.navigateTo` 必须替换为 `safeNavigateTo` +- [ ] `safeNavigateTo` 实现:页栈 ≥ 9 时自动降级为 `redirectTo` +- [ ] 新代码 review 时全局搜索 `Taro.navigateTo`,不应存在任何直接调用 + +```typescript +const MAX_PAGE_STACK = 9; +export function safeNavigateTo(url: string): void { + const pages = Taro.getCurrentPages(); + if (pages.length >= MAX_PAGE_STACK) { + Taro.redirectTo({ url }); + } else { + Taro.navigateTo({ url }); + } +} +``` + +### 规则 2.2 — 分包预加载 + +- [ ] 高频入口页必须配置 `preloadRule`(首页预加载核心分包) +- [ ] TabBar 页面必须在 `app.config.ts` 正确声明 +- [ ] 分包大小控制在微信限制内(主包 2MB,总包 20MB) + +### 规则 2.3 — 页面生命周期防重入 + +> **根因:** `useEffect` + `usePageData`(useDidShow) 双重初始化,导致页面数据加载两次。 +> **案例:** 分包页 navigateTo 后超时,因为双重 API 调用叠加。 + +- [ ] 数据加载统一通过 `usePageData` 单次回调,不额外添加 `useEffect` 初始化 +- [ ] 需要跳过首次 mount 的场景用 `mountedRef` 守卫 +- [ ] 加载状态用 `loadingRef` 防止并发重复加载(不用 `useState`,避免异步竞态) + +--- + +## 3 组件与渲染 + +### 规则 3.1 — 禁止在 render body 内定义组件 + +> **根因:** 每次父组件 render 都会创建新的组件引用,React 认为是新组件,销毁旧实例重建新实例。 +> **案例:** `dialysis/create` 的 `InputField` 在 render 内定义,14 个 Input 每次 render 全部销毁重建,导致输入焦点丢失、键盘闪烁。 + +- [ ] 组件必须定义在函数组件外部(模块顶层) +- [ ] 如需访问父组件状态,通过 props 传入(value/onChange 回调) +- [ ] Code review 时搜索 `const Xxx = () =>` 或 `function Xxx()` 出现在 return 之前、组件函数内部的模式 + +```typescript +// ✅ 正确:组件定义在模块顶层 +function InputField({ label, value, onChange }: Props) { + return ...; +} + +export default function MyForm() { + const [val, setVal] = useState(''); + return ; +} + +// ❌ 错误:组件定义在 render body 内 +export default function MyForm() { + const [val, setVal] = useState(''); + const InputField = ({ label, field }) => ( // 每次 render 重建! + ... + ); + return ; +} +``` + +### 规则 3.2 — 禁止双重 ScrollView + +> **根因:** 外层 PageShell 默认 `scroll=true`,内层再嵌套 `ScrollView scrollY`,两个滚动容器冲突。 +> **案例:** ai-report/list 页面滚动不流畅,触发双重滚动事件。 + +- [ ] 使用内层 `ScrollView` 时,外层 `PageShell` 必须设置 `scroll={false}` +- [ ] 或者不使用内层 `ScrollView`,只用 `PageShell` 自带滚动 +- [ ] Code review 时检查 `PageShell` 子树中是否包含 `ScrollView` + +### 规则 3.3 — 图片懒加载 + +- [ ] 列表页、聊天消息中的 `` 必须添加 `lazyLoad` 属性 +- [ ] 首屏可见图片不加 `lazyLoad`(避免延迟渲染) +- [ ] 图片 URL 必须使用 HTTPS(微信强制要求) + +### 规则 3.4 — 列表渲染优化 + +- [ ] 长列表使用虚拟滚动或分页加载(`onScrollToLower`) +- [ ] 渲染层消息上限(如 `MAX_RENDER_MESSAGES = 200`),超出截断并提示 +- [ ] 状态层消息上限(如 `MAX_STATE_MESSAGES = 300`),防止内存增长 + +--- + +## 4 数据与内存 + +### 规则 4.1 — 数组必须有上限 + +> **根因:** 未设上限的数组在长时间运行或高频数据场景下会无限增长。 +> **案例:** BLE 设备 readings 数组持续追加,长时间连接后占用大量内存。 + +- [ ] 所有累积型数组必须设 MAX 常量(如 `MAX_LIVE_READINGS = 200`) +- [ ] 追加时检查长度,超出时 `slice(-MAX)` 保留最新数据 +- [ ] 该规则适用于:设备数据缓存、聊天消息、日志队列等 + +```typescript +// ✅ 正确 +const MAX_LIVE_READINGS = 200; +const combined = [...this.readings, ...newReadings]; +this.readings = combined.length > MAX_LIVE_READINGS + ? combined.slice(-MAX_LIVE_READINGS) + : combined; + +// ❌ 错误 +this.readings = [...this.readings, ...newReadings]; // 无上限! +``` + +### 规则 4.2 — 去重索引与数据一致 + +> **根因:** `trimToMax` 丢弃旧数据后,去重索引 `seenKeys` 仍保留已丢弃数据的 key。 +> **案例:** DataBuffer 丢弃旧桶后 seenKeys 膨胀,且可能拒绝本应接受的新数据(key 碰撞)。 + +- [ ] 数据裁剪时必须同步重建去重索引 +- [ ] `flush()`/`clear()` 时必须清空索引 +- [ ] 不要依赖 `Set` 自动清理——它不会自动 shrink + +### 规则 4.3 — Storage 清理 + +- [ ] `logout` 时必须清理所有业务相关 Storage key +- [ ] 带动态 key 的缓存(如 `ble_buffer_{id}_{bucket}`)必须用 `getStorageInfoSync().keys` 遍历清理 +- [ ] 模块级缓存变量(JS 内存中的 `let` 变量)也必须在 logout 时重置 + +--- + +## 5 认证与会话 + +### 规则 5.1 — 模块级缓存必须可清理 + +> **根因:** `auth.ts` 顶层的 `cachedUserJson`/`cachedUserObj` 等变量在 logout 后不清除,导致 restore() 时恢复已登出的用户数据。 +> **案例:** 切换账号后看到上一个用户的头像和名称。 + +- [ ] 所有模块级缓存变量(`let` 声明的 JSON/对象缓存)必须在 logout 时重置 +- [ ] 重置为初始值(`''` / `null` / `[]` / `{}`) +- [ ] `restore()` 方法必须在开头读取最新缓存,不依赖残留值 + +```typescript +// logout 中必须包含: +cachedUserJson = ''; +cachedUserObj = null; +cachedRolesJson = ''; +cachedRolesObj = []; +cachedPatientJson = ''; +cachedPatientObj = null; +``` + +### 规则 5.2 — Token 刷新安全 + +- [ ] `getHeaders()` 中不做同步 Token 刷新预检查(仅依赖 401 重试路径) +- [ ] 刷新失败时必须清除所有认证相关 Storage +- [ ] 刷新进行中用 Promise 去重,防止多个并发请求同时触发刷新 + +### 规则 5.3 — 页面级认证恢复 + +- [ ] 所有 TabBar 页面必须在 `useDidShow` 中调用 `auth.restore()` +- [ ] 子页面依赖 `usePageData` 自动恢复 +- [ ] 不在 `useEffect` 中重复恢复(避免双重初始化) + +--- + +## 6 样式与布局 + +### 规则 6.1 — CSS 变量主题 + +- [ ] 所有颜色、字号、间距使用 `var(--tk-*)` 设计 token +- [ ] 不允许硬编码颜色值(如 `#333`、`rgb(0,0,0)`) +- [ ] 深色模式通过 CSS 变量级联覆盖,不使用条件 class 切换 + +### 规则 6.2 — 长者模式 + +- [ ] 所有页面字号 ≥ 22px(长者模式) +- [ ] 使用 `.elder-mode` CSS 变量覆盖,不逐元素调整 +- [ ] 触摸目标 ≥ 44px + +### 规则 6.3 — 医生/患者双模式 + +- [ ] 医生端使用 `.doctor-mode` 靛蓝覆盖 +- [ ] 共用页面通过 `useDoctorClass()` / `useElderClass()` 切换 +- [ ] 不为同一页面维护两套代码 + +--- + +## 7 错误处理与日志 + +### 规则 7.1 — 生产日志保留策略 + +- [ ] 生产构建保留 `console.warn` 和 `console.error` +- [ ] 仅移除 `console.log`、`console.info`、`console.debug` +- [ ] 关键路径(请求层、认证层)的异常必须有 `console.warn` + +```javascript +// config/prod.ts +pure_funcs: ['console.log', 'console.info', 'console.debug'], +// 不包含 console.warn 和 console.error +``` + +### 规则 7.2 — 用户友好错误提示 + +- [ ] 错误映射表 `ERROR_CODE_MAP` 覆盖所有后端 error_code +- [ ] 不向用户展示原始 error message 或 HTTP 状态码 +- [ ] 网络超时、服务器错误、权限不足各有独立提示文案 + +### 规则 7.3 — Markdown 渲染安全 + +> **根因:** 简单的 `replace` 正则无法正确处理 HTML 分组(如连续 `
  • ` 应合并到单个 `
      `)。 +> **案例:** AI 报告详情页每个列表项独立成列表,显示异常。 + +- [ ] Markdown → HTML 转换必须正确处理连续同类标签的分组 +- [ ] 使用 `sanitizeHtml` 防止 XSS +- [ ] 复杂 Markdown 场景考虑引入成熟库(如 marked + DOMPurify) + +--- + +## 8 审计与交付 + +### 规则 8.1 — 提交前必检清单 + +每次提交小程序代码前,逐项确认: + +- [ ] `grep -r "Taro.navigateTo" src/` — 无直接调用(全部用 `safeNavigateTo`) +- [ ] `grep -r "const.*=.*() =>" src/` — render body 内无组件定义 +- [ ] `grep -r "ScrollView" src/` — 无双重 ScrollView 嵌套 +- [ ] 长轮询使用 `requestUnlimited` 而非 `api.get` +- [ ] 数组累积操作有 MAX 上限 +- [ ] `` 列表使用 `lazyLoad` + +### 规则 8.2 — PR Review 检查点 + +| 检查项 | 搜索模式 | 期望结果 | +|--------|----------|----------| +| 直接导航 | `Taro.navigateTo` | 0 匹配 | +| render body 组件 | `const [A-Z].*=.*(=>|{)` 在 return 前 | 0 匹配 | +| 双重滚动 | `PageShell` 子树含 `ScrollView` | 有 ScrollView 则 PageShell `scroll={false}` | +| 长轮询限流 | `pollFn:.*api\.(get|post)` | 应使用 `requestUnlimited` | +| 无上限数组 | `\.push\(|\.concat\(` 无后续 slice | 应有 MAX 截断 | +| 模块缓存泄漏 | logout 方法不含缓存重置 | 应清空所有模块级变量 | + +### 规则 8.3 — 里程碑审计 + +每个功能里程碑完成后,执行: + +- [ ] 全页面导航测试(覆盖 10 层页栈场景) +- [ ] Tab 快速切换测试(连续切换 5 次 Tab,无卡死) +- [ ] 弱网测试(3G 模拟,长轮询不阻塞 UI) +- [ ] 内存泄漏检查(长时间停留后页面不卡顿) +- [ ] 登录/登出流程(切换账号后数据正确) + +--- + +## 附录 A — 问题溯源表 + +以下每条规则对应的具体 bug 和修复提交: + +| 规则 | 问题 | 严重度 | 修复提交 | +|------|------|--------|----------| +| 1.1 并发限制器 | 长轮询占满槽位导致请求饥饿 | CRITICAL | `9d50ef7` | +| 1.2 长轮询通道 | 咨询页长轮询阻塞所有 API | CRITICAL | `9d50ef7` | +| 1.3 reLaunch 去重 | 401 多次 reLaunch 崩溃 | HIGH | `9d50ef7` | +| 2.1 页栈保护 | 深层导航超 10 层失败 | HIGH | `59dd5ef` | +| 2.3 防重入 | useEffect + usePageData 双重加载 | MEDIUM | `59dd5ef` | +| 3.1 render body 组件 | InputField 每次 render 重建 | HIGH | `fcce2f5` | +| 3.2 双重 ScrollView | ai-report/list 滚动冲突 | CRITICAL | `fcce2f5` | +| 3.3 图片懒加载 | 咨询详情图片无 lazyLoad | MEDIUM | `fcce2f5` | +| 4.1 数组上限 | BLE readings 无限增长 | MEDIUM | `fcce2f5` | +| 4.2 索引一致 | DataBuffer seenKeys 不清理 | MEDIUM | `fcce2f5` | +| 5.1 缓存清理 | auth.ts 模块缓存 logout 未清 | MEDIUM | `fcce2f5` | +| 7.1 生产日志 | safeReLaunch 静默吞错 | LOW | `fcce2f5` | +| 7.3 Markdown 分组 | li 元素未合并到 ul | MEDIUM | `fcce2f5` | + +## 附录 B — 快速自查脚本 + +```bash +# 在 apps/miniprogram/ 目录下运行 + +echo "=== 1. 直接 Taro.navigateTo ===" +grep -rn "Taro\.navigateTo" src/ | grep -v "node_modules" | grep -v ".d.ts" + +echo "=== 2. render body 组件定义 ===" +grep -rn "const [A-Z].*=.*=>" src/ --include="*.tsx" | head -20 + +echo "=== 3. ScrollView 嵌套 ===" +grep -rn "ScrollView" src/ --include="*.tsx" -l | while read f; do + if grep -q "PageShell" "$f" && grep -q "ScrollView" "$f"; then + if ! grep -q "scroll={false}" "$f"; then + echo "⚠️ $f — PageShell + ScrollView 但无 scroll={false}" + fi + fi +done + +echo "=== 4. 长轮询未用 requestUnlimited ===" +grep -rn "pollFn.*api\.\(get\|post\)" src/ --include="*.tsx" + +echo "=== 5. 无上限数组累积 ===" +grep -rn "\.push(" src/ --include="*.ts" -A2 | grep -v "slice\|MAX\|limit" | head -20 + +echo "=== 6. Image 无 lazyLoad(列表页)===" +grep -rn "