問題の発端#
「偉大な授業」についてブログ記事を書く際、スクリーンショットを撮ることが多いのですが、その画像の容量が通常3MBを超えていました。記事に画像が少なければそこまで遅くはなかったのですが、記事を書くたびに少しずつページ遷移が遅くなるのを感じていました。
そこで既存の画像をwebpに変換すれば容量も減り、Lighthouseの評価でも高いスコアが取れるだろうと考え、今日この問題を解決しました。
既存の方式#
ブログ記事の執筆にはObsidianを使っています。そのため、ブログのget.github.ioリポジトリを見るとblogフォルダに.obsidianフォルダが入っています。PC上ではObsidianで該当フォルダを開いてvaultとして使用しています。
Obsidianでも設定は別途できますが、ブログ記事のすべての画像はblog/assetsフォルダに保存されるよう設定しました。
ただし、ここで一つ注意点があります。Next.jsはプロジェクトルートにあるpublicフォルダに画像を置かないと参照できません。
画像をpublicに移すために、package.jsonに以下のスクリプトを追加しました。
"scripts": {
// ...省略
"copy-dir": "mkdir -p public/blog/assets && cp -r blog/assets/* public/blog/assets/",
"predev": "yarn copy-dir",
"prebuild": "yarn copy-dir",
}yarn devでもyarn buildを実行してもcopy-dirが先に動いて画像をコピーするようにしていました。
(predevとprebuildの両方ともローカルでテストした時はうまくいったのですが、GitHub Actionsでは動作しませんでした。理由をご存知の方がいらっしゃれば教えてください...)
このようにコピーされた場所はObsidianで表示されるパスと異なるため、Next.jsの実行環境ではマークダウンファイル内の画像パスを変更する必要があります。この問題はcontentlayer.config.tsにあるremarkPluginsに自作の関数を追加して解決しました。
/**
* @type {import('unified').Plugin<[], Root>}
* @param {string} options.root
*/
const remarkSourceRedirect =
(options?: void | undefined) => async (tree: any, file: any) => {
const images: any[] = []
visit(tree, 'paragraph', (node) => {
const image = node.children.find((child: any) => child.type === 'image')
if (image) {
image.url = `/blog/${image.url}`
}
}
})
}次にGitHub Pagesでもちゃんと表示されるように.github/workflows/next.ymlファイルも修正する必要があります。
# jobs.build.stepsに追加
# ...省略
# 途中に追加してください。next buildの前に実行されるようにしました
- name: Copy blog images to public folder
run: mkdir -p ${{ github.workspace }}/public/blog/assets && cp -r ${{ github.workspace }}/blog/assets/* ${{ github.workspace }}/public/blog/assets/このように複数箇所を修正して動作するようにしましたが、GitHub Actionが実行されたAdd postの結果でArtifacts項目を見ると105MBであることが分かります。ダウンロードすると分かりますが、そのほとんどが画像の容量でした。容量だけでなくUXも良くありませんでした。特定のページに遷移するのが遅すぎたのです。そこで改善することにしました。
改善した方法#
画像の容量削減にはsharpを使用しました。Next.jsもsharpを使用していると認識しています。Linuxコマンドをそのまま使っていた既存の方式から、Node環境でファイルを読み込み、容量を削減し、別のパスにファイルを生成する方が便利だと考えました。
より良い方法は見つかりませんでしたが、Next.jsのビルド段階で提供される関数は見つけられなかったため、このようにNode環境で解決する方法を使用しました。
まずscripts/copy-image.tsというファイルを作成しました。
import fs from 'fs'
import path from 'path'
import sharp from 'sharp'
const copyImage = async (src: string, dest: string) => {
const image = sharp(src)
const resizedImage = image.webp()
await resizedImage.toFile(dest)
}
const srcDir = path.resolve(__dirname, '..', 'blog', 'assets')
const destDir = path.resolve(__dirname, '..', 'public', 'blog', 'assets')
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true })
}
fs.readdirSync(srcDir).forEach(async (file) => {
const imagePath = path.resolve(srcDir, file)
const stat = fs.statSync(imagePath)
if (stat.isFile() && /\.(PNG|JPG|JPEG|png|jpg|jpeg)$/.test(file)) {
const copyPath = path
.resolve(destDir, file)
.replace(/\.(PNG|JPG|JPEG|png|jpg|jpeg)$/, '.webp')
await copyImage(imagePath, copyPath)
}
})__dirnameのパスがscriptsフォルダ内なので、プロジェクトルートに行くために..を追加しました。ところが、tsファイルを作成してtscで実行すると同じパスにjsファイルが生成されてしまいました。実行だけされてファイルは生成されないようにしたかったので、ts-nodeをインストールしました(yarn add -D ts-node)。
既存のスクリプトもこのように変更すれば大丈夫です。
"scripts": {
// ...省略
"copy-dir": "ts-node scripts/copy-image.ts",
"predev": "yarn copy-dir",
"prebuild": "yarn copy-dir",
}そしてremarkSourceRedirect関数も変更する必要があります。webpでファイルを作成したので、既存の拡張子はObsidianでのみ使用され、webpはNext.jsで使われるように伝える必要があります。
// この部分だけ変更しました!
image.url = `/blog/${image.url}`
// このように変更すれば大丈夫です。
image.url = `/blog/${image.url.replace(/\.(PNG|JPG|JPEG|png|jpg|jpeg)$/, '.webp')}`ここまでやればローカルでうまく動作することが確認できます。
最後に.github/workflows/next.ymlも変更すればデプロイ後もうまく動作します。ただし、ts-nodeやsharpなど複数のライブラリを使用しているため、依存関係のインストール後に画像コピーを行う必要があります。
# jobs.build.stepsに追加
# ...省略
- name: Install dependencies
run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }}
- name: Copy blog images to public folder
run: ${{ steps.detect-package-manager.outputs.runner }} copy-dir
- name: Build with Next.js
run: ${{ steps.detect-package-manager.outputs.runner }} buildこれで16MBの容量に圧縮ファイルが作られます!

以前に画像問題を解決した方法#
これは改善前の話ですが、画像をコピーする前はマークダウンファイルから画像パスを取得した後、その画像をbase64に変換してdata:image/webp;base64,UklGRowzAABXのように変更していました。こうすればpublicフォルダに画像を入れなくても、マークダウン内の長いテキストに変換されますがWebで表示できるという利点があります。しかしこの方法も記事が増えるにつれ、考え直さざるを得ませんでした。今回webpで容量を削減する前は、3MBを超える画像が複数ある記事ではボタンクリックイベントが発生してからページ遷移まで数秒かかるのが問題でした。そのため最終的に画像自体を移す方法を考えるようになりました。
そしてもう一つの問題があります。base64に変換するとリンクを共有した時に画像が表示されませんでした。これはSEOスコアの低下にもつながり得ます。
Reason and free inquiry are the only effectual agents against error.
— Thomas Jefferson