
那些黏不起來的元素,讓我們加點 JS 吧!
大家有沒有這種經驗?
快樂的在 CSS 中設定了 position: sticky,給了它 top: 0 的承諾、加上背景顏色怕它迷失,甚至提高了 z-index 把它捧在手心
結果使用者隨手一滑,它就這麼頭也不回地就走了,徒留一片空白的寂寞。( ´•̥̥̥ ω •̥̥̥` )
鱈魚:「你說是不是很沒良心?゜・(PД`q。)・゜」
路人:「...你說的還是 CSS 嗎?(´・ω・`)」
甚麼是 sticky
sticky 是一種特殊的定位方式,結合了 relative 和 fixed 的特性。
當元素滾動到指定位置時,會「黏住」並固定在視窗或滾動容器內的某個位置,直到其父容器的邊界不可見為止。
嘗試滾動上面這個範例,可以發現 sticky 元素會黏在視窗的頂部,不會像 relative、absolute 元素一樣滾出視窗。
好用是很好用,但是 CSS sticky 也有限制,像是多層滾動區域無法正常工作:
雖然外層被捲動,導致內容看不見很合理,但是我就想要他黏住怎麼辦呢?
萬用 sticky
CSS 老弟沒辦法,我們還有 JS 大哥啊!◝( •ω• )◟
讓我們透過 JavaScript 來實現萬用 sticky 效果吧。
概念不複雜,過程如下:
- 找出目標元素所有的「可捲動祖先」
- 計算「多層可視區交集」
- 最後使用 translate 模擬 sticky 效果
重點在於「可視區交集」,因為內容其實被好幾層容器「裁切」後才看得到,所以元素要滾到哪,取決真正的「可視範圍」
可視範圍即為每一層 scroll 容器的可視區交集,你說甚麼叫做可視區交集?
讓我們具象化一下:✨◝( •ω• )◟✨
這樣是不是比較清楚多了?知道概念後,讓我們來實作吧( ´ ▽ ` )ノ
實作
讓我們一步一步來實作吧
工具函式
首先我們需要一些工具函式來幫助我們取得滾動容器、計算可視區交集等等。
/** 取得所有可捲動的祖先元素 */
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:
/** 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,
}
}接著是核心邏輯:更新與還原。
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;
};最後完成整個指令:
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()
},
}useEventListener、useXxxxObserver用於監聽事件並觸發更新effectScope用於管理指令的生命週期,先前的文章有提過
成果
在目標元素上加上 v-sticky,不管在哪個滾動容器中,都能正常黏住。
<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,有興趣的朋友可以自己改改看喔。( ´ ▽ ` )ノ