Goodpatch 10周年記念サイトを支える技術

Design Div 所属フロントエンドエンジニアの上垣です。

Twitter などで目にされた方もいると思いますが、2021年9月1日に、Goodpatchは10周年を迎えました!そして同日に、10周年記念サイトもリリースしました!

goodpatch.com

Goodpatch 10周年プロジェクトの一環として、企画から実装まで、全て社内のメンバーのみで(実装は私と大角の2名で担当)進めたサイト制作ですが、おかげさまで良い反応をいただくことができました。ありがとうございます!

この記事では、10周年記念サイトで使っている技術にしぼって、ざっくり解説してみたいと思います。 スクラッチで静的サイトを作る際の参考に少しでもなれば嬉しいです。

フレームワークとホスティング環境

サイトのデザインが固まる前から、Next.js で基礎部分を実装していきました。 Next.js を選んだ理由は、個人的に使い慣れているというのもありますが、以下の必須要件を、基本機能の範囲で解決できるためです。

  • パフォーマンスとSEO の観点から、ページの描画は SSG が望ましい
  • 国際化対応が必須

また、開発にかけられる時間が限られていたので、できるだけサイトそのものの開発に集中できるように、ホスティング環境は Vercel を選びました。

Next.js と Vercel を組み合わせる主な利点

  • Github と連携させるだけで、開発からデプロイまでのワークフローが整う
  • 設定なしでリソースキャッシュが最適化される

開発からデプロイまでがスムーズに完了することで、[実装 <=> デザインフィードバック]のループをたくさん回すことができ、ギリギリまで品質の向上にリソースを割り振ることが可能になりました。結局本番リリースまでサイト実装以外の問題に煩わされることがなかったので、やはり静的サイトの配信環境としては鉄板の組み合わせだと思います✌️

アニメーション

サイト内のアニメーションは、ほぼ全て gsap に依存しています。

greensock.com

gsap は今回初めて使ったのですが、独自のインターフェースにさえ慣れてしまえば、簡単に様々なアニメーションの定義ができ、非常に優秀だと感じました。(ただし、商用サービスでの利用には有償ライセンスが必要となるので注意が必要です。)

ビューポート内の要素の位置をトリガーとするアニメーション

通常は Intersection Observer API で実装するような、 要素がビューポートに入ったタイミングでトリガーされるアニメーションは、gsap の ScrollTrigger プラグインで実装しています。

例: フォトギャラリー要素のTOPがビューポートの半分を越えたら、左方向へのトランジションを開始する

import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/dist/ScrollTrigger';

useEffect(() => {
  const current = itemRef.current;
  if (current === null) return;

  gsap.to(current, {
    x: -7200,
    duration: 130,
    ease: Linear.easeNone,
    repeat: 0,
    scrollTrigger: {
      trigger: '#photoGallery',
      start: 'top center'
    }
  });
}, []);

スクロール位置に応じて背景が切り替わるアニメーション

サイト前半部分の、スクロール位置によって背景が切り替わるアニメーションも、gsap の scrollTrigger を応用して実装しています。

動画からフレーム単位で書き出した画像を、現在のスクロール位置に応じて、canvas に連続的に描画することで、アニメーションを実現しています。(パラパラ漫画をスクロールに合わせて進めたり戻したりしてるイメージです)

当然、書き出しフレーム数を増やすほどなめらかにアニメーションさせられるのですが、その分読み込む画像の数も増えていくので、このチューニングにはかなり調整が必要でした。アニメーションのパートごとにスクロールトリガーを設定したりして、なめらかな遷移を実現しました。

実装例

const images: HTMLImageElement[] = [];

// 全体フレーム数
const totalFrameCount = 300;

// アニメーションさせるオブジェクト
const animation = {
  frame: 0
};

// フレームごとの画像を配列に格納する。
for (let i = 0; i <= totalFrameCount; i++) {
  const img = new Image();
  img.src = getCurrentFrame(i);
  images.push(img);
}

// スクロールアニメーションをセットする。
gsap.fromTo(
  animation,
  {
    frame: 0 // スクロール位置によって、この値が、0 <=> frameCount -1 の間で連続的に切り替わる
  },
  {
    frame: frameCount - 1,
    snap: "frame",
    scrollTrigger: {
      markers: false,
      trigger: '#scrollTrigger',
      start: 'top bottom',
      end: 'bottom top',
      scrub: true,
      onUpdate: renderImage
    }
  }
);

// 現在のフレーム画像を canvas 描画する
function renderImage() {
 // スクロール位置に応じた画像を取得する。
  const image = images[animation.frame];
  if (!image) return;

  context.drawImage(
    image,
    canvasX,
    canvasY,
    imageWidth * canvasScale,
    imageHeight * canvasScale
  );
}

画像配信

背景アニメーションの実装で問題になったのは、1分ほどの動画であっても、なめらかな遷移を実現しようとすると、大量の画像が書き出されてしまうということです。

動画から書き出すフレームレートをギリギリまで落としても大した削減にはならず、また、書き出した画像もそのまま使うにはサイズが大きすぎるので、開発側で圧縮やリサイズなど細かいチューニングが必要になります。

ここはギリギリまで品質にこだわりたい部分なので、変更の度にリサイズとデプロイを繰り返すのは時間と労力の無駄・・ということでこの問題を解決するために、画像専用 CDN の imgix を導入しました。

dashboard.imgix.com

imgix の良かったところ

  • 画像の Origin にウェブサイトを指定できる
    Vercel でホストしている画像ファイルをそのまま利用できるので、従来のデプロイフローの変更は不要でそのままimgix を利用できます。

  • 無料プランでも1000ファイルを配信できる
    Origin 1000ファイル/月 までは無料で利用できるので、無料プランでも様々なパターンを検証可能。

  • リサイズ、品質、フォーマット、などをGETパラメーターで指定できる 幅450pxにリサイズし、フォーマットをWebP にする例: https://eample.com/hoge.jpg?w=450&fm=webp

imgix を導入することで、サイト内のオリジナル画像はすべて加工していないJPEG で配置し、webp に対応しているブラウザには WebP を、フォールバックとしてJPEGを、imgix のエッジサーバーから配信できるようになりました。

例: フォトギャラリーの画像読み込み

<picture>
  <source
    srcSet={getImagePath(image, "webp")}
    type="image/webp"
  />
  <img src={getImagePath(image)} alt="photo gallery image"/>
</picture>

動画配信

サイト中盤では、動画ファイルを再生しています。

動画ファイルは mp4 だとどうしてもサイズが大きくなってしまうので、オリジナル動画は mp4 で書き出してもらいつつ、webm 対応ブラウザにはより軽量な webm 形式の画像を配信しています。

残念ながら、今の所 imgix はmp4のフォーマット変換には対応していなかったので、手元で mp4 ⇒ webm に変換して、html で適切なフォーマットを配信しています。

動画の変換は、ffmpeg というツールを利用しました。

例: ffmpeg で mp4 ⇒ webm に変換する

$ ffmpeg -i 10th_web_pc.mp4 10th_web_pc.webm

例: video タグの実装

<video className={styles.video} playsInline muted autoPlay loop>
  <source
    src={getImageFullPath('/10th/movies/v1/10th_web_pc.webm')}
    type="video/webm"
  />
  <source
    src={getImageFullPath('/10th/movies/v1/10th_web_pc.mp4')}
    type="video/mp4"
  />
</video>

国際化(英語版サイト)

英語版サイトに関しては、Next.js の Internationalized Routing と、next-i18next を使って実装しています。

詳しい設定は、以前のテックブログの記事を参照してみてください。

goodpatch-tech.hatenablog.com

その他の細かい最適化

画像の遅延読み込み

ファーストビューで表示されない画像は、全て遅延ロードさせています。

loading 属性 はまだ対応していないブラウザがあるので、react-lazyloadライブラリを利用しました。

github.com

例:フォトギャラリー画像の遅延読み込み

import LazyLoad from 'react-lazyload';

<LazyLoad height={200} offset={600} once>
  <picture className={styles.thumbnail}>
    <source
      srcSet={getImagePath(image, true)}
      type="image/webp"
    />
    <img src={getImagePath(image)} />
  </picture>
</LazyLoad>

イベントの間引き

scroll, resize など連続して発生するイベントにフックさせる処理は、 スロットルをかけて間引いてます。throttle-debounce というライブラリを利用しました。

github.com

例:ヘッダーの表示制御

import { throttle } from 'throttle-debounce';

const toggleHeader = throttle(500, () => {
  const isVisible = 350 > window.scrollY;
  const isScrollUp = prevScrollPosition > window.scrollY;
  prevScrollPosition = window.scrollY;

  setHeaderVisible(isVisible || isScrollUp);
});

useEffect(() => {
  window.addEventListener('scroll', toggleHeader);
  return () => {
    window.removeEventListener('scroll', toggleHeader);
  };
}, []);

Lighthouse 計測結果

完成したサイトを Lighthouse 計測したところ、Desktop で All 100 点取ることができました🎉 (100点取ると紙吹雪が舞うの知らなかったからうれしいw)

おまけ

こっそり仕込んだメッセージに気づいてもらえて嬉しい 😄

まとめ

静的ページの開発とはいえ、アニメーションや画像配信の最適化などチャレンジングな要素が多く、楽しんで実装できました。 開発期間とリソースが限られているなかで無事リリースまで持っていけたのも、SaaS をうまく組み合わせて問題を解決しつつ、サイトの実装に集中できた結果かな、と思います。 (正直に言うとアニメーションとインタラクションの部分はもうちょっと詰めたかったですが・・、それはまた次の機会に try します!)

10周年の節目に、ささやかながら実装面で「デザインの力を証明する」事例作りに貢献できて嬉しく思っています。 この開発で得た知見をクライアントワークでも実践できるよう、日々精進していきます!


Goodpatch では、フロントエンドにこだわりを持って実装できる仲間を絶賛募集中です! 10周年サイトの実装、発信しているメッセージに少しでもピンと来た方は、ぜひ気軽に応募してみてください!