ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CKEditor 5工具栏配置:items解析、多行换行与嵌套下拉

CKEditor 5工具栏配置:items解析、多行换行与嵌套下拉

很多人第一次给编辑器加按钮,都是在工具栏里堆一长串名字,然后发现两件怪事:一是某些名字写进去直接报错,二是按钮多到一定程度后,工具栏右侧凭空冒出一个"更多"的下拉,把后半截按钮全吞了进去。更麻烦的是,产品那边要的是"两行工具栏,重要的放第一行",而你把文档翻了一遍,发现真正能控制这件事的配置项只有一个布尔值,剩下的全靠布局和分组去凑。

这篇内容就围绕 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 viewBox="0 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 viewBox="0 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 viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 2v16M2 10h16" stroke="currentColor" stroke-width="2" fill="none"/></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(', '));

这行代码在版本升级、换成精简构建、或者从别人手里接手项目时特别有用——它给出的是你这套产物里的真实清单,比任何文档都可靠。我现在的习惯是把它写进开发环境的启动日志里,改工具栏配置时随手一看,省掉了大量"配置写了不生效"的困惑。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进