实现类Ctrl+F的搜索高亮导航及重音匹配问题求助
解决WhatsApp Web风格聊天应用的搜索高亮问题
问题1:高亮数量无限递增导致页面崩溃
原因
当前在TextFormatter的渲染阶段(highlightText的map循环中)每次匹配都调用onMatchesCounterChange触发父组件状态更新,状态更新会导致Chat组件重新渲染,所有TextFormatter再次执行渲染逻辑,重复触发计数回调,形成无限渲染循环。此外,每次渲染都会重复计数,导致数值持续递增。
解决方案
不再在渲染时逐个触发计数,而是在TextFormatter中通过useEffect一次性计算当前文本的匹配数,再传递给父组件汇总总数:
修改TextFormatter.tsx
import { useEffect } from "react"; import "./textFormatter.css"; interface TextFormatterProps { text: string; searchQuery?: string | null; selectedResultIndex?: number | null; onMatchCountChange?: (count: number) => void; // 替换原回调 } function TextFormatter(props: TextFormatterProps) { const boldRegex = /\*([^\s*].*?[^\s*])\*/g; const italicRegex = /\_([^\s_].*?[^\s_])\_/g; const strikeRegex = /\~([^\s~].*?[^\s~])\~/g; const monospaceRegex = /\```([^```].*?[^```])\```/gs; const formattingRegex = /(\*[^*]+\*|_[^_]+_|~[^~]+~|```[^```]+```)/g; // 计算当前文本的匹配数并通知父组件 useEffect(() => { if (!props.searchQuery || props.searchQuery.trim() === "") { props.onMatchCountChange?.(0); return; } const accentsRegex = /[\u0300-\u036f]/gi; const formattedSearchQuery = props.searchQuery .normalize("NFD") .replace(accentsRegex, "") .toLowerCase(); // 提取纯文本(移除格式标签) const plainText = props.text.replace(formattingRegex, "$1"); const plainTextNoAccents = plainText.normalize("NFD").replace(accentsRegex, "").toLowerCase(); // 统计匹配次数 const matches = plainTextNoAccents.match(new RegExp(formattedSearchQuery, "gi")); const count = matches ? matches.length : 0; props.onMatchCountChange?.(count); }, [props.searchQuery, props.text]); const formatText = (text: string) => { let formattedText = text.split(formattingRegex); return formattedText; }; const formattedText = formatText(props.text); // 修改highlightText,保留原文本重音 const highlightText = (text: string) => { if (!props.searchQuery || props.searchQuery.trim() === "") { return text; } const accentsRegex = /[\u0300-\u036f]/gi; const formattedSearchQuery = props.searchQuery .normalize("NFD") .replace(accentsRegex, "") .toLowerCase(); const textNoAccents = text.normalize("NFD").replace(accentsRegex, "").toLowerCase(); const result = []; let currentIndex = 0; let matchIndex; while ((matchIndex = textNoAccents.indexOf(formattedSearchQuery, currentIndex)) !== -1) { // 添加匹配前的普通文本 if (matchIndex > currentIndex) { result.push(text.slice(currentIndex, matchIndex)); } // 添加带重音的原匹配文本并高亮 const matchLength = formattedSearchQuery.length; const originalMatch = text.slice(matchIndex, matchIndex + matchLength); result.push( <span key={currentIndex + matchIndex} className={`highlight ${ props.selectedResultIndex !== null && props.selectedResultIndex === result.length ? "selected-highlight" : "" }`} > {originalMatch} </span> ); currentIndex = matchIndex + matchLength; } // 添加剩余文本 if (currentIndex < text.length) { result.push(text.slice(currentIndex)); } return result; }; return ( <div className="copyable-text"> {formattedText.map((segment, index) => { if (boldRegex.test(segment)) { return ( <span key={index} className="bold"> {highlightText(segment.replace(boldRegex, "$1"))} </span> ); } else if (italicRegex.test(segment)) { return ( <span key={index} className="italic"> {highlightText(segment.replace(italicRegex, "$1"))} </span> ); } else if (strikeRegex.test(segment)) { return ( <span key={index} className="strike"> {highlightText(segment.replace(strikeRegex, "$1"))} </span> ); } else if (monospaceRegex.test(segment)) { return ( <span key={index} className="monospace"> {highlightText(segment.replace(monospaceRegex, "$1"))} </span> ); } else { return highlightText(segment); } })} </div> ); } export default TextFormatter;
修改Chat.tsx
//... function Chat (props: ChatProps) { //... const [search, setSearch] = useState(""); const [totalOfResults, setTotalOfResults] = useState<number>(0); const [selectedResultIndex, setSelectedResultIndex] = useState<number | null>(null); const [matchCounts, setMatchCounts] = useState<Record<string, number>>({}); // 存储每个消息的匹配数 // 汇总所有消息的匹配数 useEffect(() => { const total = Object.values(matchCounts).reduce((sum, count) => sum + count, 0); setTotalOfResults(total); }, [matchCounts]); const handleSearchBarChange = ( event: React.ChangeEvent<HTMLInputElement> ) => { const searchQuery = event.target.value; setSearch(searchQuery); setMatchCounts({}); // 重置计数 setSelectedResultIndex(null); }; // 接收单个消息的匹配数 const handleMatchCountChange = (messageId: string, count: number) => { setMatchCounts(prev => ({ ...prev, [messageId]: count })); }; //... return ( <div className="chat"> <div className="chat-header"> //... <input type="text" placeholder="Search..." value={search} onChange={handleSearchBarChange} className={`search-input ${isSearchBarVisible ? "active" : ""}`} ref={inputRef} /> </div> //... <div className="chat-content" ref={chatContentRef}> //... {props.chatData.map((message) => ( //... <div className="message-content"> <TextFormatter text={message.content} searchQuery={search} onMatchCountChange={(count) => handleMatchCountChange(message.id, count)} selectedResultIndex={selectedResultIndex} /> </div> <div className="message-bottom"> <div className="message-timestamp"> <span>{formatTimestamp(message.created_at)}</span> </div> </div> </div> ))} </div> //... </div> )
问题2:带重音的搜索内容丢失原文本重音
原因
原代码将原文本移除重音后再split,导致最终显示的是无重音的文本;若不移除重音,正则匹配会因字符编码(NFC/NFD)差异或大小写问题无法精准匹配目标内容。
解决方案
匹配时基于无重音的文本进行判断,但显示时保留原文本的带重音内容。通过indexOf遍历无重音文本找到匹配位置,再提取原文本对应片段进行高亮,既实现忽略重音的搜索,又保留原文本的正确显示。
上述修改后的highlightText函数已实现此逻辑,示例效果:
searchQuery = "Café"; sentence = "Hello, café! I'm testing accents."; // 输出:Hello, <span class="highlight">café</span>! I'm testing accents.
内容的提问来源于stack exchange,提问作者JhonesJGL
相关产品推荐
相关产品推荐

