refactor: 重构聊天显示部分以提升大量消息时web响应速度和在低性能设备上的表现 (#297)

* 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
This commit is contained in:
haorwen
2026-01-17 17:01:46 +08:00
committed by GitHub
parent c1dc354a66
commit dfe6a7e193
11 changed files with 281 additions and 59 deletions
+1 -1
View File
@@ -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",
+49
View File
@@ -8,6 +8,55 @@
/>
<meta name="theme-color" content="#527ff1" />
<meta name="description" content="Your private chat APP" />
<!-- Suppress ResizeObserver errors - must be first script -->
<script>
// Suppress benign ResizeObserver errors from virtualized lists
// This is a known browser issue and doesn't affect functionality
// Reference: https://github.com/WICG/resize-observer/issues/38
(function() {
function isResizeObserverError(message) {
return message && (
message.includes('ResizeObserver loop') ||
message.includes('ResizeObserver loop completed') ||
message.includes('ResizeObserver loop limit exceeded')
);
}
// Intercept window.onerror
const originalOnError = window.onerror;
window.onerror = function(message, source, lineno, colno, error) {
if (typeof message === 'string' && isResizeObserverError(message)) {
return true; // Suppress ResizeObserver errors
}
if (originalOnError) {
return originalOnError.apply(this, arguments);
}
return false;
};
// Intercept error events (capture phase to catch early)
window.addEventListener('error', function(e) {
if (e.message && isResizeObserverError(e.message)) {
e.stopImmediatePropagation();
e.preventDefault();
return true;
}
}, true);
// Intercept console.error
const originalConsoleError = console.error;
console.error = function() {
const args = Array.from(arguments);
if (args.length > 0 && typeof args[0] === 'string' && isResizeObserverError(args[0])) {
return; // Suppress
}
originalConsoleError.apply(console, args);
};
})();
</script>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/apple-touch-icon.png" />
<!-- splash -->
<link
+61
View File
@@ -0,0 +1,61 @@
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from '../store';
import { ChatContext } from '@/types/common';
// Base selectors - these extract raw data from the store
const selectMessageStore = (state: RootState) => 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<number, any> = {};
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] ?? [];
}
);
};
+11 -3
View File
@@ -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];
}
+12 -3
View File
@@ -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);
+8 -1
View File
@@ -242,5 +242,12 @@ const Message: FC<IProps> = ({
);
};
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
);
});
+8 -2
View File
@@ -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({
<Plate
// @ts-ignore
editorRef={editorRef}
key={id}
id={id}
onChange={(values) => {
setInput(values);
@@ -100,7 +107,6 @@ export default function MessageInput({
<Editor
sendMessage={handleSendMessage}
// className="px-2 py-3"
autoFocus
placeholder={placeholder}
/>
<MentionCombobox items={items} />
@@ -1,5 +1,17 @@
const CustomList = ({ style, ref, ...props }: any) => {
return <div style={{ ...style, width: `calc(100% - 2rem)` }} {...props} ref={ref} />;
return (
<div
style={{
...style,
width: `calc(100% - 2rem)`,
// CSS performance hint for better scrolling performance
// Note: avoid 'contain' as it interferes with ResizeObserver in virtuoso
willChange: 'transform'
}}
{...props}
ref={ref}
/>
);
};
export default CustomList;
@@ -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<VirtualMessageFeedHandle, Props>(({ 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<VirtuosoHandle | null>(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<VirtualMessageFeedHandle, Props>(({ 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<number[]>([]);
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<VirtualMessageFeedHandle, Props>(({ 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<VirtualMessageFeedHandle, Props>(({ 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<VirtualMessageFeedHandle, Props>(({ 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<VirtualMessageFeedHandle, Props>(({ 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<VirtualMessageFeedHandle, Props>(({ 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<VirtualMessageFeedHandle, Props>(({ 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 <div className="w-full h-[1px] invisible"></div>;
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 (
<>
<Virtuoso
// logLevel={LogLevel.DEBUG}
overscan={50}
increaseViewportBy={{ top: 0, bottom: 400 }}
// Reduce overscan for better performance on low-end devices
overscan={20}
// Reduce viewport extension for better performance
increaseViewportBy={{ top: 0, bottom: 200 }}
context={{ loadingMore, id, isChannel: context == "channel" }}
id={`VOCECHAT_FEED_${context}_${id}`}
className="px-1 md:px-4 py-4.5 overflow-x-hidden overflow-y-scroll"
@@ -230,10 +290,10 @@ const VirtualMessageFeed = forwardRef<VirtualMessageFeedHandle, Props>(({ 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}
+2 -2
View File
@@ -103,7 +103,7 @@ const Layout: FC<Props> = ({
{context == "dm" && <DMVoice uid={to} />}
{context == "dm" && <AddContactTip uid={to} />}
{/* 消息流 */}
<VirtualMessageFeed ref={feedRef} key={`${context}_${to}`} context={context} id={to} />
<VirtualMessageFeed ref={feedRef} context={context} id={to} />
{/* 发送框 */}
<div className={`px-2 py-0 md:p-4 ${selects ? "selecting" : ""}`}>
{readonly ? (
@@ -112,7 +112,7 @@ const Layout: FC<Props> = ({
<LicenseUpgradeTip />
) : (
<div className={clsx(`flex justify-center`, selects && "hidden")}>
<Send key={to} id={to} context={context} />
<Send id={to} context={context} />
</div>
)}
{selects && <Operations context={context} id={to} />}
+24 -14
View File
@@ -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 (
<div className={`group flex flex-col items-start gap-2 relative w-full `} {...rest}>
{divider}
@@ -125,7 +115,21 @@ const MessageWrapper = ({ selectMode = false, context, id, mid, divider, childre
)}
</div>
);
};
}, (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 <div className="w-full h-[1px] invisible"></div>;
let { created_at, mid } = curr;
@@ -169,6 +177,8 @@ export const renderMessageFragment = ({
id={contextId}
mid={mid}
selectMode={selectMode}
selected={selected}
toggleSelect={toggleSelect}
divider={divider ? <Divider className="w-full" content={divider}></Divider> : null}
>
<Message