ReactMarkdown结合SpeechSynthesis API实现朗读单词高亮问题求助
问题:Markdown文本朗读高亮丢失原有HTML标签
问题背景
在React组件中借助SpeechSynthesis API实现Markdown文本朗读时的单词高亮效果:
- 用
react-markdown渲染Markdown文本 - 用
speak-tts包与SpeechSynthesis API交互 - 当前实现:将ReactMarkdown包裹在
id="article-content"的div中,遍历子节点定位朗读位置,通过替换innerHTML添加高亮span - 核心问题:替换innerHTML会丢失子元素原有的HTML标签(比如Markdown渲染出的
<strong>、<em>等)
解决方案
核心思路:不直接替换元素的innerHTML,而是遍历DOM中的文本节点,精准定位到需要高亮的字符位置,拆分文本节点并插入高亮span,保留原有DOM结构。
修改后的完整代码
Article组件
import ReactMarkdown from "react-markdown"; import Speech from "speak-tts"; import { FunctionComponent } from "react"; import { useSpeech } from "./useSpeech"; export const Article: FunctionComponent<{ markdown: string; }> = ({ markdown }) => { const { play, pause, isPlaying } = useSpeech(markdown); return ( <div> <button onClick={isPlaying ? pause : play}> {isPlaying ? 'Pause' : 'Play'} </button> <div id="article-content"> <ReactMarkdown> {markdown} </ReactMarkdown> </div> </div> ); }
useSpeech Hook
import { useState, useEffect } from "react"; import Speech from "speak-tts"; import { useRouter } from "next/router"; export const useSpeech = (text?: string) => { const [isPlaying, setIsPlaying] = useState(false); const [speech, setSpeech] = useState<Speech | null>(null); const router = useRouter(); useEffect(() => { const initSpeech = async () => { const speechInstance = new Speech(); try { await speechInstance.init({ rate: 3, pitch: 1.4 }); setSpeech(speechInstance); } catch (err) { console.error("Speech init failed:", err); } }; initSpeech(); const cancelSpeech = () => speech?.cancel(); router.events.on("routeChangeStart", cancelSpeech); window.addEventListener("beforeunload", cancelSpeech); return () => { router.events.off("routeChangeStart", cancelSpeech); window.removeEventListener("beforeunload", cancelSpeech); speech?.cancel(); }; }, [router]); // 清除所有高亮 const removeAllHighlights = () => { const highlights = document.querySelectorAll('.speech-highlight'); highlights.forEach(highlight => { const parent = highlight.parentNode; if (parent) { // 将span内的文本节点移回父元素,再删除span while (highlight.firstChild) { parent.insertBefore(highlight.firstChild, highlight); } parent.removeChild(highlight); // 合并相邻的文本节点 parent.normalize(); } }); }; // 递归遍历所有文本节点,累计字符数,找到目标位置并插入高亮span const findAndHighlight = (node: Node, targetIndex: number, targetLength: number): { found: boolean, charCount: number } => { let charCount = 0; if (node.nodeType === Node.TEXT_NODE && node.textContent) { const textLength = node.textContent.length; if (charCount + textLength < targetIndex) { return { found: false, charCount: charCount + textLength }; } // 计算当前文本节点内的起始和结束位置 const startInNode = targetIndex - charCount; const endInNode = startInNode + targetLength; // 拆分文本节点:前半部分、高亮部分、后半部分 const before = node.textContent.slice(0, startInNode); const highlightText = node.textContent.slice(startInNode, endInNode); const after = node.textContent.slice(endInNode); const parent = node.parentNode; if (parent) { // 删除原文本节点 parent.removeChild(node); // 插入前半部分文本(如果有) if (before) parent.insertBefore(document.createTextNode(before), null); // 插入高亮span const highlightSpan = document.createElement('span'); highlightSpan.className = 'speech-highlight'; highlightSpan.style.cssText = `text-decoration: underline; text-underline-position: under; text-decoration-color: #fb923c`; highlightSpan.textContent = highlightText; parent.insertBefore(highlightSpan, null); // 插入后半部分文本(如果有) if (after) parent.insertBefore(document.createTextNode(after), null); } return { found: true, charCount: charCount + textLength }; } // 遍历子节点 for (let child of Array.from(node.childNodes)) { const result = findAndHighlight(child, targetIndex, targetLength); charCount += result.charCount; if (result.found) { return { found: true, charCount }; } } return { found: false, charCount }; }; const play = () => { if (!speech) return; if (speech.paused()) { speech.resume(); setIsPlaying(true); return; } if (!("speechSynthesis" in window)) { alert("浏览器不支持文本转语音功能"); return; } if (!text) return; // 预处理文本(保持原逻辑) const processedText = text.replaceAll(".", ",").replace(/\n/g, " "); let currentCharIndex = 0; speech.speak({ text: processedText, listeners: { onstart: () => { removeAllHighlights(); setIsPlaying(true); }, onended: () => { removeAllHighlights(); setIsPlaying(false); }, onboundary: (event: SpeechSynthesisEvent) => { if (event.name !== 'word') return; // 只处理单词边界 removeAllHighlights(); currentCharIndex = event.charIndex; const contentNode = document.getElementById("article-content"); if (contentNode) { findAndHighlight(contentNode, currentCharIndex, event.charLength); } }, }, }); }; const pause = () => { setIsPlaying(false); speech?.pause(); }; return { play, pause, isPlaying }; };
关键改进点
- 避免innerHTML替换:通过遍历文本节点拆分插入span,完全保留原有DOM结构(比如Markdown渲染的粗体、斜体标签)
- 精准字符定位:递归遍历所有文本节点,累计字符数,解决跨节点的索引计算问题
- 改进高亮清除:将高亮span替换回文本节点并合并,避免DOM残留
- 优化事件监听:使用async/await初始化Speech,添加错误处理,避免内存泄漏
内容的提问来源于stack exchange,提问作者anonymoususer8893
相关产品推荐
相关产品推荐

