基于锚点的差速滚动同步算法

2025-10-11

一、背景简介

在上一篇文章中介绍了富文本差异对比视图的实现,详情见如何从零实现一个富文本差异对比视图。实际使用中,在其中一个视图中滚动查看内容时,另一个视图没有同步滚动,我需要在另一个视图中手动滚动到对应位置,这样增加了操作成本,用户体验不是很好。本文就去探索一下这个问题的优化方案。

二、问题分析

直观的来看,这里期望的效果是一个视图滚动时,另一个视图也能跟随滚动。很容易想到的实现思路:

  1. 监听源视图的scroll事件。
  2. 在scroll事件中获取源视图的scrollTop值s1(即滚动距离)。
  3. 通过对s1应用特定规则进行计算,得到目标视图的目标滚动位置s2,调用目标视图的scrollTo方法,滚动到s2

方案一、等距离滚动同步

最简单的实现,就是让s2 = s1,即让目标视图的滚动距离与源视图滚动距离保持一致。效果如下:

优点

这个方案很容易实现,几乎不需要计算逻辑,利用DOM原生的属性和方法即可实现。

缺点

只是确保已滚动的距离相同,与内容没有相关性,当两个视图中内容差异较大时,滚动会出现比较大的偏差。

这个例子中,新视图在“第二章修改过的标题”前插入了一个比较长的段落,导致新视图中总的可滚动距离变大,即使两个视图的已滚动距离scrollTop相等,处于两个视图可视区域内的内容也有比较大的偏差,当想看“第二章修改过的标题”的差异时,在旧视图中“第二章标题”很有可能已经滚出可视区域,无法查看两个标题的差异。

方案二、等比例滚动同步

等距离算法最主要的问题是只考虑已滚动距离值,未考虑内容总高度,有可能出现滚动进度不一致的情况。进度本质上是一种百分比,我们可以将等距离优化为等比例,既允许已滚动距离不相等,但已滚动距离占当前视图总可滚动距离的比例相等。按这个思路可以目标视图的已滚动距离计算公式。

  • h1 - 源视图总可滚动距离(scrollHeight - clientHeight)
  • h2 - 目标视图总可滚动距离(scrollHeight - clientHeight)
  • s1 - 源视图已滚动距离(scrollTop)
  • s2 - 目标视图已滚动距离

效果如下图:

优点

  1. 在内容总高度有比较大的差异时,滚动偏差有一定的修正作用。
  2. 两个视图中内容滚动的进度相同,即可以保证同时滚动到底和同时滚动到顶。

缺点

  1. 虽然通过百分比修正了总高度差异带来的滚动偏差,仍未与可视区域的内容产生相关性。
  2. 内容如果总高度相近,但内容差异过大,还是会出现方案一中的情况。

通过增加和删除高度相似的内容,让总高度保持相近,虽然滚动进度相同,内容仍然出现了比较大的偏差。

效果如下:

方案三、基于锚点的滚动同步

前两个方案有个共同的问题,无法与实际的内容产生关联,当构造出一些特殊场景,就会在内容上会出现比较大的滚动偏差。要彻底解决这个问题,还是要回到需求的本质上,从一开始就着眼于实现滚动同步,但为什么要实现滚动同步?其实是为了在滚动过程中,方便观察修改差异,因此我们让修改的内容尽量处于同一位置,。

识别修改关系 —— 锚点

先看下面两段文字,由于内容完全不相关,我们无法在一整篇内容中识别出它们是修改关系。

但在下面两段内容中,添加了两个“标题一”,我们会一眼锚定到这两块相同内容,然后将紧跟它们的段落内容识别为修改关系。也就是说我们识别修改关系的内容,首先会锚定相同或相似的内容作为参考点,根据与参考点的位置关系,进而建立不同内容的修改关系。这些参考点就被称为“锚点”

保持相近位置

由于我们的大脑工作原理是优先识别锚点,如果能找出所有锚点,并在滚动过程中,保证同一对锚点能同时经过设定的基准位置,而具备修改关系的内容距锚点的相对位置是固定的,因此也就满足了具备修改关系的内容处于相同位置。

通过下面的图,观察下当存在多个锚点,每一对锚点都能同时经过基准点的前提下的滚动过程。

  1. 锚点A同时滚动经过基准线时,视图1的滚动距离是s1,视图2的滚动距离是s2,s1 < s2,因此这个过程视图2的滚动速度更快。
  2. 锚点B同时经过基准线时,视图1的滚动距离是s3,视图2的滚动距离是s4,s3 > s4,视图1的滚动速度更快。

可以总结为以下几个特点:

  1. 当经过同一对锚点间的区域时,两个视图滚动速度不同。
  2. 当经过同一对锚点间的区域时,单个视图的滚动速度相同。
  3. 当经过不同锚点间的区域时,单个视图的滚动速度变化。
  4. 两个视图同时经过锚点A,并且也能同时经过锚点B,也就是说在A与B的区间内滚动进度相同

第4点非常关键,滚动进度相同正是方案二等比例滚动同步的特点,不同之处方案二是对内容整体做等比例滚动,对于单个视图,保持相同的滚动速度;而这里有个限定条件——在锚点A与B的区间内,在区间内保持相同的滚动速度,区间发生变化时,滚动速度也会发生变化。进一步抽象就会发现方案二和方案三可以互相转化。

  1. 方案二是方案三的特例,即只在全部内容开始和结尾存在两个锚点,所有内容都属于这两个锚点的区间。
  2. 将方案三按锚点进行区间分割,再把每个区间单独拎出来看,就简化成了方案一。

如下图,用锚点A、B把整个内容分割成三个区间,在每个区间内滚动时,使用等比例滚动,这样就可以保证每个区间滚动进度相同,即同时开始和同时结束。

大致思路确定了,这个方案三个关键点:

  1. 如何确定锚点?
  2. 如何确定当前滚动位置属于哪个区间?
  3. 如何计算当前区间的等比例滚动值?

如何确定锚点?

前面提到过锚点的定义,就是相同或相似的内容块,这不正是在上一篇文章中的LCS节点吗?[传送门]所以只需要从differ模块中获取到顶层的LCS索引,就可以快速确定锚点的DOM节点。

如何确定当前滚动位置属于哪个区间?

如下图,确定区间需要先建立一个基准点,这个点就是同一对锚点会同时经过的点,可以取可视区域的任意位置,考虑到人的阅读习惯是从上到下,并且视觉关注点是在偏上的位置,建议取可视区域的起点位置(即scrollTop值)或者从可视区域往下偏移50~100像素,当然这个可以作为配置项由调用方决定。这里为了方便理解,我直接取scrollTop值,接下来只需要遍历所有锚点的垂直坐标位置,分别找到 >= scrollTop和 < scrollTop并且最接近的两个锚点。这两个锚点之间就是当前的滚动区间。

具体算法流程图

如何计算当前区间的等比例滚动值

前面提过,当把每一个区间单独拎出来,就可以简化为方案二,利用方案二中的公式

这里s1是源视图当前滚动位置距离上一个锚点的距离,h1是源视图锚点区间的高度,同理h2是目标视图对应锚点区间的高度,利用公式便可计算出目标视图应该滚动到的位置。

三个关键问题都得到解决,让我们看看方案三的最终效果。

可以看到,三个标题分别是三个锚点,在标题一和标题二的区间内,新版本内容较多,因此滚动速度更快;而标题二和三区间内,新版本内容较少,滚动速度更慢。在两个视图中,三个标题始终同时到达页面顶部。

反思与收获

  1. 在这个问题的解决过程中,随着对问题理解的深入,方案也经历了三次迭代,每一次都基于之前的方案进行优化。
  2. 当前两个方案都出现难以解决的相似卡点时,回过头重新思考问题的本质,问自己现在是为了实现什么?为什么要实现这个功能?它在解决什么问题?直到挖到问题的本质,再去思考更优的方案。(前两个方案都脱离了内容,只是在算法层面解决滚动同步的问题,但滚动同步本身也只是一种方案,我要解决的是如何在滚动过程中,能更方便的观察到修改的内容。)
  3. 把复杂问题拆解为更小的问题,并运用抽象思维,让它们尽量落在已知域内,简化问题复杂度。(比如方案三的三个关键问题,1和3都在已知域有现成解法)