MentionInput提及输入组件的实现原理与实战踩坑总结

慕容静依 组件 阅读 1,781
赞 19 收藏
二维码
手机扫码查看
反馈

谁更灵活?谁更省事?

上周又接到个需求:在富文本编辑器里加 Mention 功能,@用户后自动高亮、带下拉建议、支持回车/Tab 选中、还能嵌套在 textarea 或 contenteditable 里。我本来想直接 npm install 一个现成的,结果翻了一圈,发现没一个能直接开箱即用——不是依赖太重,就是键盘逻辑有 bug,要不就是样式死活对不齐。干脆,我把最近两年项目里踩过的几个主流方案全拎出来,真刀真枪比一比。

MentionInput提及输入组件的实现原理与实战踩坑总结

我重点对比了三个:原生 + 手写(纯 JS 实现)、react-mentions(老牌 React 库)、以及 @jztheme/mention-input(我们团队自己封装的轻量版,基于 contenteditable + Range API)。没选 slatelexical 的插件方案——不是不好,是这需求压根不需要整套编辑器,上它们等于为了煎蛋买整只鸡。

原生手写:自由度最高,但真的会掉头发

我最早在做内部 IM 时手撸过一版。核心逻辑就三块:监听 inputkeydown,检测光标前是否有 @,然后计算 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 键盘收起后浮层错位……最致命的是,它把整个输入内容塞进一个 divcontenteditable="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 删除时自动高亮相邻字符」的小优化,等我哪天不忙了补上。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论