Files
hms/wiki/miniprogram-quality-checklist.md
iven 676a6c0e13 docs(wiki): 新增小程序质量规范清单 — 8类规则+提交前检查+PR Review依据
抽象两轮审计中的 20+ 问题为可复用规范:
- 并发与请求层:限流器/长轮询独立通道/reLaunch去重
- 导航与路由:页栈保护/分包预加载/生命周期防重入
- 组件与渲染:禁止render body组件/双重ScrollView/图片懒加载
- 数据与内存:数组上限/去重索引一致/Storage清理
- 认证与会话:模块缓存清理/Token刷新安全
- 审计与交付:提交前必检/PR Review检查点/里程碑审计
2026-05-17 20:12:39 +08:00

16 KiB
Raw Blame History

小程序质量规范清单

最后更新: 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 重试时死锁)
// ✅ 正确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 飙升)
// ✅ 正确:独立通道 + 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 次,避免无限循环
// ✅ 正确:去重
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,不应存在任何直接调用
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/createInputField 在 render 内定义14 个 Input 每次 render 全部销毁重建,导致输入焦点丢失、键盘闪烁。

  • 组件必须定义在函数组件外部(模块顶层)
  • 如需访问父组件状态,通过 props 传入value/onChange 回调)
  • Code review 时搜索 const Xxx = () =>function Xxx() 出现在 return 之前、组件函数内部的模式
// ✅ 正确:组件定义在模块顶层
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) 保留最新数据
  • 该规则适用于:设备数据缓存、聊天消息、日志队列等
// ✅ 正确
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() 方法必须在开头读取最新缓存,不依赖残留值
// 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
  • 不允许硬编码颜色值(如 #333rgb(0,0,0)
  • 深色模式通过 CSS 变量级联覆盖,不使用条件 class 切换

规则 6.2 — 长者模式

  • 所有页面字号 ≥ 22px长者模式
  • 使用 .elder-mode CSS 变量覆盖,不逐元素调整
  • 触摸目标 ≥ 44px

规则 6.3 — 医生/患者双模式

  • 医生端使用 .doctor-mode 靛蓝覆盖
  • 共用页面通过 useDoctorClass() / useElderClass() 切换
  • 不为同一页面维护两套代码

7 错误处理与日志

规则 7.1 — 生产日志保留策略

  • 生产构建保留 console.warnconsole.error
  • 仅移除 console.logconsole.infoconsole.debug
  • 关键路径(请求层、认证层)的异常必须有 console.warn
// 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 前
双重滚动 PageShell 子树含 ScrollView 有 ScrollView 则 PageShell scroll={false}
长轮询限流 `pollFn:.*api.(get post)`
无上限数组 `.push( .concat(` 无后续 slice
模块缓存泄漏 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 — 快速自查脚本

# 在 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 "=== 检查完成 ==="