Zenn
🙄

markdown内の画像もnext/imageの最適化の恩恵にあずかりたい!

2025/03/27に公開
2

はじめに

Next.jsでMarkdownをレンダリングする際、よく利用されているreact-markdwonを適用するだけでは通常の img エレメントとなり、Next.jsが提供するImageコンポーネント(next/image)を利用できません。

next/imageを利用するメリットとしては以下の記事で詳しく解説されています。
https://zenn.dev/reiwatravel/articles/fb1586ea9463a1

自分はざっくりと以下のようなメリットがあると認識しています。

  • pngやjpgからもっと効率の良いwebp等の形式に変換される(でかい)
  • 遅延読み込みで初期表示を高速化してくれる
  • いい感じにキャッシュしてくれる

Markdown中の画像にnext/imageを利用しようとすると、最適な width, height を指定することが難しいという課題があります。
このような課題を「markdown内の画像もnext/imageの最適化の恩恵にあずかりたい!」と題してremark pluginを自作することで解決したので紹介したいと思います。

処理の流れ

  • remark pluginのtransformerを利用して画像の場合の処理を挟む
  • 画像のurlを取得して、urlから画像のwidthとhieghtを読み込む
  • widthとhieghtをカスタムプロパティに埋め込む
  • react側でwidthとheightのカスタムプロパティをnext/imageのwidthとheihgtに指定する

実装

urlから画像のwidthとheightを取得するメソッド

image-sizeを利用します。

image.ts
import { Buffer } from "buffer";
import sizeOf from "image-size";

export interface ImageDimensions {
  width: number;
  height: number;
  type?: string;
}

export const getImageDimensions = async (
  imgUrl: string,
  options: {
    maxSize?: number;
    timeout?: number;
  } = {}
): Promise<ImageDimensions> => {
  const {
    maxSize = 10 * 1024 * 1024,
    timeout = 10000,
  } = options;

  try {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), timeout);

    const response = await fetch(imgUrl, {
      method: "GET",
      signal: controller.signal,
      headers: {
        Accept: "image/*",
      },
    });

    clearTimeout(timeoutId);

    if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);

    const arrayBuffer = await response.arrayBuffer();
    const buffer = Buffer.from(arrayBuffer);

    if (buffer.length > maxSize)
      throw new Error(`Image too large. Maximum size is ${maxSize} bytes.`);

    const dimensions = sizeOf(buffer);

    if (!dimensions.width || !dimensions.height)
      throw new Error("Could not determine image dimensions");

    return {
      width: dimensions.width,
      height: dimensions.height,
      type: dimensions.type,
    };
  } catch (error) {
    if (error instanceof DOMException && error.name === "AbortError") {
      throw new Error("Request timed out");
    }
    throw error;
  }
};

画像のwidthとheightをカスタムプロパティに埋め込むremark pluginを作成する

remark-image-dimensions-plugin.ts
import { Node } from "unist";
import { Image } from "mdast";
import { visit } from "unist-util-visit";
import { getImageDimensions } from "@/libs/image";

export const remarkImageDimesionsPlugin = () => {
  return async function transformer(tree: Node) {
    const promises: Promise<void>[] = [];
    visit(tree, "image", (node: Image) => {
      const promise = (async () => {
        try {
          const dimensions = await getImageDimensions(node.url);
          node.data = {
            ...node.data,
            hProperties: {
              originalwidth: dimensions.width,
              originalheight: dimensions.height,
            },
          };
        } catch (error) {
          console.error("Error processing image node:", error);
        }
      })();
      promises.push(promise);
    });
    await Promise.all(promises);
  };
};

markdown rendrerに作成したremark pluginを適用し、画像のエレメントをnext/imageに差し替える

適宜react-markdwonやその他のmarkdown rendrerに読み替えてください

import { MarkdownAsync } from "react-markdown";
import { remarkImageDimesionsPlugin } from "./remark-image-dimensions-plugin";
import NextImage from "next/image";

<MarkdownAsync
    remarkPlugins={[remarkImageDimesionsPlugin]}
    components={{
    img: (
        props: React.ImgHTMLAttributes<HTMLImageElement> & {
        originalwidth?: number;
        originalheight?: number;
        }
    ) => {
        return (
        <NextImage
            className="object-cover rounded-xl"
            src={`${BLOG_CONTENTS_URL}/${props.src}`}
            alt={props.alt || ""}
            // 自作のremarkプラグインで取得した画像のサイズの利用
            width={props.originalwidth || 900}
            height={props.originalheight || 600}
        />
        );
    },
    }}
>
    {mdString}
</MarkdownAsync>

※ react-markdwonのMarkdownAsyncは9.1.0よりサポートされています。2025年2月にリリースされた機能です。

最後に

react-markdwonのMarkdownAsyncを利用すると自作remark-pluginでできる表現の幅が広がって良いなと。

ただ、頑張って自作してるうちに、「もっと簡単に markdwonでnext/image利用できる方法が存在するのでは?」ってなってます。
(rehype pluginを自作する方法は実力不足でreact-markdownではうまく動かせなかった)

mdxを使うとこの辺り最初から解決できている気がしますが、記事の内容はfrontendコードと密結合にしたくない派はのでこのような方針になってしまいました。

また、markdownのレンダリングを極めていくと最終的に react-markdownを卒業することにはなるのではと思っています。react-markdown卒業については以下のブログがとても参考になりました。
https://blog.stin.ink/articles/replace-react-markdown-with-remark

そもそもremarkとは?unifiedとは?ってなっている方は@janus_welさんの以下連載がとても参考になると思います。

2

Discussion

ログインするとコメントできます