refactor(mp): 长轮询通用化 — useLongPolling hook + 咨询详情页接入

- 新增 useLongPolling hook:generation counter 防重叠、useDidShow/Hide 可见性控制、失败退避、enabled 守卫
- 患者端 + 医生端 consultation/detail 接入,删除约 80 行重复长轮询代码
- 架构建议 5/5 全部完成 
This commit is contained in:
iven
2026-05-15 07:38:20 +08:00
parent 6d151bbfb1
commit 5baa518516
4 changed files with 145 additions and 132 deletions

View File

@@ -0,0 +1,93 @@
import { useRef, useEffect } from 'react';
import { useDidShow, useDidHide } from '@tarojs/taro';
interface LongPollOptions<T> {
/** 轮询函数,返回新数据或 null/undefined */
pollFn: () => Promise<T | null | undefined>;
/** 收到新数据后的回调 */
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<T>({
pollFn,
onData,
enabled,
intervalMs = 3000,
maxFailures = 50,
}: LongPollOptions<T>) {
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<void>((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 };
}

View File

@@ -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<ConsultationMessage[]>([]);
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 {

View File

@@ -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<ConsultationMessage[]>([]);
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 {