Skip to content

implement-sticky-using-js

那些黏不起來的元素,讓我們加點 JS 吧!

大家有沒有這種經驗?

快樂的在 CSS 中設定了 position: sticky,給了它 top: 0 的承諾、加上背景顏色怕它迷失,甚至提高了 z-index 把它捧在手心

結果使用者隨手一滑,它就這麼頭也不回地就走了,徒留一片空白的寂寞。( ´•̥̥̥ ω •̥̥̥` )


鱈魚:「你說是不是很沒良心?゜・(PД`q。)・゜

路人:「...你說的還是 CSS 嗎?(´・ω・`)

甚麼是 sticky

sticky 是一種特殊的定位方式,結合了 relative 和 fixed 的特性。

當元素滾動到指定位置時,會「黏住」並固定在視窗或滾動容器內的某個位置,直到其父容器的邊界不可見為止。

relative
absolute
sticky

嘗試滾動上面這個範例,可以發現 sticky 元素會黏在視窗的頂部,不會像 relative、absolute 元素一樣滾出視窗。

好用是很好用,但是 CSS sticky 也有限制,像是多層滾動區域無法正常工作:

滾動外層就看不見惹 ( ´•̥̥̥ ω •̥̥̥` )

雖然外層被捲動,導致內容看不見很合理,但是我就想要他黏住怎麼辦呢?

萬用 sticky

CSS 老弟沒辦法,我們還有 JS 大哥啊!◝( •ω• )◟

讓我們透過 JavaScript 來實現萬用 sticky 效果吧。

概念不複雜,過程如下:

  1. 找出目標元素所有的「可捲動祖先」
  2. 計算「多層可視區交集」
  3. 最後使用 translate 模擬 sticky 效果

重點在於「可視區交集」,因為內容其實被好幾層容器「裁切」後才看得到,所以元素要滾到哪,取決真正的「可視範圍」

可視範圍即為每一層 scroll 容器的可視區交集,你說甚麼叫做可視區交集?

讓我們具象化一下:✨◝( •ω• )◟✨

這樣是不是比較清楚多了?知道概念後,讓我們來實作吧( ´ ▽ ` )ノ

實作

讓我們一步一步來實作吧

工具函式

首先我們需要一些工具函式來幫助我們取得滾動容器、計算可視區交集等等。

ts
/** 取得所有可捲動的祖先元素 */
function getScrollableParents(el: Element | null) {
  const result: Array<HTMLElement | Window> = []
  if (!el)
    return [window]

  let parent = el.parentElement
  while (parent) {
    const style = getComputedStyle(parent)
    const oy = style.overflowY
    const ox = style.overflowX

    const scrollY = (oy === 'auto' || oy === 'scroll' || oy === 'overlay')
      && parent.scrollHeight > parent.clientHeight

    const scrollX = (ox === 'auto' || ox === 'scroll' || ox === 'overlay')
      && parent.scrollWidth > parent.clientWidth

    if (scrollY || scrollX)
      result.push(parent)
    parent = parent.parentElement
  }

  result.push(window)
  return result
}

interface RectLike {
  top: number;
  bottom: number;
  left: number;
  right: number;
  width: number;
  height: number;
}

/** 取得 window viewport,用於初始可視區 */
function windowRect(): RectLike {
  return {
    top: 0,
    left: 0,
    bottom: window.innerHeight,
    right: window.innerWidth,
    width: window.innerWidth,
    height: window.innerHeight,
  }
}

/** 計算兩個 Rect 的交集 */
function intersectRect(a: RectLike, b: RectLike): RectLike {
  const top = Math.max(a.top, b.top)
  const left = Math.max(a.left, b.left)
  const bottom = Math.min(a.bottom, b.bottom)
  const right = Math.min(a.right, b.right)
  const width = Math.max(0, right - left)
  const height = Math.max(0, bottom - top)
  return { top, left, bottom, right, width, height }
}

/** 取所有 scrollable 元素的「可視區交集」 */
function getEffectiveRootRect(parentEl: HTMLElement) {
  const parents = getScrollableParents(parentEl)
  let rect = windowRect()
  for (const p of parents) {
    if (p === window)
      continue
    rect = intersectRect(rect, (p as Element).getBoundingClientRect())
    if (rect.height <= 0 || rect.width <= 0)
      break
  }
  return rect
}

Vue 指令

這裡將 sticky 效果封裝成 Vue 指令,在目標元素上加上 v-sticky 就可以使用,相當方便。

首先定義指令參數與取得參數的 function:

ts
/** number 表示 top 值 */
type StickyValue =
  | number
  | {
    top?: number;
    bottomPadding?: number;
  }

function getOptions(value: StickyValue | undefined) {
  if (typeof value === 'number')
    return { top: value, bottomPadding: 0 }
  return {
    top: value?.top ?? 0,
    bottomPadding: value?.bottomPadding ?? 0,
  }
}

接著是核心邏輯:更新與還原。

ts
const oriStyle = {
  translate: el.style.translate,
  willChange: el.style.willChange,
};

let appliedY = 0;
let rafId = 0;

const update = async () => {
  if (rafId) return;

  // 用 requestAnimationFrame 省去多餘的更新
  rafId = requestAnimationFrame(() => {
    rafId = 0;
    if (!el.isConnected) return;

    const parentRect = parentEl.getBoundingClientRect();
    const rootRect = getEffectiveRootRect(parentEl);
    const elRect = el.getBoundingClientRect();

    /** 扣掉目前位移,原始 top 位置 */
    const naturalTop = elRect.top - appliedY;
    const deltaInParent = naturalTop - parentRect.top;

    const minTop = rootRect.top + opts.top;
    let nextY = Math.max(0, minTop - naturalTop);

    // 卡在父元素底部,不要超出去
    const maxYRaw =
      parentRect.height - (deltaInParent + elRect.height) - opts.bottomPadding;
    const maxY = Math.max(0, maxYRaw);
    nextY = Math.min(nextY, maxY);

    if (Math.abs(nextY - appliedY) > 0.5) {
      appliedY = nextY;
      el.style.translate = `0px ${appliedY}px`;
    }
  });
};

const restore = () => {
  if (rafId) cancelAnimationFrame(rafId);
  rafId = 0;
  el.style.translate = oriStyle.translate;
  el.style.willChange = oriStyle.willChange;
};

最後完成整個指令:

ts
interface StickyInstance {
  scope: ReturnType<typeof effectScope>;
  setOptions: (value: StickyValue) => void;
  restore: () => void;
}
const KEY = Symbol('vSticky')
/** 取得存在元素上的上下文資料 */
function getContext(el: HTMLElement): StickyInstance | undefined {
  return (el as any)[KEY]
}
/** 將上下文資料存到元素上,讓 updated/beforeUnmount 調用同一個上下文資料 */
function setContext(el: HTMLElement, instance: StickyInstance) {
  (el as any)[KEY] = instance
}

/** 突破 CSS sticky 限制
 *
 * 用 translate 模擬 sticky,支援多層 scroll + 動態出現的 scroll area
 */
export const vSticky: Directive<HTMLElement, StickyValue> = {
  mounted(el, binding) {
    const parentEl = el.parentElement
    if (!parentEl) {
      console.warn('v-sticky 父元素不能為空')
      return
    }

    const scope = effectScope()
    let opts = getOptions(binding.value)

    const oriStyle = {
      translate: el.style.translate,
      willChange: el.style.willChange,
    }

    let appliedY = 0
    let rafId = 0

    const update = async () => {
      if (rafId)
        return

      /** 用 requestAnimationFrame 省去多餘的更新 */
      rafId = requestAnimationFrame(() => {
        rafId = 0
        if (!el.isConnected)
          return

        const parentRect = parentEl.getBoundingClientRect()
        const rootRect = getEffectiveRootRect(parentEl)
        const elRect = el.getBoundingClientRect()

        /** 扣掉目前位移,原始 top 位置 */
        const naturalTop = elRect.top - appliedY
        const deltaInParent = naturalTop - parentRect.top

        const minTop = rootRect.top + opts.top
        let nextY = Math.max(0, minTop - naturalTop)

        // 卡在父元素底部,不要超出去
        const maxYRaw = parentRect.height - (deltaInParent + elRect.height) - opts.bottomPadding
        const maxY = Math.max(0, maxYRaw)
        nextY = Math.min(nextY, maxY)

        if (Math.abs(nextY - appliedY) > 0.5) {
          appliedY = nextY
          el.style.translate = `0px ${appliedY}px`
        }
      })
    }

    const restore = () => {
      if (rafId)
        cancelAnimationFrame(rafId)
      rafId = 0
      el.style.translate = oriStyle.translate
      el.style.willChange = oriStyle.willChange
    }

    scope.run(() => {
      el.style.willChange = 'translate'

      // capture 抓到「任何元素」的 scroll(包含後來才出現的內層 scroll area)
      useEventListener(document, 'scroll', update, { passive: true, capture: true })
      useEventListener(window, 'resize', update, { passive: true })

      useMutationObserver(parentEl, update, {
        subtree: true,
        childList: true,
        attributes: true,
        attributeFilter: ['class', 'style'],
      })

      useResizeObserver(parentEl, update)
      useResizeObserver(el, update)

      update()
    })

    // 存起來讓 updated/beforeUnmount 用
    setContext(el, {
      scope,
      setOptions: (v: StickyValue) => opts = getOptions(v),
      restore,
    })
  },

  updated(el, binding) {
    const ctx = getContext(el)
    ctx?.setOptions(binding.value)
  },

  beforeUnmount(el) {
    const ctx = getContext(el)

    ctx?.restore()
    ctx?.scope.stop()
  },
}
  • useEventListeneruseXxxxObserver 用於監聽事件並觸發更新
  • effectScope 用於管理指令的生命週期,先前的文章有提過

成果

在目標元素上加上 v-sticky,不管在哪個滾動容器中,都能正常黏住。

vue
<template>
  <div class="h-[40vh] card border-gray-100">
    <div class="h-[50vh] card border-gray-200">
      <div class="h-[60vh] card border-gray-300">
        <div class="h-[70vh] card border-gray-400">
          <div class="h-[80vh] card border-gray-500">
            <div
              v-sticky
              class="sticky top-0 text-center p-4 rounded-lg bg-blue-400 text-white"
            >
              我是內容 ヽ(́◕◞౪◟◕‵)ノ
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { vSticky } from '../../../web/directives/v-sticky'
</script>

<style scoped>
.card {
  border-width: 3px;
  padding: 40px;
  padding-left: 20px;
  border-radius: 8px;
  overflow-y: auto;
}
</style>
我是內容 ヽ(́◕◞౪◟◕‵)ノ

再也不用擔心黏不住了!✧⁑。٩(ˊᗜˋ*)و✧⁕。

總結 🐟

完整程式碼可以在此取得

  • CSS sticky 好用,但是有限制
  • 使用 JavaScript 實現萬用 sticky 效果,突破 CSS sticky 限制

目前尚未考慮 x 軸的 sticky,有興趣的朋友可以自己改改看喔。( ´ ▽ ` )ノ