CSS & JS Effect – Simulation Position Sticky (用 JavaScript 实现 position sticky)

前言

在 CSS – Position 我有提到过, 原生的 sticky 有一些 limitation. 不是每次都闪的掉.

这篇主要是通过 JS 来模拟它, 突破那些限制.

Google Ads 的 table header sticky 也是通过 JS 实现的

 

The Limitation

场景

先来看看 sticky 在什么情况下会坏掉 (limitation).

HTML

<div class="container">
  <div class="group1">
    <div class="box1"><span>1</span></div>
    <div class="box2"><span>2</span></div>
    <div class="box3"><span>3</span></div>
    <div class="box4"><span>4</span></div>
    <div class="box5"><span>5</span></div>
    <div class="box6"><span>6</span></div>
  </div>
  <div class="group2">
    <div class="box1"><span>1</span></div>
    <div class="box2"><span>2</span></div>
    <div class="box3"><span>3</span></div>
    <div class="box4"><span>4</span></div>
    <div class="box5"><span>5</span></div>
    <div class="box6"><span>6</span></div>
  </div>
</div>
<footer></footer>
View Code

CSS Style

body {
  margin: 5rem;
}

.container {
  display: grid;
  gap: 1rem;
}

[class*="group"] {
  border: 1px solid black;
  padding: 0.5em;
  display: grid;
  gap: 1rem;
}

[class*="box"] {
  background-color: green;
  padding-block: 1em;
  text-align: center;
  span {
    color: white;
    font-size: 3rem;
  }
}

.group1 {
  .box2 {
    position: sticky;
    top: 0;
  }
}

.group2 {
  [class*="box"] {
    background-color: pink;
  }
}

footer {
  height: 100vh;
}
View Code

效果

2 个 group, 一个绿色, 一个粉红色

组里分别有 6 个 item.

绿组 item 2 sticky top 0. 所以 scroll 的时候会跟着走.

max area limitation

依据 stikcy 的规则, sticky element 的 parent 就是它的 max area container (可移动范围)

假设做一个 wrapper 给 box2 

这时 box2 的 max area container 就变成了 wrapper

然后它的 sticky 效果就没有了.

因为 wrapper 的 height 是 auto 也就是 hug content 和 sticky 一样高, 也意味着没有任何可移动空间. 所以 sticky 效果就没了.

scroll container limitation

依据 stikcy 的规则, sticky element 往上层找, 第一个 scrollable element 就是它的 scroll container.

目前没有任何设置 overflow 的 style 那默认就是 document 咯

假设把 group1 设置成 overflow

.group1 {
  overflow-x: auto;
}

不管是 overflow-x 还是 y 只要是 overflow 就会被认作为 sticky 的 scroll container

于是, sticky 再次失效了.

原因就是只有 scroll container 的 scroll 能操控 sticky, 而 group1 没有 vertical scroll. 移动的是 document scroll 所以 sticky 也就不会触发了.

multiple sticky 累加问题

当有多个 sticky element 的时候, 需要自己控制 top 值, 累加之前 sticky element 的高度是很麻烦的, 虽然算不上局限性, 但是职责应该让 sticky module 负责才对. 

 

问题分析

max area container 的职责是限制 sticky element 的可移动范围

scroll container 的职责是监听 scroll, 同时作为 sticky 的定位坐标

当 sticky 和 scroll container 坐标一致, 同时 max area 有可移动空间时, 定位开始.

这个是它的规则, 而限制我们的是 max area 和 scroll container 的选择. 如果能自由选择哪一个 element 作为 max area 和 scroll container 那就灵活很多了.

要突破限制, 就需要实现自己的 sticky 定位, 然后允许自由选择 max area 和 scroll container. 而不是像默认那样.

什么叫 parent 就是 max area, 第一个 overflow 就是 scroll container. 这种规则太不灵活了吧. 完全限制住了 layout.

游览器是不太可能去改善这一点的. 它不会让 CSS 使用过于复杂. 所以如果需求闪不掉, 就只能靠 JS 来完成了.

 

解决方案

到目前为止, 我都没有找到任何 proper way 去完全模拟替代原本的 sticky 功能. 

市场主要有 2 个方法实现类似的功能, 但是都属于 workaround / hacking 的方式. 衍生出来的问题不少, 或者只能在特定场景下可用.

方案 1: fixed + absolute

我们先把元素列出来, 方便了解

1. scroll container 最外面的黑框

2. max area container 蓝色 area

3. stikcy element 红色

4. scroll container 可见区域. 紫色的框 (也就是它的 height, 下面都是 overflow scroll 看不见的.)

滚动的时候大概是这个 feeling

紫色框外面的是 overflow 看不见的哦

在 4 个关键的时刻, 修改样式.

监听的方式是 scroll, 然后通过 bounding client 获取各个相关 element 的 coordinate.

1. start sticky (scroll down)

当紫色的框顶部碰到 sticky element 时, 开始 sticky (这个例子讲的都是 sticky top 哦, bottom 就反过来, horizontal 又再反过去)

给 sticky element position fixed 让它开始定位.

2. prevent over max area (scroll down)

当 hit 到 max area (蓝色) 底部时, 把 sticky element 切换到 absolute (或者 transform translate 也行)

3. back to max area (scroll up)

又在切换回 fixed

4. end sticky (scroll up)

又在切换回 static

衍生问题

性能方面是很好的,手机都没有问题,因为 fixed 是直接把 sticky element 定位到最上层 viewport,那原本 sticky scroll container 就影响不到它了。

虽然这个方案能用,但代价也不少,因为原本的 sticky scroll container 是有它的作用的,sticky 脱离了它虽然得到了一些特性,但也失去了另一些特性。而这些失去的特性我们是需要弥补回去的。

  1. sticky 离开后的洞

    sticky element 被 position fixed 以后就脱离了原本的 layout,相等于你把 sticky remove from DOM 对 layout 的影响,sticky 以下的 element 会移上来。

    这当然不是我们期望的。

    要解决这个问题,我们需要 create 一个 element 然后 insert before sticky element 作为弥补

    当然我们还需要同步它们的高度。

    另外,多了一个 element 也可能会破坏 CSS selector,比如 odd even 这类的 (因为 element 就是多了一个嘛)

  2. overflow-x 失效

    由于 sticky 脱离了原本的 layout,它也就不再受原本 ancestor 的 overflow-x 影响

    这就导致可能原本已经被 overflow-y hide 起来的 sticky,显示出来,或者显示一半出来。

    下图是一个 table header,

    它的 horizontal scrollbar 已经移动了一些,此时 header 还没有 sticky,所以 email th 只显示了一半。

    header sticky 以后变这样 

    全部 header 显示出来了,因为它们不再受到 horizontal scrollbar overflow-x 的影响。

    要解决这个问题,我们可以用 clip-path 把多出来的地方剪掉,设置 width + overflow-x。

  3. scroll 失效
    sticky element position fixed 以后它就无法再控制它原本的 ancestor scrollbar。

    position fixed 以后,在 header scroll 会控制到 body 最上层的 scrollbar,而不是原本的 sticky ancestor scrollbar。

    这些全部都是因为脱离了原本 layout 以后失去的特性。

    要弥补回来这个 scroll 特性,我们可以监听 wheel 然后控制 div.scrollTop。

    当然手机的处理更麻烦,需要监听 touchstart, touchmove,而且手机模拟 scroll 体验更难,因为 scroll 在手机是很丝滑而且有余力了。 

封装难度

如果是针对个案去实现会容易许多,比如只是 sticky top,没有 bottom, left, right。

width 问题可能 100% 就解决了,height 可能是固定的,可以直接 hardcode。

如果是封装成 library 难度就大很多,需要顾虑许多奇葩状况 (重点是做 library 会要求假设各种奇葩它们会一起出现,但往往真实情况并不会一起出现。)

 

方案 2: transform translate

translate 的做法相对更简单, 监听 scroll > 计算 > set translate > 完事.

它没有 fixed 脱离 layout 的困扰. translate 属于原地移动 (灵魂出窍), 依然占据原本位置.

它也没有 fixed absolute 的局限性. horizontal, vertical 一起做也没问题.

但是它有一个超级无敌致命的问题. 性能

scroll + translate 就是那么卡的 (术语叫 scroll jank / jittery). 不管做什么优化都没用. 什么 will-change, translate3d 都是假的, 它的问题主要来自 scroll event.

卡还有分等级的: 

body scroll (电脑也卡) > div scroll (电脑不卡) > div 手拉 scrollbar (电脑不卡)

我是在 ads.google.com 的 table 看到这个方案的. 它做的不卡.

在电脑, google 用的是 div scroll, 并且通过拦截 wheel event 替代了 scroll event, 自己控制了 scroll 节奏.

这就是它的黑科技, 用 DevTools 关掉它的 scroll listener 会发现它的 sticky 依然是 work 的. 但如果也把 wheel listener 关掉就不 work 了.

虽然它依然有监听 scroll 并且也用来做 sticky 但是如上面说的, div scroll 电脑不一定会卡, 所以其实它的 wheel 并不是必须的, 但是可以确定有会更好.

那手机呢? 

更厉害, 把 scroll, wheel listener 都关掉后, 会发现 sticky 依然 work. google 是通过拦截 touchstart, touchmove, touchevent 来替代 scroll event 的, 所以它的 scroll 节奏是自己控制的.

对比左边原生 scroll 的余力, table 的明显比较生硬. 好像拆刹车那样, 突然就停了. 虽然 google 已经尽可能模拟的很好了.

小知识:为什么 fixed 不卡,translate 卡?

fixed 是把 sticky element 定位在 body viewport,它就定在那边一动也不动 (一直不动),自然不会卡。

translate 是一直修改自己的位置 (一直动),自然会卡。

 

总结

原生 sticky 是最省心的. 尽可能用它, 哪怕被迫修改 layout 满足 max area 和 scroll container 的要求.

如果真的没办法. 那就用 fixed, absolute 方案去解决. 但是不值得去封装它, 因为它开启了潘多拉, 很容易失控.

如果你遇到 table horizontal vertical 的情况, fixed 解决不了. 唯一的方法就是像 google 那样用 translate. 而且必须大动作, 替代 scroll event 自己控制节奏才能确保 sticky smooth.

这个就可以封装, 对比 fixed 方案, 它实现原理相对稳定, 不容易被其它影响.

 

posted @ 2022-03-12 10:37  兴杰  阅读(166)  评论(0编辑  收藏  举报