一、背景简介
在上一篇文章中介绍了富文本差异对比视图的实现,详情见如何从零实现一个富文本差异对比视图。实际使用中,在其中一个视图中滚动查看内容时,另一个视图没有同步滚动,我需要在另一个视图中手动滚动到对应位置,这样增加了操作成本,用户体验不是很好。本文就去探索一下这个问题的优化方案。
二、问题分析
直观的来看,这里期望的效果是一个视图滚动时,另一个视图也能跟随滚动。很容易想到的实现思路:
- 监听源视图的scroll事件。
- 在scroll事件中获取源视图的scrollTop值
s1
(即滚动距离)。 - 通过对
s1
应用特定规则进行计算,得到目标视图的目标滚动位置s2
,调用目标视图的scrollTo方法,滚动到s2
。
方案一、等距离滚动同步
最简单的实现,就是让s2 = s1
,即让目标视图的滚动距离与源视图滚动距离保持一致。效果如下:
优点
这个方案很容易实现,几乎不需要计算逻辑,利用DOM原生的属性和方法即可实现。
缺点
只是确保已滚动的距离相同,与内容没有相关性,当两个视图中内容差异较大时,滚动会出现比较大的偏差。
这个例子中,新视图在“第二章修改过的标题”
前插入了一个比较长的段落,导致新视图中总的可滚动距离变大,即使两个视图的已滚动距离scrollTop
相等,处于两个视图可视区域内的内容也有比较大的偏差,当想看“第二章修改过的标题”
的差异时,在旧视图中“第二章标题”
很有可能已经滚出可视区域,无法查看两个标题的差异。
方案二、等比例滚动同步
等距离算法最主要的问题是只考虑已滚动距离值,未考虑内容总高度,有可能出现滚动进度不一致的情况。进度本质上是一种百分比,我们可以将等距离优化为等比例,既允许已滚动距离不相等,但已滚动距离占当前视图总可滚动距离的比例相等。按这个思路可以目标视图的已滚动距离计算公式。
- h1 - 源视图总可滚动距离(scrollHeight - clientHeight)
- h2 - 目标视图总可滚动距离(scrollHeight - clientHeight)
- s1 - 源视图已滚动距离(scrollTop)
- s2 - 目标视图已滚动距离
效果如下图:
优点
- 在内容总高度有比较大的差异时,滚动偏差有一定的修正作用。
- 两个视图中内容滚动的进度相同,即可以保证同时滚动到底和同时滚动到顶。
缺点
- 虽然通过百分比修正了总高度差异带来的滚动偏差,仍未与可视区域的内容产生相关性。
- 内容如果总高度相近,但内容差异过大,还是会出现方案一中的情况。
通过增加和删除高度相似的内容,让总高度保持相近,虽然滚动进度相同,内容仍然出现了比较大的偏差。
效果如下:
方案三、基于锚点的滚动同步
前两个方案有个共同的问题,无法与实际的内容产生关联,当构造出一些特殊场景,就会在内容上会出现比较大的滚动偏差。要彻底解决这个问题,还是要回到需求的本质上,从一开始就着眼于实现滚动同步,但为什么要实现滚动同步?其实是为了在滚动过程中,方便观察修改差异,因此我们让修改的内容尽量处于同一位置,。
识别修改关系 —— 锚点
先看下面两段文字,由于内容完全不相关,我们无法在一整篇内容中识别出它们是修改关系。
但在下面两段内容中,添加了两个“标题一”
,我们会一眼锚定到这两块相同内容,然后将紧跟它们的段落内容识别为修改关系。也就是说我们识别修改关系的内容,首先会锚定相同或相似的内容作为参考点,根据与参考点的位置关系,进而建立不同内容的修改关系。这些参考点就被称为“锚点”。
保持相近位置
由于我们的大脑工作原理是优先识别锚点,如果能找出所有锚点,并在滚动过程中,保证同一对锚点能同时经过设定的基准位置,而具备修改关系的内容距锚点的相对位置是固定的,因此也就满足了具备修改关系的内容处于相同位置。
通过下面的图,观察下当存在多个锚点,每一对锚点都能同时经过基准点的前提下的滚动过程。
- 锚点A同时滚动经过基准线时,视图1的滚动距离是s1,视图2的滚动距离是s2,s1 < s2,因此这个过程视图2的滚动速度更快。
- 锚点B同时经过基准线时,视图1的滚动距离是s3,视图2的滚动距离是s4,s3 > s4,视图1的滚动速度更快。
可以总结为以下几个特点:
- 当经过同一对锚点间的区域时,两个视图滚动速度不同。
- 当经过同一对锚点间的区域时,单个视图的滚动速度相同。
- 当经过不同锚点间的区域时,单个视图的滚动速度变化。
- 两个视图同时经过锚点A,并且也能同时经过锚点B,也就是说在A与B的区间内滚动进度相同。
第4点非常关键,滚动进度相同正是方案二等比例滚动同步的特点,不同之处方案二是对内容整体做等比例滚动,对于单个视图,保持相同的滚动速度;而这里有个限定条件——在锚点A与B的区间内,在区间内保持相同的滚动速度,区间发生变化时,滚动速度也会发生变化。进一步抽象就会发现方案二和方案三可以互相转化。
- 方案二是方案三的特例,即只在全部内容开始和结尾存在两个锚点,所有内容都属于这两个锚点的区间。
- 将方案三按锚点进行区间分割,再把每个区间单独拎出来看,就简化成了方案一。
如下图,用锚点A、B把整个内容分割成三个区间,在每个区间内滚动时,使用等比例滚动,这样就可以保证每个区间滚动进度相同,即同时开始和同时结束。
大致思路确定了,这个方案三个关键点:
- 如何确定锚点?
- 如何确定当前滚动位置属于哪个区间?
- 如何计算当前区间的等比例滚动值?
如何确定锚点?
前面提到过锚点的定义,就是相同或相似的内容块,这不正是在上一篇文章中的LCS节点吗?[传送门]所以只需要从differ模块中获取到顶层的LCS索引,就可以快速确定锚点的DOM节点。
如何确定当前滚动位置属于哪个区间?
如下图,确定区间需要先建立一个基准点,这个点就是同一对锚点会同时经过的点,可以取可视区域的任意位置,考虑到人的阅读习惯是从上到下,并且视觉关注点是在偏上的位置,建议取可视区域的起点位置(即scrollTop值)或者从可视区域往下偏移50~100像素,当然这个可以作为配置项由调用方决定。这里为了方便理解,我直接取scrollTop值,接下来只需要遍历所有锚点的垂直坐标位置,分别找到 >= scrollTop和 < scrollTop并且最接近的两个锚点。这两个锚点之间就是当前的滚动区间。
具体算法流程图
如何计算当前区间的等比例滚动值
前面提过,当把每一个区间单独拎出来,就可以简化为方案二,利用方案二中的公式
这里s1是源视图当前滚动位置距离上一个锚点的距离,h1是源视图锚点区间的高度,同理h2是目标视图对应锚点区间的高度,利用公式便可计算出目标视图应该滚动到的位置。
三个关键问题都得到解决,让我们看看方案三的最终效果。
可以看到,三个标题分别是三个锚点,在标题一和标题二的区间内,新版本内容较多,因此滚动速度更快;而标题二和三区间内,新版本内容较少,滚动速度更慢。在两个视图中,三个标题始终同时到达页面顶部。
反思与收获
- 在这个问题的解决过程中,随着对问题理解的深入,方案也经历了三次迭代,每一次都基于之前的方案进行优化。
- 当前两个方案都出现难以解决的相似卡点时,回过头重新思考问题的本质,问自己现在是为了实现什么?为什么要实现这个功能?它在解决什么问题?直到挖到问题的本质,再去思考更优的方案。(前两个方案都脱离了内容,只是在算法层面解决滚动同步的问题,但滚动同步本身也只是一种方案,我要解决的是如何在滚动过程中,能更方便的观察到修改的内容。)
- 把复杂问题拆解为更小的问题,并运用抽象思维,让它们尽量落在已知域内,简化问题复杂度。(比如方案三的三个关键问题,1和3都在已知域有现成解法)