Skip to content

check-images-ready

甚麼?圖片還沒好喔!Σ(ˊДˋ;)

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

怎麼辦哩?來做一個圖片載入完成的檢查吧!(・∀・)9

釐清需求

先來釐清需求,這個功能的目的是:

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

那個 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
}

怎麼不用 offsetParentლ(╹ε╹ლ)

研究中有找到 offsetParent 這個屬性,當父元素 display: none 時會是 null,就可以不用一路往上找。

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

HTMLElement: offsetParent property


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

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

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

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

完整程式可以在這裡取得

來個測試

先前都用 Playwright 測試,這次來試試看 Vitest 的 Browser Mode(。・∀・)ノ゙

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

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

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

TIP

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

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

Playwright test-components

安裝與設定基本上依照官網步驟即可。

這裡我使用 Vitest 的 VSCode 擴充套件來執行測試。

建立輔助 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 這個隨機圖片服務。

建立一個 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

讓我們新增更多測試。

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

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

完整測試程式可以在這裡取得

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

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

總結 🐟

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