选择的原因

这里将wangEditor5CkEditor5作为比较

  • CkEditor优势
    1. 文档详细
    2. 用户量大
    3. 自定义插件扩展非常容易
  • CkEditor劣势
    1. 纯英文文档,无翻译
    2. 未解决的_issues_尚且较多

npm月下载量比较

CKEditor月下载量如下图所示:
ckeditor-using-quantity.png

wangEditor月下载量如下图所示:
wang-editor-using-quantity.png

结论:CKEditor的月下载量吊打了wangEditor,并且最新的wangEditor5月下载量不过1400,个人比较倾向于使用月下载量10000+的

github关键数据比较:

CKEditorGitHub星星数与issues数量如下图所示:

ckeditor-star.png

wangEditorGitHub星星数与issues数量如下图所示:

wangEditor-star.png

结论:wangEditorstar上和处理问题的及时程度秒杀了CKEditor, 这一定程度可能也是CKEditorstar如此少的原因,但介于wangEditor是由国人开发,国人开发的特点是star量比较虚,实际使用量和其star量不成正比,参考字节跳动web infra团队开源的Modern.js

打包体积比较

相同功能的情况下
CKEditor体积
ckeditor-build.png

wangEditor体积
wang-editor-build.png

结论:

  1. 同样的功能CKEditor的打包体积更小,B端要求不高,C端原则基本遵循满足需求的情况下,能使用小的就使用小的
  2. CKEditor功能栏采取按需加载的方式,即我们如果不需要某些功能,那些功能文件就不会被打包,测试了一下如果只留一个Bold插件的情况下可以减少_42.2kb_的js体积

功能比较

主要功能大同小异,性能上的表现也几乎一致,CKEditor的亮点在于其插件化的扩展更加友好, 除了自己编写插件,还可以继承已有的插件,重写对应的插件方法,如果需要继承,官网的插件都是提供对应的方法的,重写方法就好。并且插件的编写方式类似于编写webpack的插件,提供了一系列的钩子在特定的条件下执行,后面会详细介绍。

CkEditor基本环境搭建

根据业务需要, 使用create-react-app搭建项目, 使用react-app-rewired启动项目, 使用customize-cra修改webpack配置

安装

1
2
3
4
5
6
7
yarn add --dev \
css-loader@5 \
postcss-loader@4 \
raw-loader@4 \
style-loader@2 \
webpack@5 \
webpack-cli@4

webpack配置

CKEditor集成到项目中需要重新定义某些文件的解析规则
否则会报
Error: Cannot read property 'getAttribute' of null (ckeditor)
https://stackoverflow.com/questions/66416928/error-cannot-read-property-getattribute-of-null-ckeditor

需要做如下解析:

  1. 内置的svg文件需要使用raw-loader
  2. 如果需要做视觉集成(主题), 内置的css文件优先使用postcss-loader,并且引入@ckeditor/ckeditor5-theme-lark包, 主题将增加约_30kb_的打包体积, 开发者可以酌情考虑。链接如下:
    https://ckeditor.com/docs/ckeditor5/latest/examples/framework/theme-customization.html
  3. 对于特定的CKEditor中的css样式,可以用如下的正则表达式判断
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

const CKERegex = {
svg: /ckeditor5-[^/\\]+[/\\]theme[/\\]icons[/\\][^/\\]+\.svg$/,
css: /ckeditor5-[^/\\]+[/\\]theme[/\\].+\.css/,
};


{
loader: 'postcss-loader',
options: {
postcssOptions: styles.getPostCssConfig({
themeImporter: {
themePath: require.resolve('@ckeditor/ckeditor5-theme-lark')
},
minify: true
})
}
}

富文本的模式

  1. 经典模式如下

npm install --save @ckeditor/ckeditor5-build-classic

editor-classic.png

  1. 内联模式

npm install --save @ckeditor/ckeditor5-build-inline

editor-inline.png

  1. 气泡模式

npm install --save @ckeditor/ckeditor5-build-balloon
editor-balloon.png

  1. 气泡块模式
    npm install --save @ckeditor/ckeditor5-build-balloon-block

editor-balloon-block.png

  1. 文档模式
    npm install --save @ckeditor/ckeditor5-build-decoupled-document

editor-document.png

文档链接如下:
https://ckeditor.com/docs/ckeditor5/latest/installation/advanced/alternative-setups/predefined-builds.html

插件添加

插件添加逻辑必须遵循官方文档
个人比较推荐从以下的网站进去,定义自己的模板
https://ckeditor.com/ckeditor-5/online-builder/

按照指示下载完成后会有以下的文件,还有demo,如下图
online-builder.png

也可以根据已有的插件集,像上图那样的自己去引用官方已有的插件, 对应的所有插件如下:
https://ckeditor.com/docs/ckeditor5/latest/features/index.html

编写CkEditor组件

当前组件被antdForm包裹,作为表单组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
function CKEditor(props) {
const { onChange, readOnly, value, onError } = props;

const handleChange = useCallback((event, editor) => {
const data = editor.getData();
onChange(data)
}, [onChange])

const handleBlur = useCallback((event, editor) => {
const data = editor.getData();
onChange(data)
}, [onChange])

return (
<CKEditorContext context={Context}>
<CKEditor
editor={ClassicEditor}
config={{
// 这里可以自定义配置项
removePlugins: ['link', 'insertTable', 'mediaEmbed'],
toolbar: ['bold', 'italic', 'bulletedList', 'numberedList', 'imageUpload'],
// toolbar: ['bold', 'italic']
}}
disabled={readOnly}
data={value || '<p />'}
onReady={editor => {
// You can store the "editor" and use when it is needed.
console.log('Editor1 is ready to use!', editor);
}}
// 相当于 editor.model.document.on("change:data", handleChange)
onChange={handleChange}
onBlur={handleBlur}
onError={onError || console.error}
/>
</CKEditorContext>
);
}

export default CKEditor;

关键属性如下:

  • editor:使用的富文本编辑器模板
  • config:富文本编辑器的配置项,默认会用Editor.defaultConfig配置好的
  • disabled: 富文本是否可编辑
  • data: 富文本的html字符串
  • onReady: 编辑器刚构建好时会调用,官网推荐在这里将富文本做存储,避免重复构建
  • onChange: 改变时调用
  • onBlur: 焦点变化时调用
  • onError: 出现错误时调用

    常见问题

  1. 编辑器被重复引用:
  • 产生原因:
    • config属性中有plugins属性,官网将对应的插件直接在组件引用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';

const editorConfiguration = {
plugins: [ Essentials, Bold, Italic, Paragraph ],
toolbar: [ 'bold', 'italic' ]
};
<CKEditor
editor={ ClassicEditor }
config={ editorConfiguration }
...其他属性
/>
  • 问题链接:https://github.com/ckeditor/ckeditor5/issues/5776
  • 解决方案:编写前文图例的ckeditor.js,然后将CkEditor组件需要的配置注入,实际编写的组件中引入对应js的文件,不需要的删除,目前没看到什么场景必须使用config.plugins
  1. 打包内存溢出(我没遇到过)
    解决方案官网已经给出,链接如下:
    https://ckeditor.com/docs/ckeditor5/latest/installation/getting-started/frameworks/react.html#integrating-a-build-from-the-online-builder

中文包

全局引入

1
import "@ckeditor/ckeditor5-build-classic/build/translations/zh-cn"
1
Editor.defaultConfig.language = 'zh-cn'

自定义插件

npm包的形式

个人推荐使用ckeditor5-package-generator

  1. CkEditor5要求所有的插件包都必须以ckeditor5-开头,中间字符为0-9a-z. - _,创建和打包的时候都会正则校验
  2. 上述插件会提供一个打包模板,我们可以在这个模板上扩展,会有一个已经编写好的插件

详细的编写方式可见参考链接

简述如下:

  1. 一个插件就是一个继承于Pluginclass
  2. editor.ui.componentFactory负责添加一个工具,可以使用模型层的监听器为用户对工具栏上自定义ui定义其操作

注意点:
如果多个插件打包,并非工具生成的目录, 目录如下
可以直接打包,也可以使用dll模式,这里推荐直接打包,webpack5自带缓存,dll不会对打包速度有明显的效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
...
|- src
|- ckeditor5-plugin1
|- lang
|- translations
|- zh-cn.po
|- contexts.json
|- src
|- index.js
|- ckeditor5-plugin2
|- lang
|- translations
|- zh-cn.po
|- contexts.json
|- src
|- index.js
|- ckeditor5-plugin3
|- lang
|- translations
|- zh-cn.po
|- contexts.json
|- src
|- index.js
...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';

class InsertImage extends Plugin {
init() {
const editor = this.editor;

// 为工具栏添加一个 insertImage,使用 imageIcon
editor.ui.componentFactory.add( 'insertImage', locale => {
const view = new ButtonView( locale );
view.set( {
label: 'Insert image',
icon: imageIcon,
tooltip: true
} );

// 监听执行这个Button,会弹出输入url,监听模型层,根据输入的url展示对应的图片,并插入到模型层对应的鼠标 selection 节点上
view.on( 'execute', () => {
const imageUrl = prompt( 'Image URL' );

editor.model.change( writer => {
const imageElement = writer.createElement( 'imageBlock', {
src: imageUrl
} );

editor.model.insertContent( imageElement, editor.model.document.selection );
} );
} );

return view;
} );
}
}

自定义扩展

完整文档可见
CKEditor提供了自定义扩展,根据先有的adapter做扩展,以下都将用Image Uploader做举例

例如: Upload adapter作用是在文件编辑器和文件上传服务器之间构建一个桥梁,我们可以自定义扩展用户上传的行为以及上传到服务器的接口等,这个adapter是基于File repository plugin创建的,像image upload plugin也是基于这个创建的,``File repository plugin`是整个上传的核心插件。

区别:
自定义插件是完全从ui到功能的自定义实现
自定义扩展是基于已有的富文本编辑器功能,做一些替换或者功能的提升

工作流程

  1. 首先,图像(或图像)需要进入富文本编辑器内容。有很多方法可以做到这一点,例如:
  • 从剪贴板粘贴图像
  • 从文件系统中拖动文件
  • 通过文件系统对话框选择图像,即选择上传

这些行为都将被image uploader plugin插件捕获

  1. 对于每个上传的图像都会被image upload plugin创建出file loader的实例,通过upload adapter将其上传到服务器,并根据url正确的展示在编辑器内

  2. 在上传图片时,image upload plugin会做以下事情

  • 创建图像的占位符
  • 插入到编辑器
  • 展示每一个图像的进度条
  • 上传完成前如果做了删除图片的操作,终止上传
  1. 图片上传完成,upload adapter通知editor(调用Promise),image upload创建的标签中的srcsrcset将被替换

扩展方式

  1. image upload必须在编辑器中启用。它在所有官方版本中默认启用,如果正在自定义CKEditor 5编辑器,那么需要自己写这个插件

  2. 需要自定义upload adapter,我们可以根据使用已有的upload adapter,也可以自定义(建议自定义,将上传和回显操作控制在自己手中)

    编写UploadAdapter

  3. 自定义UploadAdapter,主要是自定义上传的服务器路径以及自定义回显方式

  4. 根据UploadAdapter参考链接`可以找到对应的方法

  • upload()返回一个Promise
  • abort()上传中止所做的操作
  1. 通常我们使用XMLHttpRequest在这个UploadAdapter中,详细请见创建一个简易的UploadAdapter,完整代码如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
class MyUploadAdapter {
constructor(loader) {
// The file loader instance to use during the upload.
this.loader = loader;
}

// Starts the upload process.
upload() {
return this.loader.file.then(
(file) =>
new Promise((resolve, reject) => {
this.initRequest();
this.initListeners(resolve, reject, file);
this.sendRequest(file);
}),
);
}

// Aborts the upload process.
abort() {
if (this.xhr) {
this.xhr.abort();
}
}

// Initializes the XMLHttpRequest object using the URL passed to the constructor.
initRequest() {
this.xhr = new XMLHttpRequest();
// Note that your request may look different. It is up to you and your editor
// integration to choose the right communication channel. This example uses
// a POST request with JSON as a data structure but your configuration
// could be different.
this.xhr.open('POST', '/api/img/upload', true);
this.xhr.responseType = 'json';
// 设置请求头(权限控制)
this.xhr.setRequestHeader('AUTHENTICATION', '111');
}

// Initializes XMLHttpRequest listeners.
initListeners(resolve, reject, file) {
const { xhr, loader } = this;
const genericErrorText = `Couldn't upload file: ${file.name}.`;
xhr.addEventListener('error', () => reject(genericErrorText));
xhr.addEventListener('abort', () => reject());
xhr.addEventListener('load', () => {
const { response } = xhr;

// This example assumes the XHR server's "response" object will come with
// an "error" which has its own "message" that can be passed to reject()
// in the upload promise.
//
// Your integration may handle upload errors in a different way so make sure
// it is done properly. The reject() function must be called when the upload fails.
if (!response || response.error) {
return reject(
response && response.error ? response.error.message : genericErrorText,
);
}

// If the upload is successful, resolve the upload promise with an object containing
// at least the "default" URL, pointing to the image on the server.
// This URL will be used to display the image in the content. Learn more in the
// UploadAdapter#upload documentation.
return resolve({
default: response.data,
});
});

// Upload progress when it is supported. The file loader has the #uploadTotal and #uploaded
// properties which are used e.g. to display the upload progress bar in the editor
// user interface.
if (xhr.upload) {
xhr.upload.addEventListener('progress', (evt) => {
if (evt.lengthComputable) {
loader.uploadTotal = evt.total;
loader.uploaded = evt.loaded;
}
});
}
}

// Prepares the data and sends the request.
sendRequest(file) {
// Prepare the form data.
const data = new FormData();

data.append('attach', file);

// Important note: This is the right place to implement security mechanisms
// like authentication and CSRF protection. For instance, you can use
// XMLHttpRequest.setRequestHeader() to set the request headers containing
// the CSRF token generated earlier by your application.

// Send the request.
this.xhr.send(data);
}
}

export default MyUploadAdapter;