悠悠楠杉
React中文本选区转超链接的实战精要
正文:
在构建现代富文本编辑器或知识管理工具时,我们常需实现这样的场景:用户选中文本片段后,通过浮动工具栏将其转换为超链接。这个看似简单的功能背后,隐藏着React虚拟DOM与真实DOM操作的微妙博弈。
选区捕获的陷阱与突围
当我们尝试在React组件中处理文本选择时,首先会遇到一个关键挑战:如何在不破坏组件状态的前提下捕获选区信息。直接使用document.getSelection()虽然可行,但会导致组件与DOM强耦合:
jsx
const handleSelection = () => {
const selection = window.getSelection();
if (selection.toString().trim() !== '') {
setSelectedText(selection.toString());
// 计算浮动工具栏定位
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
setToolbarPosition({
left: rect.left + window.scrollX,
top: rect.bottom + window.scrollY + 4
});
}
};
但直接在函数组件中使用这类原生操作会导致频繁重渲染。更优雅的解法是结合useRef与useEffect建立监听桥梁:
jsx
const selectionRef = useRef(null);
useEffect(() => {
const handleMouseUp = () => {
const sel = window.getSelection();
if (sel.toString().length > 0) {
selectionRef.current = sel;
}
};
document.addEventListener('mouseup', handleMouseUp);
return () => document.removeEventListener('mouseup', handleMouseUp);
}, []);
链接注入的动态魔法
当用户确认链接后,真正的技术难点才开始显现——如何在不重渲染整个组件的情况下,精准替换选区内容。这里需要分步破解:
选区持久化:在用户点击操作按钮前,必须保存原始的Range对象
jsx const range = selectionRef.current.getRangeAt(0); const fragment = range.cloneContents();链接包裹策略:创建锚元素并包裹选区
jsx const link = document.createElement('a'); link.href = validatedUrl; link.target = "_blank"; link.appendChild(fragment);原位替换:用新元素替换原始选区
jsx range.deleteContents(); range.insertNode(link);
但直接操作DOM会引发React的渲染不一致。此时需要引入dangerouslySetInnerHTML的替代方案——通过内容编辑库协同工作:
jsx
import { withContentEditing } from 'react-content-editor';
const LinkInserter = withContentEditing(({ content, onContentChange }) => {
const insertLink = (url, text) => {
const newContent = content.replace(
new RegExp(escapeRegExp(text), 'g'),
<a href="${url}" target="_blank">${text}</a>
);
onContentChange(newContent);
};
// 工具栏交互实现...
});
光标救赎方案
操作完成后最常见的痛点是选区丢失。通过保存选区起点与终点,可实现插入后自动恢复光标:
jsx
const saveCaretPosition = () => {
const sel = window.getSelection();
return {
start: sel.anchorOffset,
end: sel.focusOffset,
node: sel.anchorNode.parentElement
};
};
const restoreCaret = (position) => {
const range = document.createRange();
range.setStart(position.node.firstChild, position.start);
range.setEnd(position.node.firstChild, position.end);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
};
安全防御机制
动态生成链接时,XSS防御不容忽视。采用三层过滤策略:jsx
const sanitizeUrl = (input) => {
// 协议白名单
const protocolWhitelist = ['http:', 'https:', 'mailto:'];
// 解析URL
const url = new URL(input);
// 验证协议
if (!protocolWhitelist.includes(url.protocol)) {
throw new Error('非法协议类型');
}
// 编码特殊字符
return encodeURI(input).replace(/%20/g, ' ');
};
响应式工具栏实现
浮动工具栏需要智能避让边界,核心定位算法:jsx
const calcTooltipPosition = (rect, viewport) => {
const verticalPos = rect.bottom + 8 > viewport.height ?
{ bottom: viewport.height - rect.top + 4 } :
{ top: rect.bottom + 4 };
const horizontalPos = rect.left + 200 > viewport.width ?
{ right: viewport.width - rect.right } :
{ left: rect.left };
return { ...verticalPos, ...horizontalPos };
};
将上述技术点融合,我们得到完整的实现架构:jsx
const LinkCreator = ({ content }) => {
const [toolbarStyle, setToolbarStyle] = useState(null);
const selectionContext = useRef({});
useEffect(() => {
const selectionHandler = () => {
const sel = window.getSelection();
if (sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
if (!range.collapsed) {
selectionContext.current = {
text: sel.toString(),
range: range.cloneRange()
};
updateToolbarPosition(range);
}
}
};
document.addEventListener('selectionchange', selectionHandler);
return () => document.removeEventListener('selectionchange', selectionHandler);
}, []);
const createLink = (url) => {
const { range } = selectionContext.current;
const sanitizedUrl = sanitizeUrl(url);
range.deleteContents();
const link = document.createElement('a');
link.href = sanitizedUrl;
link.textContent = selectionContext.current.text;
range.insertNode(link);
// 触发React的受控更新
const parentElement = range.startContainer.parentElement;
const newContent = parentElement.innerHTML;
onContentUpdate(newContent);
};
// 工具栏渲染逻辑...
};
