はじめに

Nuxtには@nuxt/imageという画像最適化モジュールがあります。Nuxt3への対応も始まったのですが、Retina等のHiDPIへの対応がまだまだなのと、使っていくうちにもう少し細かく設定したいなと思うところがありまして、この際自前で実装してみることにしました。

手順としては以下のようになります。

  1. Newtの画像倉庫(メディアライブラリ)を外部ストレージに設定し、画像最適化サービスを接続。
  2. SSG時に画像最適化サービスのエンドポイントにアクセスし、各サイズの画像を自動生成。
  3. 自動生成された各サイズの画像はローカルに自動で保存。そのままデプロイできるようにする。

図にすると以下のような感じです。今回は画像最適化サービスにImageKitを使いました。

構成

プラグインやコンポーネントの作成等、少々手間はかかりましたがとりあえずできましたので書いていきます。

下準備

まずはNewtの設定を行います。

外部ストレージの設定

外部ストレージの設定

スペース設定にてメディアライブラリを外部ストレージに接続。これによりメディアライブラリの保存先が指定の外部ストレージになります。事前に使用する外部ストレージの設定をしておきます。やり方はこちら。管理が煩雑にならないように一応サイトのバケットと分けました。

画像最適化サービスとの連携

画像最適化サービスとの連携

外部ストレージを画像最適化サービスと連携させ、Newtにアップロードした画像が画像最適化サービスのエンドポイントでアクセスできるようにします。

やり方はこちら。今回はImageKitを利用しましたので、併せてImageKitのオリジンとして先ほどの外部ストレージを設定する作業も行います。ImageKitでの設定方法はこちらに詳しく書かれてます。

プラグインの作成

では、ImageKitへアクセスするためのプラグインを作成します。ImageKit.io Javascript SDKをインストールします。

yarn add imagekit-javascript

このSDKはパラメータを受け取りImageKitへアクセスするURLを生成するシンプルなものです。というわけで以下のように作成。

import ImageKit from 'imagekit-javascript'

const imageURL = ({ src, quality = 80, width, height, sharp = false }:
  { src: string, quality?: number, width?: number, height?: number, sharp?: boolean }) => {
  const imagekit = new ImageKit({
    urlEndpoint: 'https://ik.imagekit.io/imagekit_id',
  })

  const trData: {[key: string]: string} = {}
  trData.quality = String(quality)
  if (width) { trData.width = String(width) }
  if (height) { trData.height = String(height) }
  if (sharp) { trData.effectSharpen = '-' }

  return imagekit.url({
    src,
    transformation: [trData],
  })
}

export default defineNuxtPlugin(() => {
  return {
    provide: {
      imageURL,
    },
  }
})

エンドポイントはImageKitのダッシュボードで確認できます。適宜設定してください。

ヘルパー関数の作成

次にコンポーネントを作成するにあたって、ImageKit用のURLをローカルのパスに変換する関数を作ります。generate後にはImageKitにアクセスするのではなくstaticに保存した画像にアクセスするためで、ImageKitの利用量節約のためでもあります。

ここは環境や画像を保存するフォルダ構造で書き方は変わってきますね。

import path from 'path'

export const getRelativePath = (url: string, width: string) => {
  const baseName = path.basename(url).replace(/\?.*$/, '')
  const folder = path.dirname(url).split('/').slice(-1)[0]
  const relativePath = `/${folder}/${width}/${baseName}`
  const dir = `/${folder}/${width}`
  return { relativePath, dir }
}

コンポーネントの作成

コンポーネントを作成します。中身はただのimg要素ですが、サーバーサイドとクライアントサイドそれぞれで src や srcset のパスの指定が変わるようになってます。

<template>
  <img
    :class="attrClass"
    :src="src"
    :alt="alt"
    :srcset="srcset"
    :sizes="sizes"
    :width="width"
    :height="height"
  />
</template>


<script setup lang="ts">

interface Props {
  src: string
  alt?: string
  srcset?: Array<number>
  sizes?: Array<{[key: string]: string} | string>
  qualities?: Array<number>
  width: number
  height: number
  class?: string
}

const props = withDefaults(defineProps<Props>(), {
  src: '',
  alt: '',
  srcset: () => [],
  sizes: () => [],
  qualities: () => [],
  width: undefined,
  height: undefined,
  class: '',
})

const nuxtApp = useNuxtApp()
const srcList: Array<String> = []
const halfWidth = props.width / 2

if (props.srcset) {
  if (process.dev || (!process.dev && process.server)) {
    for (let i = 0; i < props.srcset.length; i++) {
      srcList.push(nuxtApp.$imageURL({
        src: props.src,
        quality: props.qualities[i],
        width: props.srcset[i],
        sharp: (props.srcset[i] <= halfWidth),
      }) + ` ${String(props.srcset[i])}w`)
    }
  } else {
    for (let i = 0; i < props.srcset.length; i++) {
      const width = `${String(props.srcset[i])}w`
      const { relativePath } = getRelativePath(props.src, width)
      srcList.push(`/_img${relativePath} ${width}`)
    }
  }
}

const sizesList: Array<String> = []

if (props.sizes) {
  props.sizes.forEach((value) => {
    if (typeof value === 'object') {              // min or max
      const key = Object.keys(value)[0]
      const prefix = key.substring(0, 1)
      const mqWidth = Object.keys(value)[0].substring(1)
      const wValue = Object.values(value)[0]
      if (prefix === 'n') {
        sizesList.push(`(min-width: ${mqWidth}px) ${wValue}`)
      } else {
        sizesList.push(`(max-width: ${mqWidth}px) ${wValue}`)
      }
    } else {                                      // string
      sizesList.push(value)
    }
  })
}

const attribute = reactive({
  attrClass: props.class ? `${props.class} rsp-img` : 'rsp-img',
  src: srcList.slice(-1)[0].split(' ')[0],
  srcset: srcList.join(', '),
  sizes: sizesList.join(', '),
})

const { attrClass, src, srcset, sizes } = toRefs(attribute)

</script>

サーバーサイド

この場合のパスは、パラメータをもとにImageKitのURLを生成したものになります。run devやgenerateの時はこちらです。後述しますが、generate時は最終的に相対パスへ変換されます。

クライアントサイド

この場合は保存された画像データの相対パスになります。出力した後はこちらになります。

src や srcset のセッティング以外は共通です。受け取ったパラメータをimg要素の各属性にセットしていきますが、レスポンシブなimg要素の書き方はどうしても冗長的になりがちなんで、使うときは配列でシンプルに書けるようにしました。あと、オリジナルの幅の半分以下の画像を生成する際はsharpがかかるようにしました。

このコンポーネントのセッティング例です。

<IkImg
  :src="content.coverImage.src"
  :srcset="[400, 800, 1200]"
  :qualities="[80, 80, 50]"
  :sizes="[{x768: '100vw'}, '50vw', {n1152: '510px'}]"
  :alt="''"
  :width="1200"
  :height="630"
/>

この場合、幅が400px、800px、1200pxの画像をそれぞれquality80、80、50で生成します。大きなサイズの画像をクオリティを下げて生成する手法はわりとメジャーな方法だそうで、そのようにしてみました。

また、 sizes は上記の例では

sizes="(max-width: 768px) 100vw, 50vw, (min-width: 1152px) 510px"

と出力されます。こちらもプレフィクスを使い記述が少しでも簡潔になるようにしました。

SSG時の画像の生成およびローカルへの保存

server/plugins に、SSG時に画像を生成しローカルに保存するためのNitroプラグインを作成します。 render:html 時にhtmlデータをパースし、このコンポーネントにより出力されたImgタグ[1]に対して、

  1. ImageKitのアドレスをローカルでのパスに変換し、ローカルに既にその画像データが存在するかチェック
  2. 無ければフォルダを作成してfetchしローカルへ保存
  3. src や srcset のパスを保存した画像のパスに変更してレンダリング

という動作を行います。なお、そのままデプロイできるように .output フォルダに保存していきます。

import fsp from 'fs/promises'
import fs from 'fs'
import * as cheerio from 'cheerio'
import { getRelativePath } from '~/utils/getRelativePath'

interface SrcsetItem {
  src: string;
  relativePath: string;
  dir: string;
  width: string;
}

const getSrcsetList = (srcset: string) => {
  const srcsetList: Array<SrcsetItem> = []
  srcset
    .split(',')
    .forEach((item) => {
      const srcsetItem: SrcsetItem = {
        src: '',
        relativePath: '',
        dir: '',
        width: '',
      }
      const [url, widthStr] = item.trim().split(' ')
      srcsetItem.src = url
      const { relativePath, dir } = getRelativePath(url, widthStr)
      srcsetItem.relativePath = relativePath
      srcsetItem.dir = dir
      srcsetItem.width = widthStr
      srcsetList.push(srcsetItem)
    })
  return srcsetList
}


export default defineNitroPlugin((nitroApp) => {
  !process.dev && nitroApp.hooks.hook('render:html', async (html, { _ }) => {
    html.body = await Promise.all(
      html.body.map(async (chunk: string) => {
        const $ = cheerio.load(chunk)
        const imageDir = './.output/public/_img'

        await Promise.all(
          $('img.rsp-img').map(async (_, img) => {
            const srcsetList = getSrcsetList(img.attribs.srcset)
            const srcset: Array<String> = []
            await Promise.all(
              srcsetList.map(async (item) => {
                const saveDir = imageDir + item.dir
                const savePath = imageDir + item.relativePath
                // pathが存在しない場合フォルダを作成してfetch
                if (!fs.existsSync(savePath)) {
                  await fsp.mkdir(saveDir, { recursive: true })
                  const res = await fetch(item.src)
                  const arrayBuffer = await res.arrayBuffer()
                  const buffer = Buffer.from(arrayBuffer)
                  fs.writeFileSync(savePath, buffer)
                }
                srcset.push(`/_img${item.relativePath} ${item.width}`)
              }),
            )
            $(img).attr('src', `/_img${srcsetList.slice(-1)[0].relativePath}`)
            $(img).attr('srcset', srcset.join(', '))
          }),
        )

        return $('body').html()
      }),
    )
  })
})

コンポーネント側でラベリングしていたclassである rsp-img は、この後利用する予定がなければここで削除しても良いですね。

まとめ

今回は各コンポーネントにおいて色んなサイズで使い回すことの多いアイキャッチでの利用を念頭に作ってみました。これが記事内の画像であれば、payloadの兼ね合いでまたアプローチの仕方が変わるかもしれません。

ではでは

補足

クライアントサイドで path を使っていてエラーが出たので、直で処理を書こうかどうか考えましたが、とりあえずvite-plugin-node-polyfillsで逃げました。しかしながら、バンドルサイズを確認してみる必要があるかもしれないですね。


  1. IkImg.vueで"rsp-img"というclassを追加し、ラベリングしています。 ↩︎