news 2026/9/16 1:19:00

CKEditor5实战:视频引入、预览链路与自定义工具栏全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CKEditor5实战:视频引入、预览链路与自定义工具栏全解析

最近在做一套内容管理后台,编辑器这块从零选型,第一反应就是上 CKEditor5。原因也很直白:项目里需要一个能自由插入视频、能在发布前直观预览效果、还能按不同角色收起或者扩展工具栏的富文本方案。CKEditor5 在这一轮对比中确实最合适——内置 Media Embed、自定义插件机制成熟、工具栏组件化程度高,社区里能查到的配置案例也比其他编辑器多得多。

这篇就把我落地过程的完整思路写出来,包括视频引入的几种选择、预览链路里那些"文件可能有害"的警告是怎么来的,以及自定义 toolabr 时真正该注意的细节。适合正在选型富文本编辑器、或者已经在 CKEditor5 里做二次开发的朋友参考。

1. 为什么是 CKEditor5:这次项目对编辑器的硬性要求

1.1 业务场景里三个绕不开的需求

我当时列的选型清单不算复杂,但每一条都很致命。

第一,视频引入。运营同学需要在文章中间插入视频,而且来源两种都有:一种是腾讯视频、B站这类第三方平台的分享链接,另一种是自己上传的 mp4 文件。整合进同一套内容里,意味着编辑器既要能解析 iframe 嵌入,又要能接收本地文件并转成可发布的资源地址。

第二,内容预览。编辑写完以后,发布之前要在后台看到接近真实页面的效果。这不是简单地在旁边加一个 iframe 预览静态 HTML,而是要保证编辑器数据解析出来的结果和线上渲染结果一致,尤其是视频、图片这种块级元素,不能在编辑器里是一套样子、发布出去变成另一套样子。

第三,自定义 toolbar。同一个编辑器在不同入口要呈现完全不同的操作集合。运营编辑用的入口要完整,包括标题、列表、表格、视频、代码块;审核人员的只读预览入口则不需要任何编辑按钮;还有个别特定栏目连加粗斜体都不能出现,只能插视频和写简介。这要求 toolbar 必须能在启动时动态生成,而不是写死一份配置。

另一个隐含需求是团队现有技术栈是 React + Vite。CKEditor5 提供了对 React 友好的官方封装@ckeditor/ckeditor5-react,同时也能在纯 TypeScript 环境里直接以框架无关的方式调用,迁移成本低。

1.2 与上一代编辑器对比后的取舍

团队之前用的是 CKEditor4,坦白说,功能上没什么不够用的,但有两个痛点越来越明显:一是视频插入基本靠写 HTML 源码,编辑器内部对 video/iframe 的嵌套支持很弱。二是皮肤和按钮扩展非常依赖config.toolbar的模板套路,一旦要加一个自定义按钮,就得去翻旧插件体系的文档,整体心智负担挺重。

CKEditor5 不一样的地方在于它的架构做了彻底的模块化。编辑器实例本身是由一堆插件组合出来的,toolbar只是 UI 层的配置入口,背后每个按钮都对应一个 command,每个 command 又由插件注册。这种设计让"自定义工具栏"变成一件非常自然的事——你不需要改框架源码,只需要往插件集合里加自己的插件,再把插件提供的按钮挂到 toolbar 配置项上。

从数据层看,CKEditor5 默认的模型是Content数据模型,加上HtmlDataProcessor,最终输出的 HTML 结构相对干净,不会像旧版那样在源码模式下留下一堆 或者style=""。这对后续前端渲染和搜索结果摘要提取都友好很多。

1.3 选择哪种编辑器构建方式

CKEditor5 官网提供了三种落地方式:

  • 直接引入现成构建包,比如ckeditor5-build-classic
  • 用在线构建器挑选插件生成压缩包
  • 用 npm 包在自己工程里按需组装

我选了第三种,原因只有一个:需要相对完整的可扩展性。用在线构建器虽然方便,但每次想调一个插件版本或者自定义 schema,就得重新去生成一次包,代码仓库里也很难 review。按需组装虽然初始化代码写得长一点,但好处是插件依赖清晰、版本可控,出现问题可以直接定位到具体包。

实际工程里我的最小依赖大致是这样的:

npm install ckeditor5 @ckeditor/ckeditor5-react

最近几个大版本ckeditor5包已经把常用功能整合到了一起,不用再分别安装几十个@ckeditor/ckeditor5-*小包。不过注意,一些冷门插件仍然需要单独装,比如后面要讲的 Media Embed。

2. 视频引入的技术路线:Media Embed、自定义上传与 iframe 形态

2.1 先看清 CKEditor5 的视频方案地图

CKEditor5 早期版本并没有一个独立的"视频插件",它把视频分成了两条路:

  • 在线视频:通过MediaEmbed插件,解析第三方视频平台分享链接,输出成 iframe
  • 本地视频:早期版本需要社区插件,新版本则可以用通用的video标签配合上传适配器实现

如果你用的是ckeditor5-build-classic这种全量构建包,里面默认是带MediaEmbed的。但如果你像我一样按需组装,就需要手动安装:

npm install @ckeditor/ckeditor5-media-embed

然后在插件列表里注册:

import { MediaEmbed } from 'ckeditor5'; import { MediaEmbedToolbar } from 'ckeditor5'; ClassicEditor.create(document.querySelector('#editor'), { plugins: [MediaEmbed, MediaEmbedToolbar], toolbar: ['mediaEmbed', '|', ...], mediaEmbed: { previewsInData: true } });

previewsInData这个选项值得单独说。它决定生成的 HTML 数据里,媒体内容是以一个简单的占位链接存在,还是直接展开成完整的 iframe 预览结构。默认是false,也就是编辑器里看到的是卡片样式,但最终输出的 HTML 里只有原始 URL;设置成true后,HTML 里会直接输出 iframe,这样在后台预览时就能看到真实视频画面。代价是数据体积变大——一个视频卡片会携带整段 iframe 代码。

如果你需要兼容微信内置浏览器这类环境,建议线上用previewsInData: false,然后在前端渲染时自己根据 URL 动态生成对应的 iframe,这样发布出去的内容更可控。

2.2 给 Media Embed 扩展自定义视频平台

第三方平台远不止 B 站、腾讯视频这些自带支持的类型。实际项目里可能还会遇到企业内部的视频点播平台,或者某个不太主流的直播回放链接。CKEditor5 的 Media Embed 允许通过providers配置自定义 URL 解析规则:

mediaEmbed: { previewsInData: true, providers: [ { name: 'internal-video', url: /^https?:\/\/media\.example\.com\/video\/(.+)/, html: (match) => { const videoId = match[1]; return `<iframe src="https://media.example.com/player?vid=${videoId}" width="100%" height="400" frameborder="0" allowfullscreen></iframe>`; } } ] }

url字段是一个正则表达式,用来做匹配;html字段接收一个函数,返回拼接好的 iframe 代码。这样在编辑器里粘贴https://media.example.com/video/xxxx时,CKEditor5 就会把它识别为一个媒体卡片,并且按照你自己的规则渲染预览。

这块有个容易踩的坑:CKEditor5 默认的 provider 规则会优先匹配,如果自定义规则排在后面,而内置规则的正则又恰好能匹配你的链接,那就会走内置逻辑。比较好的做法是把自定义 provider 放在数组最前面,或者干脆用更严格的正则保证唯一匹配。

2.3 本地视频上传:用通用上传适配器接收 mp4

本地视频引入本质上和图片上传路径一样,都是通过editor.plugins.get('FileRepository')来处理。CKEditor5 把文件上传抽象成适配器模式,常见的做法是自定义一个uploadAdapter,把文件交给后端接口,返回线上地址。

我在这里直接把图片上传适配器复用了一份,在此基础上扩展了文件类型判断,让同一个适配器同时支持图片和视频。核心逻辑如下:

class UploadAdapter { constructor(loader, config) { this.loader = loader; this.config = config; } upload() { return this.loader.file.then((file) => { const isVideo = file.type.startsWith('video/'); const endpoint = isVideo ? this.config.videoEndpoint : this.config.imageEndpoint; return new Promise((resolve, reject) => { const formData = new FormData(); formData.append('file', file); fetch(endpoint, { method: 'POST', body: formData, }) .then((res) => res.json()) .then((data) => { if (data.url) { resolve({ default: data.url }); } else { reject(data.message || '上传失败'); } }) .catch(reject); }); }); } abort() { // 终止上传逻辑 } }

在编辑器初始化时,通过editor.plugins.get('FileRepository').createUploadAdapter = (loader) => new UploadAdapter(loader, config)注入。

但这里有一个非常重要的细节:CKEditor5 默认的媒体嵌入命令并不能直接处理本地视频文件。如果你在工具栏上加了一个"上传视频"按钮,然后直接调用execute('upload'),你会发现视频并不会像图片那样被插入。

正确的处理方式有两种:

第一种,如果用的是较新版本,可以直接在 schema 里声明支持video标签,然后自己写一个插入命令。这种方式自由度最高,但需要了解模型和视图的转换关系,复杂度相对高一些。

第二种,也是我实际采用的方式,是让后端在文件上传成功后,返回一个能覆盖业务需求的媒体地址,然后前端把这段地址包装成一个标准链接,再触发 Media Embed 的解析命令。比如用户上传了一个 mp4,后端返回/uploads/demo.mp4,前端就把它组合成业务路由下的一个视频播放页链接,再调用editor.execute('mediaEmbed', '/play/xxx'),CKEditor5 就会走已有的 Media Embed 逻辑,输出 iframe。

这样做的最大好处是不需要线性处理video标签的上下左右对齐、封面图、播放控件这些细节,全部交给业务播放器页面去管。坏处是会有一次额外的页面跳转。如果产品接受这个交互,实现成本会低非常多。

3. “文件可能有害”提示背后:预览链路的文件类型与 MIME 陷阱

3.1 预览警告的真实来源

项目中有一个让我卡了很久的问题:用户在编辑器里插入一段在线视频后,后台内容列表页的预览区域偶尔会弹出一条系统提示,大意是"你尝试预览的文件可能对你的计算机有害。如果你信任此文件以及其来源,请打开此文件"。

一开始我以为是编辑器的问题,后来排查发现根本不是。

这条提示是操作系统层面的文件预览机制发出的,常见于 Windows 资源管理器的预览窗格。当你在文件管理器中选中一个从网络驱动器、共享目录或者带Mark of the Web(MOTW)标记的文件时,系统会检测到文件来源不可信,于是跳出警告。视频文件尤其容易触发,因为预览窗格要用系统播放器去解析它,如果文件里还带了脚本或者奇怪的编码,Windows 会在预览阶段就拦截。

那么这和 CKEditor5 有什么关系?关系在于我们后台的"预览"功能,为了让运营快速查看文章效果,直接把编辑器输出的 HTML 保存成了一个临时.html文件,存到内网共享目录里,然后在预览页面通过一个iframe去加载这个文件。这个文件在 Windows 看来就是"来自其他计算机的文件",于是预览窗格和浏览器在读取它的时候都会先警告。

3.2 富文本编辑器嵌入文件时如何被误判

更深一层的问题是,即使不走共享目录,编辑器本身也可能在预览阶段被安全策略卡住。

比如你在mediaEmbed里配置了自定义 provider,返回的 iframe 指向一个内网视频地址。如果内网视频地址的响应没有正确设置Content-Type,浏览器会把响应体当作纯文本或者二进制流,那在编辑器预览区域就只会看到一个空白框或者下载动作,根本不会渲染视频。

另一个常见场景是把本地 HTML 文件拖进编辑器去预览。CKEditor5 的HtmlDataProcessor对 HTML 文件内容进行解析,但解析出来的 HTML 如果包含<script>标签,编辑器模型默认会丢弃。这是 CKEditor5 自带的 XSS 防护机制,本意是好的,但会造成"编辑器里看不到我拖进去的东西"的困惑,看起来像预览失败。

所以当你看到"无法预览"之类的提示时,可以先按这个思路排查:

  1. 当前预览的是文件系统的本地文件,还是网络资源?
  2. 如果是本地文件,是否带有 MOTW 标记?
  3. 如果是网络资源,HTTP 响应头里的Content-Type是否正确?
  4. 目标地址是否允许被 iframe 嵌套加载?

3.3 我这边最终采用的预览方案

经过两轮测试,最终我把文章预览的链路改成了这样:

  • 预览不再生成临时 HTML 文件放到文件目录,而是通过一个内部接口动态渲染一个预览页
  • 该预览页的响应头强制设置Content-Type: text/html; charset=utf-8,并加上X-Content-Type-Options: nosniff
  • 预览页里面嵌的视频地址,统一走公司视频点播平台的 HTTPS 正式播放地址,不再直接引用上传目录里的原始 mp4 文件
  • 预览页本身增加了白名单校验,只允许后端配置的可信域名被 iframe 加载

这样操作之后,之前频繁出现的"此文件可能有害"提示基本消失了。本质上不是去关闭安全警告,而是绕开了那些触发警告的场景。

顺带一提,如果只是给运营内部自用,想要最省事的方案,可以让后端直接把编辑器数据渲染成完整 HTML 字符串,由前端window.open一个新窗口展示。这个窗口里的内容是后端拼接的,不涉及本地文件,自然就不会触发操作系统的文件来源检查。

4. 自定义 toolbar 的实现:从内置按钮到自己的功能

4.1 先看清 toolbar 配置的数据结构

CKEditor5 的 toolbar 配置看起来就是一组字符串,但实际上背后有一套分组逻辑。一个按钮项可以是:

  • 字符段:比如'bold',对应一个按钮
  • 分隔符:'|'是竖线分隔符,'-'是行分隔符
  • 子数组:用来把多个按钮包进一个下拉组

我项目里的最小配置大概是这样的:

toolbar: { items: [ 'heading', '|', 'bold', 'italic', 'link', 'bulletedList', 'numberedList', '|', 'mediaEmbed', 'insertVideo', '|', 'undo', 'redo' ] }

有一点容易被忽略:很多构建包默认带的 toolbar 配置比这个长得多,如果你手动指定toolbar.items,一定要确保被引用的每个按钮名都有对应插件在plugins数组里。否则编辑器启动时会直接报错,提示找不到某个组件。

4.2 动态生成 toolbar:同一套编辑器适配多个角色

文章开头我提过,需要根据不同入口控制编辑能力。这个需求本质上是在初始化时根据条件拼装 toolbar 数组。

我的做法是写了一个函数,根据当前用户角色返回不同的配置:

function buildToolbar(role: string): string[] { const base = ['undo', 'redo', '|', 'heading', '|']; const editingTools = ['bold', 'italic', 'link', 'bulletedList', 'numberedList']; const mediaTools = ['mediaEmbed', 'insertVideo']; if (role === 'admin') { return [...base, ...editingTools, '|', ...mediaTools]; } if (role === 'editor') { return [...base, ...editingTools]; } if (role === 'reviewer') { return []; } return [...base, ...mediaTools]; }

reviewer 的工具栏是空数组,这并不代表编辑器只能只读。CKEditor5 里真正的只读模式是通过editor.isReadOnly控制的,而不是靠隐藏按钮。空数组只表示 UI 上不显示任何操作按钮,但你仍然可以用 API 修改内容。所以审核预览场景我是直接把编辑器实例设置为只读:

editor.isReadOnly = true;

4.3 给工具栏添加一个真正的自定义按钮

Media Embed 自带的mediaEmbed按钮只能针对当前选区操作,如果用户想在不输入任何文字的情况下,直接点一个按钮来弹窗上传本地视频,就需要注册自己的组件。

自定义按钮的官方做法是写一个插件,插件里向editor.ui.componentFactory注册一个新的按钮名,然后在工具栏配置里引用这个名称。下面是精简后的代码:

import { Plugin } from 'ckeditor5'; import { ButtonView } from 'ckeditor5'; class InsertVideoButton extends Plugin { init() { const editor = this.editor; editor.ui.componentFactory.add('insertVideo', (locale) => { const button = new ButtonView(locale); button.set({ label: '插入视频', icon: videoIcon, // 这里需要自己准备一个 SVG 图标 tooltip: true }); // 点击按钮后触发一个自定义命令 this.listenTo(button, 'execute', () => { editor.execute('insertVideoFromUrl'); editor.editing.view.focus(); }); return button; }); } }

为了让execute('insertVideoFromUrl')能正常工作,还需要注册一个 command。命令可以直接扩展Command类,内部调用媒体嵌入逻辑:

import { Command } from 'ckeditor5'; class InsertVideoFromUrlCommand extends Command { execute(url: string) { const editor = this.editor; const selection = editor.model.document.selection; editor.model.change((writer) => { // 在光标位置插入一个 media 元素 const mediaElement = writer.createElement('media', { url: url }); editor.model.insertContent(mediaElement, selection); }); } refresh() { // 可以在这里根据选择状态控制按钮的可用性 this.isEnabled = true; } }

命令写好后,要在插件初始化时把它绑定到编辑器命令表上:

editor.commands.add('insertVideoFromUrl', new InsertVideoFromUrlCommand(editor));

这样整套链路就通了:工具栏按钮 → 命令 → 模型插入 → 视图渲染(通过 Media Embed 的转换器)→ 生成 iframe 预览。

4.4 自定义按钮图标与 UI 细节处理

用在线构建器或全量构建包时,内置按钮是不需要额外打包图标的。但自定义按钮必须要自己传一个 SVG 字符串或者 URL。比较省事的方式是从 CKEditor5 源码仓库的ckeditor5-icons里挑一个图标,或者直接用设计稿里现成的 SVG。

另一个实际问题是按钮在窄屏下的表现。编辑器工具栏在移动端默认会收起到一个More下拉里,但前提是 UI 层检测到空间不足。如果你的后台是桌面端为主,通常会希望工具栏可以换行,而不是收起。配置方式:

toolbar: { shouldNotGroupWhenFull: true }

设置为true后,工具栏按钮超过宽度限制时会自动换行,不会折叠到下拉菜单里。这对内容编辑场景反而更友好,因为用户一眼能看到所有可用功能。

5. 线上遇到过的真实坑:版本差异、缓存与兼容性

5.1 版本升级带来的配置不兼容

CKEditor5 版本更新比较激进,大版本之间经常会出现配置项改名、插件拆分的情况。我在项目中途从 v36 升到 v38 时,就遇到过两个问题。

第一个是mediaEmbed相关的包名变了。早期版本里有一个@ckeditor/ckeditor5-embed,到了新版本合并进了ckeditor5主包里的MediaEmbed。如果你按照老文档安装,会出现模块不存在的报错。

第二个是Alignment插件的 toolbar item 命名从'alignment'变成了具体按钮,比如'alignLeft''alignCenter'。如果你在旧项目里配置了toolbar: ['alignment'],升级之后按钮不会显示,而且没有任何 warning,比较坑。

建议升级后做一个 Smoke Test,检查所有自定义按钮是否还挂在工具栏上、Media Embed 能不能正常解析现有数据里的链接。

5.2 按钮有图标但点不动时的排查链路

遇到次数最多的诡异问题是"自定义按钮正常显示,图标也在,但点击没有任何反应"。这种情况基本都发生在命令还没注册成功,或者componentFactory里返回的按钮实例没有正确listenTo点击事件。

我的排查顺序一般是这样:

  1. 打开控制台,输入editor.commands.get('insertVideoFromUrl'),看返回结果是 command 实例还是 undefined
  2. 如果 command 是 undefined,说明插件初始化里editor.commands.add没有执行,或者插件的init()方法根本没跑
  3. 检查编辑器实例上是否存在editor.plugins.get('InsertVideoButton'),如果不存在,查看 plugins 数组里有没有正确传入自定义插件类
  4. 如果 command 存在,再检查按钮点击监听有没有被注册,可以在execute回调里加一个console.log验证

还有一种非常隐蔽的情况:多个编辑器实例共用同一个 DOM 容器,前一个实例销毁后没有完全清空事件监听,导致第二个实例的按钮 execute 触发后被前一个实例悄悄拦截。这种问题定位时要靠浏览器开发者工具里的元素面板检查当前容器绑定的是哪个实例的 view。

5.3 视频预览的跨浏览器差异

不同浏览器对 iframe 预览的处理策略差别不小。Chrome 对无allowfullscreen的 iframe 会默认禁用全屏按钮,Firefox 则不会主动拦截;Edge 在加载包含内部分辨率不明确的视频时会先显示一个黑框。

这让"预览效果"在不同电脑上看起来完全不同。我的应对策略是:

  • mediaEmbed.providers的自定义 HTML 模板中,强制把 iframe 宽度设置为容器百分比,而不是固定像素值
  • 给 iframe 统一加上allowfullscreenallow="autoplay; fullscreen; picture-in-picture"
  • 在编辑器初始化完成后,通过editor.editing.view.change遍历所有 iframe,如果发现缺失allowfullscreen属性,就动态补上

最后还有一个来自同事反馈的兼容性问题:部分用户在用 IE 内核的办公环境下打开后台,CKEditor5 直接白屏。CKEditor5 官方从 v27 开始就不再支持 IE11 了,这个无解,只能在前端入口做浏览器检测,提示用户切换到 Chromium 内核浏览器。


工具选型和落地过程中最深的体会是:CKEditor5 的文档虽然全面,但很多细节需要结合业务场景才能理解为什么这么设计。Media Embed 和自定义 toolbar 只是它能力的一部分,真正值得花时间的其实是去弄懂插件、命令、schema 这三者之间的关系。你把这条链路理清楚了,后面不管加视频还是加音频,或者做自定义表格功能,思路都会顺很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 1:18:42

LaTeX双栏模板跨栏图表排版详解:dblfloatfix实战指南

写毕业论文的那段日子&#xff0c;我几乎被LaTeX双栏模板里的跨栏图表折磨疯了。明明单栏插图都好好的&#xff0c;一到figure*这种跨栏环境&#xff0c;图表就开始“不听话”——有的跑到文章最后一页&#xff0c;有的直接消失&#xff0c;有的风格怪异地在页面顶部孤零零地待…

作者头像 李华
网站建设 2026/9/16 1:18:02

ROS/C++ 入门(Introduction to ROS/C++)

原文&#xff1a;https://syrotek.ciirc.cvut.cz/course/ROS_CPP_INTRO 当我看到这一个教程的时候很兴奋&#xff0c;因为这里面的东西很实在&#xff0c;干货比较多。这个系列的教程有基于stage的仿真&#xff0c;有实物对照还是不错的。所有的翻译不一定完全遵照原文&#x…

作者头像 李华
网站建设 2026/9/16 1:17:51

JavaMail邮件收发系统实战:SMTP/POP3协议与Java实现详解

简介&#xff1a;面向计算机相关专业毕业生的Java邮件收发系统毕业设计资料包&#xff0c;内含项目报告、开题报告、任务书、外文翻译、文献综述和答辩PPT等全套文档&#xff0c;覆盖从选题到答辩的完整流程。系统设计基于JavaMail API实现&#xff0c;底层采用简单邮件传输协议…

作者头像 李华
网站建设 2026/9/16 1:17:09

VsCode配置C/C++开发环境:从MinGW-w64安装到调试全攻略

1. 为什么我劝新手直接选VsCode而不是Visual Studio很多刚接触编程的朋友总在纠结一个问题&#xff1a;学C/C到底该用什么工具&#xff1f;有人推荐Visual Studio&#xff0c;有人推荐Code::Blocks&#xff0c;还有人推荐Dev-C。我的建议很直接&#xff0c;如果你不是专门做Win…

作者头像 李华
网站建设 2026/9/16 1:17:01

SpringBoot校园新闻平台开发与部署实战

1. 项目背景与核心价值校园新闻发布平台是高校信息化建设中不可或缺的一环。传统校园新闻发布往往面临几个痛点&#xff1a;内容更新滞后、多终端适配困难、审核流程繁琐、数据统计缺失。基于SpringBoot的解决方案恰好能系统性解决这些问题。我在实际开发中发现&#xff0c;Spr…

作者头像 李华
网站建设 2026/9/16 1:16:52

STM32F103串口通信实战:标准库UART配置与调试全解析

简介&#xff1a;面向嵌入式初学者与STM32开发者&#xff0c;这套实验工程以STM32F103C8T6为核心&#xff0c;基于标准外设库演示UART串口通信的完整实现流程。工程通过串口输入1、2、3任意数字&#xff0c;分别输出不同内容&#xff0c;可直观观察字符接收、分支判断与回显发送…

作者头像 李华