前端周刊6-我们如何提高富文本编辑器的加载速度
当我们尝试在编辑器中打开大型文档时,需要几秒钟才能加载,我们希望优化一下加载速度
CKEditor是一款高度可定制好的富文本编辑器。但是他的强大和可扩展的架构是有代价的,那就是性能。CKEditor 5在加载大型或者结构异常的文档时比之前的版本慢。
这么多年,我们也进行了一些理论上显而易见性能会好的解决方案,但是编辑器的性能并没有我们达到我们的预期。我们想做一些能带来显著性能提升的想法,但是这可能会出现一些根本性的架构变化,风险也较高,可能是一个长达一年时间的项目。对很多应用可能也会出现破坏性变化。
这些想法都需要我们找到解决方案。因此,我们决定启动一个时间跨度更短,只有几个月的性能改进项目,专注于现有的架构内的深度变革。我们的目标是立即缓解一些痛点,同时为重大项目争取更多的时间。我们希望将加载时间减少30%,甚至40%。
结果超出我们的预期,不止40%,甚至不止50%,可能500%甚至2000%,这取决于文档的大小。这让我们大吃一惊,今天我们想分享这个项目背后的故事,以及我们如何实现这些成果的。
什么是巨型文档
巨型文档首先考虑文件量,文件有多少页,或者文件包含多少单词,因为页数通常受到字体大小等格式因素的影响。
实际上,问题出在其他地方。从编码器架构的角度讲,真正重要的是文档包含的html标签和属性的数量。一本长达一百页的书的加载时间,可能与一篇包含链接、一些表格和大量文本格式的几页长的文章加载时间相同。
然而,最大的挑战是一下两种使用情况:
遗留html文档,通常在在其他软件中创建,包含杂乱的非语义html,以及许多html标签和属性,这些通常甚至不会影响文档的外观。
使用大量html标签的特殊情况,例如需要为文档中特定或全部单词分配标签的程序。
经常有人问我们:编辑器能支持多长的文档,同时还能保持流畅的用户体验。这个问题很难直接回答。
html标签虽然更为精确,但并不是一个完美的衡量标准,因为文档的具体结构也很重要,编辑器哪些功能最常使用也是影响因素,更不用说外部因素,比如集成应用中使用的其他库(尽管在这个项目中我们并不需要考虑这些。)。还有文档中的文本数量。
因此,大型文档是一个相对的术语,它并不定义规模。可能是200页,也可能是2000甚至3000页。
测试方法和结果
因此我们生成了几份文档,每份文档都侧重于一个特定的用例·。如果感兴趣,可以在CKEditor 5的仓库(CKEditor 5)查看这些文档的内容。
这些文件包含:
5000段落,无格式(约400页A4纸)
3000列表项,无格式嵌套结构(约100页A4纸)
一张表格,2000行,10列(总计20000单元格)
40大段落,过度格式化(约100页A4纸)
30大段落,作为内联样式提供,过度格式化(约100页A4纸)
维基百科主页源码,复制4次,用于测试不支持的HTML。
一下是没有优化的加载时间:
我们测试了完整的CKEditor设置,不包括协助功能。编辑器放在空白页上,没有其他标记或脚本,我们使用隐身标签页测量时间,并关闭浏览器控制台,以限制影响结果的外部因素。
我们记录了从编辑器初始化的第一行到编辑器完整初始化所经历的时间。
对于每个测试,编辑器都会初始化十次,每次都是在刷新整个页面后初始化。我们丢弃了两个异常的结果,浏览器行为的波动是我们无法控制的,而且我们发现十次尝试中会有一次花费的时间明显更长,8个最好结果的平均值作为最终结果。
那么我们优化后的结果如何呢,看以下表格:
简而言之,CKEditor的平均加载速度比之前快6-7倍。而且文档越大,收益越大。
我们在提高加载速度的改进机制也用在了编辑器保存数据时。结果巧合的是,我们将保存速度提高了类似的倍数。
编辑器架构以及重要性
15年,我们重写CKEditor,当时CKEditor 4使用的是旧架构,基于DOM,严重依赖浏览器行为。CKEditor 5比4更灵活、更稳定。但是它的内部更加复杂,性能较低。
CKEditor 4的架构主要基于DOM结构,该结构由浏览器生成和维护。除了可编辑区域中呈现的DOM结构外,它不会维护自己的状态。它更快,因为激活所有工作都在用户看到的视图层中完成的。
而CKEditor 5实现了3个层:
抽象的树状结构的数据模型
类似于虚拟DOM的结果(称为视图)
真正的DOM
当编辑器加载HTML文档时,它会生成DOM,然后生成视图,然后编辑器会解析视图中的每个元素,以生成模型。为了在浏览器中显示文档,我们再次获取模型,将其转换为视图,然后转换为DOM,并将其放入可编辑元素中。
因此CKEditor 5需要一些额外的步骤来加载文档。视图↔模型转换尤其耗时,因此每个DOM节点/模型元素都必须由多个功能处理。
分析性能问题
我们首先使用的工具是浏览器的性能分析器。系统很复杂,大部分时间都花在几十个小函数或短循环中。call tree底部没有大块函数,没有明显的瓶颈。唯一的出的结论是:事情本就如此,只是需要处理大量的数据
call tree显示了函数如何相互嵌套,以及每个子调用函数对父调用函数的时间贡献了多少时间。在大型项目中,如果没有明显的性能瓶颈,可能很难重复利用这一点。
有时候,一个函数会被多次使用,在多个调用路径中,它可能花费大量时间,但是从call tree中很难被识别。因此,我们使用自下而上的方法,总结了给定函数的所有调用。可以了解到哪个函数占用了总初始化时间的较大部分。
我们首先查看的是self time列,看是否有函数占用大量的总时间(>5%),并且我们知道他们调用的频率并不高。这些都是很简单能确定需要优化的函数。但是这种情况比较少见,因为我们之间已经解决了很多这种问题。
然后我们查看总时间,忽略前面几个排名较高的结果,因为他们是预期耗时的顶级函数。我们要找出列表中相对靠前的低级别函数。通过这种方式,我们定位那些在多次调用,不同执行路径中可能存在问题的高频函数。虽然每次调用可能耗时较少,但累计起来就有可能成为问题。
整个过程需要直觉经验,和对编辑器引擎的扎实理解。但是好在很多CKEditor 5原团队成员仍然在开发这个项目。这样我们可以记住那些我们最开始开发的时候忽略的性能问题。
另一方面,我们知道代码中有一部分肯定会引发性能问题,但在性能分析中没有看见。例如我们知道TreeWalker类被过度使用,在数据初始化时创建了大量的Postion实例。我们针对这些做了优化,但是并没有给编辑器性能带来变化。
并且一开始,我们一致同意跳过JavaScript引擎微优化。对于不熟悉这方面的人来说可能会觉得:如果你知道JavaScript引擎的内部是如何编写的,你可以利用这一点来编写和构建你的代码,使其运行的更快,而无需更改代码逻辑。
但其实,如果你的代码存在算法级别的问题,如果你能降低计算复杂度,可能比微优化更有用。而且,寻找可能是JavaScript引擎微优化是一个很难的任务,特别是像CKEditor这种大型代码以及每个浏览器都是使用不同的JavaScript引擎。
在深度探讨具体的解决问题前,有两个事情我们想说一下。
首先,我们一直维护着一个逻辑清晰、结构良好的代码架构。切宝每个关注点和职责明确分离,公共和私有API划分明确
我们的代码覆盖率达到100%。
性能优化
计算子组件在其父组件内的位置
编辑器数据模型是一个树形结构,其父元素可能包含子节点(通常是文本)。字符被组织成文本节点。每个文本节点是一个单一对象,将具有相同属性集(如格式)的所有连续字符分组。自然地,文本节点具有各种长度。
父元素里所有文本内容被存成一个列表。通常,一个段落里可能有一到几个子内容,具体看文本排版。不过,在某些特殊情况,一个段落里会有一大堆子内容,比如你的程序需要给每个字加上一些额外信息
上述内容可以简化为以下结构:
{
"element": "paragraph",
"children": [
{
"text": "This is ",
"attributes": []
},
{
"text": "formatted",
"attributes": [ { "bold": true, "italic": true } ]
},
{
"text": " text",
"attributes": [ { "bold": true } ]
}
]
}
这个设计有个明显的缺点:没法快速找到文章中任意一个字符的位置。比如,想找第12个字符在哪儿,就得花不少时间。反过来也一样:看到一个文字片段,我们也不知道它在文章里是从哪儿开始的。因为这些问题我们经常要用到,所以早点解决很重要。
直到最近我们才开始解决这个问题,我们通常从第一个子节点开始,依次遍历所有子节点,累加它们的大小,直到达到请求的位置。当然,这意味着需要大量的处理。
我们可能首先会想到的两个解决方案是:
把每个字符都当成独立的东西存着。但这会带来两个麻烦。一个是内存占用太大。现在为文本节点存的所有属性,得给每个字符再复制一遍。二是性能问题。很多时候,一下子遍历整个文本节点比逐个遍历几百个字符更划算。
缓存一些值以便更快地找到请求的位置。
理论从定义上来说,使用缓存会引入一个不同的问题:更新和清理缓存。
从总体上来看,实现缓存有多种解决方案,在保存速度、读取速度、更新速度和代码复杂性之间做出选择。
就我们的情况来讲,我们有以下几种选择:
每个子节点要记住自己的位置,这个位置是前面所有子节点大型的综合。虽然这样记录位置看起来没太大用处,因为你还是不能快速找到某个特定位置的字符。但这样处理之后,你可以把子节点的位置当成一个有序的列表,然后用二分搜索快速找到包含你想要位置的子节点。这能让查找速度从线性的(一个一个找)提升到对数级别(更快)。不过,更新这些位置星系的速度还是线性的。如果你在段落的开头插入一个字符,就得重新更新所有文本节点的位置。
再加一个数组,用来记录每个位置上的子节点。这样读取数据时,直接从数组里找,速度很快。但如果有数据变化,所有内容都需要更新,更新的工作量是根据字符数量来算的,而不是子节点数量。举个例子,一个段落可能有几百个字符,但只有一个子节点(如果这些字符格式都一样)。所以这种方法更新起来会更麻烦,而且还会占用更多内存。
找一个合适的数据结构,让它既能快速读取,又能快速更新,而且这些操作的时间复杂度都要是对数级别的。这其实挺难的,因为我们还要考虑几个限制条件:存储的东西大小不一,随时可能变,还能在任何位置添加或删除。另外,我们还想很快知道某个东西从哪个位置开始(这相当于问“这个东西的位置在哪”,和“从位置X读取”是相反的问题)。还好,AVL 树能完美满足这些要求,所有操作都能在对数时间内完成。
示例:
第一个解决方案:
const paragraph = {
children: [
{ text: "This is " },
{ text: "formatted" },
{ text: " text" }
]
};
function findCharacter(paragraph, targetPosition) {
let currentPosition = 0;
for (const node of paragraph.children) {
if (currentPosition + node.text.length >= targetPosition) {
return node.text.charAt(targetPosition - currentPosition);
}
currentPosition += node.text.length;
}
return null;
}
const targetPosition = 12;
console.log("Found character:", findCharacter(paragraph, targetPosition));
第二个解决方案:
const paragraph = {
children: [
{ text: "This is " },
{ text: "formatted" },
{ text: " text" }
]
};
function calculatePositions(paragraph) {
let positions = [], currentPosition = 0;
for (const node of paragraph.children) {
positions.push({ start: currentPosition, end: currentPosition + node.text.length, node });
currentPosition += node.text.length;
}
return positions;
}
function findCharacter(positions, targetPosition) {
let low = 0, high = positions.length - 1;
while (low <= high) {
let mid = Math.floor((low + high) / 2);
let midPosition = positions[mid];
if (targetPosition >= midPosition.start && targetPosition <= midPosition.end) {
return midPosition.node.text.charAt(targetPosition - midPosition.start);
}
if (targetPosition <= midPosition.start) high = mid - 1;
else low = mid + 1;
}
return null;
}
const positions = calculatePositions(paragraph);
const targetPosition = 12;
console.log("Found character:", findCharacter(positions, targetPosition));
AVL树:
// AVL 树节点类
class AVLNode {
constructor(text, attributes) {
this.text = text;
this.attributes = attributes;
this.length = text.length;
this.prefixSum = 0; // 该节点之前的所有文本长度总和
this.left = null;
this.right = null;
this.height = 1; // 树的高度,用于平衡
}
}
// AVL 树类
class AVLTree {
constructor() {
this.root = null;
}
// 插入文本节点
insert(node) {
this.root = this._insert(this.root, node);
}
// 内部插入方法,保持平衡
_insert(root, node) {
if (!root) {
return node;
}
// 更新前缀和
node.prefixSum = root.prefixSum + root.length;
if (node.prefixSum < root.prefixSum) {
root.left = this._insert(root.left, node);
} else {
root.right = this._insert(root.right, node);
}
// 更新树高度并平衡
root.height = Math.max(this._getHeight(root.left), this._getHeight(root.right)) + 1;
return this._balance(root);
}
// 查找第 N 个字符
findCharAtPosition(n) {
return this._findCharAtPosition(this.root, n);
}
// 内部查找方法,利用前缀和快速定位
_findCharAtPosition(root, n) {
if (!root) {
return null;
}
if (n < root.prefixSum) {
return this._findCharAtPosition(root.left, n);
} else if (n > root.prefixSum + root.length) {
return this._findCharAtPosition(root.right, n);
} else {
// 找到目标节点,返回字符
return root.text.charAt(n - root.prefixSum);
}
}
// 查找文本节点的起始位置
findNodeStartPosition(node) {
return node.prefixSum;
}
// 平衡树的方法(略)
_balance(root) {
// 省略具体平衡操作,通常包括旋转等操作
return root;
}
// 获取树高度
_getHeight(node) {
return node ? node.height : 0;
}
}
// 使用示例
const avlTree = new AVLTree();
const paragraph = [
{ text: "This is ", attributes: [] },
{ text: "formatted", attributes: [{ bold: true, italic: true }] },
{ text: " text", attributes: [{ bold: true }] }
];
paragraph.forEach(node => avlTree.insert(new AVLNode(node.text, node.attributes)));
// 查找第 12 个字符
console.log(avlTree.findCharAtPosition(12)); // 输出: 'o'
// 查找第一个文本节点的起始位置
console.log(avlTree.findNodeStartPosition(avlTree.root.left)); // 输出: 0
但是最后,我们选择了第二种解决方案。因为虽然在特定场景之外,使用AVL树上最佳选择,因为所有操作的耗时比较低。不过,我们还得考虑其他因素。
首先,一般来说,系统中读取操作比插入/更新操作更常用。虽然理论上插入操作会因为更新缓存而变得很慢,但实际应用中,比如富文本编辑器,用户通常会在段落末尾继续打字,插入操作也大多发生在那里。文档的加载和保存也是这种情况,这是我们重点关注的地方。所以,我们实际上让读取和插入操作都保持了一个稳定的速度。
我们一开始测试的是在长段落开头输入的情况,老实说,我们以为这种情况下性能会变得很差,这个方案可能没法用。我们其实只是想先看看降低计算复杂度是否真的能提升编辑器性能。我们觉得AVL树的方案更好,但在确认缓存能解决问题之前,不想投入太多时间,毕竟之前在其他地方我们也判断失误了。最终,我们发现输入性能并没有受到负面影响。因此,我们决定将这个方案作为最终版本保留下来。
需要注意的是,此解决方案确实会增加编辑器的内存消耗。在优化领域,内存与处理性能之间的较量已有千年之久,很多时候你不得不在这两者之间做出选择。我们将密切关注这一变化对客户应用程序的影响。不过,我们还有AVL树这个备选方案,再加上我们有大量的自动化测试和代码抽象层的支持,所以即使要改方案,我们也能轻松搞定,不用担心会影响现有的功能。
改善从模型到视图到位置映射
另一个用到缓存的地方是模型树和视图树的对应关系。
我们之前聊了很多模型树——这是我们自己设计的数据结构,专门存文档数据,而且只存文档数据。它比DOM或编辑器保存数据时返回的HTML更简单。但编辑器还维护一个“实时”的视图树结构,它是由模型生成的,接近最终的DOM/HTML。
当模型中的某个节点变了,视图也会跟着变。虽然一些模型元素可以轻松对应到视图元素,但如果有文字格式或复杂功能,这种对应关系就变得难处理。
我们可以记录哪些模型和视图元素是相关的。但模型的文本节点很活跃,经常会被创建、合并、拆分或删除,特别是在编辑器引擎通过底层API做改动时。目前的架构下,没法保存这些节点,也没法把它们与视图中的对应部分连起来。
很多时候,我们需要根据模型中的位置去找视图中的位置。因为这两种结构可能有差异,所以我们从一个“已知”的地方(对应视图元素的起点)开始,一路遍历,直到经过的视图节点的大小加起来等于模型中的位置。
在这个例子中,我们要找到第17个位置。我们从头开始看,数到第12个字符后停下。格式化后的文字只有10个字符长,所以第17个位置就在这段格式化文字里。我们最终发现,模型中的第17个位置,对应到视图里的第5个位置。
加了缓存之后,我们还是从头看,但这次会记住之前的结果。这样,要么能马上找到答案,要么能从更接近的地方继续数,不用从头开始。
把模型位置对应到视图位置的机制,比直接找某个字符复杂得多,所以缓存机制也更复杂。下面给你讲个大概的思路。
首先,我们先看看模型和视图中有哪些位置是相同的。
在视图里跳过文本节点时,我们会把这些节点的大小累加到模型里,并为每个访问的视图位置保存这个数值。我们用两个结构来缓存这些映射:一个是Map,另一个是数组,用来记录模型和视图元素之间的对应关系。
这次我们不存所有可能的位置,而是只缓存节点之后的位置。要找任意一个位置时,先对缓存的位置数组做二分查找,找到最接近(较小的)的那个位置,然后走一步或几步就能得到最终结果。
举个例子,如果我们想找模型中的位置 [paragraph, 25],我们会先找到缓存中的 [paragraph, 22] = [i, 2],然后从 <i>
元素开始,快速发现位置 25 就在视图的“text”节点里。如果没有缓存,就得从头开始遍历。
我们利用了编辑器的工作原理和常见的场景。加载文档时,最常查的就是视图节点之后的位置,也就是我们在遍历视图树时访问和存储的那些位置。为了更快地映射这些位置,我们用了Map结构。如果位置在Map里,直接就能返回。虽然这看似是个小优化,但从Map里找数据基本是恒定时间,比二分查找快不少,这一点在我们的性能测试里很明显。
最后,缓存需要有时失效。当视图树里有任何变化时,我们检查变化的具体位置,然后把缓存数组和Map里后面的条目都删掉。
下次查某个位置时,我们从最近的缓存位置(比如12)开始遍历视图,并重新构建缓存。
为了确保这个方案尽可能独立,我们把它实现为一个私有类,只在另一个类里用。
经验教训
要及时处理优化未提,不要等到为时已晚的时候。有些优化是过早的。
Profiler是一个很强大的工具,您甚至可以使用外部工具来分析浏览器生成的配置文件。但是需要自己学会如何使用它。
在执行时间方面:“什么都不做”是最快的“解决方案”。因此,缓存尽管简单粗暴,但却非常强大。然而,很多时候它会带来复杂性和内存使用成本的增加,所以要谨慎选择。
强大的测试覆盖率和适当的架构是任何变更的推动因素。
下一步
我们将继续研究其他领域,比如输入的性能,更快的文档保存。还有content streaming 内容流媒体。
还有就是使用CKEditor的内部数据模型作为文档数据,而非保存和加载HTML。这一方案也伴随着一系列不太理想的后果,并涉及到我们产品生态系统的多个部分。
英文原文:How we made our rich text editor load faster - Part 1 | CKEditor
版本号
2025年3月24日
React Router 7.4
Ionic 8.5 跨平台JS APP开发平台
文章作者:Administrator
文章链接:http://halo.chenkeyan.com/archives/fe-weekly-6-rich-text-editor-load-speed
版权声明:本博客所有文章除特别声明外,均采用CC BY-NC-SA 4.0 许可协议,转载请注明出处!
评论