初探富文本之基于虚拟滚动的大型文档性能优化方案
虚拟滚动是一种优化长列表性能的技术,其通过按需渲染列表项来提高浏览器运行效率。具体来说,虚拟滚动只渲染用户浏览器视口部分的文档数据,而不是整个文档结构,其核心实现根据可见区域高度和容器的滚动位置计算出需要渲染的列表项,同时不渲染额外的视图内容。虚拟滚动的优势在于可以大大减少DOM
操作,从而降低渲染时间和内存占用,解决页面加载慢、卡顿等问题,改善用户体验。
描述
前段时间用户向我们反馈了一个问题,其产品有比较多的大型文档在我们的文档编辑器上进行编辑,但是因为其文档内容过长且有大量表格,导致在整个编辑的过程中卡顿感比较明显,而且在消费侧展示的时候需要渲染比较长的时间,用户体验不是很好。于是我找了一篇比较大的文档测试了一下,由于这篇文档实在是过大,首屏的LCP
达到了6896ms
,即使在各类资源有缓存的情况下FCP
也需要4777ms
,单独拎出来首屏的编辑器渲染时间都有2505ms
,整个应用的TTI
更是达到了13343ms
,在模拟极限快速输入的情况下FPS
仅仅能够保持在5+
,DOM
数量也达到了24k+
,所以这个问题还是比较严重的,于是开始了漫长的调研与优化之路。
方案调研
在实际调研的过程中,我发现几乎没有关于在线文档编辑的性能优化方案文章,那么对于我来说几乎就是从零开始调研整个方案。当然社区还是有很多关于虚拟滚动的性能优化方案的,这对最终实现整个方案有很大的帮助。此外,我还在想把内容都放在一篇文档里这个行为到底是否合适,这跟我们把代码都写在一个文件里似乎没什么区别,总感觉组织形式上可能会有更好的方案,不过这就是另一个方向上的问题了,在这里我们还是先关注于大型文档的性能问题。
- 渐进式分页加载方案: 通过数据驱动的方式,我们可以渐进式获取分块的数据,无论是逐页请求还是
SSE
的方式都可以,然后逐步渲染到页面上,这样可以减少首屏渲染时间,紧接着在渲染的时候同样也可以根据当前实际显示的页来进行渲染,这样可以减少不必要的渲染从而提升性能。例如Notion
就是完全由数据驱动的分页加载方式,当然数据还是逐步加载的,并没有实现按需加载数据,这里需要注意的是按需加载和按需渲染是两个概念。实际上这个方案非常看重文档本身的数据设计,如果是类似于JSON
块嵌套的表达结构,实现类似的方案会比较简单一些,而如果是通过扁平的表达结构描述富文本,特别是又存在块嵌套概念的情况下,这种方式就相对难以实现。 Canvas
分页渲染方案: 现在很多在线文档编辑器都是通过Canvas
来进行渲染的,例如Google Docs
、腾讯文档等,这样可以减少DOM
操作,Canvas
的优势在于可以自定义渲染逻辑,可以实现很多复杂的渲染效果与排版效果,但是缺点也很明显,所有的东西都需要自行排版实现,这对于内容复杂的文档编辑器来说就变得没有那么灵活。实际上使用Canvas
绘制文档很类似于Word
的实现,初始化时按照页数与固定高度构建纯空白的占位结构,在用户滚动的时候才挂载分页的Canvas
渲染视口区域固定范围的页内容,从而实现按需渲染。- 行级虚拟滚动方案: 绝大部分基于
DOM
的在线文档编辑器都会存在行或者称为段落的概念,例如飞书文档、石墨文档、语雀等,或者说由于DOM
本身的结构表达,将内容分为段落是最自然的方式,这样就可以实现行级虚拟滚动,即只渲染当前可见区域范围的行,这样可以减少不必要的渲染从来提升性能。通常我们都仅会在主文档的直属子元素即行元素上进行虚拟滚动,而对于嵌套结构例如行内存在的代码块中表达出的行内容则不会进行虚拟滚动,这样可以减少虚拟滚动的复杂度,同时也可以保证渲染的性能。 - 块级虚拟滚动方案,从
Notion
开始带动了文档编辑器Block
化的趋势,这种方式可以更好的组织文档内容,同时也可以更好的实现文档的块结构复用与管理,那么此时我们基于行的表达同样也会是基于Block
的表达,例如飞书文档同样也是采用这种方式组织内容。在这种情况下,我们同样可以基于行的概念实现块级虚拟滚动,即只渲染当前可见区域范围的块,实际上如果独立的块比较大的时候还是有可能影响性能,所以这里仍然存在优化空间,例如飞书文档就对代码块做了特殊处理,即使在嵌套的情况下仍然存在虚拟滚动。那么对于非Blocks
表达的文档编辑器,块级虚拟滚动方案仍然是不错的选择,此时我们将虚拟滚动的粒度提升到块级,对于很多复杂的结构例如代码块、表格、流程图等块结构做虚拟滚动,同样可以有不错的性能提升。
虚拟滚动
在具体实现之前我思考了一个比较有意思的事情,为什么虚拟滚动能够优化性能。我们在浏览器中进行DOM
操作的时候,此时这个DOM
是真正存在的吗,或者说我们在PC
上实现窗口管理的时候,这个窗口是真的存在的吗。那么答案实际上很明确,这些视图、窗口、DOM
等等都是通过图形化模拟出来的,虽然我们可以通过系统或者浏览器提供的API
来非常简单地实现各种操作,但是实际上些内容是系统帮我们绘制出来的图像,本质上还是通过外部输入设备产生各种事件信号,从而产生状态与行为模拟,诸如碰撞检测等等都是系统通过大量计算表现出的状态而已。
那么紧接着,在前段时间我想学习下Canvas
的基本操作,于是我实现了一个非常基础的图形编辑器引擎。因为在浏览器的Canvas
只提供了最基本的图形操作,没有那么方便的DOM
操作从而所有的交互事件都需要通过鼠标与键盘事件自行模拟,这其中有一个非常重要的点是判断两个图形是否相交,从而决定是否需要按需重新绘制这个图形来提升性能。那么我们设想一下,最简单的判断方式就是遍历一遍所有图形,从而判断是否与即将要刷新的图形相交,那么这其中就可能涉及比较复杂的计算,而如果我们能够提前判断某些图形是不可能相交的话,就能够省去很多不必要的计算。那么在视口外的图层就是类似的情况,如果我们能够确定这个图形是视口外的,我们就不需要判断其相交性,而且本身其也不需要渲染,那么虚拟滚动也是一样,如果我们能够减少DOM
的数量就能够减少很多计算,从而提升整个页面的运行时性能,至于首屏性能就自不必多说,减少了DOM
数量首屏的绘制一定会变快。
当然上边只是我对于提升文档编辑时或者说运行时性能的思考,实际上关于虚拟滚动优化性能的点在社区上有很多讨论了。诸如减少DOM
数量可以减少浏览器需要渲染和维持的DOM
元素数量,进而内存占用也随之减少,这使得浏览器可以更快地响应用户操作。以及浏览器的reflow
和重绘repaint
操作通常是需要大量计算的,并且随着DOM
元素的增多而变得更加频繁和复杂,通过虚拟滚动个减少需要管理的DOM
数量,同样可显著提高渲染性能。此外虚拟滚动还有更快的首屏渲染时间,特别是大文档的全量渲染很容易导致首屏渲染时间过长,还能够减少React
维护组件状态所带来的Js
性能消耗,特别是在存在Context
的情况下,不特别关注就可能会存在性能劣化问题。
那么在研究了虚拟滚动的优势之后,我们就可以开始研究虚拟滚动的实现了,在进入到富文本编辑器的块级虚拟滚动之前,我们可以先来研究一下虚拟滚动都是怎么做的。那么在这里我们以ArcoDesign
的List
组件为例来研究一下通用的虚拟滚动实现。在Arco
给予的示例中我们可以看到其传递了height
属性,此时如果我们将这个属性删除的话虚拟列表是无法正常启动的,那么实际上Arco
就是通过列表元素的数量与每个元素的高度,从而计算出了整个容器的高度,这里要注意滚动容器实际上应该是虚拟列表的容器外的元素,而对于视口内的区域则可以通过transform: translateY(Npx)
来做实际偏移,当我们滚动的时候,我们需要通过滚动条的实际滚动距离以及滚动容器的高度,配合我们配置的元素实际高度,就可以计算出来当前视口实际需要渲染的节点,而其他的节点并不实际渲染,从而实现虚拟滚动。当然实际上关于Arco
虚拟列表的配置还有很多,在这里就不完整展开了。
<List
{
/* ... */}
virtualListProps={
{
height: 560,
}}
{
/* ... */}
/>
通过简单分析Arco
的通用列表虚拟滚动,我们可以发现实现虚拟滚动似乎并没有那么难,然而在我们的在线文档场景中,实现虚拟滚动可能并不是简单的事情。此处我们先来设一下在文档中图片渲染的实现,通常在上传图片的时候,我们会记录图片的大小也就是宽高信息,在实际渲染的时候会通过容器最大宽高以及object-fit: contain;
来保证图片比例,当渲染时即使图片未实际加载完成,但是其高度占位是已经固定的。然而回到我们的文档结构中,我们的块高度是不固定的,特别是文本块的高度,在不同的字体、浏览器宽度等情况下表现是不同的,我们无法在其渲染之前得到其高度,这就导致了我们无法像图片一样提前计算出其占位高度,从而对于文档块结构的虚拟滚动就必须要解决块高度不固定的问题,由此我们需要实现动态高度的虚拟滚动调度策略来处理这个场景。而实际上如果仅仅是动态高度的虚拟滚动也并不是特别困难,社区已经有大量的实现方案,但是我们的文档编辑器是有很多复杂的模块在内的,例如选区模块、评论功能、锚点跳转等等,要兼容这些模块便是在文档本体虚拟滚动之外需要关注的功能实现。
模块设计
实际上富文本编辑器的具体实现有很多种方式,基于DOM
与Canvas
绘制富文本的区别我们就不聊了,在这里我们还是关注于基于DOM
的富文本编辑器上,例如Quill
是完全自行实现的视图DOM
绘制,而Slate
是借助于React
实现的视图层,这两者对于视图层的实现方式有很大的不同,在本文中是偏向于Slate
的实现方式,也就是借助于React
来构建块级别的虚拟滚动,当然实际上如果能够完全控制视图层的话,对于性能可优化的空间会更大,例如可以更方便地调度闲时渲染配合缓存等策略,从而更好地优化快速滚动时的体验。实际上无论是哪种方式,对于本文要讲的核心内容差距并没有那么大,只要我们能够保证富文本引擎本身控制的选区模块、高度计算模块、生命周期模块等正确调度,以及能够控制实际渲染行为,无论是哪种编辑器引擎都是可以应用虚拟滚动方案的。
渲染模型
首先我们来构思一下整个文档的渲染模型,无论是基于块模型的编辑器还是基于段落描述的编辑器都脱离不了行的概念,因为我们描述内容的时候通常都是由行来组成的一篇文档的,所以我们的文档渲染也都是以行为基准来描述的。当然这里的行只是一个比较抽象的概念,这个行结构内嵌套的可能是个块结构的表达例如代码块、表格等等,而无论是如何嵌套块,其最外层总会是需要包裹行结构的表达,即使是纯Blocks
的文档模型,我们也总能够找到外层的块容器DOM
结构,所以我们在这里需要明确定义行的概念。
实际上在此处我们所关注的行更倾向于主文档直属的行描述,而如果在主文档的某个行中嵌套了代码块结构,这个代码块的整个块结构是我们要关注的,而对于这个代码块结构的内部我们先不做太多关注,当然这是可以进一步优化的方向,特别是对于超大代码块的场景是有必要的,但是我们在这里先不关注这部分结构优化。此外,对于Canvas
绘制的文档或者是类似于分页表达的文档同样不在我们的关注范围内,只要是能够通过分页表达的文章,我们直接通过页的按需渲染即可,当然如果有需要的话同样也可以进行段落级别的按需渲染,这同样也可以算作是进一步的优化空间。
那么我们可以很轻松地推断出我们文档最终要渲染的结构,首先是占位区域placeholder
,这部分内容是不在视口的区域,所以会以占位的方式存在;紧接着是buffer
,这部分是提前渲染的内容,即虽然此区域不在视口区域,但是为了用户在滚动时尽量避免出现短暂白屏的现象,由此提前加载部分视图内容,通常这部分值可以取得视口高度的一半大小;接下来是viewport
部分,这部分是真实在视口区域要渲染的内容;而在视口区域下我们同样需要buffer
和placeholder
来作为预加载与占位区域。
placeholder
|
buffer
|
viewpoint
|
buffer
|
placeholder
需要注意的是,在这里的placeholder
我们通常会选择直接使用DOM
进行占位,可能大家会想着如果直接使用translate
是更好的选择,效率会高一些并且能触发GPU
加速,实际上对于普通的虚拟列表是没什么问题的,但是在文档结构中DOM
结构会比较复杂,使用translate
可能会出现一些预期之外的情况,特别是在复杂的样式结构中,所以使用DOM
进行占位是比较简单的方式。此外,因为选区模块的存在,在实现placeholder
的时候还需要考虑用户拖拽长选区的情况,也就是说如果用户在进行选择操作时将viewport
的部分选择并不断滚动,然后直接将其拖拽到了placeholder
区域,此时如果不特殊处理的话,这部分DOM
会消失且会并作占位DOM
节点,此时选区则会出现问题无法映射到Model
,所以我们需要在用户选择的时候保留这部分DOM
节点,且在这里使用DOM
进行占位会方便一些,使用translate
适配起来相对就麻烦不少,因此此时的渲染模型如下所示。
placeholder
|
selection.anchor
|
placeholder
|
buffer
|
viewpoint
|
buffer
|
placeholder
|
selection.focus
|
placeholder
滚动调度
虚拟滚动的实现方式本质上就是在用户滚动视图时,根据视口的高度、滚动容器的滚动距离、行的高度等信息计算出当前视口内需要渲染的行,然后在视图层根据计算的状态来决定是否要渲染。而在浏览器中关于虚拟滚动常用的两个API
就是Scroll Event
与Intersection Observer API
,前者是通过监听滚动事件来计算视口的位置,后者是通过观察元素的可见性来判断元素位置,基于这两种API
我们可以分别实现虚拟滚动的不同方案。
首先我们来看Scroll Event
,这是最常见的滚动监听方式,通过监听滚动事件我们可以获取到滚动容器的滚动距离,然后通过计算视口的高度与滚动距离来计算出当前视口内需要渲染的行,然后在视图层根据计算的状态来决定是否要渲染。实际上基于Scroll
事件监听来单纯地实现虚拟滚动方案非常简单,当然同样的也更加容易出现性能问题,即使是标记为Passive Event
可能仍然会存在卡顿问题。其核心思路是通过监听滚动容器的滚动事件,当滚动事件触发时,我们需要根据滚动的位置来计算当前视口内的节点,然后根据节点的高度来计算实际需要渲染的节点,从而实现虚拟滚动。
在前边也提到了,针对于固定高度的虚拟滚动是比较容易实现的,然而我们的文档块是动态高度的,在块未实际渲染之前我们无法得到其真实高度。那么动态高度的虚拟滚动与固定高度的虚拟滚动区别有什么,首先是滚动容器的高度,我们在最开始不能够知道滚动容器实际有多高,而是在不断渲染的过程中才能知道实际高度;其次我们不能直接根据滚动的高度计算出当前需要渲染的节点,在固定高度时我们渲染的起始index
游标是直接根据滚动容器高度和列表所有节点总高度算出来的,而在动态高度的虚拟滚动中,我们无法获得总高度,同样的渲染节点的长度也是如此,我们无法得知本次渲染究竟需要渲染多少节点;再有我们不容易判断节点距离滚动容器顶部的高度,也就是之前我们提到的translateY
,我们需要使用这个高度来撑起滚动的区域,从而让我们能够实际做到滚动。
那么我们说的这些数值都是无法计算的嘛,显然不是这样的,在我们没有任何优化的情况下,这些数据都是可以强行遍历计算的。那么我们就来想办法计算一下上述的内容,根据我们前边聊的试想一下,对于文档来说无非就是基于块的虚拟滚动罢了,那么总高度我们可以直接通过所有的块的高度相加即可,在这里需要注意的是即使我们在未渲染的情况下无法得到其高度,但是我们却是可以根据数据结构推算其大概高度,在实际渲染时纠正其高度即可。记得之前提到的我们是直接使用占位块的方式来撑起滚动区域,那么此时我们就需要根据首尾游标来计算具体占位,具体的游标值我们后边再计算,现在我们先分别计算两个占位节点的高度值,并且将其渲染到占位位置。
const startPlaceHolderHeight = useMemo(() => {
return heightTable.slice(0, start).reduce((a, b) => a + b, 0);
}, [heightTable, start]);
const endPlaceHolderHeight = useMemo(() => {
return heightTable.slice(end, heightTable.length).reduce((a, b) => a + b, 0);
}, [end, heightTable]);
return (
<div
style={
{
height: 500, border: "1px solid #aaa", overflow: "auto", overflowAnchor: "none" }}
onScroll={
onScroll.run}
ref={
onUpdateInformation}
>
<div data-index={
`0-${
start}`} style={
{
height: startPlaceHolderHeight }}></div>
{
/* ... */}
<div data-index={
`${
end}-${
list.length}`} style={
{
height: endPlaceHolderHeight }}></div>
</div>
);
那么大概估算的总高度已经得到了,接下来处理首尾的游标位置也就是实际要渲染块的index
,对于首部游标我们直接根据滚动的高度来计算即可,遍历到首个节点的高度大于滚动高度时,我们就可以认为此时的游标就是我们需要渲染的首个节点,而对于尾部游标我们需要根据首部游标以及滚动容器的高度来计算,同样也是遍历到超出滚动容器高度的节点时,我们就可以认为此时的游标就是我们需要渲染的尾部节点。当然,在这游标的计算中别忘了我们的buffer
数据,这是尽量避免滚动时出现空白区域的关键。此外,在这里我们都是采用暴力的方式相加计算的,对于现代机器与浏览器来说,执行加法计算需要的性能消耗并不是很高,例如我们实现1
万次加法运算,实际上的时间消耗可能也只有不到1ms
。
const getStartIndex = (top: number) => {
const topStart = top - buffer.current;
let count = 0;
let index = 0;
while (count < topStart) {
count = count + heightTable[index];
index++;
}
return index;
};
const getEndIndex = (clientHeight: number, startIndex: number) => {
const topEnd = clientHeight + buffer.current;
let count = 0;
let index = startIndex;
while (count < topEnd) {
count = count + heightTable[index];
index++;
}
return index;
};
const onScroll = useThrottleFn(
() => {
if (!scroll) return void 0;
const scrollTop = scroll.scrollTop;
const clientHeight = scroll.clientHeight;
const startIndex = getStartIndex(scrollTop);
const endIndex = getEndIndex(clientHeight, startIndex);
// ...
},
);
在这里我们聊的是虚拟滚动最基本的原理,所以在这里的示例中基本没有什么优化,显而易见的是我们对于高度的遍历处理是比较低效的,即使进行万次加法计算的消耗并不大,但是在大型应用中还是应该尽量避免做如此大量的计算,特别是Scroll Event
实际上触发频率相当高的情况下。那么显而易见的一个优化方向是我们可以实现高度的缓存,简单来说就是对于已经计算过的高度我们可以缓存下来,这样在下次计算时就可以直接使用缓存的高度,而不需要再次遍历计算,而出现高度变化需要更新时,我们可以从当前节点到最新的缓存节点之间,重新计算缓存高度。而且这种方式相当于是递增的有序数组,还可以通过二分等方式解决查找的问题,这样就可以尽可能地避免大量的遍历计算。
height: 10 20 30 40 50 60 ...
cache: 10 30 60 100 150 210 ...
IntersectionObserver
现如今已经被标记为Baseline Widely Available
,在March 2019
之后发布的浏览器都已经实现了该API
现已并且非常成熟。接下来我们来看下Intersection Observer API
的虚拟滚动实现方式,不过在具体实现之前我们先来看看IntersectionObserver
具体的应用场景。根据名字我们可以看到Intersection
与Observer
两个单词,由此我们可以大概推断这个API
的主要目标是观测目标的交叉状态,而实际上IntersectionObserver
就是用以异步地观察目标元素与其祖先元素或顶级文档视口的交叉状态,这对判断元素是否出现在视口范围非常有用。
那么在这里我们需要关注一个问题,IntersectionObserver
对象的应用场景是观察目标元素与视口的交叉状态,而我们的虚拟滚动核心概念是不渲染非视口区域的元素。所以这里边实际上出现了一个偏差,在虚拟滚动中目标元素都不存在或者说并未渲染,那么此时是无法观察其状态的。所以为了配合IntersectionObserver
的概念,我们需要渲染实际的占位节点,例如10k
个列表的节点,我们首先就需要渲染10k
个占位节点,实际上这也是一件合理的事,除非我们最开始就注意到文档的性能问题,而实际上大部分都是后期优化文档性能,特别是在复杂的场景下。假设原本有1w
条数据,每条数据即使仅渲染3
个节点,那么此时我们如果仅渲染占位节点的情况下还能将原本页面30k
个节点优化到大概10k
个节点。这对于性能提升本身也是非常有意义的,且如果有需要的话还能继续进行完整的性能优化。
当然如果不使用占位节点的话实际上也是可以借助Intersection Observer
来实现虚拟滚动的,只不过这种情况下需要借助Scroll Event
来辅助实现强制刷新的一些操作,整体实现起来还是比较麻烦的。所以接下来我们还是来实现一下基于IntersectionObserver
的占位节点虚拟滚动方案,首先需要创建IntersectionObserver
,同样的因为我们的滚动容器可能并不一定是window
,所以我们需要在滚动容器上创建IntersectionObserver
,此外根据前边聊的我们会对视口区域做一层buffer
,用来提前加载视口外的元素,这样可以避免用户滚动时出现空白区域,这个buffer
的大小通常选择当前视口高度的一半。
useLayoutEffect(() => {
if (!scroll) return void 0;
// 视口阈值 取滚动容器高度的一半
const margin = scroll.clientHeight / 2;
const current = new IntersectionObserver(onIntersect, {
root: scroll,
rootMargin: `${
margin}px 0px`,
});
setObserver(current);
return () => {
current.disconnect();
}