From 5baa5185166934d4cfa3b3fa7a43a168f182b8b6 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 15 May 2026 07:38:20 +0800 Subject: [PATCH] =?UTF-8?q?refactor(mp):=20=E9=95=BF=E8=BD=AE=E8=AF=A2?= =?UTF-8?q?=E9=80=9A=E7=94=A8=E5=8C=96=20=E2=80=94=20useLongPolling=20hook?= =?UTF-8?q?=20+=20=E5=92=A8=E8=AF=A2=E8=AF=A6=E6=83=85=E9=A1=B5=E6=8E=A5?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 useLongPolling hook:generation counter 防重叠、useDidShow/Hide 可见性控制、失败退避、enabled 守卫 - 患者端 + 医生端 consultation/detail 接入,删除约 80 行重复长轮询代码 - 架构建议 5/5 全部完成 ✅ --- apps/miniprogram/src/hooks/useLongPolling.ts | 93 +++++++++++++++++++ .../src/pages/consultation/detail/index.tsx | 89 +++++------------- .../doctor/consultation/detail/index.tsx | 91 +++++------------- wiki/miniprogram.md | 4 +- 4 files changed, 145 insertions(+), 132 deletions(-) create mode 100644 apps/miniprogram/src/hooks/useLongPolling.ts diff --git a/apps/miniprogram/src/hooks/useLongPolling.ts b/apps/miniprogram/src/hooks/useLongPolling.ts new file mode 100644 index 0000000..435a3bb --- /dev/null +++ b/apps/miniprogram/src/hooks/useLongPolling.ts @@ -0,0 +1,93 @@ +import { useRef, useEffect } from 'react'; +import { useDidShow, useDidHide } from '@tarojs/taro'; + +interface LongPollOptions { + /** 轮询函数,返回新数据或 null/undefined */ + pollFn: () => Promise; + /** 收到新数据后的回调 */ + onData: (data: T) => void; + /** 是否启用轮询(受外部状态控制) */ + enabled: boolean; + /** 成功轮询间隔 ms,默认 3000 */ + intervalMs?: number; + /** 连续失败上限,默认 50 */ + maxFailures?: number; +} + +/** + * 通用长轮询 hook。 + * + * - generation counter 杜绝新旧循环重叠 + * - useDidShow/useDidHide 控制页面可见性 + * - 失败退避:delay = min(failCount * 2000, 30000) + * - 自动在 enabled 变 false 时停止(如会话关闭) + */ +export function useLongPolling({ + pollFn, + onData, + enabled, + intervalMs = 3000, + maxFailures = 50, +}: LongPollOptions) { + const generation = useRef(0); + const mountedRef = useRef(true); + const pollFnRef = useRef(pollFn); + const onDataRef = useRef(onData); + pollFnRef.current = pollFn; + onDataRef.current = onData; + + const start = () => { + const gen = ++generation.current; + runPoll(gen, 0); + }; + + const stop = () => { + generation.current += 1; + }; + + const runPoll = async (gen: number, failCount: number) => { + if (gen !== generation.current || !mountedRef.current) return; + if (failCount >= maxFailures) return; + try { + const data = await pollFnRef.current(); + if (gen !== generation.current || !mountedRef.current) return; + if (data != null) { + onDataRef.current(data); + } + failCount = 0; + } catch { + failCount++; + } + if (gen !== generation.current || !mountedRef.current) return; + const delay = failCount > 0 ? Math.min(failCount * 2000, 30000) : intervalMs; + await new Promise((r) => setTimeout(r, delay)); + if (gen === generation.current && mountedRef.current) { + runPoll(gen, failCount); + } + }; + + // enabled 变 false → 停止;变 true → 启动 + useEffect(() => { + if (enabled) { + start(); + } else { + stop(); + } + }, [enabled]); + + useDidShow(() => { + if (enabled) start(); + }); + useDidHide(() => { + stop(); + }); + + useEffect(() => { + return () => { + generation.current += 1; + mountedRef.current = false; + }; + }, []); + + return { start, stop }; +} diff --git a/apps/miniprogram/src/pages/consultation/detail/index.tsx b/apps/miniprogram/src/pages/consultation/detail/index.tsx index 4b40d77..95deda7 100644 --- a/apps/miniprogram/src/pages/consultation/detail/index.tsx +++ b/apps/miniprogram/src/pages/consultation/detail/index.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import { View, Text, Input, Image, ScrollView } from '@tarojs/components'; -import Taro, { useRouter, useDidShow, useDidHide } from '@tarojs/taro'; +import Taro, { useRouter } from '@tarojs/taro'; import { getSession, listMessages, @@ -12,16 +12,11 @@ import { } from '@/services/consultation'; import Loading from '@/components/Loading'; import { useElderClass } from '@/hooks/useElderClass'; +import { useLongPolling } from '@/hooks/useLongPolling'; import './index.scss'; -/** DOM 节点数量上限,超过时只渲染最新的消息 */ const MAX_RENDER_MESSAGES = 200; -/** React state 中保留的消息上限(比渲染上限略多,保留滚动缓冲) */ const MAX_STATE_MESSAGES = 300; -/** 成功轮询后最小间隔(ms),防止后端快速响应时紧密递归 */ -const POLL_INTERVAL_MS = 3000; -/** 连续失败上限,超过后停止轮询 */ -const MAX_CONSECUTIVE_FAILURES = 50; export default function ConsultationDetail() { const router = useRouter(); @@ -32,74 +27,37 @@ export default function ConsultationDetail() { const [sending, setSending] = useState(false); const [loading, setLoading] = useState(true); const scrollViewRef = useRef(''); - const pollingGeneration = useRef(0); - const mountedRef = useRef(true); const messagesRef = useRef([]); const modeClass = useElderClass(); + const dataLoadedRef = useRef(false); + + useLongPolling({ + pollFn: () => { + const cur = messagesRef.current; + const lastId = cur.length > 0 ? cur[cur.length - 1].id : undefined; + return pollMessages(sessionId, lastId); + }, + onData: (newMsgs) => { + if (!Array.isArray(newMsgs) || newMsgs.length === 0) return; + setMessages((prev) => { + const existing = new Set(prev.map((msg) => msg.id)); + const fresh = newMsgs.filter((msg) => !existing.has(msg.id)); + const next = [...prev, ...fresh].slice(-MAX_STATE_MESSAGES); + messagesRef.current = next; + scrollViewRef.current = `msg-${next.length}`; + return next; + }); + }, + enabled: !!sessionId && dataLoadedRef.current && session?.status !== 'closed', + }); useEffect(() => { if (sessionId) { loadData(); markRead(); - startLongPolling(); } - return () => { - pollingGeneration.current += 1; - mountedRef.current = false; - }; }, [sessionId]); - // 页面可见时恢复轮询,不可见时暂停(防止 DevTools 后台页面累积轮询) - useDidShow(() => { - if (sessionId && session?.status !== 'closed') { - startLongPolling(); - } - }); - useDidHide(() => { - pollingGeneration.current += 1; - }); - - useEffect(() => { - if (session?.status === 'closed') { - pollingGeneration.current += 1; - } - }, [session?.status]); - - const startLongPolling = () => { - const gen = ++pollingGeneration.current; - longPoll(gen); - }; - - const longPoll = async (gen: number, failCount = 0) => { - if (gen !== pollingGeneration.current || !mountedRef.current) return; - if (failCount >= MAX_CONSECUTIVE_FAILURES) return; - try { - const currentMessages = messagesRef.current; - const lastId = currentMessages.length > 0 ? currentMessages[currentMessages.length - 1].id : undefined; - const newMsgs = await pollMessages(sessionId, lastId); - if (gen !== pollingGeneration.current || !mountedRef.current) return; - if (newMsgs && newMsgs.length > 0) { - setMessages((prev) => { - const existing = new Set(prev.map((msg) => msg.id)); - const fresh = newMsgs.filter((msg) => !existing.has(msg.id)); - const next = [...prev, ...fresh].slice(-MAX_STATE_MESSAGES); - messagesRef.current = next; - return next; - }); - scrollViewRef.current = `msg-${currentMessages.length + newMsgs.length}`; - } - failCount = 0; - } catch { - failCount++; - } - if (gen !== pollingGeneration.current || !mountedRef.current) return; - const delay = failCount > 0 ? Math.min(failCount * 2000, 30000) : POLL_INTERVAL_MS; - await new Promise((r) => setTimeout(r, delay)); - if (gen === pollingGeneration.current && mountedRef.current) { - longPoll(gen, failCount); - } - }; - const loadData = async () => { setLoading(true); try { @@ -112,6 +70,7 @@ export default function ConsultationDetail() { setMessages(msgs); messagesRef.current = msgs; scrollViewRef.current = `msg-${msgs.length}`; + dataLoadedRef.current = true; } catch { Taro.showToast({ title: '加载失败', icon: 'none' }); } finally { diff --git a/apps/miniprogram/src/pages/doctor/consultation/detail/index.tsx b/apps/miniprogram/src/pages/doctor/consultation/detail/index.tsx index 1cdabaa..24fd976 100644 --- a/apps/miniprogram/src/pages/doctor/consultation/detail/index.tsx +++ b/apps/miniprogram/src/pages/doctor/consultation/detail/index.tsx @@ -1,23 +1,18 @@ import { useState, useEffect, useRef } from 'react'; import { View, Text, Input, ScrollView } from '@tarojs/components'; -import Taro, { useRouter, useDidShow, useDidHide } from '@tarojs/taro'; +import Taro, { useRouter } from '@tarojs/taro'; import { getSession, listMessages, pollMessages, markSessionRead, sendMessage, closeSession, type ConsultationSession, type ConsultationMessage, } from '@/services/doctor/consultation'; import Loading from '@/components/Loading'; -import { useElderClass } from '../../../../hooks/useElderClass'; +import { useElderClass } from '@/hooks/useElderClass'; +import { useLongPolling } from '@/hooks/useLongPolling'; import './index.scss'; -/** DOM 节点数量上限,超过时只渲染最新的消息 */ const MAX_RENDER_MESSAGES = 200; -/** React state 中保留的消息上限(比渲染上限略多,保留滚动缓冲) */ const MAX_STATE_MESSAGES = 300; -/** 成功轮询后最小间隔(ms),防止后端快速响应时紧密递归 */ -const POLL_INTERVAL_MS = 3000; -/** 连续失败上限,超过后停止轮询 */ -const MAX_CONSECUTIVE_FAILURES = 50; export default function ConsultationDetail() { const router = useRouter(); @@ -29,73 +24,36 @@ export default function ConsultationDetail() { const [sending, setSending] = useState(false); const [loading, setLoading] = useState(true); const scrollViewRef = useRef(''); - const pollingGeneration = useRef(0); - const mountedRef = useRef(true); const messagesRef = useRef([]); + const dataLoadedRef = useRef(false); + + useLongPolling({ + pollFn: () => { + const cur = messagesRef.current; + const lastId = cur.length > 0 ? cur[cur.length - 1].id : undefined; + return pollMessages(sessionId, lastId); + }, + onData: (newMsgs) => { + if (!Array.isArray(newMsgs) || newMsgs.length === 0) return; + setMessages((prev) => { + const existing = new Set(prev.map((msg) => msg.id)); + const fresh = newMsgs.filter((msg) => !existing.has(msg.id)); + const next = [...prev, ...fresh].slice(-MAX_STATE_MESSAGES); + messagesRef.current = next; + scrollViewRef.current = `msg-${next.length}`; + return next; + }); + }, + enabled: !!sessionId && dataLoadedRef.current && session?.status !== 'closed', + }); useEffect(() => { if (sessionId) { loadData(); markRead(); - startLongPolling(); } - return () => { - pollingGeneration.current += 1; - mountedRef.current = false; - }; }, [sessionId]); - // 页面可见时恢复轮询,不可见时暂停(防止 DevTools 后台页面累积轮询) - useDidShow(() => { - if (sessionId && session?.status !== 'closed') { - startLongPolling(); - } - }); - useDidHide(() => { - pollingGeneration.current += 1; - }); - - useEffect(() => { - if (session?.status === 'closed') { - pollingGeneration.current += 1; - } - }, [session?.status]); - - const startLongPolling = () => { - const gen = ++pollingGeneration.current; - longPoll(gen); - }; - - const longPoll = async (gen: number, failCount = 0) => { - if (gen !== pollingGeneration.current || !mountedRef.current) return; - if (failCount >= MAX_CONSECUTIVE_FAILURES) return; - try { - const currentMessages = messagesRef.current; - const lastId = currentMessages.length > 0 ? currentMessages[currentMessages.length - 1].id : undefined; - const newMsgs = await pollMessages(sessionId, lastId); - if (gen !== pollingGeneration.current || !mountedRef.current) return; - if (newMsgs && newMsgs.length > 0) { - setMessages((prev) => { - const existing = new Set(prev.map((msg) => msg.id)); - const fresh = newMsgs.filter((msg) => !existing.has(msg.id)); - const next = [...prev, ...fresh].slice(-MAX_STATE_MESSAGES); - messagesRef.current = next; - return next; - }); - scrollViewRef.current = `msg-${currentMessages.length + newMsgs.length}`; - } - failCount = 0; - } catch { - failCount++; - } - if (gen !== pollingGeneration.current || !mountedRef.current) return; - const delay = failCount > 0 ? Math.min(failCount * 2000, 30000) : POLL_INTERVAL_MS; - await new Promise((r) => setTimeout(r, delay)); - if (gen === pollingGeneration.current && mountedRef.current) { - longPoll(gen, failCount); - } - }; - const loadData = async () => { setLoading(true); try { @@ -108,6 +66,7 @@ export default function ConsultationDetail() { setMessages(msgs); messagesRef.current = msgs; scrollViewRef.current = `msg-${msgs.length}`; + dataLoadedRef.current = true; } catch { Taro.showToast({ title: '加载失败', icon: 'none' }); } finally { diff --git a/wiki/miniprogram.md b/wiki/miniprogram.md index 69d62ec..d822e64 100644 --- a/wiki/miniprogram.md +++ b/wiki/miniprogram.md @@ -285,6 +285,7 @@ POST /auth/wechat/login { code } | `usePagination` | 通用分页逻辑 | | `useAuthRequired` | 登录态检查 | | `useElderClass` | 长者模式 CSS class | +| `useLongPolling` | **通用长轮询**:generation counter 防重叠 + useDidShow/Hide 可见性控制 + 失败退避(delay = min(failCount×2s, 30s))+ enabled 条件守卫。咨询详情页(患者+医生端)已接入 | ### 服务层(10+ 个文件) @@ -496,7 +497,7 @@ secret = "<通过环境变量 ERP__WECHAT__SECRET 设置>" #### 架构建议(已完成标注 ✅) 1. ~~**统一数据加载模式**~~ ✅ 已完成:所有列表/详情页使用 `usePageData` hook,44/58 页面已接入 -2. **长轮询通用化**:`consultation/detail` 和 `doctor/consultation/detail` 的长轮询逻辑几乎相同,应抽取为 `useLongPolling` hook +2. ~~**长轮询通用化**~~ ✅ 已完成:抽取 `useLongPolling` hook(generation counter + useDidShow/Hide + 失败退避),患者端+医生端 consultation/detail 已接入 3. ~~**服务端过滤优先**~~ ✅ 已完成:所有列表页的 Tab 过滤传参给后端 4. ~~**BLE 管理器生命周期**~~ ✅ 已完成:改为 `useRef` 懒初始化 5. ~~**getStorageSync 出渲染路径**~~ ✅ 已完成:通过 Zustand store 获取 @@ -906,6 +907,7 @@ node scripts/audit-pages.mjs --role doctor --batch-size 8 | 日期 | 变更 | |------|------| +| 2026-05-15 | **架构重构 P3:长轮询通用化 useLongPolling**:抽取 `useLongPolling` hook(generation counter + useDidShow/Hide 可见性 + 失败退避 + enabled 守卫);患者端 + 医生端 consultation/detail 接入,删除 ~80 行重复代码;架构建议 #2 全部完成 ✅ | | 2026-05-15 | **架构重构 P2:request.ts 模块级状态收编 + AbortSignal + Analytics 受控**:提取 `ConcurrencyLimiter` 类(并发限制)、`ResponseCache` 类(缓存+去重+patientId 绑定);新增 `resetForTesting()` 测试隔离函数;`api.get/post/put/delete` 支持 `AbortSignal` 请求取消;app.tsx Analytics 定时器改为 `useDidShow`/`useDidHide` 控制后台暂停;构建通过 + 测试 74/75 | | 2026-05-15 | **患者端登录后卡死深度审查(3 专家组)**:根因 — 全局并发请求超微信 10 限制排队阻塞;端点可达性验证 33/33 全部存在;Tab 切换请求链路分析(最坏 13 并发);修复 HIGH×3(doRefresh 状态清理 + 401 跳转登录页 + 全局并发限制 MAX_CONCURRENT=8)+ MEDIUM×3(长轮询 generation counter + 首页/健康页 loadingRef 防重入 + refreshToday 去重) | | 2026-05-15 | **全量审计修复(第二轮)**:修复 CRITICAL×1(pollingRef 未定义回归,咨询详情页 loadData 引用已删除的 pollingRef → 闭会话时 ReferenceError 崩溃);HIGH×3 — 401 重试递归占用双 slot 改为循环结构释放后重入、4 个医生端列表页(consultation/alerts/dialysis/prescription)添加 loadingRef 防重入、safeNavigateTo 页栈溢出保护(栈≥9 自动 redirectTo);新增 `safeNavigateTo` 工具函数(`utils/navigate.ts`) |