
做后台管理系统久了就会明白一件事任何自由输入的文本框到了运营手里都有被塞满的潜力。我第一次被“富文本输入长度”这个需求恶心到是运营直接把五千字产品介绍粘进了 quill-editor一条记录差点让数据库字段撑爆。从那以后只要项目里出现富文本编辑器我第一件事就是补上输入内容长度限制。今天这篇文章不写教科书式原理只把我给 quill-editor 做长度限制时的口径、方案、代码和踩坑记录全部摊开。如果你用的是原生 Quill、Vue 封装的 quill-editor 还是 React 的 react-quill只要最终能拿到 quill 实例下面的思路都能直接套。1. 长度限制第一步先统一“一个字”到底怎么算1.1 Quill 没有 maxlength但它会给你更好的工具我知道你可能会想文本框不是有maxlength属性吗富文本编辑器为什么不能直接用原因在于 Quill 这一类基于contenteditable的编辑器底层是一棵可变的 DOM/Blot 树浏览器根本不认识“编辑器字数”这个概念。用户在页面上输入的内容可以被任意格式化、插入图片、添加列表甚至整段拖拽单一的 HTMLmaxlength压根拦不住。更麻烦的是富文本的“长度”在服务器端也很难定义。你存进数据库的是一段 HTML里面可能带几十个p、span、style标签如果你直接统计 HTML 字符串长度用户才输入 100 个汉字HTML 可能已经 800 个字节了这个数字对运营没有任何参考价值。所以做 Quill 限制长度的正路是不跟浏览器属性较劲直接在 Quill 的事件和内容模型层做拦截。Quill 提供了几个很趁手的工具quill.getText()拿到编辑器纯文本quill.getContents()拿到结构化的 Delta 对象quill.on(text-change, handler)监听每一次内容变化quill.setContents(delta, source)可以回滚内容我踩过的第一个坑就是把“长度”这个概念搞错了。1.2 空编辑器的长度竟然是 1直接用getLength()或getText().length的朋友大概率会遇到“用户才打了几个字计数器就显示多了一个字”的诡异现象。来看一个最简单场景quill.setText(); console.log(quill.getText()); // 输出 \n console.log(quill.getLength()); // 输出 1为什么是 1因为 Quill 的文档模型在最底层是由 Blot 组成的树文档末尾始终有一个换行 Blot 作为边界。它不是用户输入的空行而是文档自带的“地基”。所以一个全新编辑器文本内容就已经是\n。如果你直接把getLength()当字数用限制 500 字时会发现用户最多只能输入 498 或 499 个字符而且不同浏览器表现还可能不一致。这种差 1 的问题特别难排查因为它不会报错只是计数器总是怪怪的。我在项目里统一用了一个“有效字数”函数把文档末尾的强制换行剔除function countDelta(delta, options {}) { let len 0; (delta.ops || []).forEach(op { if (typeof op.insert string) { len op.insert.length; } else if (op.insert typeof op.insert object) { // 图片、视频等 embed 对象默认按一个字符占位 if (options.countEmbed ! false) len 1; } }); return Math.max(len - 1, 0); // 去掉 Quill 强制补的末尾换行 }为什么要用getContents()而不是getText()来统计因为纯文本模式会把图片、音视频等嵌入对象丢失。比如用户插了一张图getText()得到的内容可能只是一个空行或者一个占位符你根本不知道这这里到底占用了几格。而 Delta 里对嵌入对象会单独记录为一个insert对象遍历insert时能明确判断它是字符串还是对象统计口径完全可控。举个例子用户输入了“你好”然后又插入一张图片getContents()的 ops 大概长这样[ { insert: 你好 }, { insert: { image: https://xxx.com/a.png } }, { insert: \n } ]按上面的countDelta统计结果就是 2 1 3再减掉末尾换行最终是 3。此时若你的限制是 500剩余额度就是 497。这个口径不跟业务确认清楚后面所有逻辑都会跟着歪。2. 三种限长方案对比别一上来就写代码2.1 键盘拦截只能当辅助不能当主力最直觉的思路是在keydown里拦截用户按一个字符键就判断当前字数是否已满满了就preventDefault()。quill.root.addEventListener(keydown, (e) { if (e.key.length 1 !e.ctrlKey !e.metaKey) { if (getCurrentCount() MAX_LENGTH) { e.preventDefault(); } } });这段代码看起来很直白但实际用起来到处都是洞中文输入法按下拼音时e.key可能是Process或空字符串根本不会被拦截。用户右键粘贴、拖拽图片、自动填充都不走keydown。如果用户全选了 200 个字符准备粘贴 100 个新字符替换掉它此时当前字数已经超限按逻辑会被拦截但用户本来是想把超长内容删掉再替换的。工具栏按钮插入图片、链接等完全不经过keydown。所以键盘拦截只能用来做体验增强比如剩余 0 字时禁用工具栏的“插入图片”按钮或者给用户一个视觉提示。真要保证不超限还得看事件层方案。2.2 变化后回滚稳定可靠适合绝大多数业务第二种思路是“先让 Quill 变化变化完发现不对立刻回滚”。Quill 的text-change事件会在内容变化后触发回调参数里带着本次变化前后的 Delta 快照。我们可以把上一次合法内容保存下来每次变化后判断一下let lastValidDelta quill.getContents(); quill.on(text-change, (delta, oldDelta, source) { if (getCurrentCount() MAX_LENGTH) { quill.setContents(lastValidDelta, silent); } else { lastValidDelta quill.getContents(); } });这段代码韧性很强。用户手动输入、粘贴、拖拽、API 注入、甚至协同编辑只要最终内容超限都能被拉回合法状态。因为 Quill 的所有内容变化最终都会反映到文档模型上text-change事件是统一出口。代价是用户已经看到了“打进去又弹回来”的效果体验上有一点中断感。但如果只是超限前几个字回滚速度非常快用户通常不会察觉中文输入法需要特殊处理这个我放在后面单独讲。2.3 变化前预裁剪体验最好但复杂度也最高第三种思路是在变化发生前就算出“如果这次插入完会新增多少个字符”然后只允许放入剩余额度内的内容多出来的直接裁剪掉。比如粘贴 1 万字只取剩余 500 字放进去。这种方式用户体验最好不会出现“整段输入被吞掉”的挫败感。但实现复杂度明显更高因为要分析 Delta 里的 insert、retain、delete 操作还要考虑光标位置、格式属性、嵌入对象长度。尤其是一次跨多段落的粘贴裁剪边界要处理得很精准否则会出现文字跑到列表下面、格式错乱之类的怪问题。我的建议是普通后台管理系统优先用变化后回滚如果产品对“粘贴长文被整段拒绝”特别敏感再在paste事件里做前置截断。下面是三个方案的对比方案实现成本中文输入法粘贴/图片撤销栈影响推荐程度keydown 拦截低差覆盖不到无只做辅助text-change 回滚中需加 compose 处理能覆盖需要 cutoff推荐delta 预裁剪高需仔细处理能精调低高交互场景3. 可直接抄的限长实现3.1 记录合法内容快照我实际写进项目的是一个基于text-change的限长器。核心变量只有两个一个是最近一次合法内容的 Delta 快照一个是当前是否处于中文输入法组合阶段。先看骨架const MAX_LENGTH 500; let lastValidDelta quill.getContents(); let composing false; let restoring false; function getCurrentCount() { return countDelta(quill.getContents()); } function restoreToValid() { restoring true; const selection quill.getSelection(); quill.setContents(lastValidDelta, silent); requestAnimationFrame(() { if (selection) { const len quill.getLength(); quill.setSelection(Math.min(selection.index, len), 0); } restoring false; }); } quill.on(text-change, () { if (composing || restoring) return; if (getCurrentCount() MAX_LENGTH) { restoreToValid(); if (quill.history) quill.history.cutoff(); } else { lastValidDelta quill.getContents(); } });说几个容易被忽略的细节。setContents(lastValidDelta, silent)里的silent是必须的。如果不传这个参数Quill 默认把这次恢复当成一次api来源的新变化会再次触发text-change你的判断逻辑写得不严谨时很容易出现“回滚 → 触发事件 → 再回滚”的死循环。传了silent后Quill 不会对外广播事件代码就干净很多。每次合法变化后都要及时刷新lastValidDelta否则你回滚时用的永远是第一版内容。假如用户先输入 100 个字符有效再输入 100 个字符超限了如果快照停在“输入 100 字之前”回滚会把已经合法输入的 200 个字统统丢掉这显然不是想要的行为。getCurrentCount()用的是getContents()而不是getText()原因在第一部分已经说过这样才能把图片、链接卡片之类的嵌入对象一并纳入统计。3.2 光标位置不恢复这个功能就别上线回滚内容很容易难的是回滚之后光标也别乱跑。setContents()会替换整个文档浏览器原来的光标位置会失效。如果你不处理用户每次超出字数按一下回车或空格光标都会跳到文档末尾甚至回到开头体验直接崩掉。恢复光标的思路是回滚前先把getSelection()存下来回滚后通过setSelection把光标放回去。因为setContents是即刻生效的DOM 还在同一帧刷新我习惯把setSelection放到requestAnimationFrame里执行requestAnimationFrame(() { if (selection) { const len quill.getLength(); quill.setSelection(Math.min(selection.index, len), 0); } restoring false; });这段代码唯一要小心的是回滚后文档长度可能比原来短所以要取Math.min(selection.index, quill.getLength())防止索引越界导致 Quill 报错或发生怪异行为。3.3 撤销栈也要处理CtrlZ 不能变穿越回滚本身解决了超限问题但 Quill 的history模块并不会因为你自己setContents就清空撤销栈。用户的撤销记录里还保留着“插入超长文本”这个操作按一下 CtrlZ 可能会把已经回滚掉的超长内容又找回来然后再次触发回滚形成一种“怎么撤销都撤销不干净”的诡异状态。处理方式是在回滚后调用if (quill.history) quill.history.cutoff();cutoff()会把当前撤销栈做一个分割让后续的回退操作不会跨过这次回滚点。相当于告诉 Quill“到此为止以前的历史都别带进新的撤销路径了。”当然副作用是用户无法再通过 CtrlZ 退回到回滚前的那些步骤。在大多数 CMS 和评论表单里这个副作用是可接受的否则限长逻辑根本保不住。4. 中文输入法、粘贴、图片三个容易翻车的场景4.1 拼音上屏不被打断如果直接跑上一节的代码你会发现一个很严重的问题微博热搜、文章标题、评论内容大部分场景都要输入中文。而中文输入法在“拼音组合”期间会不断产生字符变化Quill 一次一次触发text-change我们的回滚逻辑就会在用户还没打完拼音时介入把候选字和已上屏的拼音全部回滚掉。解决办法是监听输入法的组合状态quill.root.addEventListener(compositionstart, () { composing true; }); quill.root.addEventListener(compositionend, () { composing false; // 组合结束立刻做一次最终校验 enforceLimit(); });compositionstart到compositionend之间我们只更新计数不做回滚组合结束后的第一个时间点再执行enforceLimit()把真正超限的内容拦下来。我建议text-change里计数不要被composing拦住这样用户在打拼音时也能看到剩余字数变化等组合结束才触发强制回滚。代码逻辑大致是quill.on(text-change, () { updateCounter(); if (composing || restoring) return; enforceLimit(); });注意不同浏览器对组合期间的text-change触发时机并不完全一致。Chromium 可能在拼音字母上屏时就触发变化Safari 则可能攒到候选词确认后才触发。所以compositionend之后一定要补一次enforceLimit()否则某些浏览器里“最后一个字”会逃过校验。4.2 粘贴长文整体拒绝还是截断基于回滚的方案遇到用户一次性粘贴一万字会把整块内容回滚掉用户刚才粘贴的内容消失得无影无踪。如果只是偶尔发生用户还能接受但如果你们是运营后台、公告系统这种每天都有人粘长文的地方建议在paste事件里做一次前置处理。最简单实用的做法是读取剪贴板的纯文本如果超过剩余额度截断后再插入。quill.root.addEventListener(paste, (e) { const text e.clipboardData e.clipboardData.getData(text/plain); const remain MAX_LENGTH - getCurrentCount(); if (remain 0) { e.preventDefault(); return; } if (text text.length remain) { e.preventDefault(); const clipped text.slice(0, remain); const sel quill.getSelection(); const index sel ? sel.index : quill.getLength(); quill.insertText(index, clipped, user); } });这个方案对纯文本粘贴非常有效但拦截不了复制的富文本。粘贴带格式的内容时Quill 会走另一套 clipboard 流程生成带header、bold、list等属性的 Delta。如果你不想处理复杂的 Delta 裁剪可以继续让text-change回滚逻辑兜底然后在onExceed里弹一句“内容超长请减少文字后再粘贴”。我个人的实操习惯是分场景选择评论、留言这种对格式要求不高的直接按纯文本截断CMS 文章这种用户可能精心排版的整段拒绝并提示更安全因为截断富文本容易把列表嵌套和图片位置搞坏。4.3 图片、视频算字数一个函数搞定如果你的编辑器支持插入图片那字数统计必须回答一个问题图片算不算不同的产品答案完全不一样。有的团队把图片当成一个字符因为它在数据库里确实占一个嵌入位。有的团队只统计文字图片附属于一篇文章不该占用正文限制。还有的团队把图片当成一个词或一个对象需要单独按比例折算。前面封装的countDelta里我用countEmbed ! false来控制图片是否计位// 不统计图片时 const count countDelta(quill.getContents(), { countEmbed: false }); // 统计图片时 const count countDelta(quill.getContents());这里有一个小坑用户插入一张图片后如果再删除它Delta 里对应的insert: { image: xxx }会变成delete操作但你的统计函数只需要对最新getContents()做遍历即可不用关心历史。哪怕文档里有几百张图只要每次遍历一遍 ops几百个对象的性能开销对浏览器来说完全不是问题。5. 高频问题速查与最终封装模板5.1 我踩过的坑按优先级排个序以下这些问题都是我或身边同事在项目里实际遇到的整理成表格方便排查问题现象根因处理计数器差 1空编辑器显示 1 字Quill 文档自带末尾换行用countDelta并在最后减 1中文拼音被打断打拼音时内容被回滚compositionstart阶段未加保护加composing标志compositionend再校验光标跳到最后超限回滚后光标乱跑setContents重置了选区保存getSelection()rAF后恢复粘贴内容整段消失大段粘贴被整体回滚没有前置处理长粘贴在paste事件里截断或提示CtrlZ 出超长撤销回到超限状态撤销栈保留了非法操作回滚后调history.cutoff()图片不计入限制插入无数张图都不超用getText()统计字数改用getContents()遍历 ops程序赋值绕过setContents后照样超长只拦截了source user不区分 source统一校验无限循环回滚控制台疯狂报错setContents没传silent恢复内容时用silent参数5.2 封装成 createQuillLimiter为了不复用代码时到处复制粘贴我把这套逻辑封装成一个独立函数项目里直接调用即可export function createQuillLimiter(quill, maxLength, { countEmbed true, onExceed null, } {}) { let lastValidDelta quill.getContents(); let composing false; let restoring false; function countDelta(delta) { let len 0; (delta.ops || []).forEach(op { if (typeof op.insert string) { len op.insert.length; } else if (op.insert typeof op.insert object) { if (countEmbed) len 1; } }); return Math.max(len - 1, 0); } function currentCount() { return countDelta(quill.getContents()); } function remain() { return Math.max(maxLength - currentCount(), 0); } function enforceLimit() { if (restoring || composing) return; if (currentCount() maxLength) { lastValidDelta quill.getContents(); return; } restoring true; const selection quill.getSelection(); quill.setContents(lastValidDelta, silent); requestAnimationFrame(() { if (selection) { const len quill.getLength(); quill.setSelection(Math.min(selection.index, len), 0); } restoring false; }); if (quill.history) quill.history.cutoff(); if (onExceed) onExceed(); } function onTextChange() { enforceLimit(); } function onCompositionStart() { composing true; } function onCompositionEnd() { composing false; requestAnimationFrame(enforceLimit); } quill.on(text-change, onTextChange); quill.root.addEventListener(compositionstart, onCompositionStart); quill.root.addEventListener(compositionend, onCompositionEnd); return { currentCount, remain, destroy() { quill.off(text-change, onTextChange); quill.root.removeEventListener(compositionstart, onCompositionStart); quill.root.removeEventListener(compositionend, onCompositionEnd); }, }; }用起来也简单。比如在 Vue3 里配合vueup/vue-quillconst limiter ref(null); function onReady(quill) { limiter.value createQuillLimiter(quill, 500, { onExceed() { message.warning(最多只能输入 500 个字符); }, }); }如果你用的还是 Vue2 时代的vue-quill-editor拿到 quill 实例的位置一般在change或ready回调里事件参数里的editor就是实例调用方式一样。React 项目则可以在react-quill的ref回调里拿到实例后调用同一个函数。6. 最后分享几点体会限长这件事前端永远只是第一道门后端必须再做一次校验。原因是 Quill 的text-change再严密也只是浏览器里的 JS 逻辑用户改一下页面代码或者直接抓接口提交超长 HTML就能绕过所有前端限制。数据库字段该用varchar(500)就用varchar(500)后端该校验长度就校验千万不要只依赖这里的思路。我建议你至少在项目里留一组自动化或手工测试用例单键连打、粘贴长文本、粘贴富文本、插入图片、中文输入法整句输入、CtrlZ 撤销全部跑一遍再上线。很多时候线上出问题不是回滚逻辑没写而是没人测过输入法组合期间的行为。最后是字数口径问题。产品经理如果说“最多 500 字”一定要追问一句图片算不算换行算不算空格算不算。不考虑这些等 UI 设计稿和文案都出了再改口径改的就是一轮代码加一轮需求评审。我目前的默认口径是“图片算 1、换行算 1、空格算 1、HTML 标签不计”这套口径在大多数场景下都能直接落地你可以拿它当团队对齐的初始版本。