![[為你自己寫 Vue Component] AtomicScrollbar](https://ithelp.ithome.com.tw/upload/images/20240917/20120484k7TeFrFAEL.png)
在專案中,你會如何設定 Scrollbar 的樣式呢?
當我們想要自定義網頁的 Scrollbar 時,最常見的方式之一就是用 CSS 改寫原生的 Scrollbar 樣式。儘管 CSS 可以做到 Scrollbar 的客製化,但也有不少限制。例如,跨瀏覽器問題:在不同瀏覽器上,我們需要使用不同的前綴甚至不同的方法來實現。
不過,有時候我們還是希望能有更高的可控性與跨瀏覽器的支援度,這時 <AtomicScrollbar> 元件可以滿足我們這些需求。

在開始實作前,我們先研究各個 UI Library 的 Scrollbar 元件是如何設計的。
Element Plus
<ElScrollbar height="400px">
  <li v-for="item in 20" :key="item">{{ item }}</li>
</ElScrollbar>
Element Plus 提供 <ElScrollbar> 作為容器,高度可透過 props 傳入,這個高度用於定義 <ElScrollbar> 內部 Wrap 的大小。如果內容超出 Wrap,則會啟動 Scrollbar。此外,若希望自定義 Viewport 的 HTML tag,<ElScrollbar> 接受 tag 這個 props 來設定。
PrimeVue
<ScrollPanel style="width: 100%; height: 200px">
  <li v-for="item in 20" :key="item">{{ item }}</li>
</ScrollPanel>
PrimeVue 的元件名稱為 <ScrollPanel>,使用方式與 Element Plus 基本相同。不同的是,Element Plus 的 height 設定是透過 props,並將其應用於 Wrap 上,而 PrimeVue 則是直接限制最外層元件的大小來達到效果。
Radix Vue
<ScrollAreaRoot style="--scrollbar-size: 10px">
  <ScrollAreaViewport>
    <ul :style="{ padding: '15px 20px' }">
      <li v-for="item in 20" :key="item">{{ item }}</li>
    </ul>
  </ScrollAreaViewport>
  <ScrollAreaScrollbar orientation="vertical">
    <ScrollAreaThumb />
  </ScrollAreaScrollbar>
  <ScrollAreaScrollbar orientation="horizontal">
    <ScrollAreaThumb />
  </ScrollAreaScrollbar>
</ScrollAreaRoot>
Radix Vue 的使用方式看似較為複雜,但這其實提供了一個明確的架構指南,讓我們能輕易看到 Scrollbar 元件的組成。經過封裝後,使用起來也和前兩者無異。自定義 tag 的部分,Radix Vue 幾乎所有元件都支援使用 as 來設定 HTML tag。
<ScrollArea class="h-[200px] w-[350px] rounded-md border p-4">
  <p>
    <!-- 略 -->
  </p>
</ScrollArea>
Radix Vue 與 Element Plus、PrimeVue,或是未提及的 Vuetify、Nuxt UI 不同。Radix Vue 僅提供最基本的樣式(甚至幾乎沒有樣式)與結構,讓使用者可以自由定義風格。
在實作上,我們甚至不需要處理 Props,因此我們可以從 <template> 開始。
<div class="atomic-scrollbar">
  <div class="atomic-scrollbar__viewport">
    <slot name="default" />
  </div>
  <div class="atomic-scrollbar__track atomic-scrollbar__track--vertical">
    <div class="atomic-scrollbar__thumb" />
  </div>
  <div class="atomic-scrollbar__track atomic-scrollbar__track--horizontal">
    <div class="atomic-scrollbar__thumb" />
  </div>
</div>
在這個結構中,Root 是用來提供 Track 定位的容器;Viewport 則是承載內容的容器。這樣做可以防止 Track 元素影響使用者的內容與語義化結構。
由於我們目的是自己設計 Scrollbar,因此需要先用 CSS 隱藏瀏覽器原生的 Scrollbar。
.atomic-scrollbar {
  overflow: hidden;
  &__viewport {
    height: inherit;
    overflow: auto;
    scrollbar-width: none;
    &::-webkit-scrollbar {
      display: none;
    }
  }
}
scrollbar-width: none; 可以隱藏瀏覽器的 Scrollbar,而 &::-webkit-scrollbar { display: none; } 則用於隱藏 iOS 瀏覽器的 Scrollbar。
接下來,我們讓 Track 被定位在 Root 的右側與下方,並加上樣式來美化 Scrollbar。
.atomic-scrollbar {
  position: relative;
  &__track {
    --atomic-scrollbar-track-size: 8px;
    position: absolute;
    background-color: transparent;
    border-radius: 10px;
    &--vertical,
    &--horizontal {
      right: 0;
      bottom: 0;
    }
    &--vertical {
      top: 0;
      width: var(--atomic-scrollbar-track-size);
    }
    &--horizontal {
      left: 0;
      height: var(--atomic-scrollbar-track-size);
    }
    &:hover {
      --atomic-scrollbar-track-size: 10px;
      background-color: rgba(lightgray, 0.2);
    }
  }
  &__thumb {
    background-color: rgba(gray, 0.2);
    border-radius: 10px;
  }
  &__track--vertical &__thumb {
    width: 100%;
  }
  &__track--horizontal &__thumb {
    height: 100%;
  }
}
樣式部分已經處理完畢,接下來進入較為複雜的數學計算。
Scrollbar 分為 Y 軸與 X 軸,本篇文章以 Y 軸為範例。
首先,我們要根據 Viewport 高度與 Content(整個內容高度)的比例,計算出 Thumb 應該設置的高度。

由圖片可以推導出這樣的比例關係:Content : Viewport = Track : Thumb。
我們已知 Content、Viewport 和 Track 的高度,所以我們可以藉由國中數學的比例式運算,內項相乘=外項相乘,計算出 Thumb 應該要有的高度。

由於畫面上的 Viewport 高度等於 Track 高度,因此可以寫成:
const viewportRef = ref<HTMLElement>();
const thumbHeight = ref(0);
const thumbWidth = ref(0);
const update = () => {
  const viewport = viewportRef.value;
  if (!viewport) return;
  const { offsetHeight, offsetWidth } = viewport;
  thumbHeight.value = (offsetHeight * offsetHeight) / viewport.scrollHeight;
  thumbWidth.value = (offsetWidth * offsetWidth) / viewport.scrollWidth;
}
試著計算一下,假設 Content 高度為 2000,Viewport 高度為 500,那麼算出的 Thumb 高度應該是 (500 * 500) / 2000,等於 125。
但如果 Thumb 算出的數值過小,可能會影響使用體驗,因此我們希望 Thumb 的最小高度不小於 20。
const update = () => {
  const viewport = viewportRef.value;
  if (!viewport) return;
  const { offsetHeight, offsetWidth } = viewport;
  const originalHeight = (offsetHeight * offsetHeight) / viewport.scrollHeight;
  const originalWidth = (offsetWidth * offsetWidth) / viewport.scrollWidth;
  const height = Math.max(originalHeight, MIN_SIZE);
  const width = Math.max(originalWidth, MIN_SIZE);
  thumbHeight.value = height;
  thumbWidth.value = width;
}
不過這樣可能導致算出來得 Thumb 高度超過 Viewport,因此需要再進行微調。
const update = () => {
  const viewport = viewportRef.value;
  if (!viewport) return;
  const offsetHeight = viewport.offsetHeight;
  const offsetWidth = viewport.offsetWidth;
  const originalHeight = (offsetHeight * offsetHeight) / viewport.scrollHeight;
  const originalWidth = (offsetWidth * offsetWidth) / viewport.scrollWidth;
  
  const height = Math.max(originalHeight, MIN_SIZE);
  const width = Math.max(originalWidth, MIN_SIZE);
  thumbHeight.value = height < offsetHeight ? height : 0;
  thumbWidth.value = width < offsetWidth ? width : 0;
};
大多數情況下,Content : Viewport 與 Viewport : Thumb 的比例相等。也就是說,當 Viewport 往下滾動 10%,Thumb 也會往下 10%,此時移動比率(ratioY)為 1。
但是當 originalHeight 小於 MIN_SIZE 時,我們會取用 MIN_SIZE,這樣前面的比例就會不相等。
因此,我們需要計算 Thumb 的移動比率:
let ratioY = 1;
let ratioX = 1;
const update = () => {
  // 略
  ratioY = originalHeight / (offsetHeight - originalHeight) / (height / (offsetHeight - height));
  ratioX = originalWidth / (offsetWidth - originalWidth) / (width / (offsetWidth - width));
};
用程式碼表達比較複雜,列成數學式會更易理解:

由於最終計算出的 height 只會小於或等於 offsetHeight,所以我們可以推算出 ratioY 的值會在小於等於 1 到大於 0 之間。
Thumb 的位置計算與之前類似,我們需要計算出 Thumb 的位置。
const thumbTop = ref(0);
const thumbLeft = ref(0);
const onScroll = () => {
  const viewport = viewportRef.value;
  if (!viewport) return;
  const { offsetHeight, offsetWidth, scrollTop, scrollLeft } = viewport;
  thumbTop.value = ((scrollTop * 100) / offsetHeight);
  thumbLeft.value = ((scrollLeft * 100) / offsetWidth);
};
通過滾動距離與總滾動量的比例,可以計算出 Thumb 應該處於的位置。

不過,還需要考慮到 Content : Viewport 與 Viewport : Thumb 比例不相等的情況,此時需要用到前面計算的 ratioY 與 ratioX。

const onScroll = () => {
  const viewport = viewportRef.value;
  if (!viewport) return;
  const { offsetHeight, offsetWidth, scrollTop, scrollLeft } = viewport;
  thumbTop.value = ((scrollTop * 100) / offsetHeight) * ratioY;
  thumbLeft.value = ((scrollLeft * 100) / offsetWidth) * ratioX;
};
了解如何計算 Thumb 的高度、比例與位置後,我們可以將這些數值應用到元件上。
<div class="atomic-scrollbar__track atomic-scrollbar__track--vertical">
  <div
    class="atomic-scrollbar__thumb"
    :style="{ 
      transform: `translateY(${thumbTop}%)`, 
      height: `${thumbHeight}px`
    }"
  />
</div>
接下來,我們來探討何時計算 Thumb 的大小與位置。
更新 Thumb 的大小與比例的時機包括:元件 mounted 後、Viewport 大小改變後、內容變更後。
const update = () => {
  // 略
};
const viewportRef = ref<HTMLElement>();
let unobserve: (() => void) | null = null;
onMounted(() => {
  const observer = new ResizeObserver(update);
  observer.observe(viewportRef.value);
  unobserve = () => observer.disconnect();
})
onUpdated(update);
onBeforeUnmount(() => {
  unobserve();
  unobserve = null;
})
大多數情況下,我強烈不建議在
onUpdated中更新響應式資料,這很可能會導致無限更新循環的問題。在這個元件中,我們更新 Thumb 的大小與比例後會再次觸發
onUpdated,但由於更新值與第一次相同,因此不會產生無限更新循環。
在處理滑鼠點擊 Thumb 拖曳前,我們先來規劃整個流程:
因此,我們需要針對 Thumb 監聽 pointerdown 事件,並在按下後開始對整個網頁監聽 pointermove 來記錄移動量,並將其應用到 Viewport 上。同時,我們也需要監聽 pointerup 事件來移除監聽的 pointermove 與 pointerup 事件。
對整個網頁監聽
pointermove是為了確保滑鼠移出 Thumb 時仍能繼續拖曳。
onThumbPointerdown
onThumbPointerdown 的目的是記錄滑鼠按下的位置,並啟動對整個網頁的 pointermove 與 pointerup 事件監聽。
let mousePosition: number = 0;
let scrollOffset: number | null = null;
const onThumbPointerdown = (event: PointerEvent) => {
  if (event.ctrlKey || event.button !== 0) return;
  const viewport = viewportRef.value;
  if (!viewport) return;
  mousePosition = event.pageY;
  scrollOffset = viewport.scrollTop;
  document.addEventListener('pointermove', onDocumentPointermove);
  document.addEventListener('pointerup', onDocumentPointerup);
};
onDocumentPointermove
在 onDocumentPointermove 中,我們計算滑鼠移動的距離,並將其反映到 Viewport 上。
const onDocumentPointermove = (event: PointerEvent) => {
  const viewport = viewportRef.value;
  if (!viewport) return;
  const offset = (event.pageY - mousePosition) / viewport.offsetHeight;
  viewport.scrollTop = scrollOffset! + offset * viewport.scrollHeight;
};
offset 是滑鼠移動距離相對於 Viewport 高度的比值:

將這個比值乘上 Viewport 的 scrollHeight,就能得到滑鼠移動所反映的移動距離,最後將移動距離與預先記錄的 scrollOffset 相加,就得到了最終的 scrollTop。

這樣,我們就能在滑鼠移動時讓 Viewport 跟著移動了!
onDocumentPointerup
最後,當滑鼠放開時,我們要移除對 pointermove 與 pointerup 的監聽,並清空暫存變數。
const onDocumentPointerup = () => {
  document.removeEventListener('pointermove', onDocumentPointermove);
  document.removeEventListener('pointerup', onDocumentPointerup);
  currentOrientation = null;
  scrollOffset = null;
  mousePosition = 0;
  if (document.onselectstart !== originalOnSelectStart) {
    document.onselectstart = originalOnSelectStart;
  }
};
我們來看看滑鼠點擊 Thumb 拖曳的效果。

Thumb 拖曳的部分,我們需要考量到在行動裝置上操作,在行動裝置上沒有滑鼠,也就沒有
MouseEvent,這時我們使用PointerEvent會是更好的選擇。
當點擊原生 Scrollbar 的 Track 時,會逐步跳到指定位置。我們希望 <AtomicScrollbar> 也具備這樣的功能。此處實作一個簡單版本,當使用者點擊 Track 時,Thumb 會直接跳到指定位置。
我們可以換算出點擊 Track 的位置對應到 Content 上的位置,然後將 Viewport 移動到該位置。
例如:Track 高度為 100,Content 高度為 1000,點擊 Track 的位置為 20,則對應的 Content 位置為 200,這表示 Viewport 要移動到 200 的位置。

Content 的高度剛好等於 Viewport 可滾動的高度,因此程式碼實作如下:
const onTrackPointerdown = (
  event: PointerEvent,
  orientation: OrientationKey
) => {
  const viewport = viewportRef.value;
  if (!viewport) return;
  const track = event.currentTarget as HTMLElement;
  const rect = track.getBoundingClientRect();
  const point = Math.abs(rect.top - event.pageY);
  viewport.scrollTop = point * (viewport.scrollHeight / track.offsetHeight);
};
這樣一來,我們就可以在點擊 Track 時,讓 Viewport 與 Thumb 移動到指定的位置。

最後,我們希望 Scrollbar 在沒有互動時可以隱藏。
互動行為包括:滑鼠進入 Thumb、滑鼠拖曳 Thumb、滑鼠滾動。因此,我們可以使用兩個 flag 變數來記錄這些狀態。
const active = ref(false);
const dragging = ref(false);
在拖曳過程中,dragging 設為 true,而滑鼠移動或滾動時,active 設為 true。
dragging
const onDocumentPointermove = (event: PointerEvent) => {
  // 略
  dragging.value = true;
};
const onDocumentPointerup = () => {
  // 略
  dragging.value = false;
};
active
const scheduleHideScrollbar = () => {
  time && clearTimeout(time);
  time = setTimeout(() => {
    active.value = false;
  }, 1000);
};
const onScroll = () => {
  // 略
  scheduleHideScrollbar();
  active.value = true;
};
const onTrackPointerenter = () => {
  // 略
  active.value = true;
};
const onTrackPointerleave = scheduleHideScrollbar;
最後,在 Track 上加上 v-show 條件,當 active 或 dragging 為 true 時顯示,否則隱藏。
<div
  v-show="active || dragging"
  class="atomic-scrollbar__track atomic-scrollbar__track--vertical"
>
  <!-- 略 -->
</div>
目前每一個 Scrollbar 都會有一個獨立的 ResizeObserver。如果畫面上同時有很多 <AtomicScrollbar> 存在,將造成不少的資源浪費。
我們可以使用在 <AtomicLink> 裡面用過的「單例模式」,讓每個 <AtomicScrollbar> 都共享同一個 ResizeObserver 實例。
let unobserve: (() => void) | null = null;
onMounted(() => {
  const observer = createResizeObserver();
  unobserve = observer.observe(viewportRef.value, update);
});
onBeforeUnmount(() => {
  unobserve?.();
  unobserve = null;
});
createResizeObserver 的實作如下:
type CallbackFn = () => void;
type ObserveFn = (element: Element, callback: CallbackFn) => () => void;
let cache: { observe: ObserveFn } | undefined;
export default function createResizeObserver() {
  if (cache) return cache;
  let observer: ResizeObserver | null = null;
  const callbacks = new Map<Element, CallbackFn>();
  const observe: ObserveFn = (element, callback) => {
    if (!observer) {
      observer = new ResizeObserver(entries => {
        for (const entry of entries) {
          const callback = callbacks.get(entry.target);
          callback?.();
        }
      });
    }
    callbacks.set(element, callback);
    observer.observe(element);
    return () => {
      callbacks.delete(element);
      observer!.unobserve(element);
      if (callbacks.size === 0) {
        observer!.disconnect();
        observer = null;
      }
    };
  };
  return (cache = { observe });
}
與 <AtomicLink> 裡面實作的 createIntersectionObserver 方式相同,第一層的 cache 確保無論呼叫多少次 createResizeObserver 都只會在第一次建立一個 { observe } 物件並在之後共用;第二層的單例模式則確保無論呼叫多少次 observe,都只會有一個 ResizeObserver 實例。
詳細拆解說明可以參考 AtomicLink 中的「減少重複的 IntersectionObserver」章節。
這樣,我們就可以減少重複的 ResizeObserver 實例,提高效能。
對於我們看得見的人來說,畫面上那條小小的東西很容易被認為是 Scrollbar,但對於使用螢幕閱讀器的人來說,這些東西可能不易辨識。因此,我們需要透過 role 來明確告知這是一個 Scrollbar。
首先,我們需要為 Track 加上 role="scrollbar" 屬性,這樣螢幕閱讀器才能識別這是一個 Scrollbar。
<div
  class="atomic-scrollbar__track atomic-scrollbar__track--vertical"
  role="scrollbar"
>
  <div
    class="atomic-scrollbar__thumb"
    :style="{
      transform: `translateY(${thumbTop}%)`,
      height: `${thumbHeight}px`,
    }"
  />
</div>
為了配合 role="scrollbar",我們還需要加上一些 aria-* 屬性,這樣螢幕閱讀器可以清楚讀取 Scrollbar 的狀態。
aria-controls:指定 Scrollbar 控制的元素。aria-orientation:指定 Scrollbar 的方向。aria-valuenow:指定 Scrollbar 的當前值。aria-valuemax:指定 Scrollbar 的最大值,預設為 100。aria-valuemin:指定 Scrollbar 的最小值,預設為 0。我們需要計算 aria-valuenow 的值。不同於 thumbTop 是 Thumb 移動的百分比,aria-valuenow 表示已滾動的百分比。
最簡單的算法如下:
const valuenowY = computed(() => {
  const viewport = viewportRef.value;
  if (!viewport) return 0;
  const { scrollTop, offsetHeight, scrollHeight } = viewport;
  return (scrollTop / (scrollHeight - offsetHeight)) * 100;
});
但是這樣的數據不會隨著滾動自動更新,因為 scrollTop、offsetHeight 和 scrollHeight 都不是 Vue 追蹤的響應式變數。此時,我們可以使用一個小技巧來讓 valuenowY 隨著 Scrollbar 滾動更新。
const valuenowY = computed(() => {
  const viewport = viewportRef.value;
  if (!viewport) return 0;
  // 追蹤依賴
  void thumbTop.value;
  const { scrollTop, offsetHeight, scrollHeight } = viewport;
  return (scrollTop / (scrollHeight - offsetHeight)) * 100;
});

computed 會在第一次運算時,收集函數中的響應式資料(如 ref、reactive 及 computed),當這些依賴變化時,computed 會重新計算。
thumbTop.value 是響應式變數,會隨著 Scrollbar 滾動更新。使用 void thumbTop.value 可以使 thumbTop.value 的變動觸發 valuenowY 的重新計算,從而達到追蹤當前滾動量的效果。
在製作 <AtomicScrollbar> 的過程中,我們進行了許多數學運算,計算了 Thumb 的高度、寬度與位置,並計算了滑鼠拖曳 Thumb 的移動量。最後,在實現無障礙功能時也用到了數學運算。如果一次看不懂,建議帶入一些具體的數字來計算,這樣更容易理解。
Scrollbar 雖然不是常見的元件,但相比純 CSS 處理 Scrollbar,使用 <AtomicScrollbar> 元件可以在不同作業系統和瀏覽器上達到一致的效果,特別是解決了 Windows 上 Scrollbar 寬度約 17px 的問題。
雖然我們已盡力模擬瀏覽器原生 Scrollbar 的行為,但例如在行動裝置上,點擊 Scrollbar 的震動回饋等功能尚未完全普及(特別是在 Safari 上)。因此,使用前建議與合作夥伴充分理解這些方案的優缺點,以找到最適合的解決方案。
<AtomicScrollbar> 原始碼:AtomicScrollbar.vue