iT邦幫忙

2024 iThome 鐵人賽

DAY 2
5
Modern Web

為你自己寫 Vue Component系列 第 2

[為你自己寫 Vue Component] AtomicLink

  • 分享至 

  • xImage
  •  

[為你自己寫 Vue Component] AtomicLink

連結(Link)在網站開發中是不可或缺的存在。為了讓我們更好的實現 SPA(Single Page Application)網站,我們通常會使用 Vue Router 作為路由管理的解決方案。

元件分析

既然 Vue Router 中已經有了 <RouterLink> 元件,為什麼我們還需要自己實作 <AtomicLink> 呢?

在大多數使用情境下,<RouterLink> 已經足夠好用,但有時候還是會遇到一些使用上的限制。

<RouterLink to="https://mini-ghost"> Blog </RouterLink>

我們希望點擊這個連結可以導向我的 Blog,但是熟悉 Vue Router 的人一定一眼就發現這裡有個問題,<RouterLink> 並不支援像這樣的外部連結應用。

這裡渲染出來的結果會是這樣:

<a href="/https://mini-ghost.dev/"> Blog </a>

為了解決這個問題,我們可以實作 <AtomicLink>,讓不論是內部連結還是外部路徑,都能夠按照我們預期的方式呈現。

解決以上問題,並結合自身經驗,我們統整出 <AtomicLink> 的功能:

  • 可以接受 to 並支援外部連結。
  • 如果有傳入 target 並且不等於 _self,則視為外部連結。
  • 可以透過 external 強制視為外部連結。

元件實作

首先,我們將需求中提到的功能整理成 props 的介面,我們會需要下列屬性:

屬性 型別 預設值 說明
to RouteLocationRaw '' 路由目標
target string undefined 連結目標
external boolean false 是否為外部連結
interface AtomicLinkProps {
  to?: RouteLocationRaw
  target?: string
  external?: boolean
}

const props = withDefaults(defineProps<AtomicLinkProps>(), {
  to: '',
  target: undefined,
});

因為 Vue Router 已經提供了 <RouterLink> 元件,所以我們不需要從頭實作,在這裡我們基於 <RouterLink> 進行擴充。

擴充的思路很簡單:如果開發人員傳入的是內部連結,我們就使用 <RouterLink> 元件;如果是外部連結,則使用 <a> 元素。

首先,我們要判斷 to 是否為外部連結。除了判斷 targetexternal 的設定外,如果 to 是一個字串且開頭是「通訊協議」(例如 https://),那我們就判定它是一個外部連結,實作如下。

import { hasProtocol } from 'ufo';
import { computed } from 'vue';

const isExternal = computed(() => {
  // 明確設定外部屬性
  if (props.external) {
    return true;
  }

  // 當有設定 target 且不是 `_self` 時視為外部連結
  if (props.target && props.target !== '_self') {
    return true;
  }

  // 當 `to` 是一個路由物件時,它就是一個內部路由
  if (typeof props.to === 'object') {
    return false;
  }

  // 當 `to` 是一個空字串或是有協定(如:https://)時視為外部連結
  return props.to === '' || hasProtocol(props.to, { acceptRelative: true });
});

是否包含通訊協定的邏輯我們交給 ufo 這個工具來處理,這樣我們可以減少許多邊緣案例的判斷。

接下來,我們要解析出給 <a> 使用的 href。如果 to 是一個物件,我們就使用 router.resolve 來解析出 href,否則直接使用 to 作為 href

// ...

const router = useRouter();

const href = computed(() => {
  return typeof props.to === 'object'
    ? router.resolve(props.to)?.href ?? null
    : props.to;
});

我們依據 isExternal 的值來決定要使用 <RouterLink> 還是 <a>,並且在 <a> 中加入 rel="noopener noreferrer" 來增加安全性。

<template>
  <template v-if="!isExternal">
    <RouterLink :to="to">
      <slot name="default" />
    </RouterLink>
  </template>
  <template v-else>
    <a
      :href="href"
      rel="noopener noreferrer"
      :target="target"
    >
      <slot name="default" />
    </a>
  </template>
</template>

這樣就可以將前面的範例換成 <AtomicLink> 試試看:

<AtomicLink to="https://mini-ghost.dev"> Blog </AtomicLink>

此時渲染出來的結果會是:

<a href="https://mini-ghost.dev/" rel="noopener noreferrer"> Blog </a>

這樣就完成了 <AtomicLink> 的實作,這個元件可以讓我們更方便地處理外部連結,並且可以透過 external 這個 prop 來強制視為外部連結。

進階功能

Smart Prefetching

除了讓元件可以兼容外部連結之外,我們還可以進一步擴充這個元件。例如可以加入 Smart Prefetching 機制來提升使用者體驗。

Smart Prefetching 是當連結是內部連結時,我們可以在連結進入畫面後,預先將連結指向的頁面元件下載下來,這樣使用者在點擊連結切換頁面時就能有更低的延遲體驗。

首先,我們來實作 prefetch 的邏輯。

我們可以從 route.matched 確認是否有可能可以 prefetch 的頁面元件。並且,因為畫面上可能同時出現多個相同的連結需要被 prefetch,我們需要加入一些判斷機制來避免不必要的效能浪費。

async function preloadRouteComponents(
  to: RouteLocationRaw,
  router: Router & {
    _routePreloaded?: Set<string>;
  }
): Promise<void> {
  const { path, matched } = router.resolve(to);

  if (!matched.length) return;
  if (!router._routePreloaded) router._routePreloaded = new Set();
  if (router._routePreloaded.has(path)) return;

  router._routePreloaded.add(path);

  // ...
}

我們從 route.matched 中找到所有需要 prefetch 的元件,並執行 prefetch:

async function preloadRouteComponents(
  to: RouteLocationRaw,
  router: Router & {
    _routePreloaded?: Set<string>;
  }
): Promise<void> {
  const { path, matched } = router.resolve(to);

  // ...

  const promises: Promise<any>[] = []
  const components = matched
    .map(component => component.components?.default)
    .filter(component => isFunction(component));

  for (const component of components) {
    const promise = Promise.resolve((component as Function)())
      .catch(() => {})

    promises.push(promise);
  }

  await Promise.all(promises);
}

接著,我們使用 IntersectionObserver 來偵測元件是否進入畫面,當元件進入畫面時觸發 prefetch。

const linkRef = ref<HTMLElement>();

let observer: IntersectionObserver | null = null;
onMounted(() => {
  observer = new IntersectionObserver(entries => {
    for (const entry of entries) {
      const isVisible = entry.isIntersecting || entry.intersectionRatio > 0;
    
      if (isVisible) {
        observer?.unobserve(entry.target);
        observer?.disconnect();
        observer = null;

        if (isExternal.value) return;
        preloadRouteComponents(props.to, router);
      }
    }
  });

  observer.observe(linkRef.value!);
});

onBeforeUnmount(() => {
  observer?.unobserve(linkRef.value!);
  observer?.disconnect();
  observer = null;
});

linkRef 表示當前元件的 DOM 元素,我們可用下面的方法取得渲染後的 <a> 元素。

<template>
  <template v-if="!isExternal">
    <RouterLink
      :ref="(instance) => {
        linkRef.value = instance?.$el;
      }"
      :to="to as any"
    >
      <slot name="default" />
    </RouterLink>
  </template>
  <template v-else>
    <!-- 不需要 prefetch -->
  </template>
</template>

這樣我們就完成了具備 Smart Prefetching 功能的 <AtomicLink> 元件,當連結進入畫面時,會預先下載連結指向的頁面元件。

減少重複的 IntersectionObserver

不過,連結在大型網站中可能會是一個數量非常龐大的元素。如果每個 <AtomicLink> 內部都建立一個新的 IntersectionObserver 來觀察元素是否進入畫面,這樣會造成效能浪費。

我們可以使用「單例模式」來確保只會建立一個 IntersectionObserver 實例,並透過 Map 來記錄每個元素對應的 callback。這樣一來,當元素進入畫面時,就可以取出 callback 執行它。

我們實作一個 createIntersectionObserver<AtomicLink> 更容易使用它。

type CallbackFn = () => void;
type ObserveFn = (element: Element, callback: CallbackFn) => () => void;

let cache: { observe: ObserveFn } | undefined;
export default function createIntersectionObserver() {
  if (cache) return cache;

  const observe: ObserveFn = (element, callback) => {
    // ...
  };

  return (cache = { observe });
}

如果建立過 observe,就直接回傳,否則建立一個新的 observe 後儲存在 cache 中並回傳。

接著,我們在內部建立一個 IntersectionObserver 實例,並透過 Map 記錄每個元素對應的 callback。

// ...

export default function createIntersectionObserver() {
  if (cache) return cache;

  let observer: IntersectionObserver | null = null;
  const callbacks = new Map<Element, CallbackFn>();

  const observe: ObserveFn = (element, callback) => {
    if (!observer) {
      observer = new IntersectionObserver(entries => {
        for (const entry of entries) {
          const callback = callbacks.get(entry.target);
          const isVisible = entry.isIntersecting || entry.intersectionRatio > 0;
          if (isVisible && callback) {
            callback();
          }
        }
      });
    }

    callbacks.set(element, callback);
    observer.observe(element);
  };

  return (cache = { observe });
}

這裡應用了兩層單例模式。第一層的 cache 確保無論呼叫多少次 createIntersectionObserver 建立一個 { observe } 物件並在之後共用;第二層單例模式則確保無論呼叫多少次 observe 都只會有一個 IntersectionObserver 實例。

與元件中的實作不同,我們不會在 element 進入畫面後立即解除觀察。是否解除觀察應該由使用的地方自行負責,這樣才能讓 createIntersectionObserver 更有彈性,不會因應未來需求變化而無法重複使用或需要修改原本的實作。

不過,我們可以在 observe 中回傳一個 unobserve 函式,讓使用者可以在不需要觀察時呼叫此函式來解除觀察。

// ...
export default function createIntersectionObserver() {
  if (cache) return cache;

  let observer: IntersectionObserver | null = null;
  const callbacks = new Map<Element, CallbackFn>();

  const observe: ObserveFn = (element, callback) => {
    // ...

    return () => {
      callbacks.delete(element);
      observer!.unobserve(element);

      if (callbacks.size === 0) {
        observer!.disconnect();
        observer = null;
      }
    };
  };

  return (cache = { observe });
}

我們順便加上了是否有其他元素在使用 IntersectionObserver 的判斷。當 callbacks 被清空,表示目前沒有任何地方在使用,此時我們可以考慮釋放記憶體,直到下次有地方呼叫 observe 時再重新建立。

最後,我們將 createIntersectionObserver 導入 <AtomicLink> 中,並使用它來觀察元素是否進入畫面。

onMounted(() => {
  if (!linkRef.value) return;

  const { observe } = createIntersectionObserver();
  unobserve = observe(linkRef.value, () => {
    unobserve?.();
    unobserve = null;

    if (isExternal.value) return;
    preloadRouteComponents(props.to, router);
  });
});

onBeforeUnmount(() => {
  unobserve?.();
  unobserve = null;
});

這樣一來,就算畫面上同時出現了幾百甚至上千個 <AtomicLink> 元件,我們也只會建立一個 IntersectionObserver 實例。這樣不但滿足了 Smart Prefetching 的需求,如果其他元件也需要相同的 IntersectionObserver,我們也可以使用 createIntersectionObserver 來避免建立多餘的 IntersectionObserver 實例。

總結

<AtomicLink> 中,我們基於 <RouterLink> 擴充了兩個功能:讓 to 可以是外部連結,另一個是 Smart Prefetching。這兩個功能可以讓我們更方便地處理外部連結,也讓網站使用者獲得更好的使用體驗。

Smart Prefetch 在這裡採用的機制是當元件進入畫面後觸發 prefetch,但根據需求也可以考慮其他方式,像是使用 mouseover 事件來觸發 prefetch 也是個不錯的選擇。

查看 Vue 主流的 UI Library,例如:Vuetify、Element Plus、PrimeVue 等等,它們都沒有特別實作類似的元件。但如果有使用過 Nuxt 的讀者可能已經發現了,<AtomicLink> 的功能跟 <NuxtLink> 幾乎是一樣的!<AtomicLink> 的實作大量參照了 <NuxtLink> 的原始碼。如果你的專案是使用 Nuxt,那麼直接使用 <NuxtLink> 就能達到完全相同的效果。但如果專案是使用 Vite + Vue,不妨將 <AtomicLink> 導入你的專案中試試看!

Nuxt 的 <NuxtLink> 在 v3.13.0 中已經支援了不同的 Smart Prefetch 機制,有興趣的可以參考看看。

參考資料


上一篇
[為你自己寫 Vue Component] 簡介
下一篇
[為你自己寫 Vue Component] AtomicButton
系列文
為你自己寫 Vue Component30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

1
Dylan
iT邦新手 1 級 ‧ 2024-10-20 19:39:56

想問把 v-ifv-else 掛在 template 上,而不是內層的 RouterLinka 身上是什麼原因呢?

Alex Liu iT邦新手 4 級 ‧ 2024-10-20 21:45:26 檢舉

這部分比較是我個人的習慣(怪僻),但綁定在 <RouterLink><a> 上是完全沒有問題的。

在下列場景我通常會選擇綁定在 <template> 上,像是 <RouterLink> 上還有其他屬性,特別是超過兩個以上,我不太想把元件的 props 與其他 Vue 的語法混在一起。

<template v-if="!isExternal">
  <RouterLink
    :ref="resolveRef"
    :to="to as any"
  >
    <slot name="default" />
  </RouterLink>
</template>

如果是元素只有一個屬性或是沒有屬性,我通常會選擇綁定在元素上。

<span
  v-if="prepend || $slots.prepend"
  class="atomic-textarea__prepend"
>
  <!-- 略 -->
</span>
<span v-if="shouldShowCount">
  <!-- 略 -->
</span>

我這樣做的目的僅在追求我自己視覺上的平衡,以及一部分受到其他框架的影響,所以通常會這樣做但也不是 100% 這樣做。

Dylan iT邦新手 1 級 ‧ 2024-10-21 10:48:40 檢舉

了解~感謝大大解惑!

我要留言

立即登入留言