原生 JS 解决多节点滚动同步联动

7,891 阅读9分钟

在现在的前端开发流程中,我们偶尔会遇见节点滚动同步的需求,所谓的滚动同步就是对于两个节点的滚动条位置进行同步,在滚动一个节点时同时,另一个节点也同时滚动到相应的位置。比较常见的类似于使用 markdown 进行文章编辑的博客网站中通常会有两边做实时编辑对比,从而需要同步滚动条。而比较不常见的是类似于一般不太可能会在 web 端实现的数据对比工具,但是偶尔你可能会碰到某个后台管理项目要你做一个表格数据对比工具。这个时候也要利用到节点滚动同步。但是这篇文章的标题我们加上了 联动 二字,这是什么意思呢?让我们接着往下看吧。

前情提要

首先,对于滚动同步的解决方案,已经有前辈做了很透彻的分析,这篇文章在于解决滚动同步更多细节上的问题,使同步事件更加的完美,大家如果只是为了单纯实现两个节点的滚动同步,可以参考一下作者 @清夜 在很早之前就发布的这篇非常详细的掘金文章 《原生JS控制多个滚动条同步跟随滚动》,那让我们长话短说,先总结一下我们的需求。

按照通常的代码思想来思考,想要实现滚动同步,例如同步 dom1dom2 两个节点的滚动,你可能会第一时间想出如下的代码:

    <div class="dom1">...</div>
    <div class="dom2">...</div>
    
    let dom1 = document.querySelector('.dom1'),
        dom2 = document.querySelector('.dom2')
    dom1.addEventListener('scroll', function () {
        let top = this.scrollTop,
            left = this.scrollLeft;
        dom2.scrollTo(left, top)
    })
    dom2.addEventListener('scroll', function () {
        let top = this.scrollTop,
            left = this.scrollLeft;
        dom1.scrollTo(left, top)
    })

那么在页面上执行代码后,你会发现鼠标拖动滚动条,确实能实现滚动同步,但当你使用滚轮的时候,反而有点滚不下去。这是因为 dom1 的滚动事件中带动了 dom2 的滚动,而 dom2 的滚动事件中又带动了 dom1 的滚动,两边会一直相互同步,相互阻碍。

常用解决方法

要解决这样的问题也很简单,最常见的方法使用 mouseenter 对节点进行标注,标注后的节点即为鼠标触发滚动的节点,我们只要让非鼠标触发的节点不执行同步就可以了,如以下方法

    let sign = void 0 // 公共标注
    const addScrollEvent = (node, eventFn) => {
        if (!eventFn) return
        node.addEventListener('mouseenter', e => sign = node.className) // 这里不同的节点用不同的 class 值
        let event = eventFn.bind(node)
        node.addEventListener('scroll', event)
    }
    
    addScrollEvent(dom1, function (e) {
        if(sign !== this.className) return // 如果滚动不是自己触发的,直接返回
        let top = this.scrollTop,
            left = this.scrollLeft;
        dom2.scrollTo(left, top)
    })
    
    addScrollEvent(dom2, function (e) {
        if(sign !== this.className) return
        let top = this.scrollTop,
            left = this.scrollLeft;
        dom1.scrollTo(left, top)
    })

这个方法为了 可复用性,我将它抽象成公用的方法,mousewheel 的方案很明显不能用来实现这样的功能,所以我们就不再赘述。

问题所在

下面我们就要说这篇文章的主要讨论点,也是这种方法所遗漏的地方,这种遗漏所造成的问题多出现于文章开头所说的 数据对比 需求,类似于表格数据对比查看,两个多功能表格进行对比查看,大家都使用过这样的表格,支持固定列固定表头,然后表格固定左列和固定右列和主表格滚动同步。

首先,我不怎么清楚一些 UI 框架的表格滚动同步是怎么实现的,他们有可能是跟 Element UI 一样,在有固定列的时候,固定列是一个完全复制主表格的所有行列节点,然后三个表格堆叠在一起,固定列的表格中非固定的列数据用 visibility: hidden; 隐藏。不得不说,这样的实现在 数据量过大 的情况下会异常卡顿。

那如果是按照我们以上的方案 (用鼠标进入事件触发进行标注) 实现主表格和固定列的滚动同步那就可能会出现这篇文章所解答的问题了,由于我们的滚动同步,是需要以 鼠标事件 为前提的。我们要同步两个带固定列的表格滚动条,可能会这么想——

首先,找到两个表格的滚动条主体,对他们进行绑定滚动事件,用上面的方法同步两个主体的滚动条位置,然后他们自己会同步自己的固定列,这样应该就完成了吧。如下图所示

那我们来简化这个流程,如图所示,假如有 6 个 div 代表着两个表格的左中右,我们用上面的办法同步每个表格各自左中右的滚动条之后,如下图所示

然后我们作为一个外面的使用者,再使用上述方式 同步两个表格的主表部分,也就是 DEMO 中间的滚动条主体

addScrollEvent(tableMain1, function () {
    if (sign !== this.className) return
    let left = this.scrollLeft,
        top = this.scrollTop
    tableMain2.scrollTo(left, top)
})

addScrollEvent(tableMain2, function () {
    if (sign !== this.className) return
    let left = this.scrollLeft,
        top = this.scrollTop
    tableMain1.scrollTo(left, top)
})

随之你就发现没有想象中的那么容易,问题来了。

可以看到,我们并不能把所有的节点通过这种方式进行同步。

为什么会这样?

原因其实很简单,因为我们这样的方式实现滚动同步需要一个共同的前提,用 mouseenter 或者 mouseover 事件来做一个标注,当另外一个表格这样实现了之后,表格三列的滚动条同步是需要以三列当中任意一列触发鼠标事件才会执行他们的同步代码。如下图所示:

解决方案

滚动同步事件的区间

既然我们知道了为什么会造成这种状况的原因,就意味着要实现滚动同步我们就不能使用 mouseenter 或者 mouseover 来做标注标注的方案,也就是说我们必须从 scroll 事件中开始然后从 scroll 事件结束,同时又不能让他们互相触发同步,互相阻碍滚动。于是我们可以这么想,我们的滚动事件同步,总是要通过一个开始滚动的节点,去同步其他的节点,这和之前 标注法 的思想一致,标注法 只是鼠标事件去创建一个变量,代表滚动的起源,然后其他节点的滚动事件中只要滚动起源不是自己就不执行同步其他节点的滚动。

所以同样的,我们需要抽象出这么一个方法,可以传入需要同步的节点,然后在方法中设定一个自由变量:

    const syncScroller = function () {
        let nodes = Array.prototype.filter.call(arguments, item => item instanceof HTMLElement)
        if (!nodes.length || nodes.length === 1) return
        let sign; // 用于标注
        nodes.forEach((ele, index) => {
            ele.addEventListener('scroll', function () { // 给每一个节点绑定 scroll 事件
            
            });
        });
    })
    // usage
    syncScroller(node1, node2, node3)

那么这个标注是什么比较好呢?还可以跟前面代码一样使用 className 吗?当然不适合,面对这样的问题我们其实应该问的不是滚动的起源是什么,因为触发滚动事件的节点就是滚动的起源,我们反而应该问的是 什么时候滚动的同步才会结束?

这其实很像一个买卖的问题,滚动的起源是负责生产的卖方,而需要同步的节点是买方。 作为卖方,我们最大的收益需要什么条件? 很简单,买方要多少,我们就生产多少然后卖多少。

所以在这个问题中,我们就又转化为了一个买方需要多少的问题,所以这个标注其实就是需要滚动同步的数量,而问题的解决方法在知道这个标注之后也十分了然——滚动的起源生产一定的数量并带动所有节点滚动,在每个节点滚动后这个数量减去一,直到零则结束。

所以这个方法就变成了这样:

    const syncScroller = function () {
        let nodes = Array.prototype.filter.call(arguments, item => item instanceof HTMLElement)
        let max = nodes.length
        if (!max || max === 1) return
        let sign = 0; // 用于标注
        nodes.forEach((ele, index) => {
            ele.addEventListener('scroll', function () { // 给每一个节点绑定 scroll 事件
                if (!sign) { // 标注为 0 时 表示滚动起源
                    sign = max - 1;
                    let top = this.scrollTop
                    let left = this.scrollLeft
                    for (node of nodes) { // 同步所有除自己以外节点
                        if (node == this) continue;
                        node.scrollTo(left, top);
                    }
                } else
                -- sign; // 其他节点滚动时 标注减一
            });
        });
    })
    // usage
    syncScroller(node1, node2, node3)

最终结果

让我们来看一下结果:

    // 在两个表格中用这种方法同步左中右
    syncScroller(tableFixedLeft, tableMain, tableFixedRight)
    
    let tableMain1, tableMain2 // 获取两个主表格的滚动主体节点
    
    // 在外部依旧使用 标注法 同步两个主表格
    addScrollEvent(tableMain1, function () {
        if (sign !== this.className) return
        let left = this.scrollLeft,
            top = this.scrollTop
        tableMain2.scrollTo(left, top)
    })
    
    addScrollEvent(tableMain2, function () {
        if (sign !== this.className) return
        let left = this.scrollLeft,
            top = this.scrollTop
        tableMain1.scrollTo(left, top)
    })

如下图所示:

非常的流畅完美!可以感受到这个方法在写这种需要滚动同步的组件当中非常实用,而且最重要的一点是,我们最后这个例子也证明了我们在组件使用这个方法相当于把三个节点绑在一起,即使外界从当中单拎一个节点出来也没有任何影响!

框架中使用

在框架中使用前,为了能够卸载事件,我们需要先改造一下代码

function syncScroller () {
        let nodes = Array.prototype.filter.call(arguments, item => item instanceof HTMLElement)
        let max = nodes.length
        if (!max || max === 1) return
        let sign = 0; // 用于标注
        
        function event () {
            if (!sign) { // 标注为 0 时 表示滚动起源
                    sign = max - 1;
                    let top = this.scrollTop
                    let left = this.scrollLeft
                    for (node of nodes) { // 同步所有除自己以外节点
                        if (node == this) continue;
                        node.scrollTo(left, top);
                    }
                } else
                -- sign; // 其他节点滚动时 标注减一
        }
        
        nodes.forEach((ele, index) => {
            ele.addEventListener('scroll', event);
        });
        
        return () => {
            nodes.forEach((ele, index) => {
                ele.removeEventListener('scroll', event);
            });
        }
    })

React hooks

function syncScroller() {...}

function useSyncScrollerEffect (...args) {
    const targets = args.map(item => item.current ?? item)
    useEffect(() => syncScroller(...targets), args ?? [])
}

function App () {
    
    const tableA = useRef()
    const tableB = useRef()
    
    useSyncScrollerEffect(tableA, tableB)
    
    return (
        <div>
            <table ref={tableA} />
            <table ref={tableB} />
        </div>
    )
}

Vue 2.x

function syncScroller() {...}
export default {
    data() {
        return {
            event: null,
        }
    }
    mounted() {
       this.event = syncScroller(this.$refs.domA, this.$refs.domB)
    }
    destroyed() {
        this.event()
    }
}

Vue 3.x

function syncScroller() {...}
function useSyncScroller (...args) {
    const targets = args.map(item => unRef(item))
    let event = () => {}
    onMounted(() => {
        event = syncScroller(...targets)
    })
    onUnmounted(event) 
}

总结

为了解决表格同步这个功能我真的是想破脑袋了,作者我翻遍了全内外网的滚动同步解决方案,最后还是自己来动手解决了。

这时候想必大家都知道标题中滚动同步的 联动 二字是指什么意思了,但是这个方法我在目前使用还未遇到问题,如果你使用我的方法遇到了问题,可以在文章评论下方提出,我看到后会及时回复你的哟(如果我解决得了的话)。

这就是本次文章要展示的所有内容了~ 觉得质量不错的话请麻烦点个赞叭~

加油!奥里给!