Skip to content

Commit 4a1ddda

Browse files
committed
1.3.9.07
【优化】 - 搜索高亮性能
1 parent ce261f6 commit 4a1ddda

3 files changed

Lines changed: 216 additions & 54 deletions

File tree

src/components/interactions/SearchDrawer/SearchResultItem.tsx

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,43 +8,63 @@ import {
88
Stack,
99
Tooltip,
1010
Typography,
11-
useMediaQuery,
12-
useTheme
1311
} from "@mui/material";
1412
import { GitHub as GitHubIcon } from "@mui/icons-material";
1513
import { g3BorderRadius, G3_PRESETS } from "@/theme/g3Curves";
1614
import { highlightKeyword, highlightKeywords, resolveItemHtmlUrl } from "./utils";
1715
import type { RepoSearchItem } from "@/hooks/github/useRepoSearch";
1816
import { useI18n } from "@/contexts/I18nContext";
1917
import React from "react";
18+
import type { CSSProperties } from "react";
2019

2120
interface SearchResultItemProps {
2221
item: RepoSearchItem;
2322
keyword: string;
23+
keywordLower: string;
24+
highlightRegex: RegExp | null;
25+
isSmallScreen: boolean;
2426
onClick: (item: RepoSearchItem) => void;
2527
onOpenGithub: (item: RepoSearchItem) => void;
28+
style?: CSSProperties;
29+
ariaAttributes?: {
30+
"aria-posinset": number;
31+
"aria-setsize": number;
32+
role: "listitem";
33+
};
2634
}
2735

2836
export const SearchResultItem: React.FC<SearchResultItemProps> = ({
2937
item,
3038
keyword,
39+
keywordLower,
40+
highlightRegex,
41+
isSmallScreen,
3142
onClick,
32-
onOpenGithub
43+
onOpenGithub,
44+
style,
45+
ariaAttributes
3346
}) => {
34-
const theme = useTheme();
35-
const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm'));
3647
const { t } = useI18n();
3748

38-
const pathParts = highlightKeyword(item.path, keyword);
49+
const pathParts = highlightKeyword(item.path, keyword, keywordLower);
3950
const githubUrl = resolveItemHtmlUrl(item);
4051

4152
const snippet = ("snippet" in item && typeof (item as { snippet?: unknown }).snippet === "string")
4253
? (item as { snippet?: string }).snippet
4354
: undefined;
44-
const snippetParts = snippet !== undefined && snippet.length > 0 ? highlightKeywords(snippet, keyword) : null;
55+
const snippetParts = snippet !== undefined && snippet.length > 0
56+
? highlightKeywords(snippet, keyword, highlightRegex)
57+
: null;
58+
59+
const listItemProps = {
60+
disablePadding: true,
61+
alignItems: "flex-start" as const,
62+
...(style !== undefined ? { style } : {}),
63+
...(ariaAttributes ?? {})
64+
};
4565

4666
return (
47-
<ListItem disablePadding alignItems="flex-start">
67+
<ListItem {...listItemProps}>
4868
<Box
4969
sx={{
5070
display: "flex",

src/components/interactions/SearchDrawer/SearchResults.tsx

Lines changed: 145 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import {
22
Alert,
3+
Box,
34
Button,
4-
List,
5+
List as MuiList,
56
ListItem,
67
ListItemText,
78
Typography,
@@ -10,9 +11,13 @@ import {
1011
} from "@mui/material";
1112
import { g3BorderRadius, G3_PRESETS } from "@/theme/g3Curves";
1213
import { SearchResultItem } from "./SearchResultItem";
14+
import { getHighlightRegex } from "./utils";
1315
import type { RepoSearchItem } from "@/hooks/github/useRepoSearch";
1416
import { useI18n } from "@/contexts/I18nContext";
15-
import React from "react";
17+
import React, { useMemo } from "react";
18+
import { AutoSizer } from "react-virtualized-auto-sizer";
19+
import { List as VirtualList, useDynamicRowHeight } from "react-window";
20+
import type { RowComponentProps } from "react-window";
1621

1722
interface SearchResultsProps {
1823
items: RepoSearchItem[];
@@ -28,6 +33,51 @@ interface SearchResultsProps {
2833
disableSearchButton: boolean;
2934
}
3035

36+
interface SearchResultRowData {
37+
items: RepoSearchItem[];
38+
keyword: string;
39+
keywordLower: string;
40+
highlightRegex: RegExp | null;
41+
isSmallScreen: boolean;
42+
onResultClick: (item: RepoSearchItem) => void;
43+
onOpenGithub: (item: RepoSearchItem) => void;
44+
}
45+
46+
const VIRTUALIZE_THRESHOLD = 30;
47+
48+
const SearchResultRow = ({
49+
ariaAttributes,
50+
index,
51+
style,
52+
...rowData
53+
}: RowComponentProps<SearchResultRowData>): React.ReactElement => {
54+
const item = rowData.items[index];
55+
56+
if (item === undefined) {
57+
return (
58+
<div
59+
style={style}
60+
{...ariaAttributes}
61+
aria-hidden="true"
62+
/>
63+
);
64+
}
65+
66+
return (
67+
<SearchResultItem
68+
item={item}
69+
keyword={rowData.keyword}
70+
keywordLower={rowData.keywordLower}
71+
highlightRegex={rowData.highlightRegex}
72+
isSmallScreen={rowData.isSmallScreen}
73+
onClick={rowData.onResultClick}
74+
onOpenGithub={rowData.onOpenGithub}
75+
style={style}
76+
ariaAttributes={ariaAttributes}
77+
/>
78+
);
79+
};
80+
3181
export const SearchResults: React.FC<SearchResultsProps> = ({
3282
items,
3383
keyword,
@@ -41,6 +91,33 @@ export const SearchResults: React.FC<SearchResultsProps> = ({
4191
const theme = useTheme();
4292
const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm'));
4393
const { t } = useI18n();
94+
const listHeight = isSmallScreen ? 300 : 400;
95+
const shouldVirtualize = items.length >= VIRTUALIZE_THRESHOLD;
96+
const keywordLower = useMemo(() => keyword.toLowerCase(), [keyword]);
97+
const highlightRegex = useMemo(() => getHighlightRegex(keyword), [keyword]);
98+
const defaultRowHeight = isSmallScreen ? 72 : 88;
99+
const dynamicRowHeight = useDynamicRowHeight({ defaultRowHeight, key: keyword });
100+
101+
const rowData = useMemo(
102+
(): SearchResultRowData => ({
103+
items,
104+
keyword,
105+
keywordLower,
106+
highlightRegex,
107+
isSmallScreen,
108+
onResultClick,
109+
onOpenGithub
110+
}),
111+
[
112+
items,
113+
keyword,
114+
keywordLower,
115+
highlightRegex,
116+
isSmallScreen,
117+
onResultClick,
118+
onOpenGithub
119+
]
120+
);
44121

45122
const showEmptyIndexResult =
46123
!loading &&
@@ -82,40 +159,74 @@ export const SearchResults: React.FC<SearchResultsProps> = ({
82159
</Alert>
83160
)}
84161

85-
<List
86-
sx={{
87-
maxHeight: isSmallScreen ? 300 : 400,
88-
overflowY: "auto",
89-
overflowX: "hidden",
90-
width: "100%"
91-
}}
92-
>
93-
{items.map(item => (
94-
<SearchResultItem
95-
key={`${item.branch}:${item.path}`}
96-
item={item}
97-
keyword={keyword}
98-
onClick={onResultClick}
99-
onOpenGithub={onOpenGithub}
162+
{shouldVirtualize && items.length > 0 ? (
163+
<Box sx={{ height: listHeight, width: "100%" }}>
164+
<AutoSizer
165+
renderProp={({ width, height }) => {
166+
if (width === undefined || height === undefined) {
167+
return null;
168+
}
169+
170+
return (
171+
<VirtualList
172+
rowCount={items.length}
173+
rowHeight={dynamicRowHeight}
174+
rowComponent={SearchResultRow}
175+
rowProps={rowData}
176+
overscanCount={6}
177+
tagName="ul"
178+
style={{
179+
height,
180+
width,
181+
overflowX: "hidden",
182+
listStyle: "none",
183+
margin: 0,
184+
padding: 0
185+
}}
186+
/>
187+
);
188+
}}
100189
/>
101-
))}
102-
{searchResult !== null && searchResult.items.length === 0 && (
103-
<ListItem>
104-
<ListItemText
105-
primary={t('search.results.noResults')}
106-
secondary={t('search.results.noResultsHint')}
107-
slotProps={{
108-
primary: {
109-
variant: isSmallScreen ? "body2" : "body1"
110-
},
111-
secondary: {
112-
variant: isSmallScreen ? "caption" : "body2"
113-
}
114-
}}
190+
</Box>
191+
) : (
192+
<MuiList
193+
sx={{
194+
maxHeight: listHeight,
195+
overflowY: "auto",
196+
overflowX: "hidden",
197+
width: "100%"
198+
}}
199+
>
200+
{items.map(item => (
201+
<SearchResultItem
202+
key={`${item.branch}:${item.path}`}
203+
item={item}
204+
keyword={keyword}
205+
keywordLower={keywordLower}
206+
highlightRegex={highlightRegex}
207+
isSmallScreen={isSmallScreen}
208+
onClick={onResultClick}
209+
onOpenGithub={onOpenGithub}
115210
/>
116-
</ListItem>
117-
)}
118-
</List>
211+
))}
212+
{searchResult !== null && searchResult.items.length === 0 && (
213+
<ListItem>
214+
<ListItemText
215+
primary={t('search.results.noResults')}
216+
secondary={t('search.results.noResultsHint')}
217+
slotProps={{
218+
primary: {
219+
variant: isSmallScreen ? "body2" : "body1"
220+
},
221+
secondary: {
222+
variant: isSmallScreen ? "caption" : "body2"
223+
}
224+
}}
225+
/>
226+
</ListItem>
227+
)}
228+
</MuiList>
229+
)}
119230
</>
120231
);
121232
};

src/components/interactions/SearchDrawer/utils.ts

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,17 @@ export const parseExtensionInput = (value: string): string[] => {
2222
* 高亮文本中的关键字
2323
*/
2424
export const highlightKeyword = (
25-
text: string,
26-
keyword: string
25+
text: string,
26+
keyword: string,
27+
lowerKeywordOverride?: string
2728
): { text: string; highlight: boolean }[] => {
2829
if (keyword.trim().length === 0) {
2930
return [{ text, highlight: false }];
3031
}
3132

3233
const parts: { text: string; highlight: boolean }[] = [];
3334
const lowerText = text.toLowerCase();
34-
const lowerKeyword = keyword.toLowerCase();
35+
const lowerKeyword = lowerKeywordOverride ?? keyword.toLowerCase();
3536
let lastIndex = 0;
3637
let index = lowerText.indexOf(lowerKeyword);
3738

@@ -59,20 +60,18 @@ export const highlightKeyword = (
5960
const escapeRegExp = (value: string): string =>
6061
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6162

62-
/**
63-
* 高亮文本中的多个关键字
64-
*/
65-
export const highlightKeywords = (
66-
text: string,
67-
keyword: string
68-
): { text: string; highlight: boolean }[] => {
63+
const HIGHLIGHT_REGEX_CACHE_LIMIT = 50;
64+
// 关键词高亮正则缓存,减少重复编译
65+
const highlightRegexCache = new Map<string, RegExp>();
66+
67+
export const getHighlightRegex = (keyword: string): RegExp | null => {
6968
const tokens = keyword
7069
.split(/\s+/)
7170
.map((token) => token.trim())
7271
.filter((token) => token.length > 0);
7372

7473
if (tokens.length === 0) {
75-
return [{ text, highlight: false }];
74+
return null;
7675
}
7776

7877
const uniqueTokens = Array.from(new Set(tokens))
@@ -81,10 +80,42 @@ export const highlightKeywords = (
8180

8281
const pattern = uniqueTokens.join("|");
8382
if (pattern.length === 0) {
84-
return [{ text, highlight: false }];
83+
return null;
84+
}
85+
86+
const cached = highlightRegexCache.get(pattern);
87+
if (cached !== undefined) {
88+
return cached;
8589
}
8690

8791
const regex = new RegExp(`(${pattern})`, "gi");
92+
highlightRegexCache.set(pattern, regex);
93+
94+
if (highlightRegexCache.size > HIGHLIGHT_REGEX_CACHE_LIMIT) {
95+
// 简单淘汰最早插入项,避免缓存无限增长
96+
const firstKey = highlightRegexCache.keys().next().value;
97+
if (typeof firstKey === "string") {
98+
highlightRegexCache.delete(firstKey);
99+
}
100+
}
101+
102+
return regex;
103+
};
104+
105+
/**
106+
* 高亮文本中的多个关键字
107+
*/
108+
export const highlightKeywords = (
109+
text: string,
110+
keyword: string,
111+
regexOverride?: RegExp | null
112+
): { text: string; highlight: boolean }[] => {
113+
const regex = regexOverride ?? getHighlightRegex(keyword);
114+
if (regex === null) {
115+
return [{ text, highlight: false }];
116+
}
117+
118+
regex.lastIndex = 0;
88119
const parts: { text: string; highlight: boolean }[] = [];
89120
let lastIndex = 0;
90121
let match = regex.exec(text);

0 commit comments

Comments
 (0)