Avatar 元件是一個很常見且簡單的元件,像是在電商平台、論壇、個人部落格或是 ERP 系統中經常會看到。它很簡單,所以初期在規劃網站時經常漏掉將它編入元件設計中,經常到了專案中後期才發現已經有各種不同的 Avatar 實現散落在各處。
這次讓我們來好好正視這個元件,並且將它納入我們的元件設計中。
在開始實作前,我們先研究各個 UI Library 的 Avatar 元件是如何設計的。
Element Plus
<template>
<ElAvatar :size="50" :src="circleUrl" />
<ElAvatar shape="square" :size="50" :src="squareUrl" />
</template>
Element Plus 提供了 shape
這個 prop 讓開發人員可以選擇圓形或是正方形的 Avatar,以及 size
這個 prop 讓開發人員可以設定 Avatar 的大小。
size
的部分,除了範例中接受傳入數字外,也可以接收 large
、medium
、small
這三個字串。
Vuetify
<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-small
、x-large
等字串,也可以使用數字設定。另外,也接受使用 rounded
設定圓角大小,可接受 0
或其他規範好的字串。
Nuxt UI
<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>
的功能:
size
這個 prop 來設定 Avatar 的大小,可以接收 large
、medium
、small
這三個字串,也可以接收數字。rounded
這個 prop 來設定 Avatar 的圓角,可以接收 full
或是任意數字來設定圓角。<img>
元素的常見屬性如:src
、alt
、loading
來設定 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 的內容。
再來,我們處理 size
與 rounded
的設定,兩者差不多,所以我們以 size
為例。如果開發人員設定 size
為 small
、medium
、large
這樣的字串,我們就直接套用這個字串到 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 數量過多,將部分隱藏不顯示是一個可行的方法,以免畫面過於雜亂。
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 的 size
和 rounded
等等。
名稱 | 型別 | 預設值 | 說明 |
---|---|---|---|
max | ${number} , number |
3 |
最多顯示 Avatar 的數量 |
size | small , medium , large , ${number} , number |
medium |
Avatar 的寬高 |
rounded | ${number} , number , full |
full |
Avatar 的圓角大小 |
在這個元件上有兩個挑戰:
max
時,超出的部分要裁切,並顯示 +n
的提示。要如何實現呢?
越前面的 Avatar 要在越上層
在沒有特別處理的情況下,越後面渲染的結構會在越上層。
最簡單的方式是透過 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;
}
}
這還是沒解決後渲染的 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 涵蓋了以下幾個情境:
接著取得所有的子元件。
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>
我們就得到了我們期望的顯示效果了。
當 Avatar 數量超過 max
時,超出的部分要裁切,並顯示 +n
提示
再來,我們需要處理兩件事:
+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,我們就能實現更多有趣的元件設計。
<AtomicAvatar>
原始碼:AtomicAvatar.vue
想問使用 new Image()
的方式載入圖片的話,會不會造成即使有加 loading=“lazy”
仍然會馬上下載圖片呢🤔
這確實是我遺漏的部分!不過我在 VueUse 的 useImage
看到這一段。
async function loadImage(options: UseImageOptions): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
// ... 略
if (loading)
img.loading = loading
})
}
👉 useImage Source Code | VueUse
目前實測有效(我蠻意外的),我需要花點時間釐清有效的原因,感謝提醒 💚