Nuxt3で画像の静的生成機能を自前で実装する(Static Images)

公開日:2023/1/31更新日:2023/7/29

追記:公式モジュールで画像を静的生成できることを確認しました。やり方を別記事で紹介しています。

Nuxt3には画像変換モジュールとしてNuxt/Imageがありますが、長らく静的画像の生成機能がサポートされず、いまだに公式ページにはStatic Image support is under progress for Nuxt 3.のメッセージが表示されています。

いつかはサポートされるのだと思いますが、いい加減待てなくなったので、自前で実装してみました。パスやフォルダ構造など適宜変換しながらコードを眺めていただければと思います。

前提

  • Nitroプラグインのフックを使用
  • フック名はrender:html
  • 必要な外部モジュールはsharpcheerio
  • 画像生成とは別に読み込む側のパス修正が必要(例:https://example.com/images/hoge.jpeg ~/images/hoge.jpeg)
  • 外部リソース(バックエンド)はWordPressで、動的ルーティング(pages/blog/[postId].vue)でページを生成
  • 画像を読み込む側(例:Post.vue)で画像のパスを修正、その後、画像リソースからローカルに画像を生成、の順番になるため、修正後のパスを元のパスに戻す余計な処理が発生
  • imgタグの情報を元にwebp画像も同時に生成(picturesourceのタグを使用)
  • 画像生成失敗のエラーハンドリングなどは記述していません。

手順

以下、画像を読み込むページ(例:Post.vue)のパス修正から画像生成までを紹介します。

ヘルパー関数の用意

server/utilsフォルダ内に以下の3つのファイルを作成します。

generatePostContent.ts

用途:本文をパースして画像パスを修正する

import { load } from "cheerio";
import getSrcsetInfo from "../utils/getSrcsetInfo";
import getStaticImageInfo from "../utils/getStaticImageInfo";

export default function (rawHtml: string) {
  const $ = load(rawHtml);

  !process.dev &&
    $("figure img").each((_, img) => {
      const srcset = img.attribs.srcset;
      const $picture = $("<picture>");
      const imageDir = `/blog/images`;
      const $img = $("<img>");

      for (const [key, value] of Object.entries(img.attribs)) {
        if (key !== "src" && key !== "srcset") {
          $img.attr(key, value);
        }
      }

      if (srcset) {
        const srcsetArr = getSrcsetInfo(srcset).map(({ src, size }) => {
          const { filePath } = getStaticImageInfo({
            src,
            imageDir,
          });
          return {
            original: `${filePath.original} ${size}w`,
            webp: `${filePath.webp} ${size}w`,
          };
        });

        $picture.append(
          $("<source>")
            .attr("sizes", img.attribs.sizes)
            .attr("type", "image/webp")
            .attr("srcset", srcsetArr.map(({ webp }) => webp).join(","))
        );
        $img.attr(
          "srcset",
          srcsetArr.map(({ original }) => original).join(",")
        );
      }

      const { filePath } = getStaticImageInfo({
        src: img.attribs.src,
        imageDir,
      });
      $picture.append(
        $img.attr("src", filePath.original).attr("alt", img.attribs.alt)
      );
      $(img).replaceWith($picture);
    });

  return $.html();
}

getSrcsetInfo.ts

用途:srcsetの文字列からsrcsizeを分ける

export default (srcset: string) => {
  return srcset
    .split(",")
    .map((url) => {
      const [src, sizeWithString] = url.trim().split(" ");
      const size = Number(sizeWithString.replace("w", ""));

      return {
        src,
        size,
      };
    })
    .sort((a, b) => (a.size > b.size ? 1 : -1));
};

getStaticImageInfo.ts

用途:srcと画像を保存するルートフォルダの場所を引数で与え、画像を保存するフォルダと画像パスを取得する。

export default ({ src, imageDir }: { src: string; imageDir: string }) => {
  const [originalFilename, month, year] = src.split("/").reverse();
  const fileDir = `${imageDir}/${year}/${month}/`;
  const filename = {
    original: originalFilename,
    webp: originalFilename.replace(/.jpg|.png/, `.webp`),
  };
  const filePath = {
    original: fileDir + filename.original,
    webp: fileDir + filename.webp,
  };

  return {
    fileDir,
    filePath,
  };
};

読み込む側で画像のパスを修正

動的ページ(例:pages/blog/[postId].vue)内で、外部から取得した本文中の画像パスを修正します。パス変換には先ほど作成したヘルパー関数generatePostContent.tsを使用します。

<script lang="ts" setup>
import type { IBlogPost } from "~/queries/types";
import query from "~/queries/post.gql";
import generatePostContent from "~~/server/utils/generatePostContent";

const route = useRoute();
const { data } = await useAsyncQuery<IBlogPost>(query, {
  postId: Number(route.params.postId),
});
if (!data.value?.postBy) {
  throw new Error("Not found post data");
}
if (process.dev || (!process.dev && process.server)) {
  data.value.postBy.content = generatePostContent(data.value.postBy.content);
}
const { title, content } = data.value.postBy;
</script>

<template>
  <article>
    <h1>{{ title }}</h1>
    <div v-html="content"></div>
  </article>
</template>

画像をローカルに生成する

server/plugins内にファイルを作成し(例:postContentHandler.ts)、以下のコードを記述します。example.comの部分は適宜修正してください。

import fs from "fs/promises";
import sharp from "sharp";
import { load } from "cheerio";
import getSrcsetInfo from "../utils/getSrcsetInfo";
import getStaticImageInfo from "../utils/getStaticImageInfo";

export default defineNitroPlugin((nitroApp) => {
  !process.dev &&
    nitroApp.hooks.hook("render:html", async (html, { event }) => {
      const generateAbsoluteImagePath = (relativePath: string) =>
        relativePath.replace(
          "/blog/images",
          `https://example.com/wp-content/uploads`
        );

      html.body = await Promise.all(
        html.body.map(async (chunk: string) => {
          const $ = load(chunk);
          const imageDir = `./.output/public/blog/images`;

          await Promise.all(
            $("figure img").map(async (_, img) => {
              const src = generateAbsoluteImagePath(img.attribs.src);
              const srcset = img.attribs.srcset;
              const srcsetInfo = srcset ? getSrcsetInfo(srcset) : false;
              const sourceUrl = srcsetInfo
                ? generateAbsoluteImagePath(srcsetInfo.reverse()[0].src)
                : src;
              const res = await fetch(sourceUrl);
              const arrayBuffer = await res.arrayBuffer();
              const sharpImg = sharp(Buffer.from(arrayBuffer));

              if (srcsetInfo) {
                await Promise.all(
                  srcsetInfo.map(async ({ src, size }) => {
                    const { fileDir, filePath } = getStaticImageInfo({
                      src,
                      imageDir,
                    });
                    await fs.mkdir(fileDir, { recursive: true });
                    const resizedImg = sharpImg.resize({ width: size });
                    await Promise.all([
                      resizedImg.toFile(filePath.original),
                      resizedImg.webp().toFile(filePath.webp),
                    ]);
                  })
                );
              } else {
                const { fileDir, filePath } = getStaticImageInfo({
                  src,
                  imageDir,
                });
                await fs.mkdir(fileDir, { recursive: true });
                await sharpImg.toFile(filePath.original);
              }
            })
          );
          return chunk;
        })
      );
    });
});

補足①:私が使用している実際のコードでは、event.context.paramsの値を使ってブログページかどうかを判別しています。

補足②:この関数は自動的に読み込まれるため、nuxt.config.tsを変更する必要はありません。

まとめ

以上で外部リソースの画像をローカルに生成することができます。

画像パスの修正と画像生成を同じタイミングにしたかったのですが、sharpモジュールがエラーを吐き出したり、逆にエラーを吐き出さないタイミングで行おうとすると、payloadとhtmlファイルの情報で食い違いが起きたりと、なかなか上手くいきませんでした。私が使用しているNuxt Apolloモジュールとの兼ね合いもありました。

少し不恰好なコードですが、参考にしていただければ幸いです。