很多人第一次给编辑器加按钮都是在工具栏里堆一长串名字然后发现两件怪事一是某些名字写进去直接报错二是按钮多到一定程度后工具栏右侧凭空冒出一个更多的下拉把后半截按钮全吞了进去。更麻烦的是产品那边要的是两行工具栏重要的放第一行而你把文档翻了一遍发现真正能控制这件事的配置项只有一个布尔值剩下的全靠布局和分组去凑。这篇内容就围绕 CKEditor 5 的工具栏配置展开讲清楚三件事items 数组到底是怎么被解析成一条工具栏的、多行换行不折叠到底该怎么开、开了之后会遇到什么、嵌套下拉这种把低频按钮收起来的写法怎么配才不出错。读者对象是已经能把编辑器跑起来、但被工具栏布局卡住的前端同学完全没接触过这个库的人也能看懂因为我会从最基础的映射关系讲起。需要提前说明一点这个库的工具栏 API 在几年间改过几轮老项目里的isMultiline和新项目里的shouldNotGroupWhenFull指的基本是同一件事配置形态也存在数组简写和对象完整写两种。下面的内容以对象写法为主因为它能表达的语义更全同时会标注哪些写法在旧版本里可能不生效。版本差异是个绕不开的坑我会尽量把边界说清楚。2. 配置字符串到屏幕按钮中间隔了三层2.1 items 里的每个字符串其实是组件工厂里的一个注册名工具栏配置里写的bold、heading、insertTable这些字符串并不是 DOM 选择器也不是按钮的显示文案而是UI 组件工厂componentFactory中的注册名。编辑器初始化时会把每个功能插件注册的 UI 组件挂进工厂工具栏渲染阶段再按 items 的顺序逐个向工厂要实例。这个链路决定了三件事第一名字必须和插件注册的名字严格一致大小写敏感insertImage写成insertimage就不认。第二如果对应的插件没被打进构建产物工厂里就没有这个注册名工具栏渲染会直接抛错而不是静默跳过。第三同一个组件在工厂里是单例复用的工具栏里出现两次同一个名字实际拿到的是同一个按钮视图实例位置会错乱——所以重复配置是没有意义的。理解这一层之后很多玄学就有解释了。比如你从某个示例里抄了mediaEmbed但自己的构建里根本没加媒体嵌入插件报错信息会指向工具栏项不可用而不是插件缺失容易让人往配置方向排查半天。这时候正确的动作是去检查构建配置里的插件列表而不是在工具栏里改名字。2.2 简写与完整写法什么时候必须用对象最省事的写法是直接给一个数组ClassicEditor.create(document.querySelector(#editor), { toolbar: [undo, redo, |, heading, bold, italic, link] });这个数组会被内部包一层等价于{ items: [...] }。日常够用但一旦你要控制折叠行为、要加嵌套下拉、要移除某一项就必须写完整对象ClassicEditor.create(document.querySelector(#editor), { toolbar: { items: [undo, redo, |, heading, bold, italic, link], shouldNotGroupWhenFull: true } });我个人的习惯是从第一天就用对象写法哪怕只有 items 一个字段。原因是后期需求变化时比如产品突然要求窄屏不折叠改结构比改字段代价大得多而对象写法天然容纳扩展。另外团队协作时对象写法在代码审查里辨识度更高一眼能看出这是工具栏配置而不是别的东西。2.3 构建方式决定了你能配出什么这一点经常被忽略工具栏里能放什么不取决于你怎么写配置而取决于构建产物里塞了哪些插件。三种常见的构建路径差异很大。构建方式工具栏可配置范围适用场景在线构建器产出的包构建时勾选的功能快速验证、功能固定的后台官方预置构建经典/内联等预置功能全集未勾选的不可用想省事但要全功能源码集成 自定义插件集完全由你决定体积敏感、需要深度定制我见过不止一次这样的情况本地开发用的是预置构建什么按钮都能加上线换成了自己打包的精简构建工具栏里的三个按钮凭空消失控制台报组件不可用。所以配工具栏之前先确认手里的构建里到底有什么比对着文档抄名字有效得多。3. 把按钮排成有语义的分组而不是一串平铺3.1 竖线分隔符承担的是视觉呼吸而不是逻辑隔离items 数组里的|会在工具栏里渲染成一条竖直分隔线。它没有任何功能性约束纯粹是视觉分组。但恰恰是这种无功能的东西决定了工具栏的可读性。一个反例把所有按钮按字母顺序或者按插件安装顺序铺开用户扫视的成本会非常高因为相邻按钮之间没有语义关联。正确做法是按编辑动作的流程分组——历史操作撤销/重做一组块级结构标题、引用、代码块一组行内样式加粗、斜体、颜色一组插入类图片、表格、链接一组清理类清除格式单独放末尾。这样用户的视线是沿着我要做什么移动的而不是沿着按钮列表移动。我一般会在组与组之间只放一个分隔符连放两个| | |会在视觉上产生过宽的间隙看起来像排版错误。如果确实需要更强的区隔感用嵌套下拉或者调整 CSS 间距别用重复分隔符。3.2 分隔符位置的实战取舍有几个位置我踩过坑值得单独说。开头不要放分隔符。[|, undo, redo]会在最左侧渲染一条悬空的竖线视觉上很别扭虽然不报错。结尾同理。分隔符不能连续出现两次以上除了难看之外没有实际收益。移动端隐藏分组这件事不要指望分隔符能帮上忙。窄屏下按钮被折叠进下拉后分隔符的语义就消失了用户看到的是一个混在一起的下拉列表。真要处理响应式思路是换一套 items 而不是改分隔符这个后面会展开讲。3.3 分组数量与工具栏宽度的换算这里给一个粗略的估算方法用于判断你的配置在目标宽度下会不会溢出。假设每个图标按钮的宽度约 32px分隔符约 12px下拉按钮如标题选择器更宽大概 90–120px。以 1200px 的内容区为例一行大概能放下28–32 个图标按钮扣掉下拉的额外宽度后实际更少。按这个算法一组 4–6 个按钮加一个分隔符是比较舒服的密度。如果你的配置算下来超过 30 个图标位就别硬撑一行了直接上多行或者嵌套下拉否则用户一定会遇到折叠。提示估算时不要按最大宽度算按你的侧边栏展开后的内容区宽度算。后台系统里侧边栏吃掉 240px 是常事按满宽配出来的工具栏在实际布局里必然折叠。4. 多行换行shouldNotGroupWhenFull 打开之后会发生什么4.1 默认的更多折叠是怎么工作的默认状态下shouldNotGroupWhenFull: false工具栏是一个动态行为的容器容器宽度变化时它会尝试把所有按钮铺在一行放不下的部分被塞进最右侧一个下拉里。这个下拉通常显示为省略号图标或者显示更多的按钮。这个机制的好处是工具栏高度恒定编辑区域不会因为窗口缩小而上下跳动。坏处是可发现性骤降——用户根本不知道那个下拉里藏了什么尤其是当被折叠的恰好是高频按钮比如链接、表格时投诉率会明显上升。触发折叠的判定是根据容器实际测量的宽度和按钮累计宽度实时计算的所以它和 CSS 强相关。字体大小变化、图标尺寸调整、容器 padding 改动都会影响折叠点。这意味着你在开发机上看到的排版和用户那边的真实排版可能不一样——特别是用户把浏览器缩放调到 125% 或 150% 的时候。4.2 打开换行之后的三个连带影响把shouldNotGroupWhenFull设为true工具栏就切换成换行策略按钮不再被折叠放不下时自动折到下一行形成多行工具栏。这个开关看似只是一行配置实际会带来三个必须处理的问题。第一高度不再恒定。窗口从宽拖到窄工具栏可能从一行变两行、三行编辑区域被持续向下推。如果你的页面是固定高度布局编辑器下方的按钮会被挤出视口。解决办法是给编辑器容器一个合理的min-height并考虑把工具栏做成吸顶position: sticky——但吸顶只在编辑器高度大于视口时才有意义矮编辑器上反而会遮挡。第二分隔符在换行处的表现。分隔符如果恰好落在行尾会出现一条悬在行末的竖线看起来像排版错误。这个问题没有配置项可以解决只能通过调整分组顺序让分隔符尽量规避敏感位置或者在 CSS 里对行尾分隔符做隐藏处理。实际项目中我通常接受这个瑕疵因为它的出现概率和对用户的干扰都有限。第三预留空间。如果你用的是固定高度的编辑区域打开换行后不要让编辑器自己撑开容器而是给工具栏外层一个固定高度的容器超出部分让它自己换行配合overflow: hidden或者滚动。否则窗格内滚动条会来回跳体验很差。4.3 新旧字段名与版本兼容老版本里这个开关叫isMultiline写法是toolbar: { items: [bold, italic], isMultiline: true }后来该字段被重命名功能语义也更明确——从是否多行变成是否在空间不足时不折叠即shouldNotGroupWhenFull。如果你接手的是一个几年前的项目看到isMultiline不必惊讶反过来如果你把新写法丢进老版本它会被忽略也不会报错而是继续走默认折叠排查起来非常隐蔽。这种情况我的处理方式是配置里两个字段都写上重复的布尔字段不会冲突老版本读老的新版本读新的并在代码旁边加一行注释说明原因。虽然有点丑但比上线后发现折叠行为不对再回滚要便宜得多。toolbar: { items: [ /* ... */ ], // 兼容旧版本字段名新版本读取 shouldNotGroupWhenFull isMultiline: true, shouldNotGroupWhenFull: true }4.4 混合策略谁该折叠谁该换行有时候产品的要求是主工具栏必须全展示辅助功能可以折叠。单靠一个布尔值做不到这种分级但可以用两个层级去模拟把高频按钮直接平铺在工具栏里并开启换行把低频按钮统一收进一个嵌套下拉下一节讲这样即使整体宽度紧张被折叠的也只是那个下拉本身高频操作始终可见。这个思路的本质是把折叠这个系统级行为降级成你主动设计的收纳行为。系统折叠是不可预期的主动收纳是可控的。我在几个后台系统里都用过这套组合用户投诉率明显低于纯系统折叠。5. 嵌套下拉把低频按钮做成一个有名字的入口5.1 匿名下拉的配置写法items 数组里除了字符串和|还可以放对象。当一个对象带有label、icon和items三个字段时它会被渲染成一个下拉按钮点击展开里面的项目平铺显示。toolbar: { items: [ undo, redo, |, heading, bold, italic, link, |, { label: 插入, icon: svg viewBox0 0 20 20 ....../svg, items: [insertImage, insertTable, mediaEmbed, horizontalLine, specialCharacters] }, |, removeFormat ], shouldNotGroupWhenFull: true }几个注意点。label是下拉里的分组标题也是无障碍读屏的文本来源必填且要有实际意义。写更多是最差的选择写插入才让人知道点进去是干什么的。icon接受 SVG 字符串或者图标对象。不传 icon 时下拉会退化成纯文字按钮在某些主题下宽度会明显大于图标按钮破坏工具栏的节奏感。所以我一般都会给一个图标哪怕是通用的加号。items里支持嵌套分隔符|也支持继续嵌套——理论上可以套多层但实际上超过两层之后用户的点击路径就太长了不建议。5.2 图标从哪里来图标来源这件事在不同版本里差别很大。较早的时候各个功能包里会各自导出一个图标常量你需要从对应包里 import较新的版本把常用图标集中到了独立的图标包里导入路径更统一。// 较新写法图标集中管理 import { IconPlus, IconImage } from ckeditor/ckeditor5-icons; // 较早写法从功能包导入路径因包而异 import { IconPlus } from ckeditor/ckeditor5-core/src/index;这两种写法不要混着用混用会导致同一个图标被两个不同来源加载虽然视觉上没差别但打包体积会白白增加。如果你不确定手上是哪种最稳妥的做法是先跑一次构建看导入路径是否报解析错误能解析的就是当前版本支持的路径。还有一个偷懒但可用的方案直接把 SVG 字符串内联进配置。缺点是字符串很长配置文件的观感会变差优点是零依赖不会因为包结构变化而失效。我在给非核心功能做下拉时经常这么干。5.3 下拉里能放什么、不能放什么不是所有按钮都适合塞进下拉。判断标准很简单这个按钮是否具有持续状态。像加粗、斜体、标题选择这种按钮其激活状态会实时反映光标位置的格式用户需要一眼看到当前状态。把它们放进下拉用户必须先展开才能知道现在是不是加粗了交互成本陡增。反过来插入图片、插入表格、插入分隔线这种一次性动作点完就结束了没有持续状态可言天然适合收进下拉。所以我的分组原则是状态型按钮平铺动作型按钮收纳。还有一个细节下拉里的按钮如果本身就是下拉比如标题选择器嵌套后交互会变得很怪——点开外层下拉再点内层下拉两层遮罩叠在一起用户容易迷失。这种组合我建议直接放弃改成一个平级的下拉。5.4 分割按钮与菜单栏两种更重的组织方式除了普通下拉还有两种组织形式值得知道。分割按钮的形态是左边点击立即执行右边小三角展开更多选项适合主操作 变体的场景。比如一个插入分割按钮左半边直接插入默认尺寸的表格右半边可以选择行列数。配置上和下拉类似区别是要额外指定默认行为实现上需要自定义 UI 组件纯配置做不到完整效果。菜单栏是另一种完全不同的思路把功能按文件、编辑、插入、格式这种桌面软件的分类方式组织成顶部菜单工具栏本身可以隐藏。这个模式在文档编辑类产品里接受度很高因为用户的习惯是从办公软件迁移过来的。启用方式是在编辑器配置里加上menuBar: { isVisible: true }具体菜单条目由加载的插件自动注册你能控制的是显隐和少量条目过滤。三种方式的取舍我做了一张对照表组织形式点击次数可发现性适合的按钮类型平铺 分隔符1最高高频、状态型嵌套下拉2中低频、动作型分割按钮1默认/ 2变体中高有主次之分的动作菜单栏2中功能极多、桌面习惯用户选型逻辑不复杂能一次点击完成的别做成两次。只有在工具栏宽度确实紧张、或者某个动作确实低频的时候才动用下拉。6. 配置写完跑不通几条高频排查路径6.1 报错指向工具栏项不可用控制台出现形如toolbarview-item-unavailable的错误含义很直接items 里写了某个名字但组件工厂里没有对应的注册项。可能的原因有三类。一是插件没装。构建配置里的插件列表漏了对应功能最常见于从示例工程拷配置但没拷依赖的情况。二是名字拼错。功能包注册的名字和你写的不一致比如有的插件注册的是insertTable你写成了table有的注册的是blockQuote你写成了quote。这类错误没有自动补全只能对着该插件的文档确认。三是插件被移除了。用了removePlugins把某个功能移除但工具栏里还留着它的名字。这种组合必然报错因为移除插件的同时组件注册也消失了。排查顺序建议是先注释掉一半 items 看是否还报错用二分法快速定位到具体是哪个名字然后去构建配置里确认该插件是否存在最后核对注册名。这个流程我在几个项目里反复用过通常五分钟内能定位。6.2 编辑器在隐藏容器里初始化工具栏全部折叠这是一个非常隐蔽的坑。如果你的编辑器挂载在一个初始display: none的容器里比如弹窗、未激活的标签页初始化时工具栏测量到的容器宽度是 0折叠逻辑会把所有按钮塞进下拉。等容器显示出来工具栏也不会自动恢复因为宽度变化的监听在某些容器状态切换下不一定触发重算。处理方式有两条一是延迟到容器可见后再初始化编辑器用弹窗的 open 事件或者标签页的切换事件作为触发点二是初始化后用编辑器实例上的工具栏视图手动触发一次重算——但这条路径涉及内部 API不同版本差异大不推荐。我现在的做法固定成模式编辑器实例懒初始化容器真正可见时才创建销毁时机跟着弹窗关闭走。这样既避开测量问题也避免了多个隐藏编辑器同时占用内存。6.3 自定义按钮加不进去给工具栏加自定义按钮正确姿势是写成插件在插件的init()里往editor.ui.componentFactory注册组件。这样注册发生在工具栏渲染之前配置里写上按钮名就能生效。import { Plugin } from ckeditor/ckeditor5-core; import { ButtonView } from ckeditor/ckeditor5-ui; export default class MyCustomAction extends Plugin { init() { const editor this.editor; editor.ui.componentFactory.add(myCustomAction, (locale) { const view new ButtonView(locale); view.set({ label: 自定义动作, tooltip: true, withText: false, icon: svg viewBox0 0 20 20.../svg }); view.on(execute, () { // 这里拿到 editor 做实际的事情 console.log(editor.getData().length); }); return view; }); } }反过来如果你在ClassicEditor.create().then(editor ...)里才调用componentFactory.add工具栏已经渲染完了新组件不会自动出现。这时候看到的现象就是配置里写了控制台也不报错但按钮就是不显示很容易误判成配置问题。还有一个连带问题组件注册名必须和 items 里的字符串完全一致。我在插件里注册MyCustomAction配置里写myCustomAction是不匹配的——工厂的 key 是大小写敏感的。这个错误极难发现因为两处看起来几乎一样。约定俗成的做法是组件名统一用 camelCase 且首字母小写和内置按钮保持一致减少认知负担。6.4 工具栏在小屏下的处理多行换行能解决放不下的问题但解决不了屏幕太窄的问题。手机宽度下哪怕开了换行工具栏也会占掉三四行编辑区域被压得很小。这时候更合理的做法是配置两套工具栏桌面端用完整 items窄屏用精简 items。实现方式是在初始化前根据窗口宽度或者媒体查询选择不同的配置对象而不是指望 CSS 去隐藏按钮——CSS 隐藏只是视觉上的按钮仍然占据布局计算折叠逻辑照样生效。const isNarrow window.matchMedia((max-width: 720px)).matches; const toolbarConfig isNarrow ? { items: [undo, redo, |, bold, italic, link], shouldNotGroupWhenFull: true } : { items: [ /* 完整配置 */ ], shouldNotGroupWhenFull: true };代价是窗口尺寸变化后需要重新创建编辑器或者至少重建工具栏视图这会丢失当前焦点和部分选区状态。如果产品对这点敏感就退一步只做初始化时的一次性判断跨尺寸切换的场景交给用户刷新页面——实践中这个妥协是可以接受的。7. 一份可以直接拿去改的完整配置把前面所有内容串起来给一份完整可跑的配置。这份配置的假设是经典编辑器、后台管理场景、中文界面、需要多行换行、需要把插入类动作收进下拉。import { ClassicEditor } from ckeditor/ckeditor5-editor-classic; import { Essentials } from ckeditor/ckeditor5-essentials; import { Paragraph } from ckeditor/ckeditor5-paragraph; import { Heading } from ckeditor/ckeditor5-heading; import { Bold, Italic, Underline } from ckeditor/ckeditor5-basic-styles; import { Link } from ckeditor/ckeditor5-link; import { List } from ckeditor/ckeditor5-list; import { Table, TableToolbar } from ckeditor/ckeditor5-table; import { Image, ImageToolbar, ImageCaption, ImageStyle, ImageResize } from ckeditor/ckeditor5-image; import { RemoveFormat } from ckeditor/ckeditor5-remove-format; import { Autoformat } from ckeditor/ckeditor5-autoformat; ClassicEditor.create(document.querySelector(#editor), { plugins: [ Essentials, Paragraph, Heading, Bold, Italic, Underline, Link, List, Table, TableToolbar, Image, ImageToolbar, ImageCaption, ImageStyle, ImageResize, RemoveFormat, Autoformat ], toolbar: { items: [ undo, redo, |, heading, |, bold, italic, underline, |, bulletedList, numberedList, |, link, { label: 插入, icon: svg viewBox0 0 20 20 xmlnshttp://www.w3.org/2000/svgpath dM10 2v16M2 10h16 strokecurrentColor stroke-width2 fillnone//svg, items: [insertTable, insertImage] }, |, removeFormat ], shouldNotGroupWhenFull: true }, table: { contentToolbar: [tableColumn, tableRow, mergeTableCells] }, image: { toolbar: [imageStyle:inline, imageStyle:block, imageStyle:side, |, toggleImageCaption, imageTextAlternative], resizeOptions: [ { name: resizeImage:original, value: null, label: 原始尺寸 }, { name: resizeImage:50, value: 50, label: 50% }, { name: resizeImage:75, value: 75, label: 75% } ] } }).then((editor) { window.editor editor; }).catch((error) { console.error(编辑器初始化失败, error); });这份配置里有几个点值得单独解释。table.contentToolbar和image.toolbar是另外两条独立的工具栏它们不在主工具栏的管辖范围内而是在表格或图片被选中时以气泡形式出现。很多人配完主工具栏发现表格操作按钮没有其实是因为这两个配置没写。它们同样走 items 机制同样支持分隔符排查思路和主工具栏一致。图片样式按钮的名字带冒号后缀imageStyle:inline这类名字属于同一个组件的多个变体和普通按钮的命名规则不同写错一样会报组件不可用。这类名字建议直接从图片插件文档里复制不要手写。heading下拉的选项不在这里配。标题的级别列表H1–H6、段落是在 heading 插件的配置里定义的工具栏只负责放一个入口。想改级别就改heading.options想改默认级别就改heading.options里的默认项。removeFormat放在最后是个小习惯。它是对当前选区做清理的动作和前面的格式化操作性质相反放在末尾在语义上更顺用户也更容易找到。最后分享一个我踩过不止一次的教训工具栏配置里的名字永远以你手上这版构建的实际注册名为准而不是以文档为准。文档更新有滞后预置构建和自定义构建的注册名也可能有细微差异。上线前花十分钟在控制台里把editor.ui.componentFactory上有哪些名字打印出来对着看一遍比事后排查半天便宜得多。// 一行代码列出当前构建里所有可用组件名 console.log([...editor.ui.componentFactory.names()].sort().join(, ));这行代码在版本升级、换成精简构建、或者从别人手里接手项目时特别有用——它给出的是你这套产物里的真实清单比任何文档都可靠。我现在的习惯是把它写进开发环境的启动日志里改工具栏配置时随手一看省掉了大量配置写了不生效的困惑。