甚麼?圖片還沒好喔!Σ(ˊДˋ;)
最近開發網頁列印功能時,發現使用者列印當下,圖片可能還沒出現,導致頁面不完整。(◞‸◟ )
怎麼辦哩?來做一個圖片載入完成的檢查吧!(・∀・)9
釐清需求
先來釐清需求,這個功能的目的是:
- 確保指定 DOM 下所有圖片都載入完成
- 如果圖片不可見則忽略
- 需考慮 CSS background-image
實作
熟悉 Web 開發的人,應該都知道圖片載入完成可以透過 load
事件來處理。
知道這點後,其實就很簡單了,讓我們一步一步來。
這裡以 Vue 為例,建立一個名為 useImagesReady
的 Composition API。
取得目標的 HTML 元素
我們的參數支援直接使用 Vue 元件,所以要先處理一下,讓我們可以取得目標的 HTML 元素。
composables\use-images-ready.ts
interface UseImagesReadyParams {
/** 不指定則為目前元件 */
target?: MaybeElement;
/** 強制延遲(ms),保險起見
* @default 0
*/
forceDelay?: number;
/** 是否包含 background-image
* @default true
*/
includeBackgroundImages?: boolean;
}
/** 偵測目標下所有圖片是否載入完成
*
* 目前不考慮動態新增的圖片
*/
export function useImagesReady(params: UseImagesReadyParams = {}) {
const {
target,
forceDelay = 0,
includeBackgroundImages = true,
} = params
const totalImages = ref(0)
const isReady = ref(false)
/** 取得目前元件實例 */
const instance = getCurrentInstance()
onMounted(() => {
const rootElement = pipe(
toValue(target),
(value) => {
if (value && '$el' in value) {
return value.$el
}
return value
?? instance?.vnode?.el
?? instance?.proxy?.$el
?? document.documentElement
},
(value) => {
if (!value) {
return undefined
}
if (value instanceof HTMLElement) {
return value
}
throw new Error(
'[useImagesReady] 取得目標元素異常,請確認 target 是否為 HTMLElement 或 Vue 元件',
)
},
)
if (!rootElement) {
isReady.value = true
console.warn('[useImagesReady] 目標元素不存在,無法偵測圖片載入狀態')
}
})
return {
isReady,
totalImages,
}
}
那個 pipe
是甚麼咚咚?
不知道的朋友們可以來這裡看看
取得所有圖片
接著是取得圖片部分,先新增一個判斷 image 是否可見的 function。
若其父元素隱藏,則圖片一定也看不到,所以要一路找上去,把列祖列宗都找出來 ლ(´∀`ლ)。
function isElementVisible(el: HTMLElement | null) {
/** 一路往父層檢查 */
while (el) {
const style = getComputedStyle(el)
if (style.display === 'none' || style.visibility === 'hidden') {
return false
}
el = el.parentElement
}
return true
}
怎麼不用 offsetParent
?ლ(╹ε╹ლ)
研究中有找到 offsetParent
這個屬性,當父元素 display: none
時會是 null
,就可以不用一路往上找。
只是父元素為 position: fixed
也會是 null
,這樣會誤判,所以不行。ლ(╹ε╹ლ)
接著取得目標元素下的所有圖片。
const imageElements = Array.from(rootElement.querySelectorAll('img'))
.filter((img) => !!img.src && isElementVisible(img))
接著取得背景圖片並轉換成 HTMLImageElement
,最後合併到 imageElements
。
const backgroundImageElements = Array.from(
includeBackgroundImages
? rootElement.querySelectorAll('*')
: [],
).reduce((list, el) => {
if (!(el instanceof HTMLElement) || !isElementVisible(el)) {
return list
}
const bgValue = getComputedStyle(el).getPropertyValue('background-image')
if (bgValue && bgValue !== 'none') {
// 只處理 url 的背景圖片
const url = bgValue.match(/url\(["']?([^"']+)["']?\)/)
if (!url || !url[1]) {
return list
}
const img = new Image()
img.src = url[1]
list.push(img)
}
return list
}, [] as HTMLImageElement[])
const imageElements = Array.from(rootElement.querySelectorAll('img'))
.filter((img) => !!img.src && isElementVisible(img))
.concat(backgroundImageElements)
圖片載入事件
圖片到齊了,剩下就是處理載入事件了,這裡我們使用 VueUse 的 useEventListener
。
composables\use-images-ready.ts
export function useImagesReady(params: UseImagesReadyParams = {}) {
// ...
onMounted(() => {
// ...
totalImages.value = imageElements.length
if (totalImages.value === 0) {
isReady.value = true
return
}
let loadedCount = 0
async function checkCompletion() {
if (loadedCount === totalImages.value) {
await promiseTimeout(forceDelay)
isReady.value = true
}
}
function handleEvent() {
loadedCount++
checkCompletion()
}
imageElements.forEach((img) => {
if (img.complete) {
loadedCount++
return
}
useEventListener(img, 'load', handleEvent, { once: true })
useEventListener(img, 'error', handleEvent, { once: true })
})
checkCompletion()
})
return {
isReady,
totalImages,
}
}
這樣就完成了,當所有圖片載入完成(或錯誤)後,isReady
會變成 true
。
雖然還有一些情境與邊界問題還未考慮,不過這樣已經很夠用惹。ԅ(´∀` ԅ)
完整程式可以在這裡取得
來個測試
先前都用 Playwright 測試,這次來試試看 Vitest 的 Browser Mode。(。・∀・)ノ゙
Browser Mode 會直接在瀏覽器中執行測試,不是模擬環境,可以更接近實際使用情境。
與 Playwright 最大的差別在於不用開啟頁面,可以直接測試 Vue 元件。
Vitest 會在內部自動處理,開發者用起來就如同平常寫單元測試一樣單純。
TIP
Playwright 其實也可以測試元件,不過此功能正在實驗中。
而因單元測試很常使用 Vitest,如果測試都能在 Vitest 完成,整合性更高、更方便。
安裝與設定基本上依照官網步驟即可。
這裡我使用 Vitest 的 VSCode 擴充套件來執行測試。
建立輔助 function
第一步來做一個產生基本內容的 Vue 元件吧,這裡我們直接使用 h
function 建立,就不用獨立 .vue
檔案了。
composables\use-images-ready.spec.ts
type Images = Array<{ src: string; id?: string; style?: string }>
function createTestComponent(
images: Images,
slots: VNode[] = [],
) {
return defineComponent({
setup() {
const {
isReady,
totalImages,
} = useImagesReady()
return () => h('div', null, [
h('h1', '測試元件'),
...images.map((img) => h('img', img)),
...slots,
h('div', { id: 'status' }, isReady.value ? 'Ready' : 'Loading'),
h('div', { id: 'totalImages' }, `total: ${totalImages.value}`),
])
},
})
}
測試用的圖片我使用 Picsum 這個隨機圖片服務。
建立一個 getImageSrc
function 取得圖片 URL。
const sizeList = range(1, 10).map(
(i) => `${i * 20}`,
) as [string, string, ...string[]]
function getImageSrc(params?: { width: number; height: number }) {
if (!params) {
const sizes = sample(sizeList, 2)
return `https://picsum.photos/${sizes.join('/')}`
}
const { width, height } = params
return `https://picsum.photos/${width}/${height}`
}
建立測試
現在來建立第一個測試。
import type { VNode } from 'vue'
import { page } from '@vitest/browser/context'
import { map, pipe, range, sample } from 'remeda'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-vue'
import { defineComponent, h, nextTick } from 'vue'
import { useImagesReady } from './use-images-ready'
// ...
/** 負責斷言 Ready */
function expectReady(screen: ReturnType<typeof render>) {
return expect.element(screen.getByText('Ready'), {
/**
* 不斷檢查元素是否存在,直到超過 timeout 或找到元素
*
* https://vitest.dev/guide/browser/assertion-api.html#:~:text=expect.poll%20and%20expect.element%20APIs
*/
interval: 100,
}).toBeInTheDocument()
}
describe('useImagesReady', () => {
it('如果沒有圖片,isReady 應該立即設為 true', async () => {
const screen = render(createTestComponent([]))
await expect.element(screen.getByText('測試元件')).toBeInTheDocument()
await expectReady(screen)
})
})
執行測試後,沒有意外的話會開啟瀏覽器並顯示測試結果,如下圖。
讓我們新增更多測試。
// ...
describe('useImagesReady', () => {
it('如果沒有圖片,isReady 應該立即設為 true', async () => {
// ...
})
it('1 張圖片載入後,文字從 Ready 變 Loading', async () => {
const data = [
{ src: getImageSrc() },
]
const screen = render(createTestComponent(data))
await expect.element(screen.getByText(`total: ${data.length}`)).toBeInTheDocument()
await expect.element(screen.getByText('Loading')).toBeInTheDocument()
await expectReady(screen)
})
it('2 張圖片載入後,文字從 Ready 變 Loading', async () => {
const data: Images = [
{ src: getImageSrc() },
{ src: getImageSrc() },
]
const screen = render(createTestComponent(data))
await expect.element(screen.getByText(`total: ${data.length}`)).toBeInTheDocument()
await expect.element(screen.getByText('Loading')).toBeInTheDocument()
await expectReady(screen)
})
it('hidden 的圖片會被忽略', async () => {
const data: Images = [
{ src: getImageSrc() },
{ src: getImageSrc(), style: 'display: none;' },
{ src: getImageSrc(), style: 'visibility: hidden;' },
]
const screen = render(createTestComponent(data))
await expect.element(screen.getByText(`total: 1`)).toBeInTheDocument()
await expect.element(screen.getByText('Loading')).toBeInTheDocument()
await expectReady(screen)
})
it('父層元素 hidden 的圖片也應被被忽略', async () => {
const data: Images = [
{ src: getImageSrc() },
]
const screen = render(createTestComponent(data, [
h('div', { style: 'display: none;' }, [
h('img', { src: getImageSrc() }),
]),
h('div', { style: 'visibility: hidden;' }, [
h('img', { src: getImageSrc() }),
]),
]))
await expect.element(screen.getByText(`total: ${data.length}`)).toBeInTheDocument()
await expect.element(screen.getByText('Loading')).toBeInTheDocument()
await expectReady(screen)
})
it('更父層元素 hidden 的圖片也應被被忽略', async () => {
const data: Images = [
{ src: getImageSrc() },
]
const screen = render(createTestComponent(data, [
h('div', { style: 'display: none;' }, [
h('div', [
h('div', [
h('img', { src: getImageSrc() }),
]),
]),
]),
]))
await expect.element(screen.getByText(`total: ${data.length}`)).toBeInTheDocument()
await expect.element(screen.getByText('Loading')).toBeInTheDocument()
await expectReady(screen)
})
it('須包含 background-image 圖片', async () => {
const data: Images = [
{ src: getImageSrc() },
]
const screen = render(createTestComponent(data, [
h('div', {
style: [
'width: 100px',
'height: 100px',
`background-image: url(${getImageSrc()})`,
].join(';'),
}),
]))
await expect.element(screen.getByText(`total: 2`)).toBeInTheDocument()
await expect.element(screen.getByText('Loading')).toBeInTheDocument()
await expectReady(screen)
})
it('父層元素隱藏,要忽略 background-image 圖片', async () => {
const data: Images = [
{ src: getImageSrc() },
]
const screen = render(createTestComponent(data, [
h('div', { style: 'display: none;' }, [
h('div', {
style: [
'width: 100px',
'height: 100px',
`background-image: url(${getImageSrc()})`,
].join(';'),
}),
]),
h('div', { style: 'visibility: hidden;' }, [
h('div', {
style: [
'width: 100px',
'height: 100px',
`background-image: url(${getImageSrc()})`,
].join(';'),
}),
]),
]))
await expect.element(screen.getByText(`total: 1`)).toBeInTheDocument()
await expect.element(screen.getByText('Loading')).toBeInTheDocument()
await expectReady(screen)
})
})
測試順利過關!( ‧ω‧)ノ╰(‧ω‧ )
完整測試程式可以在這裡取得
Vitest Browser Mode 體驗真滴很不錯,不過還在實驗中,不清楚會不會有奇怪的問題。
期待正式釋出的那一天。(ゝ∀・)b
總結 🐟
- 測試圖片載入狀態的功能,確保列印時圖片都載入完成
- 使用 Vue 的 Composition API 實作
useImagesReady
- 使用
useEventListener
偵測圖片載入事件 - 使用 Vitest 的 Browser Mode 進行測試