iT邦幫忙

2024 iThome 鐵人賽

DAY 11
2
Modern Web

為你自己寫 Vue Component系列 第 11

[為你自己寫 Vue Component] AtomicAvatar

  • 分享至 

  • xImage
  •  

[為你自己寫 Vue Component] AtomicAvatar

Avatar 元件是一個很常見且簡單的元件,像是在電商平台、論壇、個人部落格或是 ERP 系統中經常會看到。它很簡單,所以初期在規劃網站時經常漏掉將它編入元件設計中,經常到了專案中後期才發現已經有各種不同的 Avatar 實現散落在各處。

這次讓我們來好好正視這個元件,並且將它納入我們的元件設計中。

元件分析

元件架構

AtomicAvatar 元件架構

  1. Image:Avatar 的圖片。
  2. Content:預設的 Slot 內容,當沒有傳入圖片時顯示。

功能設計

在開始實作前,我們先研究各個 UI Library 的 Avatar 元件是如何設計的。

Element Plus

Element Plus Avatar

<template>
  <ElAvatar :size="50" :src="circleUrl" />
  <ElAvatar shape="square" :size="50" :src="squareUrl" />
</template>

Element Plus 提供了 shape 這個 prop 讓開發人員可以選擇圓形或是正方形的 Avatar,以及 size 這個 prop 讓開發人員可以設定 Avatar 的大小。

size 的部分,除了範例中接受傳入數字外,也可以接收 largemediumsmall 這三個字串。

Vuetify

Vuetify Avatar

<template>
  <VAvatar color="primary" size="x-small">32</VAvatar>
  <VAvatar rounded="0" color="secondary">48</VAvatar>
  <VAvatar color="info" size="x-large">64</VAvatar>
</template>

在 Vuetify 中,Avatar 一樣可以透過 size 這個 prop 來設定大小,可以傳入像是 x-smallx-large 等字串,也可以使用數字設定。另外,也接受使用 rounded 設定圓角大小,可接受 0 或其他規範好的字串。

Nuxt UI

Nuxt UI Avatar

<template>
  <UAvatar
    chip-color="primary"
    chip-text=""
    chip-position="top-right"
    size="xl"
    src="https://avatars.githubusercontent.com/u/739984"
    alt="Avatar"
  />
</template>

相較於前兩者的設計,Nuxt UI 的 Avatar 在 props 設計上更包山包海。除了 Avatar 常見的設定外,Nuxt UI 的 Avatar 元件還整合了他們的 Chip 元件設定。

對於 Nuxt UI 的這項設計,個人認為對元件庫來說有些過度,因為並非每個專案在使用 Avatar 時會同時會需要 Chip 的功能。如果專案真有這樣的需求頻繁出現,屆時再將兩個元件整合起來使用會是更彈性的選擇。

綜合以上並結合自身經驗,我們統整出 <AtomicAvatar> 的功能:

  1. 可接受 size 這個 prop 來設定 Avatar 的大小,可以接收 largemediumsmall 這三個字串,也可以接收數字。
  2. 可接受 rounded 這個 prop 來設定 Avatar 的圓角,可以接收 full 或是任意數字來設定圓角。
  3. 可接受 <img> 元素的常見屬性如:srcaltloading 來設定 Avatar 的圖片和替代文字以及是否 lazy loading。

使用結構如下:

<template>
  <AtomicAvatar
    src="https://avatars.githubusercontent.com/u/39984251"
    alt="Alex Liu"
    size="60"
  />
</template>

元件實作

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

名稱 型別 預設值 說明
size small, medium, large, ${number}, number medium Avatar 的寬高
rounded ${number}, number, full full Avatar 的圓角大小
src string - Avatar 的圖片來源
alt string - Avatar 的替代文字
loading lazy, eager lazy Avatar 的圖片載入方式
interface AtomicAvatarProps {
  size?: 'small' | 'medium' | 'large' | `${number}` | number;
  rounded?: `${number}` | number | 'full';
  src?: string;
  alt?: string;
  loading?: 'lazy' | 'eager';
}

const props = withDefaults(defineProps<AtomicAvatarProps>(), {
  size: 'medium',
  rounded: 'full',
  src: undefined,
  alt: undefined,
  loading: 'lazy',
});

接著我們先把 template 規劃出來。

<span class="atomic-avatar">
  <template v-if="src">
    <img
      :alt="alt"
      class="atomic-avatar__image"
      decoding="async"
      draggable="false"
      :height="size"
      :loading="loading"
      :src="src"
      :width="size"
    >
  </template>
  <span v-else>
    <slot name="default" />
  </span>
</span>

考量到開發人員可能想要放的是 SVG 或文字而沒有傳入圖片,所以我們加了一個判斷,當需求沒有要放入圖片時,我們就顯示 default slot 的內容。

再來,我們處理 sizerounded 的設定,兩者差不多,所以我們以 size 為例。如果開發人員設定 sizesmallmediumlarge 這樣的字串,我們就直接套用這個字串到 class 上。

<span
  class="atomic-avatar"
  :class="[
    typeof size !== 'number' ? `atomic-avatar--${size}` : '',
  ]"
>

SCSS 的部分,加上關於 size 的設定:

.atomic-avatar {
  &--small {
    width: 24px;
    height: 24px;
  }

  &--medium {
    width: 40px;
    height: 40px;
  }

  &--large {
    width: 56px;
    height: 56px;
  }
}

但如果開發人員傳的是數字,我們就不能把這個數字直接套用到 class 上了!這時我們可以選擇把寬高寫在 style 上。

<span
  class="atomic-avatar"
  :style="{
    width: typeof size === 'number' ? `${size}px` : undefined,
    height: typeof size === 'number' ? `${size}px` : undefined,
  }"
>

還有另一個方法,我們可以使用 CSS 變數的功能:

<span
  class="atomic-avatar"
  :style="{
    '--avatar-size': typeof size === 'number' ? `${size}px` : undefined,
  }"
>

接著我們就可以在 SCSS 中這樣處理:

.atomic-avatar {
  width: var(--avatar-size);
  height: var(--avatar-size);

  &--small {
    --avatar-size: 24px;
  }

  &--medium {
    --avatar-size: 40px;
  }

  &--large {
    --avatar-size: 56px;
  }
}

這樣看起來簡潔一點,因為剛好寬和高都是一樣的,這樣處理就不必重複寫兩次。

這裡有個可以小小增強的地方:

<AtomicAvatar :size="60"> 

這樣在 <AtomicAvatar> 元件內部接收到的會是 60數字,但是作為開發人員,儘管 Vue 已經簡化到可以用 :size 代替 v-bind:size,我還是希望連冒號也可以省下來。

<AtomicAvatar size="60"> 

這樣在元件內部接收到的會是 '60'字串

為了加上這個彈性,我們新增一個 function 幫助我們判斷開發人員傳入的是數字還是數字形式的字串。

function isNumberish(value: unknown): value is number | `${number}` {
  return typeof value === 'number' || (typeof value === 'string' && !isNaN(Number(value)));
}

這樣我們就可以這樣調整:

<span
  class="atomic-avatar"
  :class="[
    !isNumberish(size) ? `atomic-avatar--${size}` : '',
  ]"
  :style="{
    '--avatar-size': isNumberish(size) ? `${size}px` : undefined,
  }"
>

這樣我們就可以支援開發人員傳入像是 60 或是 '60' 了!

進階功能

圖片錯誤處理

有時候我們的圖片可能會因為各種因素導致無法正常載入,如果我們需想要在圖片載入失敗時顯示備用圖片或文字,我們或許可以透過 error 事件來處理。

<template v-if="error">
  <slot name="fallback" />
</template>
<img 
  v-else 
  src="https://avatars.githubusercontent.com/u/39984251"
  @error="error = true"
>

這似乎是個好方法?但如果使用 Server Side Rendering 的框架的話可能就會遇到儘管圖片載入失敗 @error="error = true" 卻沒有作用到問題。要理解這個問題這我們得先簡略探討 Server Side Rendering 的運作方式。

如果今天的網頁是 Client Side Rendering,我們在網頁上點擊檢視原始碼會發現,<body> 裡面只有一個空的 <div id="app"></div>,這時我們在網頁上看到的內容是由 Vue 在瀏覽器上生成的。

但如果是 Server Side Rendering,我們在瀏覽器看到的內容是 Server 端生成後送到瀏覽器裡的。在拿到這 HTML 後,瀏覽器開始解析並繪製畫面,隨後 Vue 會在瀏覽器裡依照 Server 端來的資料初始化,過程中會嘗試找到元件與 HTML 的對應關係,並綁定資料與事件。

這個過程叫做 hydration(水合)。

在 Vue 還沒對 <img> 進行水合前,error 還沒被綁定到元素上。因此,如果水合的速度比圖片下載失敗的響應速度還要快,我們就會接到錯誤事件,反之就算錯誤發生我們也無法得知。因此,使用 @error="error = true" 這個做法有高機率會失敗(圖片下載越慢成功率越高)。

所以我們得換個做法,使用 new Image() 是一個不錯的方式。

const error = ref(false);

if (typeof window !== 'undefined') {
  watch(
    () => props.src,
    (value) => {
      if (!value) { return error.value = false }

      const img = new Image();
      img.onload = () => (error.value = false);
      img.onerror = () => (error.value = true);
      img.src = value;
    },
    { immediate: true },
  );
}

這樣一來,我們就不用擔心水合完成在圖片載入失敗之後的問題了。

多個 Avatar 並列顯示

當我們需要顯示多個參與人員(協作人員)時,可能需要顯示多個 Avatar。如果 Avatar 數量過多,將部分隱藏不顯示是一個可行的方法,以免畫面過於雜亂。

Atomic Avatar Group Demo

Nuxt UI 提供了一個 <UAvatarGroup> 元件來實現這個功能。

<template>
  <UAvatarGroup :max="3">
    <UAvatar
      src="https://avatars.githubusercontent.com/u/28706372"
      alt="Daniel Roe"
    />
    <UAvatar
      src="https://avatars.githubusercontent.com/u/5158436"
      alt="Pooya Parsa"
    />
    <UAvatar
      src="https://avatars.githubusercontent.com/u/904724"
      alt="Sébastien Chopin"
    />
    <UAvatar
      src="https://avatars.githubusercontent.com/u/640208"
      alt="Alexander Lichter"
    />
    <UAvatar
      src="https://avatars.githubusercontent.com/u/4312154"
      alt="Xin Du"
    />
  </UAvatarGroup>
</template>

類似的使用方式在前面的 <AtomicAccordion> 有出現過,所以我們這裡也新增一個 <AtomicAvatarGroup> 來實現這個功能。

除了 max 外,我們可以把 <AtomicAvatar> 上除了與圖片相關的 props 也引入到 <AtomicAvatarGroup> 上,這樣開發人員就可以直接在 <AtomicAvatarGroup> 上設定 Avatar 的 sizerounded 等等。

名稱 型別 預設值 說明
max ${number}, number 3 最多顯示 Avatar 的數量
size small, medium, large, ${number}, number medium Avatar 的寬高
rounded ${number}, number, full full Avatar 的圓角大小

在這個元件上有兩個挑戰:

  • 在 UI 表現上越前面的 Avatar 要在越上層。
  • 當 Avatar 數量超過 max 時,超出的部分要裁切,並顯示 +n 的提示。

要如何實現呢?

越前面的 Avatar 要在越上層

在沒有特別處理的情況下,越後面渲染的結構會在越上層。

Atomic Avatar Group Render

最簡單的方式是透過 z-index 來處理,這就需要知道總共有幾個 Avatar,z-index 從第一個由高到低遞減即可。但這裡想來嘗試看看其他方法,使用 flex 排版。

<template>
  <div class="atomic-avatar-group">
    <slot name="default" />
  </div>
</template>
.atomic-avatar-group {
  display: flex;
  flex-direction: row-reverse;
  justify-content: flex-end;
  align-items: center;

  & .atomic-avatar + .atomic-avatar {
    // 原本
    // margin-left: -1rem;

    // row-reverse 之後
    margin-right: -1rem;
  }
}

Atomic Avatar Group Render 2

這還是沒解決後渲染的 Avatar 要在下層的問題,但成功讓 UI 在視覺上看起來正確。剩下的部分,我們在下一步處理。

反轉 Avatar 渲染的順序

如果我們能夠讓開發人員傳入的 Avatar 結構反轉顯示,搭配前面的 CSS 反轉處理,就能達到我們要的效果。

例如:開發人員傳入的 Avatar 結構是這樣的:

Daniel Roe -> Pooya Parsa -> Sébastien Chopin -> Alexander Lichter -> Xin Du

我們希望選染出來的 HTML 結構是反過來的:

Xin Du -> Alexander Lichter -> Sébastien Chopin -> Pooya Parsa -> Daniel Roe

這樣搭配 CSS 達到反轉再反轉的效果。我們就可以讓視覺上在第一個 Avatar 顯示在最上層。

我們需要使用 Vue 的 Render Function 來處理這個問題。

在實作 <AtomicPopover> 時有提到,我們可以拿到元件的 slots,做處理後再渲染。這裡我們也採用這個方法。

<AtomicPopover> 不同的是,這裡我們要取得 default slot 的所有子元件,而不是只找第一個子元件。需要一個新 function 幫助找到所有子元素。

function resolveSlotChildren(nodes: VNode[] | undefined) {
  if (!nodes) return null;

  return nodes
    .map(node => {
      if (node.type === Fragment) return node.children;
      if (
        node.type === Comment 
        || node.type === Text
        || node.type === 'svg'
        || isString(node.type)
      ) return
    
      return node;
    })
    .flat()
    .filter(Boolean) as VNode[];
}

這個 function 涵蓋了以下幾個情境:

  • 如果遇到 Fragment,則取出其 children。
  • 如果遇到 Comment、Text、SVG 或字串,則忽略。
  • 其他情況則返回節點。

接著取得所有的子元件。

interface AtomicAvatarGroupSlots {
  default?: () => ReturnType<Slot>;
}

const slots = defineSlots<AtomicAvatarGroupSlots>();

const children = computed(() => resolveSlotChildren(slots.default?.()));

再來,我們反轉 children 的順序。

const DefaultVNode = computed(() => {
  const nodes = children.value;
  if (!nodes) return;

  const cloned = nodes
    .slice(0, nodes.length)
    .map(node => cloneVNode(node, sharedProps))
    .reverse();

  return h(Fragment, cloned);
});

使用了 Vue 的 cloneVNode,這個方法可以複製一個 VNode 並修改 props。這樣我們可以將 <AtomicAvatarGroup> 的 props 傳給 <AtomicAvatar>

最後,我們將 DefaultVNode 交由 Vue 的 <component> 元素渲染。

<template>
  <div class="atomic-avatar-group">
    <component :is="DefaultVNode" />
  </div>
</template>

我們就得到了我們期望的顯示效果了。

Atomic Avatar Group Render 3

當 Avatar 數量超過 max 時,超出的部分要裁切,並顯示 +n 提示

再來,我們需要處理兩件事:

  1. 統計 Avatar 數量,並裁切多餘的 Avatar。
  2. 若有裁切 Avatar,顯示 +n 提示。

我們修改上面實作的 DefaultVNode

const DefaultVNode = computed(() => {
  const nodes = children.value;
  if (!nodes) return;

  const length = nodes.length;
  const sharedProps = { size: props.size, rounded: props.rounded };

  let max = Number(props.max);
  if (Number.isNaN(max) || max < 1) max = 1;

  const cloned = nodes
    .slice(0, max)
    .map(node => cloneVNode(node, sharedProps))
    .reverse();

  if (length > max) {
    const ellipsis = h(AtomicAvatar, sharedProps, () => `+${length - max}`);
    cloned.unshift(ellipsis);
  }

  return h(Fragment, cloned);
});

我們將多餘的 Avatar 切掉,並顯示 +${length - max}。注意:將 ellipsis 放在陣列的第一個是因為 CSS 會再反轉一次顯示順序,最終顯示順序會正確。

這裡不選用 z-index 是為了避免濫用造成管理困難。但 HTML 結構與視覺順序相反,可能會讓使用螢幕閱讀器的使用者困惑。因此,選用哪種方式依需求決定,沒有絕對的優劣。

總結

Avatar 本身是一個很簡單的元件,但在實作過程中,我們加入了一些讓開發人員使用體驗更好的小巧思。不僅僅是 <AtomicAvatar>,在前面提到或未來要實作的元件中,我們都可以思考如何讓開發人員更方便地使用。

<AtomicAvatarGroup> 的部分,我們再次運用了 Vue 的 Render Function 進行處理,像是裁切 Avatar 數量、顯示 +n 提示、翻轉 Avatar 順序等等。

Render Function 是比較少見且進階的使用方式,但如果能活用 Render Function,我們就能實現更多有趣的元件設計。

參考資料


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

1 則留言

1
Dylan
iT邦新手 1 級 ‧ 2024-10-29 08:23:33

想問使用 new Image() 的方式載入圖片的話,會不會造成即使有加 loading=“lazy” 仍然會馬上下載圖片呢🤔

Alex Liu iT邦新手 4 級 ‧ 2024-10-29 14:51:57 檢舉

這確實是我遺漏的部分!不過我在 VueUse 的 useImage 看到這一段。


async function loadImage(options: UseImageOptions): Promise<HTMLImageElement> {
  return new Promise((resolve, reject) => {
    // ... 略
    if (loading)
      img.loading = loading
  })
}

👉 useImage Source Code | VueUse

目前實測有效(我蠻意外的),我需要花點時間釐清有效的原因,感謝提醒 💚

我要留言

立即登入留言