Quill富文本编辑器配置实战:从初始化到图片上传的完整指南

发布时间:2026/9/8 20:08:48
Quill富文本编辑器配置实战:从初始化到图片上传的完整指南 做后台管理系统的人十有八九都逃不过“高亮编辑器选型”这道坎。组件库自带的富文本功能往往隔靴搔痒样式难统一、扩展更无从谈起折腾到最后还是要自己接一个成熟方案。我在前后端项目里试过不少编辑器最后长期固定在Quill上。这篇文章就把“富文本编辑器Quill的配置”这件事展开讲清楚从引入初始化、工具栏配置到图片上传、内容回显再到我踩过的那些坑一次整理给你。不管你是第一次在项目里接富文本还是已经用Quill但经常被各种配置项搞晕这篇文章都适合你。我会用实际项目中跑过的配置代码来演示同时解释每个配置项背后的逻辑这样你拿到别人的配置也能自己改而不是只会复制粘贴。1. 为什么选Quill方案选型的技术账1.1 富文本编辑器选型时真正该看什么前端可用的富文本编辑器不少老的UEditor、轻量的wangEditor、功能全的TinyMCE和CKEditor再加上Quill一度让选型变成一件很纠结的事。我自己的筛选标准一直是四看看数据结构、看扩展性、看维护状态、看体积和依赖。Quill在这四点上都比较均衡。它的核心数据模型是Delta一种JSON描述的结构化文档格式。相比直接把HTML字符串抛来抛去Delta能把每一次编辑操作描述为一个op数组比如插入一段文字、加粗一个范围都对应明确的数据结构。这带来的实际好处很直接前端能精确对比内容变化后端能基于Delta做内容校验甚至历史版本而不是对着HTML正则硬抠。对比起来会更直观我把常用的几个编辑器按数据模型和扩展难度整理了一张表编辑器数据模型扩展难度维护状态适合场景QuillDelta JSON中模块化设计自定义Blot有门槛但文档清晰活跃2.x持续迭代内容结构可控的业务系统、在线文档UEditorHTML字符串低但代码历史包袱重基本停滞老项目维护不建议新选型wangEditorHTML字符串低但高级定制受限活跃5.x重写过简单后台场景追求快速接入TinyMCEHTML字符串中高插件体系庞大活跃高度仿Word的编辑需求CKEditor自定义XML/HTML混合高学习成本大活跃复杂文档场景团队有精力深度定制我对选型的看法是如果业务只是“填一个介绍框”HTML字符串完全够用但如果内容要参与业务流程、需要安全管理、要频繁做自定义控件Quill的模块化结构会省心很多。模块化的意义不是它自带多少功能而是你可以像搭积木一样只引入需要的部分工具栏、快捷键、粘贴逻辑都能分开接管。这也是我最终选择它的核心理由。1.2 什么时候别用Quill讲完优点我也得泼一盆冷水。有些场景Quill并不合适硬上也只会给自己挖坑。如果你只是需要一个一次性输入的备注框用textarea就够了。富文本带来的数据量、样式污染、XSS风险都要额外处理为了一个一百字的备注引入几十KB的编辑器不划算。如果你做的是重度办公文档系统要求从Word粘贴后样式几乎无损Quill默认的粘贴过滤机制会让你做大量二次开发。TinyMCE和CKEditor在这条路上沉淀得更久。Quill更适合内容格式相对可控的场景公告、资讯、富文本详情、邮件模板这类而不是“Word替代品”。如果你是做多人实时协同编辑Quill官方只解决本地编辑部分协同需要额外引入CRDT方案如Yjs并写适配层。这个技术栈比单机编辑器复杂一个量级如果团队没有专门的前端基础设施人员建议直接考虑按协同能力设计的成熟产品。结论就是Quill适合大多数“业务系统里的富文本”场景但它是编辑器内核不是一个开箱即完的交付物。项目初期多花半天想清楚数据怎么存、图片怎么传、权限怎么控比上线后再补要节省太多时间。2. 配置前的基础准备引入方式与初始化2.1 两种引入方式的选择建议Quill的引入我见过很多新人在这里就开始踩坑。第一个坑叫“样式没生效”第二个坑叫“工具按钮点了没反应”根源多数是引入了JS但没引入对应主题的CSS或引入了不同版本的JS和CSS。先看npm方式这是正式项目推荐的做法npm install quill然后在需要的地方引入import Quill from quill; import quill/dist/quill.snow.css;如果你用bubble主题就把snow换成bubble。这个CSS对应的是工具栏外观和编辑器基础样式必需。如果你还在用1.x版本确认一下项目中是否有其他依赖传递了不同版本的quillnpm的依赖提升很容易导致出现两份Quill到时候事件不触发、模块不生效的排查会让你怀疑人生。锁版本是个好习惯2.x的API和1.x有差异团队协作时一定要统一。如果你只是想快速起一个demo验证功能用CDN最快link hrefhttps://cdn.quilljs.com/2.0.3/quill.snow.css relstylesheet / script srchttps://cdn.quilljs.com/2.0.3/quill.js/scriptCDN方式适合本地测试和写博客示例正式项目里我一般不用因为无法锁版本CDN服务状态不可控离线部署更会直接挂掉。2.2 最基本的初始化配置初始化Quill前页面里需要有一个容器节点div ideditor-container/div然后是初始化代码const quill new Quill(#editor-container, { theme: snow, placeholder: 请输入正文..., readOnly: false, modules: { toolbar: [ [bold, italic, underline], [{ list: ordered }, { list: bullet }], [clean] ] } });这个config里的几个重点我逐个说。theme决定整体UI风格snow是带顶部工具栏的经典样式bubble则是选中文本时浮出工具条适合极简界面。placeholder在内容为空时显示但要注意它依赖.ql-editor.ql-blank::before这个伪元素样式如果你覆盖了背景或做了深色主题很容易把提示文字弄丢。readOnly设为true时编辑器不能编辑但可以选中复制适合预览场景。至于modules.toolbar它只是一个二维数组配置每一项对应一组按钮组内按钮是关联操作。我这里给了最简单的默认工具栏后面会有完整推荐配置。需要提醒的是即使工具栏里出现某个按钮对应的format支持不一定默认开启这就是为什么很多人配置了字体下拉但选不了字体——前端显示出来了底层并不认识这个格式。实操心得初始化后如果发现工具栏按钮样式错乱先检查是否同时引了多个主题的CSS。如果编辑器区域高度塌陷不要设置容器div的高度应该设置.ql-editor的min-height这个坑我在第5部分会展开讲。2.3 高度、字体和基础样式调整很多项目接进来之后就问“怎么编辑器就一行高”。这个问题几乎都是同一个原因把高度设置在了外层容器上但实际可编辑区域是内部class为ql-editor的div。#editor-container .ql-editor { min-height: 240px; padding: 16px 20px; font-size: 15px; line-height: 1.75; }设置min-height而不是height的用意是内容少的时候以240px起内容多了自动撑开不会因为定高把内容截断或者产生内部滚动条。如果项目里有严格的视觉稿调整.ql-editor的字体和行高即可。字号这块Quill默认提供的是small、false、large、huge这套语义而不是像素值。如果你想暴露更多字号选项在toolbar里配置size的数组就行。另外默认编辑器里的链接、代码等格式样式都在编辑器的基础CSS里定义好了改动时注意不要破坏.ql-editor内部的层级关系。常见问题设置好了.ql-editor的字体但工具栏里font下拉依然没有字体可选。这是因为官方默认只注册了sans-serif、serif、monospace这几个字体自定义字体需要手动注册FontClassAttributor并配置白名单否则浏览器渲染出来始终是默认字体。这个属于进阶定制如果只是做普通后台字体下拉保持默认就够了。3. 工具栏配置与常用模块从demo到可用系统3.1 工具栏配置的两层结构分组与格式化声明工具栏是富文本使用频率最高的部分Quill的工具栏配置看起来就是一个二维数组但这里面的门道值得讲清楚。二维数组的第一层是把按钮分组组和组之间在UI上会有间隔第二层是具体操作项可以是一个字符串如bold也可以是一个配置对象如{ header: [1, 2, 3, false] }。下面是一份我实际项目中常用到的完整工具栏配置const toolbarOptions [ [bold, italic, underline, strike], [blockquote, code-block], [{ header: [1, 2, 3, 4, 5, 6, false] }], [{ list: ordered }, { list: bullet }], [{ script: sub }, { script: super }], [{ indent: -1 }, { indent: 1 }], [{ direction: rtl }], [{ size: [small, false, large, huge] }], [{ color: [] }, { background: [] }], [{ font: [] }], [{ align: [] }], [clean] ];这里有两个细节我特别说明。第一color和background的值是空数组这表示让Quill默认提供全部颜色选项可以按业务需要改成白名单比如只允许公司品牌色避免用户用出彩虹配色。第二clean是一个特殊按钮作用是清除选中内容的格式相当于“橡皮擦”实测中这个按钮对用户很有价值因为粘贴内容带来的垃圾样式靠它一键清理。工具栏配置了之后还需要在Quill初始化时声明formats这一步经常被忽略。formats的作用是声明编辑器允许哪些格式存在。工具栏按钮响应的是用户操作而formats管的是数据结构层。如果你不声明某个format粘贴进来的内容里即使带了对应的HTML样式也会被过滤掉。这套“入口控制”其实是Quill最优雅的地方它从根上阻止脏数据进入内容模型。const quill new Quill(#editor-container, { modules: { toolbar: toolbarOptions }, formats: [ header, bold, italic, underline, strike, blockquote, code-block, list, script, indent, direction, size, color, background, font, align, clean ] });实操心得不要只看“用户能点什么”要关注“内容里能有什么”。我自己经历过一个事故用户从网页复制的历史文章带了大量行内样式因为formats没有严格过滤数据库里堆满了垃圾HTML页面渲染速度直接掉一截。后来把formats收紧到业务需要的清单文章提交体积降了将近一半。3.2 图片处理的正确姿势从base64到上传服务图片是富文本里的第一个大坑也是最能体现一个编辑器配置是否专业的分水岭。Quill默认的图片行为是用户点击工具栏图片按钮或直接粘贴图片时图片会被转成base64字符串塞进内容里。看起来“能用了”但后果很严重。一张500KB的图片转成base64体积大约增加33%如果文章里插入5张图一次提交的数据就是好几兆。数据库扛不住、接口超时、编辑器卡顿问题全来了。正确做法是拦截图片插入操作先上传到服务器或对象存储拿到可以公开访问的URL再把URL插入编辑器。Quill的toolbar模块预留了handlers钩子可以覆盖默认行为我实现的自定义图片上传是这样的const quill new Quill(#editor-container, { modules: { toolbar: { container: toolbarOptions, handlers: { image: function () { const input document.createElement(input); input.setAttribute(type, file); input.setAttribute(accept, image/*); input.click(); input.onchange async () { const file input.files[0]; if (!file) return; const formData new FormData(); formData.append(file, file); try { const res await axios.post(/api/upload, formData); const url res.data.url; const range this.quill.getSelection(); this.quill.insertEmbed(range.index, image, url); } catch (err) { console.error(上传失败, err); } }; } } } } });注意这里面有两个细节。第一handlers里的this指向toolbar模块this.quill就是当前的编辑器实例。第二插入前要getSelection拿到当前光标位置否则图片会跑到文章末尾用户会以为按钮失灵。第三上传过程中应该给用户一个loading状态最好在工具栏按钮上做防重复点击不然用户在接口慢的时候连点三次文章里会出现三张重复图片。还有一个入口是粘贴图片。用户从剪贴板直接CtrlV图片时走的是另一个通路需要单独处理。Quill允许通过clipboard.addMatcher注册DOM节点的匹配处理我通常用它在粘贴阶段就把base64图片拦截下来转成上传任务quill.clipboard.addMatcher(img, (node, delta) { const src node.getAttribute(src) || ; if (src.startsWith(data:)) { // 这里把base64的src提取出来转成File对象后走上传接口 // 拿到返回url后替换src } return delta; });这里还要提示一个业务层面的坑用户插入图片后如果删掉了图片Quill并不会通知后端删除已经上传的文件服务器上会积累大量孤儿文件。常规做法是加一个定时的垃圾回收任务对比数据库里存活的图片URL清理没有人引用的文件。这个问题很多团队把它忘到脑后直到OSS账单出现才开始追查。3.3 其他内建模块的按需开关除了toolbarQuill还内置了clipboard、keyboard、history、syntax这几个模块。它们的开关都在modules配置里默认行为对多数业务已经合理但也有一两个值得手动调整。history模块负责管理撤销重做的历史栈。默认的maxStack是100也就是最多存100步操作记录。如果编辑器承载的是长文创作100步会显得不够用户写了一千字后发现要撤销到前面某个位置按撤销键半天回不去。把它调大一点就能解决modules: { history: { maxStack: 500, delay: 1000 } }delay参数控制的是合并操作的时间窗口。比如连续输入1秒内的多次字符输入会被合并成一条历史记录这样撤销的时候不会一个字母一个字母地回退。默认值1000毫秒如果觉得撤销太“碎”或太“整”可以适当调整。syntax模块是代码高亮功能需要额外安装highlight.js并注册语言类型。如果业务里需要程序员的代码分享这个模块是刚需如果只是普通内容编辑我建议不启用因为引入highlight.js会增加不少包体积。Quill的模块化精神就在于此不需要的功能就不要背上这也是它在性能上始终优于某些全家桶编辑器的原因。keyboard模块支持自定义快捷键和绑定这里只提一句。真正复杂的场景比如给文字加自定义注释、插入特殊业务卡片单靠keyboard做不完善那已经属于自定义Blot的范畴需要单独写一篇。4. 内容获取、回显与业务集成4.1 监听内容变化与字数限制编辑器接入业务系统第一件事是监听内容变化。Quill提供了text-change事件借助这个事件可以做字数统计、非法内容拦截、自动保存等功能。quill.on(text-change, (delta, oldDelta, source) { const plainText quill.getText().trim(); console.log(当前纯文本长度, plainText.length); console.log(触发来源, source); });source参数有三个可能的值user表示用户操作api表示通过API调用产生silent表示静默模式。这个参数在业务里很关键比如自动保存功能要监听的应该是user触发的变化而代码初始化setContents时产生的变化不能被当成一次“保存事件”。字数限制是另一个常见的需求。Quill没有提供一个maxLength的配置项但实现起来并不难。一个很简洁的思路是在text-change事件里检查内容是否超长超长就回退到合法内容并用setSelection恢复光标位置const MAX_LENGTH 2000; quill.on(text-change, (delta, oldDelta, source) { if (source user quill.getLength() - 1 MAX_LENGTH) { // getLength()比纯文本多1因为End of Document默认有一个\n const text quill.getText().trim(); const truncated text.slice(0, MAX_LENGTH); const ops [{ insert: truncated }]; quill.setContents(ops, silent); quill.setSelection(MAX_LENGTH, 0, silent); } });这里有个细节值得注意setContents时传入的ops可以简写成只有insert的数组Quill会自动把它解析成Delta。setSelection的第三个参数传silent是为了避免触发额外的text-change死循环。这种写法虽然简单直接但它会把已有格式打平适用于纯文本限制的场景。如果业务要求保留富文本格式同时限制字数就需要在delta层面做精细裁剪复杂度会上一个台阶。4.2 提交到后端与回显方案内容提交到后端时需要对Post提交的数据做一次选择。我实际项目中采用的方案是把两种内容一起提交一个是完整HTML用于页面渲染另一个是纯文本用于列表页摘要和全文搜索。const htmlContent quill.root.innerHTML; const plainText quill.getText().trim(); axios.post(/api/article, { title, htmlContent, plainText });纯文本的价值往往被低估。列表页展示摘要如果直接截取HTML要么截断在标签中间导致样式错乱要么需要先转成纯文本再截。与其每次接口返回时在服务端做转换不如提交的时候就冗余一份。至于Delta JSON多数业务系统并不需要把它也存一份只有做版本对比、历史轨迹这类功能时才用得上存了反而增加数据体积我在项目里一般不会存。回显同样有正反两条路。如果后端返回的是HTML字符串用dangerouslyPasteHTMLquill.clipboard.dangerouslyPasteHTML(htmlContent);这个方法看名字就知道有点危险它会把HTML原样解析成Delta并塞入编辑器。官方建议只对可信内容使用如果你在服务端已经做了消毒或者内容来自自己的编辑器生成一般没有太大问题。如果后端返回的是Delta JSON用setContentsquill.setContents({ ops: deltaOps }, silent);无论走哪条路回显时机都要注意。如果编辑器容器还在v-if隐藏状态或者还没有挂载完成初始化就会报错。弹窗里的富文本尤其容易翻车弹窗组件生命周期里要在确认DOM渲染完后再初始化Vue里一般用nextTickReact里在useEffect里做这个细节我在4.3节一并展开。4.3 与Vue3/React集成时容易翻车的点富文本编辑器在Vue3和React项目里都绕不开几个固定的坑这里挑最常见的说。如果你直接用Vue3最简单的用法是在mounted生命周期里初始化然后在watch中同步内容。需要注意v-model不能直接接到Quill实例上Quill本身不是一个受控组件它的内容由内部管理外部需要通过事件手动同步template div refeditorRef/div /template script setup langts import { ref, onMounted, watch, nextTick } from vue; import Quill from quill; import quill/dist/quill.snow.css; const editorRef refHTMLElement(); const props defineProps{ modelValue: string }(); const emit defineEmits([update:modelValue]); let quill: Quill; onMounted(() { quill new Quill(editorRef.value!, { theme: snow }); quill.on(text-change, () { emit(update:modelValue, quill.root.innerHTML); }); }); watch( () props.modelValue, (newVal) { if (quill newVal ! quill.root.innerHTML) { quill.clipboard.dangerouslyPasteHTML(newVal); } } ); /script我对这里有两点自己的体会。第一watch回显时一定要先判断newVal ! quill.root.innerHTML否则会导致光标跳回开头因为dangerouslyPasteHTML会把整个内容重新解析插入。第二组件卸载时最好解除事件监听并销毁实例尤其是弹窗反复打开关闭的页面不销毁实例会造成内存泄漏和事件重复绑定。React侧的逻辑思路完全一致就是放到useEffect和useRef里。另外还要提醒很多封装库比如vue-quill、ngx-quill虽然在官方基础上做了封装但版本升级通常滞后于Quill官方如果项目对Quill版本有要求或者后续要做深度定制我建议团队自己封装一个组件几十行代码而已换来的是完全可控。5. 常见问题与排查技巧实录5.1 配置中常见问题速查表把我在实际项目中遇到的高频问题整理成一张速查表排查问题时可以先对照一遍现象常见原因解决办法工具栏按钮不显示或样式错乱主题CSS未引入或引入多个主题检查quill.snow.css/quill.bubble.css是否只引入一个编辑器内容区只有一行高高度设置在了外层容器上改为设置.ql-editor的min-height点击图片按钮没反应或插入base64没有自定义image handler参照3.2节自定义上传逻辑字体下拉有选项但选中无效自定义字体未注册到attributor使用FontClassAttributor注册并声明formats从Word/网页粘贴后样式混乱默认粘贴过滤不够严格使用clipboard.addMatcher按需过滤撤销一次会回退多个字或回退粒度不对history的delay配置不当调整delay参数控制合并时间窗口setContents后光标位置跳到开头回显时未判断内容是否相同增加相等性判断仅在变化时重新设置弹窗中初始化报错找不到容器父容器还没渲染完成使用nextTickVue或useEffectReact延后初始化代码块高亮不生效syntax模块未启用或highlight.js未引入增加syntax配置并引入语言注册编辑器里输入的内容刷新后消失数据未提交后端或未做本地持久化确认提交逻辑或加上draft草稿缓存这张表很多问题我都是第一次踩的时候花了一晚上才弄明白比如第一个主题CSS问题症状是工具栏看起来“残疾”按钮有但没样式。当时排查了很久最后发现是同事的公共样式里引了一个老版本的quill.snow.css两个CSS互相覆盖。所以建议在项目里全局搜索quill相关的CSS引用有没有多份这是最快定位方式。5.2 排查编辑器问题的三条实战心得第一把Quill官方playground当成调试基准。Quill的官网有一个官方的编辑器示例工具栏和模块配置和文档一一对应。遇到行为和你预期不符的先在playground上还原一遍操作如果playground也复现说明是理解问题如果playground正常就是自己项目配置或环境的问题。这一招能帮你把排查范围砍掉一半。第二用quill.getContents()检查数据层是否正常。很多“看起来没生效”的问题其实是视图层的错觉。比如用户说加粗没生效但getContents()拿到的delta里明明有bold: true说明是CSS样式被覆盖了。反过来如果delta里根本没有bold说明是工具栏联动或者formats声明的问题。两条路分头排查比在DOM和CSS里瞎转悠高效得多。第三操作前习惯性打印getSelection。Quill很多异常行为都和光标位置有关尤其是insertEmbed、format这些API它们默认都基于当前光标。如果你在插入图片或字符时先getSelection拿到range问题大概率就已经解决了。这个方法也能用在自动化测试里每个关键操作后断言selection的位置能提前暴露很多隐患。5.3 封装组件前先想清楚这几个业务问题开发配置久了我对“接一个富文本”这件事形成了固定的前置思考清单。在写第一行代码之前我会先和业务方确认内容是纯文本还是有固定格式要求图片是直接粘贴还是必须走上传后端存储HTML还是Delta列表页需要纯文本摘要吗这些问题决定了工具栏怎么配、formats怎么声明、事件监听哪些、甚至要不要引入Quill还是直接换一个编辑器。这些判断不是技术层面的取舍而是业务层面的定义。技术只是按需求落地的工具。很多项目富文本做得烂不是因为Quill难用而是业务方根本没想清楚内容边界前端就按默认配置交付了结果上线后所有问题都变成“编辑器不好用”。拿一张纸先把问题写清楚比研究任何高级配置都更重要。我在实际使用中发现Quill是一个下限很低、上限很高的编辑器。按默认配置五分钟就能跑起来但真要稳定地服务于业务需要在这几分钟之外想清楚数据流、上传链路和格式边界。这篇文章里给出的配置和踩坑记录都是我真实项目中的沉淀。如果你也在配置Quill希望它能帮你把那些我曾经踩过的坑一次跳过。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询