本文档旨在解析一个基于 React 的交互功能:用户在一段不可编辑的文本内容上,通过鼠标划词选中,将选中的文本添加为一个“关键词”,并使该段落中所有与关键词匹配的文本都高亮显示。
此功能的核心在于巧妙地结合了浏览器原生 API、React 的状态管理和动态渲染机制。
一、 核心逻辑概览
整个功能的实现可以分解为三个主要步骤:
-
捕获用户选择: 监听鼠标事件,当用户在文本上完成划词动作时,获取选中的文本内容及其在屏幕上的位置。
-
状态驱动交互: 将选中的文本作为“关键词”存入 React 的 state 中。同时,根据鼠标位置,动态显示一个操作浮窗(Popover),引导用户确认操作。
-
动态渲染高亮: 监听“关键词” state 的变化。当 state 更新时,重新计算并渲染文本内容,将所有匹配关键词的部分用特定样式的
<span>标签包裹,从而实现高亮效果。
二、 技术实现详解
1. 捕获用户选择 (handleMouseUp 事件)
这是整个交互的起点。我们不在乎用户拖拽的过程,只关心最终选定的结果,因此 onMouseUp 事件是最佳的触发时机。
-
事件绑定: 在展示话术内容的
<div>元素上绑定onMouseUp={handleMouseUp}。 -
获取选中对象: 使用浏览器原生 API
window.getSelection()来获取一个Selection对象,该对象代表用户当前选中的文本范围。 -
提取文本与位置:
-
通过
selection.toString().trim()可以轻松获得用户选中的字符串。 -
通过
selection.getRangeAt(0).getBoundingClientRect()可以获取选中区域的DOMRect对象,其中包含了x,y,width,height,top,bottom,left,right等精确的屏幕位置信息。
-
-
显示操作浮窗: 当确定用户选中了文本后,利用获取到的位置信息(如此处代码中的
rect.bottom和rect.left),更新一个popover状态,使其visible变为true并在计算好的位置上渲染出来。
JavaScript
// 关键代码片段 1: 事件处理
const [popover, setPopover] = useState({ visible: false, x: 0, y: 0, text: '' });
const handleMouseUp = (event) => {
const selection = window.getSelection();
const selectedText = selection.toString().trim();
if (selectedText) {
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect(); // 获取选中区的几何信息
setPopover({
visible: true,
// 计算相对于父容器的位置
x: rect.left - event.currentTarget.getBoundingClientRect().left,
y: rect.bottom - event.currentTarget.getBoundingClientRect().top,
text: selectedText, // 存储选中的文本
});
} else {
// 如果没有选中内容(只是点击),则隐藏浮窗
setPopover(p => ({ ...p, visible: false }));
}
};
2. 状态管理与关键词维护
-
核心 State: 使用一个数组类型的 state
fillInBlanks来存储所有用户添加的“关键词”。这是驱动视图高亮变化的唯一数据源(Single Source of Truth)。JavaScript
const [fillInBlanks, setFillInBlanks] = useState([]); -
添加关键词: 当用户点击浮窗上的“设置为填空词”按钮时,触发
addFillInBlank函数。-
该函数将
popoverstate 中暂存的文本popover.text添加到fillInBlanks数组中。 -
去重处理: 代码中巧妙地使用了
new Set()来保证关键词列表的唯一性,避免重复添加。 -
操作完成后,清空
popover状态以隐藏浮窗,并调用window.getSelection().removeAllRanges()清除浏览器默认的文本选中蓝色背景,提升用户体验。
-
JavaScript
// 关键代码片段 2: 添加关键词
const addFillInBlank = () => {
setFillInBlanks(prev => {
// 使用 Set 进行高效去重
const newSet = new Set([...prev, popover.text]);
return [...newSet];
});
// 重置并隐藏浮窗
setPopover({ visible: false, x: 0, y: 0, text: '' });
// 清除浏览器原生选中效果
window.getSelection().removeAllRanges();
};
3. 动态渲染与高亮实现 (renderContentWithHighlights)
这是整个功能最核心、最精妙的部分。它负责将一段普通字符串,根据 fillInBlanks 列表,转换成一个包含高亮 <span> 的 React 元素数组。
-
核心思路: 使用正则表达式匹配所有关键词,并利用
String.prototype.split()方法的特性来分割字符串。 -
实现步骤:
-
准备关键词: 从
fillInBlanksstate 中获取所有需要高亮的词。 -
排序(关键优化): 将关键词按长度从长到短排序。这是一个至关重要的细节,可以防止复合词的匹配问题。例如,如果同时有 “话术” 和 “命中话术” 两个关键词,如果不排序,
"命中话术"可能会先被"话术"匹配分割,导致"命中"成为普通文本。排序后,会优先匹配更长的"命中话术"。 -
构建正则表达式:
-
对每个关键词进行特殊字符转义,防止关键词中的
.、(等字符干扰正则。 -
使用
|(或) 将所有关键词连接起来,形成一个如(命中话术|话术|填空)的正则表达式。注意两边的括号(),这是为了创建一个捕获组(Capturing Group)。
-
-
分割字符串: 调用
content.split(regex)。当split的参数是带有捕获组的正则表达式时,其返回的数组中会包含被匹配到的分隔符(也就是我们的关键词)。- 例如:
'这是命中话术'.split(/(命中话术)/)会得到['这是', '命中话术', '']。
- 例如:
-
渲染 JSX: 遍历分割后的数组
parts。检查每一部分part是否存在于我们的关键词列表中。-
如果是,渲染成
<span className={css.highlightedText}>{part}</span>。 -
如果不是,直接渲染该文本
part。
-
-
JavaScript
// 关键代码片段 3: 渲染高亮
const renderContentWithHighlights = () => {
const content = data.speechContent || '';
const wordsToHighlight = [...new Set(fillInBlanks)];
if (!wordsToHighlight.length) {
return content; // 如果没有关键词,直接返回原文
}
// 1. 按长度降序排序,避免子串匹配问题
wordsToHighlight.sort((a, b) => b.length - a.length);
// 2. 转义并构建正则表达式
const escapeRegExp = (string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedWords = wordsToHighlight.map(escapeRegExp);
const regex = new RegExp(`(${escapedWords.join('|')})`, 'g');
// 3. 使用带捕获组的正则进行分割
const parts = content.split(regex);
// 4. 遍历分割后的数组,进行条件渲染
return parts.map((part, index) =>
wordsToHighlight.includes(part) ? (
<span key={index} className={css.highlightedText}>{part}</span>
) : (
part
)
);
};
三、 总结与提炼
这个功能是一个优秀的前端交互案例,其精华在于:
-
组合利用原生 API: 充分利用了
window.getSelection来处理复杂的文本选中逻辑,而不是自己造轮子。 -
清晰的数据流: 严格遵循 React 的单向数据流,
fillInBlanksstate 是驱动所有UI变化的唯一可信源。 -
巧妙的字符串处理:
split方法配合带捕获组的正则表达式,是实现文本分割与动态插入标签的最高效、最优雅的方案之一。 -
关注细节与体验: 对关键词进行降序排序处理了匹配的边界情况;操作后清除浏览器默认选中样式,提升了交互的流畅度。
这种模式可以被广泛应用到任何需要在静态文本上进行划词标记、注解、翻译等功能的场景中。