拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Vue3+TS自研高性能Mention组件:基于contenteditable的轻量实现

做了几年前端业务里离不开这类场景评论里 一下同事、任务面板里指派负责人、聊天输入框里提醒某个人。每次接到这种需求第一反应都是去找现成的 Mention 组件但真正用起来才发现要么是绑定在某套重型富文本编辑器里面拖过来体积吓人要么是纯文本输入框实现根本支撑不了加粗、变色的富文本内容。时间长了就一个感受 人这个看似不起眼的小交互想做得顺手、流畅、还能塞进现有系统最好还是自己动手写一个。这次我用 Vue3 TypeScript 从零实现了一个高性能的提及组件。它基于 contenteditable 方案本身就是一个轻量富文本编辑器既能满足 人的核心交互又不会把整个编辑器生态绑架进来。整套组件封装成了可直接引入的独立模块支持键盘上下键选择、异步加载用户列表、大列表虚拟滚动实测在万级用户数据下也能保持输入跟手不卡顿。这篇文章会把从方案选型到核心实现、再到那些不踩一遍根本发现不了的坑全部拆开讲清楚。适合正在为 功能发愁、或者打算自研富文本交互组件的朋友参考顺着这套思路做下去能少走一大半弯路。1. 项目背景与方案选型为什么决定自研 Mention 组件1.1 业务里的真实痛点现成方案哪哪不对先说业务背景。当时团队在做一个内部协作平台需要在评论、任务描述、需求标题等多个场景里支持 用户。最开始我调研了一圈市面上的方案大致归成三类第一类是绑定在富文本编辑器里的插件比如配合 Quill、Slate、ProseMirror 的那套生态。这类方案能力完整但对应的编辑器本身是个“庞然大物”光引入编辑器内核就要几十上百 KB。而且我们有些页面根本不需要整站富文本能力为了一个 功能背一整个编辑器进来团队很难接受。第二类是独立的 Mentions 库比如各种 React 生态里常见的 react-mentions还有针对 Vue 2 时代的一些老库。问题在于很多库早就不维护了API 设计对 TypeScript 的支持比较弱泛型推断基本靠手写 any在 Vue3 TS 的全新项目里接入总有种“缝缝补补”的感觉。第三类是直接拿 textarea 或 input 做纯文本输入拼接一个假的富文本展示层。这个方案交互上也有天然缺陷不支持在文本中间插入、选中替换、局部加粗等操作更别说做到真正的富文本。对用户来说 完发现后面的字没法加粗变色体验就差了一层。所以最后我们的结论很清晰与其等一个不存在的完美三方库不如自己写一个贴合业务的组件。可控性强、体积小、还能按需扩展。1.2 技术选型分析Vue3 TypeScript contenteditable 的组合为什么合适技术栈上Vue3 TypeScript 是当前团队的主力组合没什么争议。关键在于编辑区底层到底用什么实现我对比过两条路线路线一用完整的富文本编辑器内核比如 Quill 或 TipTap。优点是不用自己处理 contenteditable 的兼容性工具栏、格式、历史记录等能力直接有缺点是体积大、定制成本高Mention 这种需要精确控制光标、动态插入节点的功能反而容易被编辑器自身的抽象层绑住手脚一旦碰到编辑器版本迭代插件代码就得跟着重构。路线二直接用 contenteditable 分层的方案。浏览器原生支持 contenteditable核心交互自己控制再抽一层轻量数据管理。这套方案更贴近我们的需求只需要 触发、文本格式化、少量富文本操作不需要复杂的历史栈和文档模型。当然直接用 contenteditable 的代价就是对 DOM 操作和浏览器兼容性要求比较高。这时候 TypeScript 的价值就体现出来了把光标位置、触发词状态、选中项这些数据全部用类型约束起来DOM 操作再乱状态边界是清晰的至少不会出现类型层面的大问题。最终我的选型是Vue3 组件层 TypeScript 类型模型 contenteditable 作为编辑区完全自研。这个组合让组件的体积控制在 15KB 左右gzip 后更小同时给后续扩展保留足够空间。1.3 功能范围定义只做核心不贪多在动手前我先拉了一张功能清单把必须做、可选做、以后做画清楚避免写着写着失控。必须做的输入 后弹出候选列表支持搜索过滤键盘上下键选择、回车确认、Esc 关闭选中后把用户信息插入正文显示为带样式的不可编辑标签chip支持任意位置插入、删除 chip 不会卡住光标支持异步加载用户列表接口返回不阻塞输入支持对外暴露当前已提及用户的数据结构方便提交表单可选做但这次没做的 标签的拖拽移动富文本的加粗、斜体、颜色工具栏多人协同中的实时光标同步范围控制太重要了。Mention 这种组件最大的陷阱就是“慢慢长成编辑器”。一旦开始纠结工具栏、快捷键、剪贴板格式就不可能坚持“高性能”这个核心定位了。2. 核心架构设计与关键技术难点2.1 组件整体架构分层交互、数据、渲染各管各的写组件之前我先在脑子里把整个系统拆成三层交互层、数据层、渲染层。交互层负责监听编辑区的输入事件、键盘事件处理光标定位。一个重要的原则是这段逻辑只处理“用户操作”不直接修改业务数据。数据层维护当前输入内容的状态。这里我选择“纯文本 mention 标记”的存储方案编辑区里既有普通文本又有 chip 标签。每次输入变化后我会从 DOM 里提取出纯文本内容和一个 mentions 数组数组里存着每个 chip 对应的用户 id、用户名、起始位置等数据。渲染层负责把数据映射回界面。简单来说就是根据 mentions 数组去渲染可点击的 chip 样式同时维护一个弹层组件根据当前光标前文的内容动态展示候选用户。这三层之间通过一个明确的事件通道通信避免互相直接操作 DOM。实际编码中我把组件拆成了三个关注点useMentionEditor管理编辑区生命周期的组合式函数useMentionQuery管理触发词检测与候选用户列表MentionPopup独立的弹层组件只负责展示和交互这样拆完之后后续想换成 tailwind 风格、想加远程搜索都只需要改某个局部模块不至于整个组件推倒重来。2.2 触发词检测如何知道用户当前正在输入 Mention 的交互核心是“识别 ”。但从技术上看这件事比想象中麻烦不仅要判断是否输入了 还要判断 是不是在合法位置比如前面不能是普通字母以及 后面已经输入了几个字符用于过滤候选列表。我的实现思路是在 contenteditable 的 input 事件触发后获取当前光标所在节点以及光标在节点内的偏移量然后把光标前的文本取出来通过正则检测最后一个 的位置。这里有个关键点光标位置信息必须从浏览器 Selection API 获取。流程是获取 window.getSelection()得到当前光标范围Range从 Range 的 startContainer 出发向前追溯文本节点拼接出光标前的完整文本跨节点的情况也要处理举个例子用户输入了“你好 zhang”后光标在“zhang”后面。我需要得到光标前所有文本然后从后往前匹配 /([\w-]*)$/匹配成功就说明当前处于触发态。这个方案看起来简单但处理跨节点文本时很容易出 bug。比如内容被拆分成了多个 span文本提取时顺序错了正则就匹配不上。我的处理方式是写一个递归函数从光标节点开始向前遍历兄弟节点把文本按顺序拼接这样才能保证结果的准确性。2.3 触发器匹配的状态机设计触发词检测不能只做一次因为用户输入是持续变化的。我采用了一个三个状态组成的状态机idle未触发不显示弹层pending输入了 但还没有选中任何用户弹层展示候选列表selected已选中用户当前文本中插入 chip回到 idle 等待下一个 状态切换的时机也有讲究。当用户在 pending 状态中输入非关键字字符比如空格、中文标点就应该马上退出 pending。这要求在 input 事件里重新检测而不是简单监听 keyup。另外还有个细节当用户正在删除一个 chip 的时候要避免触发词检测误判。比如用户删除了 chip 中间的字符光标可能落在 chip 内部。这种情况下需要先判断光标是否在一个“原子节点”内部如果是就先向外跳到正确位置再检测。2.4 contenteditable 的数据管理避免“脏 DOM”用 contenteditable 最大的坑就是 DOM 被浏览器各种行为粘贴、拖拽、自动填充改得乱七八糟而组件内部状态没有同步。所以我从一开始就定了规矩所有修改必须通过组件的统一入口进行不允许用户意外修改后直接读 DOM 生成内容。具体的做法是双轨制编辑区内是浏览器原生 DOM用户看到的是最终结果组件内部维护一份“逻辑内容”的 JSON 结构每次输入后组件会把 DOM 同步解析成 JSON再根据 JSON 渲染出新的 DOM。整个过程是单向数据流状态永远在组件手里不会出现数据和 UI 不一致的问题。当然频繁的“DOM - JSON - DOM”是性能隐患。所以我在实现时做了局部 diff 优化只有当 mention 数据发生变化时才重建相关节点普通文本输入不会触发整片重建。3. 高性能实现的核心细节3.1 性能瓶颈分析三个最容易卡顿的地方Mention 组件要做到“高性能”本质是在和三个瓶颈做斗争。第一个瓶颈是输入触发的全量检测。用户在 contenteditable 里每敲一个字input 事件都会触发如果每次都解析整块 DOM、跑正则、请求接口那输入延迟会非常明显。尤其在低端设备上比如一些测试机体验会灾难性下滑。第二个瓶颈是弹出层的候选列表渲染。当候选用户数据上万条时一次性把所有 li 渲染出来DOM 数量几万个浏览器直接崩溃都是有可能的。第三个瓶颈是光标定位与 DOM 重建的冲突。插入 chip 或删除 chip 时如果不小心重建了相关节点会导致光标漂移到开头或结尾用户瞬间失去输入位置。高性能不是一句口号而是要把以上三个瓶颈逐个击破。下面逐个说我的做法。3.2 输入防抖与优先级别让每次按键都“全力运行”对于第一个瓶颈我的做法是分层处理响应速度要求高的操作不防抖比如光标移动、显示弹层响应速度要求不高的操作做防抖比如远程搜索候选用户。具体来说当用户输入“zh”时弹层应该立刻出现——这个响应不能等否则会觉得卡。但在弹层显示后如果候选用户需要异步请求我会对请求做 200ms 防抖。因为用户在连续输入“zhang”的过程中前几次请求大概率是浪费的。我的实现里用了一个自定义的 useDebounce 函数核心逻辑是function useDebounceT extends (...args: any[]) void(fn: T, delay 200) { let timer: number | undefined; const debounced (...args: ParametersT) { if (timer) window.clearTimeout(timer); timer window.setTimeout(() { fn(...args); }, delay); }; return debounced; }同时为了让“弹层出现”这件事不被防抖拖慢我把“检测触发词”和“请求候选列表”拆成了两条链路。触发词检测在 input 事件里同步执行只要命中正则就立刻打开弹层。候选数据则是异步加载数据回来后再更新列表。这样用户感知上非常流畅。3.3 虚拟列表万级用户数据也仅渲染可视区域第二个瓶颈候选列表的渲染优化我采用了虚拟滚动方案。核心思路很简单无论数据源有多少条DOM 里只保留可视区域内的那几十条。实现上我没有引入第三方虚拟滚动库而是自己写了一个轻量版因为这里的列表高度是固定的不需要处理动态高度。每个候选项目高度固定为 40px所以可以这样算const visibleCount Math.ceil(popupHeight / ITEM_HEIGHT); const startIndex Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT) - 5); const endIndex Math.min(list.length, startIndex visibleCount 10); const visibleList list.slice(startIndex, endIndex);为了支持这个计算列表容器设成固定高度并开启 overflow-y: auto每个 item 绝对定位translateY 设置为 index * ITEM_HEIGHT 像素。这样一个 1 万人的用户列表实际渲染的 DOM 节点只有 20 多个滚动时也只是在更新 startIndex 和 endIndex完全不会卡顿。另外这里的虚拟列表必须配合 Vue3 的响应式使用。我用了 shallowRef 来保存列表数组这样列表本身不触发深层次依赖收集渲染性能更好。3.4 局部 DOM 更新避免整个编辑器重建导致光标漂移第三个瓶颈是光标漂移也是 contenteditable 方案里最容易翻车的地方。很多初版实现会图省事直接把整个编辑区 innerHTML 替换掉结果就是光标被重置到开始处打字打到一半就乱了。我采用的方案是精准更新在插入或删除 chip 前先记录当前光标位置通过 Range 获取光标所在的文本节点和偏移量只操作这个文本节点把要插入的 chip 作为相邻节点插入插入完成后手动将光标设置在 chip 后面的文本节点开头关键点在于插入要以节点为单位而不是以字符串为单位。比如用户在“你好”后面插入一个 chip我是在“你好”这个文本节点后面新增一个张三然后新增一个空文本节点用于承载光标。这个操作不会触及其他区域的 DOM所以哪怕全文有几十个 chip插入一个也只需要操作几个节点性能和稳定性都有保障。4. 完整实操从零搭建一个可复用的 Mention 组件4.1 环境准备Vite 搭建 Vue3 TypeScript 项目实操之前先把环境准备好。这里我用的构建工具是 Vite对比 Webpack 来说启动速度和热更新体验好太多。初始化命令如下npm create vitelatest mention-demo -- --template vue-ts cd mention-demo npm install这个命令会生成一个带有 Vue3 TypeScript 的最小项目。组件相关的文件我放在 src/components/Mention 目录下后续所有代码都在这个目录里。有一点补充说明近期不少项目里都遇到过 TypeScript 7.0 的弃用警告比如 tsconfig 里出现了 option baseurl is deprecated 的提示。在新项目里我直接用相对路径导入模块不再配置 baseurl避免未来升级时报警告。Vue3 项目推荐使用
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门