From dfe6a7e1939d2bf1b003ab1d188515b5b1f95903 Mon Sep 17 00:00:00 2001 From: haorwen Date: Sat, 17 Jan 2026 17:01:46 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E6=98=BE=E7=A4=BA=E9=83=A8=E5=88=86=E4=BB=A5=E6=8F=90?= =?UTF-8?q?=E5=8D=87=E5=A4=A7=E9=87=8F=E6=B6=88=E6=81=AF=E6=97=B6web?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E9=80=9F=E5=BA=A6=E5=92=8C=E5=9C=A8=E4=BD=8E?= =?UTF-8?q?=E6=80=A7=E8=83=BD=E8=AE=BE=E5=A4=87=E4=B8=8A=E7=9A=84=E8=A1=A8?= =?UTF-8?q?=E7=8E=B0=20(#297)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: optimize message feed rendering and state management - Removed unnecessary initialization state in VirtualMessageFeed. - Updated message data subscription to only track visible messages, improving performance. - Simplified props in Layout component for VirtualMessageFeed and Send components. * refactor: 针对低性能设备进行更多优化,包括消息隔离等 - Added a script to suppress ResizeObserver errors in index.html. - Introduced memoized selectors for efficient message retrieval in message selectors. - Optimized message insertion logic in message slices to reduce unnecessary sorting. - Improved memoization in Message component for better rendering performance. - Enhanced MessageInput to reset editor state on chat changes. - Updated VirtualMessageFeed to manage visible messages more efficiently and reduce initial load for better performance on low-end devices. * chore: bump version to v0.9.50 --- package.json | 2 +- public/index.html | 49 +++++++ src/app/selectors/message.ts | 61 +++++++++ src/app/slices/message.channel.ts | 14 +- src/app/slices/message.user.ts | 15 ++- src/components/Message/index.tsx | 9 +- src/components/MessageInput/index.tsx | 10 +- .../Layout/VirtualMessageFeed/CustomList.tsx | 14 +- .../chat/Layout/VirtualMessageFeed/index.tsx | 124 +++++++++++++----- src/routes/chat/Layout/index.tsx | 4 +- src/routes/chat/utils.tsx | 38 ++++-- 11 files changed, 281 insertions(+), 59 deletions(-) create mode 100644 src/app/selectors/message.ts diff --git a/package.json b/package.json index 5fe2343d..f17c3270 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vocechat-web", - "version": "0.9.49", + "version": "0.9.50", "homepage": "https://voce.chat", "dependencies": { "@metamask/onboarding": "^1.0.1", diff --git a/public/index.html b/public/index.html index 65a53c5d..3ee8e98f 100644 --- a/public/index.html +++ b/public/index.html @@ -8,6 +8,55 @@ /> + + + + state.message; +const selectChannelMessages = (state: RootState) => state.channelMessage; +const selectUserMessages = (state: RootState) => state.userMessage; + +// Create a memoized selector for visible messages +// This selector will only recompute when the specific messages change, not when any message in the store changes +export const makeSelectVisibleMessages = () => { + return createSelector( + [ + selectMessageStore, + (_: RootState, mids: number[]) => mids + ], + (messageStore, mids) => { + // Create a stable object that only changes when the actual message content changes + const messages: Record = {}; + mids.forEach(mid => { + if (messageStore[mid]) { + messages[mid] = messageStore[mid]; + } + }); + return messages; + }, + { + // Use custom equality check to prevent unnecessary recomputation + memoizeOptions: { + resultEqualityCheck: (a, b) => { + // Check if the message objects are the same + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) return false; + + for (const key of aKeys) { + if (a[key] !== b[key]) return false; + } + return true; + } + } + } + ); +}; + +// Create a memoized selector for message IDs +export const makeSelectMessageIds = () => { + return createSelector( + [ + selectChannelMessages, + selectUserMessages, + (_: RootState, context: ChatContext, id: number) => ({ context, id }) + ], + (channelMessages, userMessages, { context, id }) => { + return context === 'dm' + ? userMessages.byId[id] ?? [] + : channelMessages[id] ?? []; + } + ); +}; diff --git a/src/app/slices/message.channel.ts b/src/app/slices/message.channel.ts index 2f6b498c..75d51ee8 100644 --- a/src/app/slices/message.channel.ts +++ b/src/app/slices/message.channel.ts @@ -26,9 +26,17 @@ const channelMsgSlice = createSlice({ const midExisted = state[id]!.findIndex((id) => id == mid) > -1; const localMsgExisted = state[id]!.findIndex((id) => id == local_id) > -1; if (midExisted || localMsgExisted) return; - // 每次入库,都排序 - const newArr = [...(state[id] as number[]), +mid].sort((a, b) => a - b); - state[id] = newArr; + + // Optimize: check if new message should be at the end (most common case) + const lastMid = state[id]![state[id]!.length - 1]; + if (+mid > lastMid) { + // New message is newer, just push to end - no need to sort + state[id]!.push(+mid); + } else { + // Need to insert in sorted position + const newArr = [...(state[id] as number[]), +mid].sort((a, b) => a - b); + state[id] = newArr; + } } else { state[id] = [+mid]; } diff --git a/src/app/slices/message.user.ts b/src/app/slices/message.user.ts index 6c5d4ac5..d2b3d8d1 100644 --- a/src/app/slices/message.user.ts +++ b/src/app/slices/message.user.ts @@ -28,9 +28,18 @@ const userMsgSlice = createSlice({ const midExisted = state.byId[id].findIndex((id: number) => id == mid) > -1; const localMsgExisted = state.byId[id].findIndex((id: number) => id == local_id) > -1; if (midExisted || localMsgExisted) return; - // 每次入库,都排序 - const newArr = [...state.byId[id], +mid].sort((a, b) => a - b); - state.byId[id] = newArr; + + // Optimize: check if new message should be at the end (most common case) + const lastMid = state.byId[id][state.byId[id].length - 1]; + if (+mid > lastMid) { + // New message is newer, just push to end - no need to sort + state.byId[id].push(+mid); + } else { + // Need to insert in sorted position + const newArr = [...state.byId[id], +mid].sort((a, b) => a - b); + state.byId[id] = newArr; + } + // 只要有新消息,就检查下 if (state.ids.findIndex((uid) => uid == id) == -1) { state.ids.push(+id); diff --git a/src/components/Message/index.tsx b/src/components/Message/index.tsx index d2fe7a96..ce37aef1 100644 --- a/src/components/Message/index.tsx +++ b/src/components/Message/index.tsx @@ -242,5 +242,12 @@ const Message: FC = ({ ); }; export default React.memo(Message, (prevs, nexts) => { - return prevs.mid == nexts.mid; + // More precise memo comparison for better performance + return ( + prevs.mid === nexts.mid && + prevs.readOnly === nexts.readOnly && + prevs.read === nexts.read && + prevs.contextId === nexts.contextId && + prevs.context === nexts.context + ); }); diff --git a/src/components/MessageInput/index.tsx b/src/components/MessageInput/index.tsx index 555fa3eb..da37f384 100644 --- a/src/components/MessageInput/index.tsx +++ b/src/components/MessageInput/index.tsx @@ -48,6 +48,14 @@ export default function MessageInput({ const handleSendMessage = () => { sendMessage(); }; + + // Reset editor when chat changes without remounting + useEffect(() => { + if (editorRef.current) { + editorRef.current.reset(); + } + }, [id]); + useEffect(() => { const text = getMessageFromPlateValues(input as ParagraphInput[]); updateMessage(text); @@ -89,7 +97,6 @@ export default function MessageInput({ { setInput(values); @@ -100,7 +107,6 @@ export default function MessageInput({ diff --git a/src/routes/chat/Layout/VirtualMessageFeed/CustomList.tsx b/src/routes/chat/Layout/VirtualMessageFeed/CustomList.tsx index a20ec17b..16af67a3 100644 --- a/src/routes/chat/Layout/VirtualMessageFeed/CustomList.tsx +++ b/src/routes/chat/Layout/VirtualMessageFeed/CustomList.tsx @@ -1,5 +1,17 @@ const CustomList = ({ style, ref, ...props }: any) => { - return
; + return ( +
+ ); }; export default CustomList; diff --git a/src/routes/chat/Layout/VirtualMessageFeed/index.tsx b/src/routes/chat/Layout/VirtualMessageFeed/index.tsx index 208c3577..761436e2 100644 --- a/src/routes/chat/Layout/VirtualMessageFeed/index.tsx +++ b/src/routes/chat/Layout/VirtualMessageFeed/index.tsx @@ -11,6 +11,8 @@ import { renderMessageFragment } from "../../utils"; import NewMessageBottomTip from "../NewMessageBottomTip"; import CustomHeader from "./CustomHeader"; import CustomList from "./CustomList"; +import { makeSelectVisibleMessages } from "@/app/selectors/message"; +import { updateSelectMessages } from "@/app/slices/ui"; type Props = { context: ChatContext; @@ -28,13 +30,21 @@ const VirtualMessageFeed = forwardRef(({ contex // const { t } = useTranslation("chat"); // const [firstItemIndex, setFirstItemIndex] = useState(firstMsgIndex); const [atBottom, setAtBottom] = useState(false); - const [visibleCount, setVisibleCount] = useState(100); - const isInitializing = useRef(false); + // Reduce initial visible count for better performance on low-end devices + const [visibleCount, setVisibleCount] = useState(50); const [loadMoreMessage, { isLoading: loadingMore, isSuccess, data: historyData }] = useLazyLoadMoreMessagesQuery(); const vList = useRef(null); const [updateReadIndex] = useReadMessageMutation(); - const updateReadDebounced = useDebounce(updateReadIndex, 300); + // Increase debounce time for better performance on low-end devices + const updateReadDebounced = useDebounce(updateReadIndex, 500); + // Store debounced function in ref to avoid recreating itemContent + const updateReadDebouncedRef = useRef(updateReadDebounced); + updateReadDebouncedRef.current = updateReadDebounced; + + // Create memoized selector instance for this component + const selectVisibleMessages = useMemo(() => makeSelectVisibleMessages(), []); + const historyMid = useAppSelector( (store) => context == "dm" @@ -47,35 +57,61 @@ const VirtualMessageFeed = forwardRef(({ contex context == "dm" ? store.userMessage.byId[id] ?? [] : store.channelMessage[id] ?? [], shallowEqual ); - + + // Stabilize mids array reference - only create new array if content actually changed const mids = useMemo(() => { if (allMids.length <= visibleCount) return allMids; return allMids.slice(-visibleCount); }, [allMids, visibleCount]); + + // Use ref to track previous mids for comparison + const prevMidsRef = useRef([]); + const stableMids = useMemo(() => { + // Check if mids actually changed + if (prevMidsRef.current.length === mids.length) { + let same = true; + for (let i = 0; i < mids.length; i++) { + if (prevMidsRef.current[i] !== mids[i]) { + same = false; + break; + } + } + if (same) return prevMidsRef.current; + } + prevMidsRef.current = mids; + return mids; + }, [mids]); const selects = useAppSelector( (store) => store.ui.selectMessages[`${context}_${id}`], shallowEqual ); const loginUid = useAppSelector((store) => store.authData.user?.uid, shallowEqual); - const messageData = useAppSelector((store) => store.message, shallowEqual); + + // Create stable toggleSelect function + const toggleSelect = useCallback((mid: number, selected: boolean) => { + const operation = selected ? "remove" : "add"; + dispatch(updateSelectMessages({ context, id, operation, data: mid })); + }, [context, id, dispatch]); + + // Use memoized selector to get message data - this will only recompute when the specific messages change + const messageData = useAppSelector((store) => selectVisibleMessages(store, stableMids)); + const readChannels = useAppSelector((store) => store.footprint.readChannels, shallowEqual); const readUsers = useAppSelector((store) => store.footprint.readUsers, shallowEqual); useEffect(() => { - isInitializing.current = true; - setVisibleCount(100); - setTimeout(() => { - isInitializing.current = false; - }, 500); + // Reset visible count when switching chats + setVisibleCount(50); + setAtBottom(false); }, [id]); useEffect(() => { const feedId = `VOCECHAT_FEED_${context}_${id}`; const feedEle = document.getElementById(feedId); - + const handleScrollToMessage = (evt: CustomEvent) => { const { mid } = evt.detail; - const index = mids.findIndex((m) => m === mid); + const index = stableMids.findIndex((m) => m === mid); if (index !== -1 && vList.current) { vList.current.scrollToIndex({ index, align: "center", behavior: "smooth" }); setTimeout(() => { @@ -114,12 +150,12 @@ const VirtualMessageFeed = forwardRef(({ contex }, 100); } }; - + feedEle?.addEventListener('scrollToMessage', handleScrollToMessage as EventListener); return () => { feedEle?.removeEventListener('scrollToMessage', handleScrollToMessage as EventListener); }; - }, [context, id, mids, allMids]); + }, [context, id, stableMids, allMids]); useEffect(() => { if (isSuccess && historyData) { @@ -132,7 +168,7 @@ const VirtualMessageFeed = forwardRef(({ contex dispatch(updateHistoryMark({ type: context, id, mid: `${mid}` })); } } - }, [isSuccess, historyData, mids, context, id]); + }, [isSuccess, historyData, stableMids, context, id]); // useEffect(() => { // console.log("diff mids", prevMids, mids); // const newCount = mids.length - prevMids.length; @@ -142,9 +178,10 @@ const VirtualMessageFeed = forwardRef(({ contex // 加载更多 const handleTopStateChange = (isTop: boolean) => { console.log("reach top ", isTop); - if (isTop && !isInitializing.current) { + if (isTop) { if (allMids.length > visibleCount) { - setVisibleCount(prev => Math.min(prev + 100, allMids.length)); + // Load 50 messages at a time for better performance + setVisibleCount(prev => Math.min(prev + 50, allMids.length)); } else { if (historyMid === "reached") return; let lastMid = allMids.slice(0, 1)[0]; @@ -157,7 +194,7 @@ const VirtualMessageFeed = forwardRef(({ contex }; // 自动跟随 const handleFollowOutput = (isAtBottom: boolean) => { - const [lastMid] = mids ? mids.slice(-1) : [0]; + const [lastMid] = stableMids ? stableMids.slice(-1) : [0]; const ts = new Date().getTime(); // tricky const isSentByMyself = ts - lastMid < 1000; @@ -180,7 +217,7 @@ const VirtualMessageFeed = forwardRef(({ contex useImperativeHandle(ref, () => ({ scrollToMessage: (mid: number) => { - const index = mids.findIndex((m) => m === mid); + const index = stableMids.findIndex((m) => m === mid); if (index !== -1 && vList.current) { vList.current.scrollToIndex({ index, align: "center", behavior: "smooth" }); } else if (allMids.includes(mid)) { @@ -196,31 +233,54 @@ const VirtualMessageFeed = forwardRef(({ contex })); const readIndex = context == "channel" ? readChannels[id] : readUsers[id]; - - // 缓存itemContent函数,避免每次都创建新函数 + + // Store frequently changing values in refs to avoid recreating itemContent + const messageDataRef = useRef(messageData); + const midsRef = useRef(stableMids); + const readIndexRef = useRef(readIndex); + const loginUidRef = useRef(loginUid); + const selectsRef = useRef(selects); + const toggleSelectRef = useRef(toggleSelect); + + messageDataRef.current = messageData; + midsRef.current = stableMids; + readIndexRef.current = readIndex; + loginUidRef.current = loginUid; + selectsRef.current = selects; + toggleSelectRef.current = toggleSelect; + + // Stable itemContent function - only recreate when context or id changes const itemContent = useCallback((idx: number, mid: number) => { - const curr = messageData[mid]; + const curr = messageDataRef.current[mid]; if (!curr) return
; const isFirst = idx == 0; - const prev = isFirst ? null : messageData[mids[idx - 1]]; - const read = curr?.from_uid == loginUid || mid <= readIndex; + const prev = isFirst ? null : messageDataRef.current[midsRef.current[idx - 1]]; + // Optimize read calculation: once a message is read, it stays read + // This prevents unnecessary re-renders when readIndex updates + const read = curr?.from_uid == loginUidRef.current || mid <= readIndexRef.current; + const selected = !!(selectsRef.current && selectsRef.current.find((s: number) => s == mid)); + const handleToggleSelect = () => toggleSelectRef.current(mid, selected); return renderMessageFragment({ - selectMode: !!selects, - updateReadIndex: updateReadDebounced, + selectMode: !!selectsRef.current, + updateReadIndex: updateReadDebouncedRef.current, read, prev, curr, contextId: id, - context + context, + selected, + toggleSelect: handleToggleSelect }); - }, [messageData, mids, loginUid, readIndex, selects, updateReadDebounced, id, context]); + }, [id, context]); return ( <> (({ contex Header: CustomHeader }} // firstItemIndex={firstItemIndex} - initialTopMostItemIndex={mids.length - 1} + initialTopMostItemIndex={stableMids.length - 1} alignToBottom // startReached={handleLoadMore} - data={mids} + data={stableMids} atTopThreshold={context == "channel" ? 160 : 0} atTopStateChange={handleTopStateChange} atBottomStateChange={handleBottomStateChange} diff --git a/src/routes/chat/Layout/index.tsx b/src/routes/chat/Layout/index.tsx index 76831ba3..6a996ae3 100644 --- a/src/routes/chat/Layout/index.tsx +++ b/src/routes/chat/Layout/index.tsx @@ -103,7 +103,7 @@ const Layout: FC = ({ {context == "dm" && } {context == "dm" && } {/* 消息流 */} - + {/* 发送框 */}
{readonly ? ( @@ -112,7 +112,7 @@ const Layout: FC = ({ ) : (
- +
)} {selects && } diff --git a/src/routes/chat/utils.tsx b/src/routes/chat/utils.tsx index d863f378..e81c1e66 100644 --- a/src/routes/chat/utils.tsx +++ b/src/routes/chat/utils.tsx @@ -1,4 +1,5 @@ // import React from "react"; +import React from "react"; import { shallowEqual, useDispatch } from "react-redux"; import dayjs from "dayjs"; @@ -94,18 +95,7 @@ export const renderPreviewMessage = (message = null) => { return res; }; -const MessageWrapper = ({ selectMode = false, context, id, mid, divider, children, ...rest }) => { - const dispatch = useDispatch(); - - const selects = useAppSelector( - (store) => store.ui.selectMessages[`${context}_${id}`], - shallowEqual - ); - const selected = !!(selects && selects.find((s) => s == mid)); - const toggleSelect = () => { - const operation = selected ? "remove" : "add"; - dispatch(updateSelectMessages({ context, id, operation, data: mid })); - }; +const MessageWrapper = React.memo(({ selectMode = false, context, id, mid, divider, selected = false, toggleSelect, children, ...rest }) => { return (
{divider} @@ -125,7 +115,21 @@ const MessageWrapper = ({ selectMode = false, context, id, mid, divider, childre )}
); -}; +}, (prevProps, nextProps) => { + // Optimize re-renders by comparing only necessary props + // Compare divider by value, not reference + const dividerSame = prevProps.divider === nextProps.divider || + (prevProps.divider?.props?.content === nextProps.divider?.props?.content); + + return ( + prevProps.mid === nextProps.mid && + prevProps.selectMode === nextProps.selectMode && + prevProps.selected === nextProps.selected && + prevProps.context === nextProps.context && + prevProps.id === nextProps.id && + dividerSame + ); +}); type Params = { readonly?: boolean; selectMode: boolean; @@ -135,6 +139,8 @@ type Params = { curr: object | null; contextId: number; context: ChatContext; + selected?: boolean; + toggleSelect?: () => void; }; export const renderMessageFragment = ({ readonly = false, @@ -144,7 +150,9 @@ export const renderMessageFragment = ({ prev, curr = null, contextId = 0, - context = "dm" + context = "dm", + selected = false, + toggleSelect }: Params) => { if (!curr) return
; let { created_at, mid } = curr; @@ -169,6 +177,8 @@ export const renderMessageFragment = ({ id={contextId} mid={mid} selectMode={selectMode} + selected={selected} + toggleSelect={toggleSelect} divider={divider ? : null} >