初探富文本之文档虚拟滚动

虚拟滚动是一种优化长列表性能的技术,其通过按需渲染列表项来提高浏览器运行效率。具体来说,虚拟滚动只渲染用户浏览器视口部分的文档数据,而不是整个文档结构,其核心实现根据可见区域高度和容器的滚动位置计算出需要渲染的列表项,同时不渲染额外的视图内容。虚拟滚动的优势在于可以大大减少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的情况下,不特别关注就可能会存在性能劣化问题。

那么在研究了虚拟滚动的优势之后,我们就可以开始研究虚拟滚动的实现了,在进入到富文本编辑器的块级虚拟滚动之前,我们可以先来研究一下虚拟滚动都是怎么做的。那么在这里我们以ArcoDesignList组件为例来研究一下通用的虚拟滚动实现。在Arco给予的示例中我们可以看到其传递了height属性,此时如果我们将这个属性删除的话虚拟列表是无法正常启动的,那么实际上Arco就是通过列表元素的数量与每个元素的高度,从而计算出了整个容器的高度,这里要注意滚动容器实际上应该是虚拟列表的容器外的元素,而对于视口内的区域则可以通过transform: translateY(Npx)来做实际偏移,当我们滚动的时候,我们需要通过滚动条的实际滚动距离以及滚动容器的高度,配合我们配置的元素实际高度,就可以计算出来当前视口实际需要渲染的节点,而其他的节点并不实际渲染,从而实现虚拟滚动。当然实际上关于Arco虚拟列表的配置还有很多,在这里就不完整展开了。

<List
  {/* ... */}
  virtualListProps={{
    height: 560,
  }}
  {/* ... */}
/>

通过简单分析Arco的通用列表虚拟滚动,我们可以发现实现虚拟滚动似乎并没有那么难,然而在我们的在线文档场景中,实现虚拟滚动可能并不是简单的事情。此处我们先来设一下在文档中图片渲染的实现,通常在上传图片的时候,我们会记录图片的大小也就是宽高信息,在实际渲染的时候会通过容器最大宽高以及object-fit: contain;来保证图片比例,当渲染时即使图片未实际加载完成,但是其高度占位是已经固定的。然而回到我们的文档结构中,我们的块高度是不固定的,特别是文本块的高度,在不同的字体、浏览器宽度等情况下表现是不同的,我们无法在其渲染之前得到其高度,这就导致了我们无法像图片一样提前计算出其占位高度,从而对于文档块结构的虚拟滚动就必须要解决块高度不固定的问题,由此我们需要实现动态高度的虚拟滚动调度策略来处理这个场景。而实际上如果仅仅是动态高度的虚拟滚动也并不是特别困难,社区已经有大量的实现方案,但是我们的文档编辑器是有很多复杂的模块在内的,例如选区模块、评论功能、锚点跳转等等,要兼容这些模块便是在文档本体虚拟滚动之外需要关注的功能实现。

模块设计

实际上富文本编辑器的具体实现有很多种方式,基于DOMCanvas绘制富文本的区别我们就不聊了,在这里我们还是关注于基于DOM的富文本编辑器上,例如Quill是完全自行实现的视图DOM绘制,而Slate是借助于React实现的视图层,这两者对于视图层的实现方式有很大的不同,在本文中是偏向于Slate的实现方式,也就是借助于React来构建块级别的虚拟滚动,当然实际上如果能够完全控制视图层的话,对于性能可优化的空间会更大,例如可以更方便地调度闲时渲染配合缓存等策略,从而更好地优化快速滚动时的体验。实际上无论是哪种方式,对于本文要讲的核心内容差距并没有那么大,只要我们能够保证富文本引擎本身控制的选区模块、高度计算模块、生命周期模块等正确调度,以及能够控制实际渲染行为,无论是哪种编辑器引擎都是可以应用虚拟滚动方案的。

渲染模型

首先我们来构思一下整个文档的渲染模型,无论是基于块模型的编辑器还是基于段落描述的编辑器都脱离不了行的概念,因为我们描述内容的时候通常都是由行来组成的一篇文档的,所以我们的文档渲染也都是以行为基准来描述的。当然这里的行只是一个比较抽象的概念,这个行结构内嵌套的可能是个块结构的表达例如代码块、表格等等,而无论是如何嵌套块,其最外层总会是需要包裹行结构的表达,即使是纯Blocks的文档模型,我们也总能够找到外层的块容器DOM结构,所以我们在这里需要明确定义行的概念。

实际上在此处我们所关注的行更倾向于主文档直属的行描述,而如果在主文档的某个行中嵌套了代码块结构,这个代码块的整个块结构是我们要关注的,而对于这个代码块结构的内部我们先不做太多关注,当然这是可以进一步优化的方向,特别是对于超大代码块的场景是有必要的,但是我们在这里先不关注这部分结构优化。此外,对于Canvas绘制的文档或者是类似于分页表达的文档同样不在我们的关注范围内,只要是能够通过分页表达的文章,我们直接通过页的按需渲染即可,当然如果有需要的话同样也可以进行段落级别的按需渲染,这同样也可以算作是进一步的优化空间。

那么我们可以很轻松地推断出我们文档最终要渲染的结构,首先是占位区域placeholder,这部分内容是不在视口的区域,所以会以占位的方式存在;紧接着是buffer,这部分是提前渲染的内容,即虽然此区域不在视口区域,但是为了用户在滚动时尽量避免出现短暂白屏的现象,由此提前加载部分视图内容,通常这部分值可以取得视口高度的一半大小;接下来是viewport部分,这部分是真实在视口区域要渲染的内容;而在视口区域下我们同样需要bufferplaceholder来作为预加载与占位区域。

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 EventIntersection 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具体的应用场景。根据名字我们可以看到IntersectionObserver两个单词,由此我们可以大概推断这个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();
  };
}, [onIntersect, scroll]);

接下来我们需要对占位节点的状态进行管理,因为我们此时有实际占位,所以就不再需要预估整个容器的高度,而且只需要实际滚动到相关位置将节点渲染即可。我们为节点设置三个状态,loading状态即占位状态,此时节点只渲染空的占位节点也可以渲染一个loading标识,此时我们还不知道这个节点的实际高度;viewport状态即为节点真实渲染状态,也就是说节点在逻辑视口内,此时我们可以记录节点的真实高度;placeholder状态为渲染后的占位状态,相当于节点从在视口内滚动到了视口外,此时节点的高度已经被记录,我们可以将节点的高度设置为真实高度。

loading -> viewport <-> placeholder
type NodeState = {
  mode: "loading" | "placeholder" | "viewport";
  height: number;
};

public changeStatus = (mode: NodeState["mode"], height: number): void => {
  this.setState({ mode, height: height || this.state.height });
};

render() {
  return (
    <div ref={this.ref} data-state={this.state.mode}>
      {this.state.mode === "loading" && (
        <div style={{ height: this.state.height }}>loading...</div>
      )}
      {this.state.mode === "placeholder" && <div style={{ height: this.state.height }}></div>}
      {this.state.mode === "viewport" && this.props.content}
    </div>
  );
}

当然我们的Observer的观察同样需要配置,这里需要注意的是IntersectionObserver的回调函数只会携带target节点信息,我们需要通过节点信息找到我们实际的Node来管理节点状态,所以此处我们借助WeakMap来建立元素到节点的关系,从而方便我们处理。

export const ELEMENT_TO_NODE = new WeakMap<Element, Node>();

componentDidMount(): void {
  const el = this.ref.current;
  if (!el) return void 0;
  ELEMENT_TO_NODE.set(el, this);
  this.observer.observe(el);
}

componentWillUnmount(): void {
  const el = this.ref.current;
  if (!el) return void 0;
  this.observer.unobserve(el);
}

最后就是实际滚动调度了,当节点出现在视口时我们需要根据ELEMENT_TO_NODE获取节点信息,然后根据当前视口信息来设置状态,如果当前节点是进入视口的状态我们就将节点状态设置为viewport,如果此时是出视口的状态则需要二次判断当前状态,如果不是初始的loading状态则可以直接将高度与placeholder设置到节点状态上,此时节点的高度就是实际高度。

const onIntersect = useMemoizedFn((entries: IntersectionObserverEntry[]) => {
  entries.forEach(entry => {
    const node = ELEMENT_TO_NODE.get(entry.target);
    if (!node) {
      console.warn("Node Not Found", entry.target);
      return void 0;
    }
    const rect = entry.boundingClientRect;
    if (entry.isIntersecting || entry.intersectionRatio > 0) {
      // 进入视口
      node.changeStatus("viewport", rect.height);
    } else {
      // 脱离视口
      if (node.state.mode !== "loading") {
        node.changeStatus("placeholder", rect.height);
      }
    }
  });
});

实际上在本文中继续聊到的性能优化方式都是基于Intersection Observer API实现的的,在文档中每个块可能会存在上百个节点,特别是在表格这种复杂的表达中,而且主文档下直属的块或者说行数量通常不会很多,所以这对于节点数量的优化是非常可观的。在之前我在知乎上看到了一个问题,为什么Python内置的Sort比自己写的快速排序快100倍,以至于我每次看到Intersection Observer API都会想到这个问题,实际上这其中有个很大的原因是Python标准库是用C/C++实现的,其执行效率本身就比Python这种解释型脚本语言要高得多,而Intersection Observer API也是同样的问题,其是浏览器底层用C/C++实现的,执行效率比我们使用JS调度滚动要高不少,不过也许在JIT编译的加持下可能差距没那么大。

状态管理

在我们的文档编辑器中,虚拟滚动不仅仅是简单的滚动渲染,还需要考虑到各种状态的管理。通常我们的编辑器中是已经存在块管理器的,也就是基于各种changes来管理整个Block Tree的状态,实际上也就是对于树结构的增删改查,例如当触发的opinsert { parentId: xxx, id: yyy }时我们就需要在xxx这个节点下加入新的yyy节点。实际上在这里的的树结构管理还是比较看具体业务实现的,如果编辑器为了undo/redo的方便而不实际在树中删除某个块,仅仅是标记为已/未删除的状态,那么这个块管理器的状态管理就变成了只增不删,所以在这里基于Block的管理器还是需要看具体编辑器引擎的实现。

那么在这里我们需要关注的是在这个Block Engine上的拓展,我们需要为其增加虚拟滚动的状态,也就是为其拓展出新的状态。当然如果仅仅是加新的状态的话可能就只是个简单的问题,在我们还需要关注块结构嵌套的问题,为我们后边的场景推演作下准备。在前边提到过,我们当前关注的是主文档直属的块管理,那么对于嵌套的结构来说,当直属块处于占位状态时,我们需要将其内部所有嵌套的块都设置为占位状态。这本身会是个递归的检查过程,且本身可能会存在大量调用,所以我们需要为其做一层缓存来减少重复计算。

在这里我们的思路是在每个节点都设置缓存,这个缓存存储了所有的子树节点的引用,是比较典型的空间换时间,当然因为存储的是引用所以空间消耗也不大。这样带来的优势是,例如用户一直在修改某个块子节点的结构,在每个节点进行缓存仅会重新计算该节点的内容,而其他子节点则会直接取缓存内容,不需要重新计算。在这里需要注意的是,当对当前节点进行append或者remove子节点时,需要将该节点以及该节点所有父层节点链路上的所有缓存清理掉,在下次调用时按需重新计算。实际上因为我们整个编辑器都是基于changes来调度的,所以做到细粒度的结构管理并不是非常困难的事。

public getFlatNode() {
  if (this.flatNodes) return this.flatNodes;
  const nodes: Node[] = [];
  this.children.forEach(node => {
    nodes.push(node);
    nodes.push(...node.getFlatNode());
  });
  this.flatNodes = nodes;
  return nodes;
}

public clearFlatNode() {
  this.flatNodes = null;
}

public clearFlatNodeOnLink() {
  this.clearFlatNode();
  let node: Node | null = this.parent;
  while (node) {
    node.clearFlatNode();
    node = node.parent;
  }
}

那么我们现在已经有了完整的块管理器,接下来我们需要思考如何调度控制渲染这个行为,如果我们的编辑器引擎是自研的视图层,那么可控性肯定是非常高的,无论是控制渲染行为还是实现渲染缓存都不是什么困难的事情,但是前边我们也提到了在本身是更倾向于用React作为视图层来实现调度,所以在这里我们需要更通用的管理方案。实际上用React作为视图层的优势是可以借助生态实现比较丰富的自定义视图渲染,但是问题就是比较难以控制,在这里不光指的是渲染的调度行为,还有Model <-> View映射与ContentEditable原地复用带来的一些问题,不过这些不是本文要聊的重点,我们先来聊下比较通用的渲染控制方式。

首先我们来设想一下在React中应该如何控制DOM节点的渲染,很明显我们可以通过State来管理渲染状态,或者是通过ReactDOM.render/unmountComponentAtNode来控制渲染渲染,至于通过Ref来直接操作DOM这种方式会比较难以控制,可能并不是比较好的管理方式。我们先来看一下ReactDOM.render/unmountComponentAtNode,这个APIReact18被标记为deprecated了,后边还有可能会变化,但是这不是主要问题,最主要的是使用render会导致无法直接共享Context,也就是其会脱离原本的React Tree,必须要重新将Context并入才可以,这样的改造成本显然是不合适的。

因此最终我们还是通过State来控制渲染状态,那么此时我们还需要文档全局的管理器来控制所有块节点的状态,那么在React中很明显我们可以通过Context来完成这件事,通过全局的状态变化来影响各个ReactNode的状态。但是这样实际上将控制权交给了各个子节点来管理自身的状态,我们可能是希望拥有一个全局的控制器来管理所有的块。那么为了实现这一点,我们就实现LayoutModule模块来管理所有节点,而对于节点本身,我们需要为其包裹一层HOC,且为了方便我们选择类组件来完成这件事,由此我们便可以通过LayoutModule模块来管理所有块结构实例的状态。

class LayoutModule{
  private instances: Map<string, HOC> = new Map();
  // ...
}

class HOC extends React.PureComponent<Props> {
  public readonly id: string;
  public readonly layout: LayoutModule;
  // ...
  constructor(props: Props) {
    // ...
    this.layout.add(this);
  }
  componentWillUnmount(): void {
    this.layout.remove(this);
    // ...
  }
  // ...
}

使用类组件的话,整个组件实例化之后就是对象,可以比较方便地写函数调用以及状态控制,当然这些实现通过函数组件也是可以做到的,只是用类组件会更方便些。那么接下来我们就可以通过类方法控制其状态,此外我们还需要通过ref来获得当前组件需要观察的节点。如果使用ReactDOM.findDOMNode(this)是可以在类组件中获得DOM的引用的,但是同样也被标记为deprecated了,所以还是不建议使用,所以在这里我们还是通过包裹一层DOM并且观察这层DOM来实现虚拟滚动。此外,要注意到实际上我们的DOM渲染是由React控制的,对于我们的应用来说是不可控的,所以我们还需要记录prevRef来观测到DOM引用发生变化时,将IntersectionObserver的观察对象进行更新。

type NodeState = {
  mode: "loading" | "placeholder" | "viewport";
  height: number;
};

class HOC extends React.PureComponent<Props> {
  public prevRef: HTMLDivElement | null;
  public ref: React.RefObject<HTMLDivElement>;
  // ...
  componentDidUpdate(prevProps: Props, prevState: State): void {
    if (this.prevProps !== this.ref.current) {
      this.layout.updateObserveDOM(this.prevProps, this.ref.current);
      this.prevProps = this.ref.current;
    }
  }
  public changeStatus = (mode: NodeState["mode"], height: number): void => {
    this.setState({ mode, height: height || this.state.height });
  };
  // ...
  render() {
    return (
      <div ref={this.ref} data-state={this.state.mode}>
        {/* ... */}
      </div>
    );
  }
}

选区状态

在选区模块中,我们需要保证视图的状态能够正确映射到Model上,由于在虚拟滚动的过程中DOM可能并不会真正渲染到页面上,而浏览器的选区表达则是需要anchorNode节点与focusNode节点共同确定的,所以我们就需要保证在用户选中的过程中这两个节点是正常表现在DOM树结构中。实现这部分能力实际上并不复杂,只要我们理解浏览器的选区模型,并且由此保证anchorNode节点与focusNode节点是正常渲染的即可,通过保证节点正确渲染则我们就不需要在虚拟滚动的场景下去重新设计选区模型,据此我们来需要推演一些场景。

  • 视口内选择: 当用户在视口内选择相关块的时候,我们可以认为这部分选区在有无虚拟滚动的情况下都是正常处理的,不需要额外推演场景,保持原本的View Model映射逻辑即可。
  • 选区滚动到视口外: 当用户选择内容时正常在视口中选择,此时选区是正常选择,但是后来用户将视口区域进行滚动,导致选区部分滚动到了视口外,此时我们需要保留选区状态,否则当用户滚动回来时会导致选区丢失。那么在这种情况下我们就需要保证选区的anchorNode节点与focusNode节点正确渲染,如果粒度粗则保证其所在的块是正常渲染即可。
  • 拖拽选择长选区: 当用户进行MouseDownanchorNode在视口内,此时用户通过拖拽操作导致页面滚动,从而将anchorNode拖拽到视口外部。同样的,此时我们需要保证anchorNode所在的块/节点即使不在视口区域也需要正常渲染,否则会导致选区丢失。
  • 触发选区更新: 当因为某些操作导致选区中的内容更新时,例如通过编辑器的API操作了文档内容,此时将出现两种情况,如果更新的内容不是anchorNode节点或者focusNode节点,那么对于整体选区不会造成影响,否则我们需要在渲染完成后通过Model重新校正选区节点。
  • 全选操作: 对于全选操作我们可以认为是特殊的选区行为,我们需要保证文档的首尾的行/块节点完整渲染,所以在这里的流程是需要通过Model获得首尾节点的状态,然后强制将这两部分渲染出来,由此保证anchorNode节点与focusNode节点正确渲染出来,接下来再走正常的选区映射逻辑即可。

实际上,还记得我们的Intersection Observer API通常是需要占位节点来实现虚拟滚动的,那么既然占位节点本身都在这里,如果我们并不特别注意DOM节点的数量的话,是可以在占位的时候将Block的选区标识节点一并渲染出来的,这样可以解决一些问题,例如全选的操作就可以不需要特殊处理。如果我们将范围放的再宽泛一些的话,将文本块以及Void/Embed结构\u200B节点在占位的时候也一并渲染出来,只对于复杂块进行渲染调度,这种情况下我们甚至可以不需要关心选区的问题,此时需要标记的选区映射节点都已经渲染出来了,我们只需要关注复杂块虚拟滚动的调度即可。

视口锁定

视口锁定是比较重要的模块,对于虚拟滚动来说,如果我们每次打开的时候都是从最列表内容的开始浏览,那么通常是不需要进行视口锁定的。但是对于我们的文档系统来说这个问题就不一样了,让我们来设想一个场景,当用户A分享了一个带锚点的链接给用户B,用户B此时打开了超链接直接定位到了文档中的某个标题甚至是某个块内容区域,此时如果用户B进行向上滚动的操作就会出现问题。记得之前我们说的在我们实际渲染内容之前是无法得到块的实际高度的,那么当用户向上滚动的时候,由于此时我们的占位节点的高度和块的实际高度存在差值,此时用户向上滚动的时候就会存在视觉上跳跃的情况,而我们的视口锁定便是为了解决这个问题,顾名思义是将用户的视口锁定在当前滚动的位置。

在研究具体的虚拟滚动之前,我们先来了解一下overflow-anchor这个属性,实际上实现编辑器引擎的的困难之处有很大一部分就是在于各种浏览器的兼容,通过这个属性也可以看出来,即使是同为基于Webkit内核的ChromeSafari浏览器,Chrome就支持overflow-anchorSafari就不支持。回到overflow-anchor属性,这个属性就是为了解决上边提到的调整滚动位置以最大程度地减少内容移动,也就是我们上边说的视觉上跳跃的情况,这个属性在支持的浏览器中会默认启用。由于Safari浏览器不支持,并且在后边也会提到我们实际上是需要这个跳跃的差值的,所以在这里我们需要关闭默认的overflow-anchor行为,主动控制视口锁定的能力。当然由于实际上在锁定视口的时候不可避免地会出现获取DOMRect数据,则人工干预视口锁定会触发更多的reflow/repaint行为。

class LayoutModule{
  private scroll: HTMLElement | Window;
  // ...
  public initLayoutModule() {
    // ...
    const dom = this.scroll instanceof Window ? document.body : this.scroll;
    dom.style.overflowAnchor = "none";
  }
}

除了overflow-anchor之外,我们还需要关注History.scrollRestoration这个属性。我们可能会发现,当浏览到页面的某个位置的时候,此时我们点击了超链接跳转到了另一个页面,然后我们回退的时候返回了原本的页面地址,此时浏览器是能够记住我们之前浏览的滚动条位置的。那么在这里由于我们的虚拟滚动存在,我们不希望由浏览器控制这个跳转行为,因为其大概率是不准确的位置,现在滚动行为需要主动管理,所以我们需要关闭浏览器的这个行为。

class LayoutModule{
  // ...
  public initLayoutModule() {
    // ...
    if (history.scrollRestoration) {
      history.scrollRestoration = "manual";
    }
  }
}

那么我们还需要思考一下还有什么场景会影响到我们的视口锁定行为,很明显Resize的时候由于会导致容器宽度的变化,因此文本块的高度也会跟随发生变化,因此我们的视口锁定还需要在此处进行调整。在这里我们的调整策略也比较简单,设想一下我们需要进行视口锁定的状态无非就是loading -> viewport时才需要调整,因为其他的状态变化时其高度都是稳定的,因为我们的placeholder状态是取得真实高度的。但是在Resize的场景不同,即使是placeholder也会存在需要重新进行视口锁定,因为此时并不是要渲染的实际高度,因此我们的逻辑就是在Resize时将所有的placeholder 状态的节点都重新进行视口锁定标记。

class HOC extends React.PureComponent<Props> {
  public isNeedLockViewport = true;
  // ...
}

class LayoutModule {
  // ...
  private onResize = (event: EditorResizeEvent) => {
    const { prev, next } = event;
    if (prev.width === next.width) return void 0;
    for (const instance of Object.values(this.instances)) {
      if (instance.state.mode === "placeholder") {
        instance.isNeedLockViewport = true;
      }
    }
  };
}

接下来就是我们实际的视口锁定方法了,实际的思路还是比较简单的,当我们的组件发生渲染变更时,我们需要通过组件的状态来获取高度信息,然后根据这个高度数据来取的变化的差值,通过这个差值来调整滚动条的位置。在这里我们还需要取的滚动容器的信息,当观察的节点top值在滚动容器之上时,高度的变化就需要进行视口锁定。在调整滚动条的位置时,我们不能使用smooth动画而是需要明确的设置其值,以防止我们的视口锁定失效,并且避免多次调用时取值出现问题。此外这里需要注意的是,由于我们是实际取得了高度进行的计算,而使用margin可能会导致一系列的计算问题例如margin合并的问题,所以在这里我们的原则是在表达块时能用padding就用padding,尽量避免使用margin在块结构上来做间距调整。

class LayoutModule {
  public offsetTop: number = 0;
  public bufferHeight: number = 0;
  private scroll: HTMLElement | Window;
  // ...
  public updateLayoutInfo() {
    // ...
    const rect = this.scroll instanceof Element && this.scroll.getBoundingClientRect();
    this.offsetTop = rect ? rect.top : 0;
    const viewportHeight = rect ? rect.height : window.innerHeight;
    this.bufferHeight = Math.max(viewportHeight / 2, 300);
  }
  // ...
  public scrollDeltaY(deltaY: number) {
    const scroll = this.scroll;
    if (scroll instanceof Window){
      scroll.scrollTo({ top: scroll.scrollY + deltaY });
    } else {
      const top = scroll.scrollTop + deltaY;
      scroll.scrollTop = top;
    }
  }
  // ...
}

class HOC extends React.PureComponent<Props> {
  public isNeedLockViewport = true;
  public ref: React.RefObject<HTMLDivElement>;
  // ...
  componentDidUpdate(prevProps: Props, prevState: State): void {
    // ...
    if (this.isNeedLockViewport && this.state.mode === "viewport" && this.ref.current) {
      this.isNeedLockViewport = false;
      const rect = this.ref.current.getBoundingClientRect();
      if (rect.height !== prevState.height && rect.top <= this.layout.offsetTop) {
        const deltaY = rect.height - prevState.height;
        this.layout.scrollDeltaY(deltaY);
      }
    }
  }
  // ...
}

快速滚动

当用户进行快速滚动时,由于虚拟滚动的存在,则可能会出现短暂白屏的现象,为了尽可能避免这个问题,我们仍然需要一定的调度策略。我们之前在视图层上设置的buffer就能一定程度上解决这个问题,但是在快速滚动的场景下还是不太够。当然,实际上白屏的时间通常不会太长,而且在拥有占位节点的情况下交互体验上通常也是可以接受的,所以在这里的优化策略还是需要看具体的用户需求与反馈的,毕竟我们的虚拟滚动目标之一就是减少内存占用,进行快速滚动时通常时需要调度滚动方向上的更多块提前渲染,那么这样必定会导致内存占用的增加,因此我们还是需要在滚动白屏和内存占用中取得平衡。

先来想想我们的快速滚动策略,当用户进行一次比较大范围的滚动之后,很有可能会继续向滚动方向进行滚动,因此我们可以定制滚动策略,当突发地出现大量块渲染或者在一定时间切片内滚动距离大于N倍视口高度时,我们可以根据块渲染的顺序判断滚动顺序,然后在这个顺序的基础上进行提前渲染。提前渲染的范围与渲染调度的时间间隔同样需要进行调度,例如在两次调度快速渲染的不能超过100ms,快速渲染持续的时间可以设定为500ms,最大渲染范围定义为2000px或者取N倍视口长度等等,这个可以视业务需求而定。

此外,我们还可以通过闲时渲染策略调度来尽可能避免快速滚动的白屏现象,当用户停止滚动时,我们可以借助requestIdleCallback来进行闲时渲染,以及通过人工控制时间间隔来进行调度,也可以与快速滚动的调度策略类似,设定渲染时间间隔与渲染距离等等。如果视图层能够支持节点缓存的话,我们甚至可以将视图层优先缓存起来,而实际上并不将其渲染到DOM结构上,当用户滚动到相关位置时直接将其从内存中取出置于节点位置即可,此外即使视图层的缓存不支持,我们也可以尝试对节点的状态进行提前计算并缓存,以渲染时计算的卡顿现象。不过同样的这种方式会导致内存占用的增加,所以还是需要取得效率与占用空间的平衡。

placeholder | buffer | block 1 | block 2 | buffer | pre-render ... | placeholder

增量渲染

在前边我们大部分都是讨论块的渲染问题,除了选区模块可能会比较涉及编辑时的状态之外,其他的内容都更倾向于对于渲染状态的控制,那么在编辑的时候我们肯定是要有新的块插入的,那么这部分内容实际上也需要有管理机制,否则可能会造成一些预期外的问题。设想一个场景,当用户通过工具栏或者快捷输入的方式插入了代码块,如果在不接入虚拟滚动的情况下,此时的光标应该是直接置入代码块内部的,但是由于我们的虚拟滚动存在,首帧会置为占位符的DOM,之后才会正常加载块结构,那么此时由于ContentEditable块结构不存在,光标自然不能正确放置进去,这时通常会触发选区兜底策略,则此时就出现了预期外的问题。

因此我们在插入节点的时候需要对其进行控制,对于这个这个问题的解决方案非常简单,试想一下什么时候会有插入操作呢,必然是整个编辑器都加载完成之后了,那么插入的时候应该是什么位置呢,大概率也是在视口区域进行编辑的,所以我们的方案就是在编辑器初次渲染完成之后,将Layout模块标记为加载完成,此时再置入的HOC初始状态都认为是viewport即可。此外,很多时候我们还可能需要对HOC的顺序作index标记,在某处插入的标记我们通常就需要借助DOM来确定其index了。

class LayoutModule {
  public isEditorLoaded: boolean = false;
  // ...
  public initLayoutModule() {
    // ...
    this.editor.once("paint", () => {
      this.isEditorLoaded = true;
    });
  }
}

class HOC extends React.PureComponent<Props> {
  public index: number = 0;
  // ...
  constructor(props: Props) {
    // ...
    this.state = {
      mode: "loading"
      // ...
    }
    if (this.layout.isEditorLoaded) {
      this.state.mode = "viewport";
    }
  }
  // ...
}

实际上我们这里的模块都是编辑器引擎需要提供的能力,那么很多情况下我们都需要与外部主应用提供交互,例如评论、锚点、查找替换等等,都需要获取编辑器块的状态。举个例子,我们的划词评论能力是比较常见的文档应用场景,在右侧的评论面板通常需要取得我们划词文本的高度信息用以展示位置,而因为虚拟滚动的存在这个DOM节点可能并不存在,所以评论的实际模块也会变成虚拟化的,也就是说随着滚动渐进加载,因此我们需要有与外部应用交互的能力。实际上这部分能力还是比较简单的,我们只需要实现一个事件机制,当编辑器块状态发生改变的时候通知主应用。此外除了块状态的管理之外,视口锁定的高度值变化也是非常重要的,否则在评论面板中的定位会出现跳动问题。

class Event {
  public notifyAttachBlock = (changes: Nodes) => {
    if (!this.layout.isEditorLoaded) return void 0;
    const nodes = changes.filter(node => node.isActive());
    Promise.resolve().then(() => {
      this.emit("attach-block", nodes);
    });
  }

  public notifyDetachBlock = (changes: Nodes) => {
    if (!this.layout.isEditorLoaded) return void 0;
    const nodes = changes.filter(node => !node.isActive());
    Promise.resolve().then(() => {
      this.emit("detach-block", nodes);
    });
  }

  public notifyViewLock = (instance: HOC) => {
    this.emit("view-lock", instance);
  }
}

class HOC extends React.PureComponent<Props> {
  // ...
  componentDidUpdate(prevProps: Props, prevState: State): void {
    // ...
    if (prevState.mode !== "viewport" && this.state.mode === "viewport") {
      const changes = this.layout.blockManager.setBlockState(true);
      this.layout.event.notifyAttachBlock(changes);
    }
    if (prevState.mode !== "placeholder" && this.state.mode === "placeholder") {
      const changes = this.layout.blockManager.setBlockState(false);
      this.layout.event.notifyDetachBlock(changes);
    }
    if (this.isNeedLockViewport && this.state.mode === "viewport" && this.ref.current) {
      // ...
      this.layout.event.notifyViewLock(this);
    }
  }
  // ...
}

场景推演

在我们的文档编辑器中,很明显单独实现虚拟滚动是不够的 必须要为其做各种API兼容。实际上前边叙述的模块设计部分也可以属于场景推演的一部分,只不过前边的内容更倾向于编辑器内部的功能模块设计,而我们的当前的场景推演则是倾向于编辑器与主应用的场景与交互场景。

锚点跳转

锚点跳转是我们的文档系统的基本能力,特别是用户在分享链接的时候会用的比较多,甚至于某些用户希望分享任意的文本位置也都是可以做到的。那么类似于锚点跳转的能力在我们虚拟滚动的时候就可能会出现问题,试想一下当用户用户的hash值是在某个块中的,而显然在虚拟滚动的情况下这个块可能并不会实际渲染出来,因此无论是浏览器的默认策略或者是原本编辑器提供的能力都会失效。所以我们需要为锚点跳转单独适配场景,为类似需要定位到某个位置的场景独立控制模块出来。

那么我们可以明显地判断出来,在并入虚拟滚动之后,与先前的跳转有差别的地方就在于块结构可能还未被渲染出来,那么在这种情况下我们只需要在页面加载完成之后调度存在锚点的块立即渲染,之后再调度原来的跳转即可。那么既然存在加载时跳转的情况,当用户跳转到某个节点时,其上方的块结构可能正在从loading转移到viewport状态,那么这种情况下就需要我们在前文中描述的视口锁定能力了,以此来保证用户的视口不会在块状态发生变更的时候引起高度差异造成的视觉跳跃现象。

那么在这里我们来定义locateTo方法,在参数中我们需要明确需要搜索的Hash Entry,也就是在富文本数据结构中表达锚点的结构,因为我们最终还是需要通过数据来检索DOM节点的,在不传递blockId的情况下还需要根据Entry找到节点所属的Block。在options中我们需要定义buffer用来留作滚动的位置偏移,由于可能出现DOM节点已经存在的情况,所以我们传递domKey来尝试能否直接通过DOM跳转到相关位置,最后如果我们能确定blockId的话,则会直接预渲染相关节点,否则需要根据key value从数据中查找。

class Viewport {
  public async locateTo(
    key: string, 
    value: string, 
    options?: { buffer?: number; domKey?: string; blockId?: string }
  ) {
    const { buffer = 0, domKey = key, blockId } = options || {};
    const container = this.editor.getContainer();
    if (blockId) {
      await this.forceRenderBlock(blockId);
    }
    let dom: Element | null = null;
    if (domKey === "id"){
      dom = document.getElementById(value);
    } else {
      dom = container.querySelector(`[${domKey}="${value}"]`);
    }
    if (dom) {
      const rect = dom.getBoundingClientRect();
      const top = rect.top - buffer - this.layout.offsetTop;
      this.layout.scrollDeltaY(top);
      return void 0;
    }
    const entry = this.findEntry(key, value);
    if (entry) {
      await this.forceRenderBlock(entry.blockId);
      this.scrollToEntry(entry);
    }
  }
}

实际上通常我们都是跳转到标题位置的,甚至都不会跳转到某个嵌套块的标题,所以实际上在这种情况下我们甚至可以将Heading类型的块独立调度,也就是说其在HOC加载时即作为viewport状态而不是loading状态,这样的话也可以一定程度上避免锚点的调度复杂性。当然实际上我们独立的位置跳转控制能力还是必须要有的,除了锚点之外还有很多其他的模块可能用得到。

class HOC extends React.PureComponent<Props> {
  constructor(props: Props) {
    // ...
    if (this.props.block.type === "HEADING") {
      this.state.mode = "viewport";
    }
  }
}

查找替换

查找替换同样也是在线文档中比较常见的能力,通常是基于文档数据检索然后在文档中标记相关位置,并且可以跳转和替换的能力。由于查找替换中存在文档检索、虚拟图层等功能需求,所以在虚拟滚动的情况下对于我们的控制调度依赖更大。首先查找替换会存在跳转的问题,那么在跳转的时候也会跟上述的锚点跳转类似,我们需要在跳转的时候将相关块渲染出来,然后再进行跳转。之后查找替换还需要对接虚拟图层VirtualLayer的渲染能力,当实际渲染块的时候同样需要将图层一并渲染出来,也就是说我们的虚拟图层模块同样需要按需渲染。

那么接下来我们需要对其适配相关API控制能力,首先是位置跳转部分,在这里由于我们的目标是通过检索原本的数据结构得到的,所以我们不需要通过key value再度检索Entry,我们可以直接组装Entry数据,然后根据ModelView的映射找到与之对应的Text节点,之后借助range获取其位置信息,最后跳转到相关位置即可,当然这里的节点信息不一定是Text节点,也可以是Line节点等等,需要具体关注于编辑器引擎的实现。不过在这里需要注意的是我们需要提前保证Block的渲染状态,也就是在实际跳转之前需要调度forceRenderBlock去渲染Block

class Viewport {
  public scrollTo(top: number) {
    this.layout.scrollDeltaY(top - this.layout.offsetTop);
  }
  public getRawRect(entry: Entry) {
    const start = entry.index;
    const blockId = entry.blockId;
    const { node, offset } = this.editor.reflect.getTextNode(start, blockId);
    // ...
    const range = new Range();
    range.setStart(node, offset);
    range.setEnd(node, offset);
    const rect = range.getBoundingClientRect();
    return rect;
  }
  public async locateToEntry(entry: Entry, buffer = 0) {
    await this.forceRenderBlock(entry.blockId);
    const rect = this.getRawRect({ ...entry, len: 0 });
    rect && this.scrollTo(rect.top - buffer);
  }
}

紧接着我们需要关注查找替换的检索本身的位置跳转,通常查找替换都会存在上一处下一处的按钮,那么在这种情况下我们需要思考一个问题,因为我们的Block是可能存在不被渲染的情况的,那么此时我们不容易取得其高度信息,因此上一处下一处的调度可能是不准确的。举个例子,我们在文档的比较下方的位置有某个块结构,这个块结构之中嵌套了行和代码块,如果在检索的时候我们采用直接迭代所有状态块而不是递归地查找的话,那么就存在先跳转完成块内容之后再跳转到代码块的问题,所以我们在检索的时候需要对高度先进行预测。还记得我们之前聊到我们是有占位节点的,实际上通过占位节点作为预估的高度值便可以解决这个问题,当然这里还是需要先看查找替换的具体算法来决定,如果是递归查找的话理论上不会需要类似的兼容控制,本质上是要能够保证块渲染前后标记内容的顺序一致。

class Viewport {
  public getObservableTop(entry: Entry) {
    const blockId = entry.blockId;
    let state: State | null = this.editor.getState(blockId);
    let node: HTMLElement | null = null
    while (state) {
      if (state.node && state.node.parentNode){
        node = state.node;
        break;
      }
      state = state.parent;
    }
    if (!node) return -999999;
    const rect = node.getBoundingClientRect();
    return rect.top;
  }
}

接下来我们还需要关注在文档本体的虚拟图层渲染,也就是实际展示在文档中的标记。在前边我们提到了我们在Layout模块中置入了Event模块,那么接下来我们就需要借助Event模块来完成虚拟图层的渲染。实际上这部分逻辑还是比较简单的,我们只需要在attach-block的时刻将将存储好的虚拟图层节点渲染到块结构上,在detach-block的时刻将其移除即可。

class VirtualLayer {
  // ...
  private onAttachBlock = (nodes: Nodes) => {
    for (const node of nodes) {
      const blockId = node.id;
      this.computeBlockEntriesRect(blockId);
      this.renderBlock(blockId);
    }
  }
  private onDetachBlock = (nodes: Nodes) => {
    for (const node of nodes) {
      const blockId = node.id;
      this.removeBlock(blockId);
    }
  }
  // ...
}

划词评论

划词评论同样是在线文档产品中常见的能力,那么由于评论会存在各种跳转的功能,例如同样的上一处下一处、跳转到首个评论、文档打开时定位等等,所以我们也需要为其做适配。首先是评论的位置更新,设想一个场景,当我们打开文档时无论是锚点跳转还是文档的首屏评论定位等,都会导致文档直接滚动到相对应的位置,那么此时如果用户再向上滚动话,就会导致一个问题,由于视口锁定能力的存在,此时滚动条是不断调整的,而且块结构的高度也会发生改变,此时就必须要同等地调整评论位置,否则就会发生评论和划线偏移的现象。

同样的,我们的评论也有可能会出现块结构DOM不存在,从而导致无法正常获取其高度的问题,所以实际上我们的评论内容也是需要按需渲染的,也就是滚动到块结构的时候才正常展示评论内容。那么同样的我们只需要在虚拟滚动模块中注册评论模块的回调即可,我们可能会发现之前在实现虚拟滚动事件的时候,块的挂载与卸载都是异步通知的,而锁定视口的通知事件是同步的,因为视口锁定必须要立即执行,否则就会导致视觉上出现跳动的现象,此外评论卡片我们不能够为其设置动画,否则也可能导致视觉上的跳动,那么就需要额外的调度策略解决这个问题。

class CommentModule {
  // ...
  private onLockView = (instance: HOC) => {
    this.computeBlockEntriesRect(instance.id);
    this.renderComments(instance.props.block.id);
  }
  private onAttachBlock = (nodes: Nodes) => {
    for (const node of nodes) {
      const blockId = node.id;
      this.computeBlockEntriesRect(blockId);
      this.renderComments(blockId);
    }
  }
  private onDetachBlock = (nodes: Nodes) => {
    for (const node of nodes) {
      const blockId = node.id;
      this.removeComments(blockId);
    }
  }
  // ...
}

实际上我们前边的更新都可能会存在一个问题,设想一下当我们更新某个块的内容时,那么真的只会影响这个块的高度嘛,很明显不是这样的。当我们的某个块发生变化时,其很可能会影响当前块之后的所有块,因为我们的排版引擎就是由上至下的,某个块的高度变更大概率是要影响到其他块的。那么如果我们全量更新位置信息的话就可能会造成比较大的性能消耗,所以这里我们可以考虑HOC的影响范围由此来确定更新范围,甚至由于锁视口造成的高度变更值我们是明确的,因此每个位置高度我们都可以按需更新。对于当前块的评论我们需要全量更新,而对于当前块之后的块我们只需要更新其高度即可,我们这里的策略是通过HOCindex来确定影响范围的,所以我们需要在变更的维护HOCindex范围。

class CommentModule {
  // ...
  private onLockView = (instance: HOC, delta: number) => {
    this.computeBlockEntriesRect(instance.id);
    this.renderComments(instance.props.block.id);
    const effects = this.layout.instances.filter(it => it.index > instance.index);
    for (const effect of effects) {
      const comments = this.getComments(effect.block.id);
      comments.forEach(comment => {
        comment.top = comment.top + delta;
        comment.update();
      });
    }
  }
  // ...
}

实际上在前边我们提到过很多次我们不能通过smooth的平滑调度来处理滚动,因为我们需要明确的高度值以及视口锁定调度,那么我们同样可以思考一下这个问题,由于我们相当于完全接管了文档的滚动行为,那么明确的高度值我们只需要将其放置于变量中即可,那么视口锁定的调度的主要问题是我们不能明确地知道此时正在滚动,如果我们能够明确感知到正在滚动话就只需要在滚动结束之后再进行视口锁定的调度与块结构的渲染即可,在滚动的过程中不会调度相关的模块。

那么关于这个问题我有个实现思路,只是还没有具体实施,既然我们的滚动主要是为了解决上边两个问题,那么我们完全可以模拟这个滚动动画,也就是说对于固定的滚动delta值,我们根据计算模拟动画效果,类似于transition ease动画效果,通过Promise.all来管理所有的滚动进度,紧接着通过队列实现后续的调度效果,当需要取得当前状态时通过滚动模块决定取调度值还是scrollTop,当滚动完成之后再调度下一个任务。当然实际上我觉得这个方案可以作为后续的优化方向,即使是我们不调度动画效果,通过定位到相关位置实现目标闪烁的效果也是不错的。

Set Top 100 | [ 50, 25, 13, 7, 5 ] | Promise.all | Next Task | ...

性能考量

在我们兼容完成各类功能之后,必须要对我们的虚拟滚动方案进行性能考量,实际上我们在前期调研的时候就需要对性能进行初步测试,以确定实现此功能的ROI以及资源的投入。

性能指标

那么既然要进行性能考量,必然就需要明确我们的性能指标,我们的常用的性能测试指标通常有:

  • FP - First Paint: 即首次渲染的时间点,在性能统计指标中,从用户开始访问Web页面的时间点到FP的时间点这段时间可以被视为白屏时间。也就是说在用户访问Web网页的过程中,FP时间点之前用户看到的都是没有任何内容的白色屏幕,用户在这个阶段感知不到任何有效的工作在进行。
  • FCP - First Contentful Paint: 即首次有内容渲染的时间点,在性能统计指标中,从用户开始访问Web页面的时间点到FCP的时间点这段时间可以被视为无内容时间。也就是说在用户访问Web网页的过程中,FCP时间点之前,用户看到的都是没有任何实际内容的屏幕,注意是有像素渲染但无实际内容,用户在这个阶段获取不到任何有用的信息。
  • LCP - Largest Contentful Paint: 即最大内容绘制时间,是Core Web Vitals度量标准,用于度量视口中最大的内容元素何时可见,其可以用来确定页面的主要内容何时在屏幕上完成渲染。
  • FMP - First Meaningful Paint: 即首次绘制有意义内容的时间,当整体页面的布局和文字内容全部渲染完成后,即可认为是完成了首次有意义内容的绘制。
  • TTI - Time to Interactive: 即完全可交互时间,是一种非标准化的Web性能进度指标,定义为上一个LongTask完成时的时间点,紧随其后的是5秒钟的网络和主线程处于不活动状态。

那么由于此时我们想测试的目标是编辑器引擎,或者通俗点来说其实并不是主应用的性能指标,而是更倾向于对SDK的性能进行测试,那么我们的指标可能并不是那么通用的指标标准。此外,由于我们希望还是在线上场景下进行测试,而不是单纯基于SDK的开发版本测试,所以在这里我们选取了LCPTTI两个指标作为我们的测试标准。并且我们实际上不涉及网络状态,所以静态资源和缓存都可以启用,为了防止突发性的尖刺造成的影响,我们也可以多次测试取平均值。

  • LCP标准,在我们的编辑器引擎中通常会对初次渲染完成的进行emit,也就是在初次所有块渲染完成的那个时间点,可以认为是组件的componentDidMount时机。那么在这里我们的LCP就取这个时间点,同样也是在前边我们提到的Layout模块中的isEditorLoaded,此外实际上我们的起点也可以从编辑器实例化的时间节点开始计算,可以更加精准地排除主应用的时间消耗。那么这个方案只需要在编辑器中定义好事件触发,通过在HTML的时间戳相减即可。
  • TTI标准,由于实际上TTI是一种非标准化的Web性能进度指标,所以我们并不需要按照严格按照标准来定义这个行为,实际上我们只需要找到一个代理指标即可。在前边我们说到我们是在线上的真实场景中进行测试的,所以在系统中的功能都是存在的,所以在这里我们可以通过用户的交互行为来定义这个指标,在本次测试中选择的方案是当用户点击发布按钮,并且能够实际弹窗发布则认为是完全可交互。那么这个方案可以借助油猴脚本来完成,通过不断检查按钮的状态来自动模拟用户发布交互行为。
// HTML
var __MEASURE_START = Date.now(); // or `performance.now`

// Editor
window.__MEASURE_EDITOR_START = Date.now(); // or `performance.now`

// LCP
editor.once("paint", () => {
  const LCP = Date.now() - __MEASURE_START;
  console.log("LCP", LCP);
  const EDITOR_LCP = Date.now() - window.__MEASURE_EDITOR_START;
  console.log("EDITOR_LCP", EDITOR_LCP);
});

// TTI
// ==UserScript==
// @name         TTI
// @run-at      document-start
// ...
// ==/UserScript==
(function () {
  const task = () => {
    const el = document.querySelector(".xxx")?.parentElement;
    el?.click();
    const result = document.querySelector(".modal-xxx");
    if (result) {
      console.log("TTI", Date.now() - __MEASURE_START);
    } else {
      setTimeout(task, 100);
    }
  };
  setTimeout(task, 100);
})();

性能测试

在前期调研引入的初步性能测试中,引入虚拟滚动对性能的提升是巨大的。特别是对于很多API文档而言,大量的表格块结构会导致性能迅速劣化,表格中会嵌套大量的块结构,并且其本身也需要维护大量状态,所以实现虚拟列表实际上是非常有价值的。那么还记得前边我们最开始提到的用户反馈嘛,我们就需要在这个反馈的大文档上以上述的性能指标进行性能测试,在前边的性能数据基础上我们就可以进行对比。

  • 编辑器渲染: 2505ms -> 446ms,优化82.20%
  • LCP指标: 6896ms -> 3376ms,优化51.04%
  • TTI指标: 13343ms -> 3878ms,优化70.94%

那么如果仅对用户反馈提供的文档进行测试显然是不够的,我们还需要设计其他的测试方案来对文档进行测试,特别是固定测试文档或者是固定的测试方案,能够为以后的性能方案提供更多的数据参考。所以我们可以设计一种测试方案,那么既然我们的文档是由块结构组成的,那么很显然我们就可以生成测试块的方案来生成性能测试数据,那么此时我们便可以设计基于纯文本块、基本块、代码块的三种性能测试基准。

首先是基于纯文本的块方案,在这里我们生成1万字的纯文本文档,实际上我们的我们的文档一般也不会有特别多的字符,比如这篇文档就是3.7万字符左右,这已经算是超级大的文档了,文档绝大部分都是低于1万字符的。那么在生成文字的时候我还发现了个有趣的事情,通过选取岳阳楼记作为基础文本,随机挑选字组成基准测试文档,有趣的事情是即使是随机生成的字,也会别有一番文言文的感觉。实际上在这里对于纯文本的块我们采取的策略是全量渲染,并不会调度虚拟滚动,因为纯文本是很简单的块结构,所以由于附加了额外的模块,导致整个渲染时间会有所增加。

  • 编辑器渲染: 219ms -> 254ms,优化-13.78%
  • FCP指标: 2276ms -> 2546ms,优化-10.60%
  • TTI指标: 3270ms -> 3250ms,优化0.61%

接下来是基本块结构的测试基准,这里的基本块结构指的是简单的块,例如高亮块、代码块等单独的块结构,由于代码块的通用性且文档中可能会存在比较多的代码块结构,所以在这里选取代码块作为测试基准。在这里随机生成100个基本块结构,并且每个块结构中随机生成文本,文本随机标注加粗和斜体样式。

  • 编辑器渲染: 488ms -> 163ms,优化66.60%
  • FCP指标: 3388ms -> 2307ms,优化30.05%
  • TTI指标: 4562ms -> 3560ms,优化21.96%

最后是表格块结构的测试基准,表格结构由于其维护的状态比较多,且单个单元表格结构可能会存在大量的单元格,特别是很多文档中还会存在大表格的情况,所以表格结构对于编辑器引擎的性能消耗是最大的。在这里的表格基准是生成100个表格结构,每个表格中4个单元格,每个单元格中随机生成文本,文本随机标注加粗和斜体样式。

  • 编辑器渲染: 2739ms -> 355ms,优化87.04%
  • FCP指标: 5124ms -> 2555ms,优化50.14%
  • TTI指标: 20779ms -> 4354ms,优化79.05%

每日一题

https://github.com/WindrunnerMax/EveryDay

参考

https://developer.mozilla.org/zh-CN/docs/Web/CSS/overflow-anchor https://developer.mozilla.org/zh-CN/docs/Web/API/IntersectionObserver https://developer.mozilla.org/zh-CN/docs/Web/API/IntersectionObserverEntry https://developer.mozilla.org/zh-CN/docs/Web/API/History/scrollRestoration https://developer.mozilla.org/zh-CN/docs/Web/API/Element/getBoundingClientRect https://arco.design/react/components/list#%E6%97%A0%E9%99%90%E9%95%BF%E5%88%97%E8%A1%A8