---
url: /blog-ocean-world/check-images-ready.md
description: 確保圖片都乖乖載入完成，一個都不能少！順便玩玩看 Vitest 的 Browser Mode(・∀・)９
---

![check-images-ready](/check-images-ready.webp){.cover}

# 甚麼？圖片還沒好喔！Σ(ˊДˋ;)

最近開發網頁列印功能時，發現使用者列印當下，圖片可能還沒出現，導致頁面不完整。%(◞‸◟ )%

怎麼辦哩？來做一個圖片載入完成的檢查吧！%(・∀・)９%

## 釐清需求

先來釐清需求，這個功能的目的是：

1. 確保指定 DOM 下所有圖片都載入完成
2. 如果圖片不可見則忽略
3. 需考慮 CSS background-image

## 實作

熟悉 Web 開發的人，應該都知道圖片載入完成可以透過 `load` 事件來處理。

知道這點後，其實就很簡單了，讓我們一步一步來。

這裡以 Vue 為例，建立一個名為 `useImagesReady` 的 Composition API。

### 取得目標的 HTML 元素

我們的參數支援直接使用 Vue 元件，所以要先處理一下，讓我們可以取得目標的 HTML 元素。

`composables\use-images-ready.ts`

```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,
  }
}
```

::: tip 那個 `pipe` 是甚麼咚咚？
不知道的朋友們可以來這裡[看看](/blog-program/remeda-pipe)
:::

### 取得所有圖片

接著是取得圖片部分，先新增一個判斷 image 是否可見的 function。

若其父元素隱藏，則圖片一定也看不到，所以要一路找上去，把列祖列宗都找出來 %ლ（´∀\`ლ）%。

```ts
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
}
```

::: tip 怎麼不用 `offsetParent`？%ლ(╹ε╹ლ)%
研究中有找到 `offsetParent` 這個屬性，當父元素 `display: none` 時會是 `null`，就可以不用一路往上找。

只是父元素為 `position: fixed` 也會是 `null`，這樣會誤判，所以不行。%ლ(╹ε╹ლ)%

[HTMLElement: offsetParent property](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetParent)
:::

接著取得目標元素下的所有圖片。

```ts
const imageElements = Array.from(rootElement.querySelectorAll('img'))
  .filter((img) => !!img.src && isElementVisible(img))
```

接著取得背景圖片並轉換成 `HTMLImageElement`，最後合併到 `imageElements`。

```ts
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`](https://vueuse.org/core/useEventListener/)。

`composables\use-images-ready.ts`

```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`。

雖然還有一些情境與邊界問題還未考慮，不過這樣已經很夠用惹。%ԅ(´∀\` ԅ)%

完整程式可以在[這裡取得](https://github.com/Codfisher/cod-aquarium/blob/main/composables/use-images-ready.ts)

## 來個測試

先前都用 Playwright 測試，這次來試試看 Vitest 的 [Browser Mode](https://vitest.dev/guide/browser/)。%(｡･∀･)ﾉﾞ%

Browser Mode 會直接在瀏覽器中執行測試，不是模擬環境，可以更接近實際使用情境。

與 Playwright 最大的差別在於不用開啟頁面，可以直接測試 Vue 元件。

Vitest 會在內部自動處理，開發者用起來就如同平常寫單元測試一樣單純。

::: tip
Playwright 其實也可以測試元件，不過此功能正在實驗中。

而因單元測試很常使用 Vitest，如果測試都能在 Vitest 完成，整合性更高、更方便。

[Playwright test-components](https://playwright.dev/docs/test-components)
:::

[安裝與設定](https://vitest.dev/guide/browser/#installation)基本上依照官網步驟即可。

這裡我使用 Vitest 的 VSCode [擴充套件](https://marketplace.visualstudio.com/items?itemName=vitest.explorer)來執行測試。

### 建立輔助 function

第一步來做一個產生基本內容的 Vue 元件吧，這裡我們直接使用 `h` function 建立，就不用獨立 `.vue` 檔案了。

`composables\use-images-ready.spec.ts`

```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](https://picsum.photos/) 這個隨機圖片服務。

建立一個 `getImageSrc` function 取得圖片 URL。

```ts
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}`
}
```

### 建立測試

現在來建立第一個測試。

```ts
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)
  })
})
```

執行測試後，沒有意外的話會開啟瀏覽器並顯示測試結果，如下圖。

![browser-ui](/check-images-ready/browser-ui.png)

讓我們新增更多測試。

```ts
// ...

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)
  })
})
```

![all test](/check-images-ready/all-test.png)

測試順利過關！%( ‧ω‧)ノ╰(‧ω‧ )%

完整測試程式可以在[這裡取得](https://github.com/Codfisher/cod-aquarium/blob/main/composables/use-images-ready.spec.ts)

Vitest Browser Mode 體驗真滴很不錯，不過還在實驗中，不清楚會不會有奇怪的問題。

期待正式釋出的那一天。%(ゝ∀・)b%

## 總結 🐟

* 測試圖片載入狀態的功能，確保列印時圖片都載入完成
* 使用 Vue 的 Composition API 實作 `useImagesReady`
* 使用 `useEventListener` 偵測圖片載入事件
* 使用 Vitest 的 Browser Mode 進行測試
