# 小程序质量规范清单 > **最后更新:** 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 "