MentionInput提及输入组件的实现原理与实战踩坑总结
谁更灵活?谁更省事?
上周又接到个需求:在富文本编辑器里加 Mention 功能,@用户后自动高亮、带下拉建议、支持回车/Tab 选中、还能嵌套在 textarea 或 contenteditable 里。我本来想直接 npm install 一个现成的,结果翻了一圈,发现没一个能直接开箱即用——不是依赖太重,就是键盘逻辑有 bug,要不就是样式死活对不齐。干脆,我把最近两年项目里踩过的几个主流方案全拎出来,真刀真枪比一比。
我重点对比了三个:原生 + 手写(纯 JS 实现)、react-mentions(老牌 React 库)、以及 @jztheme/mention-input(我们团队自己封装的轻量版,基于 contenteditable + Range API)。没选 slate 或 lexical 的插件方案——不是不好,是这需求压根不需要整套编辑器,上它们等于为了煎蛋买整只鸡。
原生手写:自由度最高,但真的会掉头发
我最早在做内部 IM 时手撸过一版。核心逻辑就三块:监听 input 和 keydown,检测光标前是否有 @,然后计算 position 插入浮层,再手动处理选中逻辑。代码不长,但坑特别密。
比如这个经典问题:Chrome 下 getSelection().getRangeAt(0) 在输入法上屏后位置错乱,我调了三天才发现得用 setTimeout(() => {}, 0) 延迟读取;还有 iOS Safari 里 touchend 后光标跳变,得手动 range.collapse(true) 强制归位。
下面是核心片段(删减了兼容性兜底):
function handleInput(e) {
const sel = window.getSelection();
if (!sel.rangeCount) return;
const range = sel.getRangeAt(0);
const text = range.startContainer.textContent;
const pos = range.startOffset;
// 向前找最近的 @,且前面不能是字母/数字/下划线
const before = text.slice(0, pos);
const atMatch = before.match(/(?<![a-zA-Z0-9_])@([a-zA-Z0-9_u4e00-u9fa5]*)$/);
if (atMatch) {
showSuggestionPanel(atMatch[1], range); // 浮层定位在这里
}
}
优点?你完全掌控光标、范围、DOM 更新节奏。想加个「@所有人」快捷键?两行代码。想限制最多提 3 个用户?改个条件就行。缺点?每次浏览器版本更新都得测一遍,尤其 Safari 每次发新版必崩一个 Range 相关逻辑。我去年在 v17.4 上修了个光标偏移 bug,结果 v18.0 又换了个偏移方向……心累。
react-mentions:省事但卡得让人想砸键盘
这个库我用了三个项目,第一印象是「文档友好、例子多、配色还行」。装上就能跑,API 也直白:
<MentionInput
value={value}
onChange={setValue}
suggestions={[
{ id: '1', display: '张三' },
{ id: '2', display: '李四' },
]}
onAdd={(id) => console.log('选中了:', id)}
/>
但它底层用的是 textarea + span 模拟高亮,所有 mention 都转成 span 嵌在 textarea 上方的 overlay 里。问题来了:滚动不同步、focus 时 placeholder 闪一下、iOS 键盘收起后浮层错位……最致命的是,它把整个输入内容塞进一个 div 的 contenteditable="true",但又不接管所有编辑行为,导致 Ctrl+Z 撤销 mention 后,光标直接飞到开头。
我试过 patch 它的 onKeyDown 处理逻辑,结果发现它把 event.preventDefault() 写死在内部,根本绕不过去。最后妥协方案是:只在 PC 端用,移动端切回原生 textarea + 手动弹窗。说白了,它适合 MVP 快速上线,但不适合长期维护的业务系统。
@jztheme/mention-input:我们自己写的,现在主力用它
这个不是开源库,是我们团队从 2022 年开始迭代的,目前稳定在 v2.3。核心思路就一条:不用模拟,直接操作 contenteditable 的 DOM 结构,mention 本身是真实 @张三,保留原始语义和可访问性。
它最大的优势是「不抢控制权」:你传一个 ref 给它,它只管监听、渲染浮层、插入 DOM 节点,其他输入、删除、粘贴、undo 全部交还给浏览器原生行为。所以 Ctrl+Z 能正常撤销 mention,Shift+箭头能正常选中 mention 整体,甚至配合 spellcheck 都没问题。
用法也很干净:
import { MentionInput } from '@jztheme/mention-input';
function MyEditor() {
const editorRef = useRef(null);
useEffect(() => {
const mention = new MentionInput(editorRef.current, {
getSuggestions: async (query) => {
const res = await fetch(https://jztheme.com/api/users?q=${query});
return res.json();
},
onMentionInsert: (user) => {
console.log('插入用户:', user);
}
});
return () => mention.destroy();
}, []);
return <div ref={editorRef} contenteditable="true" />;
}
踩过的坑也有:早期没处理好 paste 事件,用户粘贴带 mention 的文本时会双倍渲染;后来加了 beforeinput 拦截 + getData('text/html') 解析才搞定。另外,它默认不支持 textarea,如果你非得用 textarea,得自己套一层 wrapper —— 我们项目里所有 mention 场景都强制用 contenteditable,所以这点对我不是问题。
我的选型逻辑
看场景:
- 小工具、临时页面、赶工期?用
react-mentions,5 分钟接入,别纠结细节; - 需要深度定制、长期维护、还要上 App WebView?我一定选手写 +
contenteditable,虽然前期投入大,但后面省心; - 团队已有基础组件库、追求一致性、不想重复造轮子?我们自己封装的
@jztheme/mention-input是我现在默认首选 —— 它没那么炫,但稳定、可 debug、出问题能直接进源码改,而不是对着 3000 行的第三方库发呆。
顺带说一句:别信「零配置」这种宣传。mention 输入看着简单,实则涉及光标定位、Range 计算、输入法兼容、无障碍支持、键盘焦点管理……随便漏一个,用户就会觉得「这个@功能怎么老抽风?」。
以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流。这个方案现在还差个「mention 删除时自动高亮相邻字符」的小优化,等我哪天不忙了补上。

暂无评论