# 小程序质量规范清单 > **最后更新:** 2026-05-17 | 来源:164 条 git 提交历史 + 两轮深度审计,全量问题模式抽象 > > **用途:** 新页面/新模块开发时的自检清单,PR Review 的检查依据,新项目启动时的基线规范。每条规则都来自真实 bug 修复,不是理论推导。 --- ## 目录 1. [[#1 并发与请求层]] — 限流器、长轮询、Token 刷新、缓存策略 2. [[#2 导航与路由]] — 页栈保护、reLaunch 去重、分包预加载 3. [[#3 组件与渲染]] — render body 定义、ScrollView 嵌套、懒加载、统一组件库 4. [[#4 数据与内存]] — 数组上限、去重索引一致性、Storage 清理 5. [[#5 认证与会话]] — 模块级缓存清理、logout 完整性、认证恢复 6. [[#6 前后端接口契约]] — 字段对齐、类型同步、API 路径一致 7. [[#7 样式与布局]] — CSS 变量主题、长者模式、设计 token、对齐原型 8. [[#8 安全]] — XSS 防护、输入验证、敏感数据、权限校验 9. [[#9 错误处理与日志]] — 静默吞错、生产日志、用户提示、ErrorBoundary 10. [[#10 定时器与副作用清理]] — setTimeout/setInterval 清理、useSafeTimeout 11. [[#11 开发环境]] — DevTools 卡死、source map、文件描述符限制 12. [[#12 审计与交付]] — 提交前检查、PR Review、里程碑审计 --- ## 1 并发与请求层 ### 规则 1.1 — 全局并发限制器 > **根因:** 微信 `wx.request` 并发上限为 10,超出排队。无限制发请求导致饥饿。 > **案例:** ConcurrencyLimiter(8) 时长轮询占满 8 个槽位 25-30s,所有新请求排队超时。 > **提交:** `9d50ef7` `74bffb4` - [ ] 所有 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 切换 = 开发者工具卡死。 > **提交:** `9d50ef7` `5baa518` - [ ] 长轮询必须使用 `requestUnlimited`(绕过限流器的独立通道) - [ ] 必须有连续失败上限(默认 10 次),达到后停止 - [ ] 必须有 generation counter 模式,组件卸载/参数变化时旧轮询自动失效 - [ ] 成功轮询间隔 ≥ 3 秒(不能 `delay=0` 立即递归,否则 CPU 飙升) ### 规则 1.3 — reLaunch 去重 > **根因:** 401 时多个并发请求同时触发 `Taro.reLaunch('/pages/login/index')`。 > **案例:** 3 个请求同时 401 → 3 次 reLaunch → 页面栈混乱。 > **提交:** `9d50ef7` - [ ] `reLaunch` 必须全局去重(Promise 锁 + 2s 冷却期) - [ ] 401 重试上限为 1 次,避免无限循环 ### 规则 1.4 — 请求缓存与去重 > **根因:** Tab 切换时同一 API 被多次并发调用,浪费请求且竞态更新。 > **案例:** 首页/健康页每次 useDidShow 都重新请求,快速切换 Tab 触发大量重复。 > **提交:** `6d151bb` `447126b` - [ ] GET 请求必须有响应缓存(TTL 60s),同 URL 短时间内命中缓存 - [ ] 同时进行的相同 GET 请求必须去重(inflight Promise 共享) - [ ] 缓存按 `patientId` 隔离,切换患者时自动失效 - [ ] `clearRequestCache()` 在 logout、切换患者时调用 ### 规则 1.5 — 请求层错误处理 > **提交:** `fcce2f5` `4ca9027` - [ ] `catch` 块不允许完全静默(至少 `console.warn`) - [ ] Token 刷新函数必须有死锁风险注释 - [ ] 网络超时/异常必须有用户友好提示(不显示原始 error message) - [ ] 后端 `error_code` 必须有前端映射表(`ERROR_CODE_MAP`) --- ## 2 导航与路由 ### 规则 2.1 — 页栈溢出保护 > **根因:** 微信 `navigateTo` 页栈上限 10 层,超出 `navigateTo:fail`。 > **案例:** 患者列表→详情→报告→化验→趋势→咨询→...→第 10 层崩溃。 > **提交:** `59dd5ef` `74bffb4` - [ ] 所有 `Taro.navigateTo` 必须替换为 `safeNavigateTo` - [ ] `safeNavigateTo`:页栈 ≥ 9 时自动降级为 `redirectTo` - [ ] 新代码 review 时全局搜索 `Taro.navigateTo`,不应存在任何直接调用 ### 规则 2.2 — 分包预加载与拆包策略 > **根因:** 主包过大导致首次加载慢;单页分包浪费空间。 > **案例:** consultation 原在主包,移出后主包减重。 > **提交:** `59dd5ef` `4c38fcd` - [ ] 高频入口页必须配置 `preloadRule`(首页预加载核心分包) - [ ] 单页分包合并(多个单页合并为一个分包减少开销) - [ ] 医生端独立分包(`pkg-doctor-core` / `pkg-doctor-clinical`) - [ ] TabBar 页面必须在 `app.config.ts` 正确声明 ### 规则 2.3 — 页面生命周期防重入 > **根因:** `useEffect` + `usePageData`(useDidShow) 双重初始化。 > **案例:** 分包页 navigateTo 后超时,双重 API 调用叠加。 > **提交:** `1fd2c7a` `59dd5ef` - [ ] 数据加载统一通过 `usePageData` 单次回调,不额外添加 `useEffect` 初始化 - [ ] 需要跳过首次 mount 的场景用 `mountedRef` 守卫 - [ ] 加载状态用 `loadingRef` 防止并发重复加载(不用 `useState`,避免异步竞态) - [ ] `usePageData` 支持 `throttleMs` 防抖(默认 5000ms) --- ## 3 组件与渲染 ### 规则 3.1 — 禁止在 render body 内定义组件 > **根因:** 每次父组件 render 创建新的组件引用,React 销毁旧实例重建新实例。 > **案例:** dialysis/create 的 InputField 在 render 内定义,14 个 Input 每次 render 全部销毁重建,输入焦点丢失。 > **提交:** `fcce2f5` - [ ] 组件必须定义在函数组件外部(模块顶层) - [ ] 如需访问父组件状态,通过 props 传入(value/onChange 回调) - [ ] Code review 时搜索 `const Xxx = () =>` 出现在 return 之前、组件函数内部的模式 ```typescript // ✅ 正确:组件定义在模块顶层,通过 props 传值 function InputField({ label, value, onChange }: Props) { return ...; } // ❌ 错误:组件定义在 render body 内 export default function MyForm() { const InputField = ({ label, field }) => ( ... ); // 每次 render 重建! } ``` ### 规则 3.2 — 禁止双重 ScrollView > **根因:** PageShell 默认 `scroll=true`,内层再嵌套 `ScrollView scrollY`,两容器冲突。 > **案例:** ai-report/list 页面滚动不流畅。 > **提交:** `fcce2f5` - [ ] 使用内层 `ScrollView` 时,外层 `PageShell` 必须设置 `scroll={false}` - [ ] 或者不使用内层 `ScrollView`,只用 `PageShell` 自带滚动 ### 规则 3.3 — 图片懒加载 > **提交:** `fcce2f5` - [ ] 列表页、聊天消息中的 `` 必须添加 `lazyLoad` 属性 - [ ] 首屏可见图片不加 `lazyLoad`(避免延迟渲染) - [ ] 图片 URL 必须使用 HTTPS(微信强制要求) ### 规则 3.4 — 列表渲染优化 > **提交:** `74bffb4` - [ ] 长列表使用虚拟滚动或分页加载(`onScrollToLower`) - [ ] 渲染层消息上限(如 `MAX_RENDER_MESSAGES = 200`),超出截断并提示 - [ ] 状态层消息上限(如 `MAX_STATE_MESSAGES = 300`),防止内存增长 ### 规则 3.5 — 统一组件库使用 > **根因:** 早期页面各自用原生 View/Text 手写布局,60 页面风格不统一。 > **案例:** 迁移了 66 页面到 PageShell + ContentCard + StatusTag + LoadingCard + SearchSection + PaginationBar。 > **提交:** `80794c9` → `900c9ba`(12 次迁移提交) - [ ] 新页面必须使用统一组件库:`PageShell` / `ContentCard` / `StatusTag` / `LoadingCard` / `SearchSection` / `PaginationBar` / `EmptyState` / `ErrorState` - [ ] 不允许在页面中手写 `.card` / `.list-item` 等重复的卡片样式 - [ ] 需要新组件时先扩展现有组件(添加 prop),不要新建 - [ ] 组件必须有独立的 `.scss` 文件,不允许在页面 `.scss` 中覆盖组件内部样式 ### 规则 3.6 — React 导入 > **根因:** Taro 4.x 的 JSX transform 不自动注入 React,但某些组件需要 React 引用。 > **案例:** 迁移组件库后运行时报 `React is not defined`。 > **提交:** `1786f0d` - [ ] 使用 JSX 的文件必须显式 `import React from 'react'`(即使 Taro 4.x 有自动 transform) - [ ] 组件使用 `React` API(如 `React.memo`、`React.useRef`)时必须有导入 - [ ] SCSS 导入路径使用 `./index.scss`(相对当前组件),不使用 `@/components/...` 绝对路径到 scss --- ## 4 数据与内存 ### 规则 4.1 — 数组必须有上限 > **根因:** 未设上限的数组在长时间运行或高频数据场景下无限增长。 > **案例:** BLE 设备 readings 数组持续追加,长时间连接后占大量内存。 > **提交:** `fcce2f5` - [ ] 所有累积型数组必须设 MAX 常量(如 `MAX_LIVE_READINGS = 200`) - [ ] 追加时检查长度,超出时 `slice(-MAX)` 保留最新数据 - [ ] 适用于:设备数据缓存、聊天消息、日志队列等 ### 规则 4.2 — 去重索引与数据一致 > **根因:** `trimToMax` 丢弃旧数据后,去重索引 `seenKeys` 仍保留已丢弃数据的 key。 > **案例:** DataBuffer seenKeys 膨胀,拒绝本应接受的新数据。 > **提交:** `fcce2f5` - [ ] 数据裁剪时必须同步重建去重索引 - [ ] `flush()`/`clear()` 时必须清空索引 - [ ] 不要依赖 `Set` 自动清理——它不会自动 shrink ### 规则 4.3 — Storage 使用规范 > **根因:** Storage 滥用导致数据残留、切换用户数据泄漏。 > **案例:** 用 Storage 传递页面间数据,导致详情页显示上一个患者的信息。 > **提交:** `0bf1822` `3c828bf` - [ ] **禁止用 Storage 传递页面间数据** — 必须通过 API 获取或 URL 参数 - [ ] logout 时必须清理所有业务相关 Storage key - [ ] 带动态 key 的缓存(如 `ble_buffer_{id}_{bucket}`)必须用 `getStorageInfoSync().keys` 遍历清理 - [ ] 模块级缓存变量(JS 内存中的 `let` 变量)也必须在 logout 时重置 - [ ] 敏感数据(token)使用 `secure-storage`(加密),不使用 `Taro.setStorageSync` 明文存储 --- ## 5 认证与会话 ### 规则 5.1 — 模块级缓存必须可清理 > **根因:** `auth.ts` 顶层的缓存变量 logout 后不清除,restore() 恢复已登出用户数据。 > **案例:** 切换账号后看到上一个用户的头像和名称。 > **提交:** `fcce2f5` - [ ] 所有模块级缓存变量(`let` 声明的 JSON/对象缓存)必须在 logout 时重置为初始值 - [ ] `restore()` 方法必须在开头读取最新缓存,不依赖残留值 - [ ] restore() 内部做变更检测,无变化时跳过 `set()` 避免不必要的重渲染 ### 规则 5.2 — Token 刷新安全 > **根因:** `getHeaders()` 中做同步 Token 刷新预检查,所有请求被阻塞。 > **案例:** getHeaders 中 `await tryRefreshToken()` 导致并发请求排队 30s。 > **提交:** `9d50ef7` `447126b` - [ ] `getHeaders()` 中不做同步 Token 刷新预检查(仅依赖 401 重试路径) - [ ] 刷新失败时必须清除所有认证相关 Storage - [ ] 刷新进行中用 Promise 去重,防止多个并发请求同时触发刷新 - [ ] 刷新失败时设置 `isLoggingOut` 标记,防止后续请求反复尝试刷新 ### 规则 5.3 — 页面级认证恢复 > **根因:** Tab 切换回来后 auth store 未恢复,页面显示为未登录状态。 > **案例:** 首页 Tab 正常 → 切到咨询 Tab → 回到首页,用户信息消失。 > **提交:** `6632985` `c314093` - [ ] 所有 TabBar 页面必须在 `useDidShow` 中调用 `auth.restore()` - [ ] 子页面依赖 `usePageData` 自动恢复 - [ ] 不在 `useEffect` 中重复恢复(避免双重初始化) - [ ] 退出登录后刷新必须清除 `isLoggingOut` 标记(`clearLoggingOut()`),否则重新登录后请求被拒 ### 规则 5.4 — 角色与权限 > **根因:** 前端未区分医生/护士/患者角色,所有页面入口暴露给所有用户。 > **案例:** 患者能看到医生端入口,点击后 403。 > **提交:** `c38967a` `81c174a` `3dac6a9` - [ ] 医生端入口必须通过 `isDoctor()` / `isNurse()` 守卫 - [ ] 页面组件使用 `useDoctorClass()` 切换样式模式 - [ ] 精简菜单:未实现的模块入口必须移除,不显示空白页 --- ## 6 前后端接口契约 ### 规则 6.1 — 字段对齐 > **根因:** 前端 TypeScript 接口与后端 DTO 字段名/类型不一致,运行时静默失败。 > **案例:** 33 个接口端到端验证发现 8 个字段不对齐。 > **提交:** `8316281` `c53f562` `c38967a` - [ ] 新增前端 service 函数时,必须对照后端 DTO 逐字段验证 - [ ] 后端 DTO 变更时,必须同步更新前端 TypeScript 接口 - [ ] 必填字段前端必须有默认值和校验 - [ ] 日期字段统一为 `string`(`YYYY-MM-DD`),数值字段注意 `number | undefined` ### 规则 6.2 — API 路径一致 > **根因:** 前端硬编码 API 路径,后端路由变更后前端 404。 > **案例:** FHIR 端点从 `/fhir` 迁移到 `/api/v1/fhir`,前端请求 404。 > **提交:** `4ca9027` `8316281` - [ ] API 路径使用 `process.env.TARO_APP_API_URL` + 相对路径,不硬编码 - [ ] Service 层统一路径前缀,不在组件中直接拼接 URL - [ ] 后端新增端点必须同步添加前端调用函数,不允许留空 ### 规则 6.3 — 必填字段完整性 > **根因:** 前端提交表单缺少后端必填字段,导致 400 错误。 > **案例:** submitRecord 缺少 `task_id` 字段,后端 CreateFollowUpRecordReq 拒绝。 > **提交:** `fbb28e6` `603af83` - [ ] 表单提交前检查所有后端 Required 字段是否已填 - [ ] 缺少字段时给出具体提示(如"缺少患者信息"),不是笼统的"操作失败" - [ ] 隐式字段(如 `patient_id`、`version`)从路由参数或 store 获取,不依赖用户输入 --- ## 7 样式与布局 ### 规则 7.1 — CSS 变量主题 > **根因:** 硬编码 px 值和颜色号,无法统一调整。 > **案例:** 68 个 SCSS 文件手动 px 值,修改字号需要改 68 个文件。 > **提交:** `890c132` `551d19d` - [ ] 所有颜色、字号、间距使用 `var(--tk-*)` 设计 token - [ ] 不允许硬编码颜色值(如 `#333`、`rgb(0,0,0)`) - [ ] 深色模式通过 CSS 变量级联覆盖,不使用条件 class 切换 - [ ] 新增 design token 在 `tokens.scss` 统一定义,不在页面中 `--custom-var` ### 规则 7.2 — 长者模式 > **根因:** 线性放大(所有 px 乘以 1.2)导致布局错乱。 > **案例:** 长者模式字号过大,卡片内容溢出。 > **提交:** `4335f7e` `257ca94` - [ ] 所有页面字号 ≥ 22px(长者模式),使用 `.elder-mode` CSS 变量覆盖 - [ ] 非线性放大:标题放大比例 > 正文(如 h1: 1.3x, body: 1.15x) - [ ] 触摸目标 ≥ 44px - [ ] 长者模式排除特定页面(如登录页,否则键盘遮挡) ### 规则 7.3 — 医生/患者双模式 > **提交:** `95e219a` - [ ] 医生端使用 `.doctor-mode` 靛蓝覆盖 - [ ] 共用页面通过 `useDoctorClass()` / `useElderClass()` 切换 - [ ] 不为同一页面维护两套代码 ### 规则 7.4 — 对齐设计原型 > **根因:** 开发时凭感觉写 UI,与设计稿差距大。 > **案例:** 文章列表/详情页原型对齐、首页渐变头部移除、Profile 积分卡片等分。 > **提交:** `b8ce19f` `b84becf` `63d8b7a` `7b2c033` `6c42d54` `29d77e8` - [ ] 新页面开发前先看原型稿,记录关键字号/间距/颜色 - [ ] 完成后截图与原型对比(不是"差不多",是像素级) - [ ] 组件变体(如 SegmentTabs pill)样式必须与原型一致 - [ ] 卡片布局必须用 ContentCard,不手写 div 模拟 ### 规则 7.5 — 状态色统一 > **根因:** 各页面自行定义状态颜色(绿色/红色/黄色),色调不一致。 > **提交:** `4a95a83` - [ ] 状态色必须对齐设计系统色板(成功绿/警告黄/危险红/信息蓝) - [ ] 使用 StatusTag 组件显示状态,不自行拼写 class 名 - [ ] CSS 变量定义一次,全局引用 --- ## 8 安全 ### 规则 8.1 — XSS 防护 > **根因:** AI 报告内容直接用 `dangerouslySetInnerHTML` 渲染,未过滤。 > **案例:** AI 分析结果注入 `