はじめて React で作った画像ジェネレーター (超バズった) の裏側の話

## React でネタ画像ジェネレーターを作った話

Twitter でこのツイートを見たこと、ありませんか？

https://x.com/aioilight/status/1828519220688363905

今回、僕が作ったネタ画像ジェネレーターの宣伝ツイートなのですが、たまたまたくさんの人に見てもらうことができて、その結果 **10 万回以上このジェネレーターで画像を作られてしまいました** (これはボタンに GA4 イベントを設置して計測した値なので、もっと多いかもしれません)。この Qiita の記事では、技術的な面から「**どのように作ったのか**」「**どんな課題があったのか**」「**どんな工夫をしたのか**」について書きます。

## どのように作ったのか

使用している技術は、以下の通りです。

- Astro (ウェブサイトのフレームワーク)
- React (ジェネレーター部分)
- Canvas API (画像生成部分)

### Astro

Astro である理由は全くないのですが (笑) ウェブサイト用フレームワークとしてトレンドなものです。静的なサイトや、今回の画像ジェネレーターのようにクライアントのブラウザ内で完結するようなページに向いています。

Astro の特徴は極力 JavaScript を排除するという設計思想にあります。これは React のコンポーネントも同じで、**デフォルトではそのままコンポーネントをレンダリングした結果で HTML を生成する**ため、

```html
<App client:load></App>
```

とすると、クライアントで React が動くようになり、期待された動作になります。

### React

今回ジェネレーターを作る際にはじめて使用しました。以前は素の JavaScirpt でジェネレーターを作っていたのですが、**少しでも複雑になると React のようなライブラリがあると楽**なので使用しています。

### Canvas API

ブラウザ上でグラフィック処理をする API で、おもに 2D 描画に向いています。

## 技術的な課題など

### Canvas 要素のパフォーマンス

開発当初は `OffscreenCanvas` で画像を作成した後、`canvas.convertToBlob();` とすることでバイナリブロブを生成し、データ URL に変換してから img 要素に表示させていたのですが、**全体的な動作がモッサリ**になっていました。描画処理は 10 ミリ秒ほどで終わっているのですが、img 要素への表示まで含めると 100 ミリ秒程度掛かってしまい、**これではモッサリも当たり前**です。

結局、プレビュー画面として `<canvas>` で編集中の様子を表示しながら、ボタンを押したとき img 要素にコピーするよう変更しました。今は 120 Hz のディスプレイを搭載するデバイスがあるため、できればプレビュー自体も 1 / 120 秒で終わってほしいですね……。

### レイアウトシフトの抑制

読み込みなどでページのレイアウトが「ガタッ」と変わってしまうことをレイアウトシフトといいます。これは [Core Web Vitals](https://web.dev/articles/vitals?hl=ja#core-web-vitals) の指標としても採用されており、Google 検索順位の影響もあるため、なるべく抑えなければなりません。

今回の画像ジェネレーターでは、作成する画像が 1920 x 1080 (16: 9) 固定のため、画像を生成する前のプレースホルダーに対してアスペクト比を指定することで解決します。CSS の `aspect-ratio` プロパティが使えますね。

```jsx
{
    savedImage !== '' ? (
        <img src={savedImage} alt='作成した画像'></img>
    ) : (
        <div className='result-placeholder'>
            <p>ここに作成した画像が<span className="ib">表示されます。</span></p>
            <p>右クリックや長押しで保存。</p>
        </div>
    )
}
```

`savedImage` にはデータ URL が入ります。`span.ib` は文章の区切りを指定する `display: inline-block;` です。


```css
.result-placeholder {
    color: #000;
    background: #ccc;
    aspect-ratio: 16 / 9;
    inline-size: 100%;
    display: grid;
    place-content: center;
    padding-inline: 24px;
    text-align: start;
}
```

するとこのようになります。

![ezgif-1-52284563a0.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498509/0b2a9126-5207-40ee-f050-e651e9a06e3c.gif)

ガタつきがないウェブページは最高です 💕💕

### Web フォントの減量

この画像ジェネレーターの元ネタでは、[けいふぉんと](https://font.sumomo.ne.jp/font_1.html) というフォントが使われていました。が、しかし、開発当初はそこまで完璧に近づけることを重視していなかったため、Google Fonts から [Noto Sans JP](https://fonts.google.com/noto/specimen/Noto+Sans+JP) を使用することにしました。

バズり始めたとき、WOFF2 に変換した「けいふぉんと」フォントを使うオプションを追加しました。TrueType フォントのままでは 4 MB くらいあったのですが、WOFF2 フォーマットを使うことで 2 MB まで減らせます。

しかし、**まだまだこれには無駄がある**と僕は考えていました。なぜなら、「けいふぉんと」は「[源真ゴシック](http://jikasei.me/font/genshin/)」のひらがな・カタカナ部分を改変したフォントであり、その「源真ゴシック」は「[源ノ角ゴシック](https://github.com/adobe-fonts/source-han-sans)」をベースとしているからです。

![ezgif-3-c3afefbe51.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498509/af3dc6b0-23e8-0ad1-37e4-aae81f15cc8a.gif)

何を隠そう、**源ノ角ゴシックは Noto Sans JP と同じ** (すごく厳密には違うでしょうが) なので、漢字部分に関しては Noto Sans JP と重複していることになります。上の動画を見ても、それが分かると思います。そのため、けいふぉんとをひらがな・カタカナのみにすることで転送量の減量が実現できます。

```powershell
pyftsubset .\keifont.woff2 --output-file=keifont_kana.woff2 --unicodes=U+3040-309F,U+30A0-30FF --layout-features='*'
WARNING: 'created' timestamp seems very low; regarding as unix timestamp
WARNING: 'modified' timestamp seems very low; regarding as unix timestamp
WARNING: mort NOT subset; don't know how to subset; dropped
```

Pythonのライブラリである [fonttools](https://github.com/fonttools/fonttools) でフォントのサブブセット化をやってみます。インストールすると `pyftsubset` が使えるようになります。`--unicodes` で Unicode 範囲を指定します。`U+3040-309F` がひらがなで、`U+30A0-30FF` がカタカナです。

なんか警告が出ていますが……。動作には関係ないでしょう。

```css
@font-face {
    font-family: "KeiFont";
    src: url("/fonts/keifont_kana.woff2");
    unicode-range: U+3040-309F, U+30A0-30FF;
}
```

CSS の `@font-face` はこうなります。これで「けいふぉんと」と Noto Sans JP を組み合わせて使うことができるようになるのですが、表示がおかしくなりました。

なぜなら、「けいふぉんと」は標準 (400) ウエイトで、Noto Sans JP は 900 ウエイトを使いたいからです。これではウェブブラウザが「けいふぉんと」を勝手に太字処理するようになるため、さらに太くなり文字が潰れてしまいます。Canvas API には `font-synthesis` に相当するものはなさそうです。

解決法は簡単で、`@font-face` で `font-weight` を指定してあげればよいです。

```css
@font-face {
    font-family: "KeiFont";
    src: url("/fonts/keifont_kana.woff2");
    unicode-range: U+3040-309F, U+30A0-30FF;
    font-weight: 900;
}
```

とすることで、

```jsx
// useKeiFont (けいふぉんとを使うかどうかのブール値)
const fontFamily = useKeiFont ? '"KeiFont", "Noto Sans JP"' : '"Noto Sans JP"';
context!.font = '900 ' + fontSize + 'px ' + fontFamily;
```

このように Canvas API でフォントを指定して、期待した動作になりました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498509/6cefe32f-2fd3-3aff-d855-75859d529e2c.png)

もともとはフォントデータだけで 2.4 MB ありましたが、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498509/9d7b7441-885a-aa04-1381-3ecf7e9b4bb3.png)

**313 KB まで減らすことに成功**しました (入力されている文字が変わると、変化します)。

## そのほか、ユーザー体験の向上のためにやったこと

### ファーストビューに無駄な情報を入れない

世の中にある画像ジェネレーターのページをいろいろと見ていると、使い方が長々と書かれていたり、入力欄が先に来ていたりするページが多くありました。

実際のところ、**長々と文字で説明があるとユーザーの離脱に繋がる**と思うので極力排除しました。ただし、「画像の作成処理はすべてブラウザ上で行われる」ことを説明する文章は、**ユーザーのペインポイント** (不安要素) の解消に有用であると思うので、入れています。

また、テキスト入力欄よりも先にプレビューを設置して、あらかじめサンプルのテキストが入った状態でプレビューをすることで、「**どんな画像が作れるのか**」を視覚的に見せています。

### あえて機能をシンプルにする

この画像ジェネレーターでは、画像については大きさを変更することしかできません。技術的には、画像を設置する X/Y座標を調節する機能も実装できるのですが、**あえてそうしませんでした**。しなかった理由としては、操作する場所が多いと、途中でやめてしまう人が増える可能性があるからです。テキスト入力欄だけでも 5 箇所あるので、あまりにも複雑にしすぎると、途中で「やーめた」となる人が増えるでしょう。

### 説明も減らす

![Screenshot_20240905-130418_Vivaldi_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498509/3d0409e5-34c0-f2ff-6e0f-8a01166e1569.png)

この画像ジェネレーターでは左上・右上・左下・右下とその下にある文字というのが最大の特徴です。テキスト入力欄を画像の位置に対応した場所に置くことで、「左上の文字」「右上の文字」……という**説明を入れずとも入力欄がどれに対応しているか分かる**ようにしました。

### 画像を作ったらスクロール

画像を作った後、

```jsx
resultRef.current?.scrollIntoView({block: 'nearest'});
```

というスクリプトを実行して、完成後の画像をスクロール範囲に収めるようにしました。また、完成した画像を表示させるときに発光するエフェクトを追加しました。プレビューが上にあるため、それと混同しないようにするためです。

`scrollIntoView()` に `'nearest'` を指定すると、ビューポート上に既に全体が映っていればスクロールしない。それぞれの方向に見切れていればそれに近い位置で止まる。というような挙動になります。

## 残っている課題

### React の勉強不足

**まだ React を勉強する必要がある**と思いました。もっとコンポーネントを細かく作って役割分担を徹底すべきですが、速く作ることを優先したばかりに粗が目立ちます。Canvas へのレンダリングを `useEffect` で行っているのも、正しいやり方か不明です。

### WebP の最適化

画像リソースとして WebP を使っていますが、**ロスレスでの保存なので思ったより容量が大きい**です。知覚的ロスレスなどで圧縮率を高めるべきでしょう。

WebP って、グラデーションとか色の移り変わりが極端に多い画像だと途端にファイルサイズが膨れ上がるような印象がありますね……。

### 画像が保存できないブラウザの特定・対策

どうやら、**一部のブラウザで画像の右クリックや長押しによる保存がうまくできない**ようです。現時点では「Galaxy 標準ブラウザ」での報告が上がってきています。僕も Android スマホにインストールして検証しましたが、Samsung Galaxy 内蔵と Google Play ストアで配布されているアプリに差異があるのか、普通にダウンロードできてしまいました。

長押しでの保存以外の方法として、ダウンロードボタンを設置した方が良いかもしれません。

### 二重縁取りの最適化

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/498509/d8d4a1a6-1c8e-ed3c-479d-95877048ad2a.png)

こちらの文字は、赤色の文字と擬似要素 2 つを使用して、2 重縁取りを実現しています。

1 つの縁取りであれば、最近全ブラウザで使えるようになった `paint-order` プロパティと `-webkit-text-stroke` プロパティで実装できますが、2 重縁取りだとまだ擬似要素を使う必要があります。

また、縁取り自体もバリのようなものができてしまっています。これは縁取りがマイター結合であるためなのですが、ラウンド結合にする `stroke-linejoin` プロパティは SVG 限定のため、マイター結合しか使えません。`stroke-miterlimit` も同じです。

```jsx
context!.lineJoin = 'round';
```

Canvas API では `lineJoin` でラウンド結合を指定できます。

`paint-order` のように HTML 要素にも適用されるようになってほしいですね！

## まとめ

https://aioilight.space/generator/minnanohannoushuu/

もしよかったら、ぜひ遊んでみて下さい。Qiita ユーザーさんたちの「**架空の『██に対するエンジニア達の反応集』**」をお待ちしております。