docs(wiki): 新增小程序质量规范清单 — 8类规则+提交前检查+PR Review依据

抽象两轮审计中的 20+ 问题为可复用规范:
- 并发与请求层:限流器/长轮询独立通道/reLaunch去重
- 导航与路由:页栈保护/分包预加载/生命周期防重入
- 组件与渲染:禁止render body组件/双重ScrollView/图片懒加载
- 数据与内存:数组上限/去重索引一致/Storage清理
- 认证与会话:模块缓存清理/Token刷新安全
- 审计与交付:提交前必检/PR Review检查点/里程碑审计
This commit is contained in:
iven
2026-05-17 20:12:39 +08:00
parent fcce2f5c51
commit 676a6c0e13
2 changed files with 412 additions and 1 deletions

View File

@@ -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]] — 连接信息 · 环境变量 · 一键启动 (**单一真相源**)

View File

@@ -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<boolean> {
const res = await Taro.request({ url: refreshTokenUrl, method: 'POST', ... });
// ...
}
// ❌ 错误Token 刷新走限流器,所有槽位被占时死锁
async function doRefresh(): Promise<boolean> {
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<void> | 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 <View>...</View>;
}
export default function MyForm() {
const [val, setVal] = useState('');
return <InputField label="名称" value={val} onChange={setVal} />;
}
// ❌ 错误:组件定义在 render body 内
export default function MyForm() {
const [val, setVal] = useState('');
const InputField = ({ label, field }) => ( // 每次 render 重建!
<View>...</View>
);
return <InputField label="名称" field="name" />;
}
```
### 规则 3.2 — 禁止双重 ScrollView
> **根因:** 外层 PageShell 默认 `scroll=true`,内层再嵌套 `ScrollView scrollY`,两个滚动容器冲突。
> **案例:** ai-report/list 页面滚动不流畅,触发双重滚动事件。
- [ ] 使用内层 `ScrollView` 时,外层 `PageShell` 必须设置 `scroll={false}`
- [ ] 或者不使用内层 `ScrollView`,只用 `PageShell` 自带滚动
- [ ] Code review 时检查 `PageShell` 子树中是否包含 `ScrollView`
### 规则 3.3 — 图片懒加载
- [ ] 列表页、聊天消息中的 `<Image>` 必须添加 `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 分组(如连续 `<li>` 应合并到单个 `<ul>`)。
> **案例:** 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 上限
- [ ] `<Image>` 列表使用 `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 "<Image" src/ --include="*.tsx" -l | while read f; do
if grep -q "\.map(" "$f" && ! grep -q "lazyLoad" "$f"; then
echo "⚠️ $f — 列表页 Image 无 lazyLoad"
fi
done
echo "=== 检查完成 ==="
```