React JS如何为可编辑文本域内的特定词汇设置不同颜色?
可编辑文本域%包裹词汇高亮实现方案
核心方案采用contentEditable可编辑容器+实时分词重渲染逻辑,既可以保留统一输入域的连续编辑体验,也能动态匹配输入内容给特殊词汇加样式,完全避开拆分多输入框、原生textarea不支持局部样式的问题。
具体实现逻辑
- 放弃原生
<textarea>和拆分多input的实现思路:原生textarea不支持内部部分文本单独设置样式,拆分多input的方案会天然破坏输入连续性,光标移动、跨片段选中文本、连续输入的体验都无法和统一输入域对齐,直接使用设置了contentEditable="true"的div作为输入载体即可。 - 绑定实时输入监听:监听可编辑div的
input事件,每次内容变化时将当前纯文本内容存入组件state,使用正则/%[^%]+%/g做全局匹配,把整段文本拆成「普通文本片段」和「%包裹的特殊词汇片段」两类,按文本原有顺序排列,避免分词错位。 - 差异化渲染片段:遍历分词结果,普通文本直接渲染为文本节点,匹配到的特殊词汇包裹在内联样式的
<span>标签中,设置绿色字体样式即可,注意不要给高亮span加contentEditable="false"属性,否则会导致这部分文本无法正常编辑、删除。 - 修复光标跳位问题:可编辑容器重渲染后默认会把光标重置到输入域开头,需要在每次重渲染前记录当前光标位置,重渲染完成后通过浏览器原生
Selection和RangeAPI把光标还原到原有位置,保证输入流畅。
核心实现代码
import { useState, useRef, useEffect } from 'react'; export default function HighlightTextarea() { const [plainText, setPlainText] = useState(''); const editorRef = useRef(null); const cursorPosition = useRef(0); // 记录重渲染前的光标位置 const saveCursorPosition = () => { const selection = window.getSelection(); if (selection.rangeCount > 0) { const range = selection.getRangeAt(0); cursorPosition.current = range.startOffset; } }; // 重渲染后还原光标位置 const restoreCursorPosition = () => { const selection = window.getSelection(); const range = document.createRange(); if (!editorRef.current.childNodes.length) return; let currentOffset = 0; for (let i = 0; i < editorRef.current.childNodes.length; i++) { const node = editorRef.current.childNodes[i]; const nodeLength = node.textContent.length; if (cursorPosition.current <= currentOffset + nodeLength) { const targetNode = node.firstChild || node; range.setStart(targetNode, cursorPosition.current - currentOffset); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); break; } currentOffset += nodeLength; } }; // 文本分词逻辑 const parseTextToFragments = (text) => { const matchReg = /%[^%]+%/g; const fragments = []; let lastMatchEnd = 0; let matchResult; while ((matchResult = matchReg.exec(text)) !== null) { // 插入匹配项前的普通文本 if (matchResult.index > lastMatchEnd) { fragments.push(text.slice(lastMatchEnd, matchResult.index)); } // 插入高亮特殊词汇 fragments.push( <span key={matchResult.index} style={{ color: '#16a34a' }}> {matchResult[0]} </span> ); lastMatchEnd = matchReg.lastIndex; } // 插入最后一段普通文本 if (lastMatchEnd < text.length) { fragments.push(text.slice(lastMatchEnd)); } return fragments; }; const handleInput = (e) => { saveCursorPosition(); setPlainText(e.currentTarget.textContent); }; useEffect(() => { restoreCursorPosition(); }, [plainText]); return ( <div ref={editorRef} contentEditable="true" onInput={handleInput} style={{ width: '100%', minHeight: '120px', padding: '8px 12px', border: '1px solid #d4d4d4', borderRadius: '6px', outline: 'none', lineHeight: 1.5 }} > {parseTextToFragments(plainText)} </div> ); }
原有方案问题说明
前两种拆分标签通过map渲染的方案,本质是把连续的文本流拆成了独立的表单控件,控件之间天然存在交互边界,无法实现单输入域的连续编辑体验;第三种固定字符串匹配的方案,没有把匹配逻辑和输入事件、state更新绑定,仅在组件初始化时执行了一次匹配,自然无法响应实时输入的内容变化更新高亮。
使用注意事项
- 提交、校验输入内容时,直接使用state中存储的
plainText纯文本变量即可,不要读取可编辑div的innerHTML,避免混入span标签字符串。 - 如果需要调整特殊词汇的匹配规则,直接修改
parseTextToFragments方法里的正则即可,支持动态匹配任意规则的关键词。
内容的提问来源于stack exchange,提问作者Anh Hoang
相关产品推荐
相关产品推荐

