RIT Tech Blog

株式会社RITのエンジニアが知見を共有する技術ブログです。

React BootstrapのAccordionで複数のAccordion Collapseを同時に開閉するボタンを作る

こんにちは。RITの関です。

React BootstrapのAccordion.Collapseを同時に開閉するボタンを作るという実装に少し苦労したので、その方法を紹介します。

※この記事はreact-bootstrap 1.6版の実装方法です。2.~版はアコーディオン周りの仕組みが異なっているためこの記事は参考にしないでください。

完成イメージ

完成イメージ
完成イメージ

まずはシンプルなAccordionを作る

test_table.tsx

import { TestList } from '@/Presentation/Component/Home/TestList';
import { NextPage } from 'next';
import React from 'react';

const TestTable: NextPage = () => {
    return (
        <div>
            <div className='mb-4 d-flex align-items-center justify-content-between'>
                <p className='mb-0 font-weight-bold'>アコーディオン一覧</p>
            </div>
            <div>
                {[
                    'accordionTextあああああ',
                    'accordionTextいいいいい',
                    'accordionTextううううう',
                ].map((text, index) => (
                    <div key={index}>
                        <TestList
                            eventKey={index.toString()}
                            accordionText={text}
                        />
                    </div>
                ))}
            </div>
        </div>
    );
};

export default TestTable;

test_list.tsx

import React, { FunctionComponent } from 'react';
import { ListGroup, Button, Accordion, Card } from 'react-bootstrap';

interface Props {
    eventKey: string;
    accordionText: string;
}

export const TestList: FunctionComponent<Props> = props => {
    return (
        <Accordion>
            <Card className='border-0 rounded-0'>
                <Accordion.Toggle as={Button} eventKey={props.eventKey}>
                    <div className='d-flex justify-content-between align-items-center'>
                        {props.accordionText}
                    </div>
                </Accordion.Toggle>
                <Accordion.Collapse eventKey={props.eventKey}>
                    <ListGroup>
                        <p>リストの中身だよ</p>
                    </ListGroup>
                </Accordion.Collapse>
            </Card>
        </Accordion>
    );
};

React Bootstrapのチュートリアルに書いてあるようなシンプルなアコーディオンを作成しました。

簡易アコーディオン
簡易アコーディオン

諸々編集する

次は、諸々更新したtest_tableをまとめて載せます。大きくやったことは下記です。

①openというstateの追加
AccordionOpenという型定義を追加して、openというstateを追加しました。
openの中身は全てを開くか全てを閉じるか判断するisOpenと、ボタンを2回以上連続クリックしても反応してくれるようにtimestampを入れています。
もしtimestampを入れない場合、isOpenの値が変化しないことから、全てを開くボタンが動作してくれなくなります。

②test_listへ渡すpropsにopenを追加
test_listがopenを受け取れるようにして、受け取ったpropsによって全アコーディオンを開閉するようにしています。

③test_list自体にisShowというstateを追加して、Accordion自体をisShowを元に開閉するように変更
Accordion activeKey={isShow ? props.eventKey : ''}と定義することで、isShow: trueのときはactiveKeyとprops.eventKeyが同じ値になるので開く、isShow: falseのときはactiveKeyとprops.eventKeyが異なる値になるので閉じるようになります。
この開閉の仕組自体はreact-bootstrap自体によるものなので、詳細は調べてみてください。

④test_listにuseEffectを追加
test_listにuseEffectを追加して、props.openが変わるごとにisShowの値をprops.openと同じにするようにしています。
これによって、全てを開くボタンを押した時に、openの値がtrueになる→test_listのisShowもtrueになる→アコーディオンが開くという処理ができるようになります!

test_table.tsx

export interface AccordionOpen {
    isOpen: boolean;
    timestamp: number;
}

const TestTable: NextPage = () => {
    const [open, setOpen] = useState<AccordionOpen>({
        isOpen: true,
        timestamp: new Date().getTime(),
    });

    return (
        <div>
            <div className='mb-4 d-flex align-items-center justify-content-between'>
                <p className='mb-0 font-weight-bold'>コンタクト一覧</p>
                <div>
                    <Button
                        variant='outline-primary mr-3'
                        onClick={() =>
                            setOpen({
                                isOpen: true,
                                timestamp: new Date().getTime(),
                            })
                        }
                    >
                        すべてを表示
                    </Button>
                    <Button
                        variant='outline-primary'
                        onClick={() =>
                            setOpen({
                                isOpen: false,
                                timestamp: new Date().getTime(),
                            })
                        }
                    >
                        すべてを閉じる
                    </Button>
                </div>
            </div>
            <div>
                {[
                    'accordionTextあああああ',
                    'accordionTextいいいいい',
                    'accordionTextううううう',
                ].map((text, index) => (
                    <div key={index}>
                        <TestList
                            eventKey={index.toString()}
                            accordionText={text}
                            open={open}
                        />
                    </div>
                ))}
            </div>
        </div>
    );
};

export default TestTable;

test_list.tsx

interface Props {
    eventKey: string;
    accordionText: string;
    open: AccordionOpen;
}

export const TestList: FunctionComponent<Props> = props => {
    const [isShow, setIsShow] = useState(true);

    useEffect(() => {
        setIsShow(props.open.isOpen);
    }, [props.open]);

    return (
        <Accordion activeKey={isShow ? props.eventKey : ''}>
            <Card className='border-0 rounded-0'>
                <Accordion.Toggle
                    as={Button}
                    eventKey={props.eventKey}
                    className='px-4 py-2 text-left'
                    onClick={() => {
                        setIsShow(!isShow);
                    }}
                >
                    <div className='d-flex justify-content-between align-items-center'>
                        {props.accordionText}
                    </div>
                </Accordion.Toggle>
                <Accordion.Collapse eventKey={props.eventKey}>
                    <ListGroup>
                        <p>リストの中身だよ</p>
                    </ListGroup>
                </Accordion.Collapse>
            </Card>
        </Accordion>
    );
};

最後に動きのイメージを貼って終わりにします。react-bootstrapを使うときの役に立てると幸いです。ありがとうございました!

完成イメージ

完成イメージ
完成イメージ

Next.jsでブラウザバック(戻る)を検知して確認ダイアログを表示する

こんにちは!エンジニアの川野です。

最近はBtoCのサービスを開発していて、ブラウザバック(戻る)で編集中のデータが消えないようにする機能を開発しました。その際の課題と解決策をお話したいと思います。

はじめに

本記事では、Next.jsにおいてユーザがブログなどの投稿画面からブラウザバックするときに確認ダイアログを表示する方法を説明します。

完成イメージ
完成イメージ

上記の機能は、ユーザの誤操作によって編集中の投稿が消えてしまうのを防ぐことで、UXの向上を目的としています。

実現したいこと

  • 投稿画面からブラウザバックするときに「保存されていないデータは削除されますが、よろしいですか?」と表示する
  • 前回保存時から投稿に変更があった場合のみ、ダイアログを表示する (未編集時にダイアログが表示されるのは煩わしいため)
  • 「保存する」を押して保存された状態であれば、ダイアログを表示しない

環境情報

  • Next.js: 11.0.1
  • TypeScript: 4.3.5
  • Bootstrap: 5.0.2

実装上の課題

上記を実現するにあたり、2つ課題がありました。

  1. Next.jsではbeforeunloadイベントでブラウザバックを検知できない
    beforeunloadイベントは、画面遷移が要求されページがアンロードされるときに発動されますが、Next.jsでのページ遷移はURLに対応したコンポーネントに差し替えているだけですので、ページがunloadされません。
    したがって、今回はこちらのイベントは使いません。 SPAでなければ、以下のサイトの方法で実現できます。
    フォームの2大誤操作「閉じる・戻る」での離脱を減らす確認ダイアログを実装しよう/15か条の10

  2. ブラウザバック(戻る)による遷移を止めるのに工夫が必要 ※後述します

実装方法

結論、以下の2つの方法で、実装することができました。

方法1: addEventListenerでpopstateイベントを追加する

popstateイベントはブラウザの「戻る」や「進む」を押下した際に発火されます。

popstate イベントは、同じ文書の2つの履歴項目の間で、アクティブな履歴項目が変わるたびにウィンドウに発行されます。
WindowEventHandlers.onpopstate

コード例

pages/posts/new.tsx

import React, { useState, useEffect } from 'react';
import { NextPage } from 'next';

export const NewPost: NextPage = () => {
  const [text, setText] = useState('');
  // 編集中かどうかをstateで管理
  const [isEdited, setIsEdited] = useState(false);

  const handlePopstate = () => {
    const isDiscardedOK = confirm(
      '保存されていないデータは削除されますが、よろしいですか?',
    );
    if (isDiscardedOK) {
      // OKの場合、historyAPIで戻るを実行します。
      window.history.back();
      setIsEdited(false);
    }
    // キャンセルの場合、 ダミー履歴を挿入して「戻る」を1回分吸収できる状態にする
    history.pushState(null, '', null);
  };

  useEffect(() => {
    // 編集中になったとき、現在のページを履歴に挿入し、handlePopstateをイベント登録
    if (isEdited) {
      // ダミー履歴を挿入して「戻る」を1回分吸収できる状態にする
      history.pushState(null, '', null);
      window.addEventListener('popstate', handlePopstate, false);
    }
    // 他のページに影響しないようclear
    return () => {
      window.removeEventListener('popstate', handlePopstate, false);
    };
  }, [isEdited]);

  return (
    <div
      className='d-flex justify-content-center'
      style={{ marginTop: '15vh' }}
    >
      <div>
        <h1>投稿画面</h1>
        <form>
          <textarea
            rows={10}
            cols={60}
            value={text}
            onChange={e => {
              setText(e.target.value);
              setIsEdited(true);
            }}
          ></textarea>
        </form>
        <div className='d-flex justify-content-end'>
          <button
            className='btn btn-primary mt-3 me-3'
            // 保存したときは、編集中フラグをfalseにする
            onClick={() => setIsEdited(false)}
          >
            保存する
          </button>
          <button className='btn btn-primary mt-3'>投稿する</button>
        </div>
      </div>
    </div>
  );
};

export default NewPost;

実際に入力欄に文字を打った後に「戻る」を押してみてください。確認ダイアログが表示されるはずです。 入力前と保存をした後であれば確認ダイアログは表示されません。

捕捉説明
history.pushState(null, '', null);で現在のページと同じ履歴を挿入することができます。パラメータは前から、state(状態オブジェクト), title, url になっており、urlにnullを指定することで現在のページの履歴が挿入されます。参考: History.pushState()

現在のページと同じ履歴、いわばダミーの履歴を挿入する理由は、「戻る」によるURLの変更を制御できないためです。

たとえば、以下のようにコメントアウトしておきます。

    ...
    if (isEdited) {
      // history.pushState(null, '', null);
      window.addEventListener('popstate', handlePopstate, false);
    }
    ...

投稿画面のURLは/posts/new、ブラウザバックで戻るURLが/postsだとします。

投稿画面のURL
投稿画面のURL

入力した後に「戻る」を押すと、確認ダイアログが出ますが、URLが/postsに戻ってしまいます。

「戻る」を押した後
「戻る」を押した後

という具合に「戻る」によるURLの変更を制御できないことがわかります。 これによって、URLと実際にレンダリングされている画面にズレが生じてしまいます。

ダミーの履歴を挿入することでこれを解決します。

posts/posts/new
となっていた履歴の間にダミー履歴を挿入して、
posts/posts/newposts/new
にします。

この状態であれば「戻る」を一度押しても、posts/newにいる状態を維持できます。

実は編集後に保存を押すと、確認ダイアログは出なくなるのですが、「戻る」を二度押さないと戻ることができません。これは上記のダミー履歴が蓄積されていることによるものです。

ユーザが編集中のデータを失わないという本質的な要件は満たしており、実装コストが見合わなそうだったので、今回はダミー履歴が蓄積されてしまう事象は解決しませんでした。

方法2: Router.beforePopStateを使う

Router.beforePopStateは、Routerのメソッドで以下の説明にあるようにpopstateイベントを捕捉してイベントを発動するために利用しています。

Router.beforePopState 場合によっては(例えば、カスタムサーバーを使用する場合)、popstate をリッスンして、ルーターが動作する前に何かしたいということがあります。
next/router

コード例

pages/posts/new.tsx

import React, { useState, useEffect } from 'react';
import { NextPage } from 'next';
import { useRouter } from 'next/router';

export const NewPost: NextPage = () => {
  const [text, setText] = useState('');
  // 編集中かどうかをstateで管理
  const [isEdited, setIsEdited] = useState(false);

  const router = useRouter();

  const setHandlePopstate = () => {
    // ダミーの履歴を挿入し、ブラウザバックを1回分吸収する
    history.pushState(null, '', null);
    router.beforePopState(() => {
      const isDiscardedOK = confirm(
        '保存されていないデータは削除されますが、よろしいですか?',
      );
      // OKの場合、historyAPIで戻るを実行します。
      if (isDiscardedOK) {
        setIsEdited(false);
        router.back();
        return true;
      }
      // キャンセルの場合、 ダミー履歴を挿入して「戻る」を1回分吸収できる状態にする
      history.pushState(null, '', null);
      return false;
    });
  };

  // trueをreturnしてページ遷移が正常に動作するように戻す
  const clearHandlePopstate = () => {
    router.beforePopState(() => true);
  };

  useEffect(() => {
    // 編集中になったとき、現在のページを履歴に挿入し、handlePopstateをイベント登録
    if (isEdited) {
      setHandlePopstate();
    } else {
      clearHandlePopstate();
    }
    // 他のページに影響しないようclear
    return () => clearHandlePopstate();
  }, [isEdited]);

  // 以下は方法1と同じ
  return (
    <div
      className='d-flex justify-content-center'
      style={{ marginTop: '15vh' }}
    >
      <div>
        <h1>投稿画面</h1>
        <form>
          <textarea
            rows={10}
            cols={60}
            value={text}
            onChange={e => {
              setText(e.target.value);
              setIsEdited(true);
            }}
          ></textarea>
        </form>
        <div className='d-flex justify-content-end'>
          <button
            className='btn btn-primary mt-3 me-3'
            // 保存したときは、編集中フラグをfalseにする
            onClick={() => setIsEdited(false)}
          >
            保存する
          </button>
          <button className='btn btn-primary mt-3'>投稿する</button>
        </div>
      </div>
    </div>
  );
};

export default NewPost;

挙動は方法1と同じです。

捕捉説明
useEffect内で、return () => clearHandlePopstate();してあげることが大切です。Routerに紐づけるイベントなのでclearしないと関係のないページで確認ダイアログが表示される可能性があります。

また、確認ダイアログを無効化するために保存によってisEditedfalseになったときもclearHandlePopstateを実行しています。

おわりに

方法1は主にブラウザの機能で実現していて、方法2は主にNext.jsの機能で実現しています。

ブラウザ機能の「戻る」が絡む実装なので、ブラウザ機能に寄せたいという方は方法1を、Next.js上で実装しているのでNext.jsに寄せたいという方は方法2を使うと良いと思います。

やや強引に実装しましたが、そもそも簡単に実装できるブラウザAPIが提供されていればよいですよね。

調べたところ「戻る」が効かないサイトを作ることができてしまうため、UXの観点から提供されていないようです。

ブラウザバックの制御については社内のエンジニアも毎回面倒くさいと嘆いていました。

この記事を読んで、ブラウザバックを制御しようとしている方の嘆きがなくなれば幸いです。

【Python】AtcoderでTLEになる計算量の閾値を調べてみた 【PyPy】

目的

最近趣味でAtcoderという競技プログラミングサイトで活動をしているのですが、 提出するコードの計算量をかなりざっくりと見積もってしまっていて、提出した結果、指定された実行時間を超えてしまいTLE(Time Limit Exceeded)になってしまうことが多いので、 どの程度の計算量であればTLEにならずに実行できるのか調査してみました

調べてみた

環境

言語: PyPy 実行時間制限: 2 sec メモリ制限: 1024 MB

10 ** 7回ループ回してみる

for _ in range(10 ** 7):
    a = 'a'

N = int(input())
print(((N - 1) // 100) + 1)

f:id:keimaeda0817:20211129180731p:plain 10**7までは大丈夫そうですね、ただ487msかかっているので10**8ループだとTLEになりそうです

10 ** 8回ループにしてみる

for _ in range(10 ** 8):
    a = 'a'

N = int(input())
print(((N - 1) // 100) + 1)

f:id:keimaeda0817:20211129181036p:plain TLEになりました。今回ループ内で行っている処理がかなり簡単なものなので、どのような処理でも10**8以上のループはTLEになりそうです

結果

10 ** 7回のループまでならTLEにならずに実行できることが分かりました。 C問題などを解いていても体感10 ** 7くらいまでなら通るので、体感通りでした。

getStaticPathsやgetStaticProps、getServerSidePropsなどが突然消える

こんにちは!エンジニアの川野です。

台風が過ぎ去って夏本番の暑さですね。怖い話でも聞いて涼みたいたいということで、 今回はVSCodeで表題の関数が突然消えるという恐怖体験について書きました。

心霊現象

Next.jsで開発をしているとき、下の画像のようなエラーに度々遭遇しました。

度々遭遇するエラー
度々遭遇するエラー

Server Error
Error: getStaticPaths was added without a getStaticProps in /posts/[id]. Without getStaticProps, getStaticPaths does nothing

This error happened while generating the page. Any console logs will be displayed in the terminal window.

直接の原因はエラーメッセージのとおり、getStaticPathsが書かれているのにgetStaticPropsが書かれていないというものでした。

ただ、実装し忘れてそうなったのではなく、実装してあったgetStaticPropsが突然、何者かによってファイルから消されてしまったのです。getServerSidePropsについても同様に消されてしまうことがありました。

消えたコードを履歴から戻せばエラー自体は解決するのですが、毎回30秒ほど時間を奪われるので原因をしっかり調査しました。

環境情報

  • VSCode: 1.58.2
  • TypeScript: 4.2.3
  • ESLint: 7.23.0

原因

その何者かは、VSCodeの到達できないコードの削除機能とsettings.jsonの設定値でした。

VSCodeにはreturnthrowの後方の、プログラム的に到達不可能なコードを削除する機能があります。

たとえば、以下のようにコードを書き、returnの後方のコードで cmd + .を押すと、Remove unreachable code とコマンドが出てきます。

到達不可能なコード
到達不可能なコード

実行すればもちろん消えてしまいます。

Remove unreachable code 実行後
Remove unreachable code 実行後

そしてこの機能はsettings.jsonで下記の設定をしていると、save時に発動するようになってしまいます。

settings.json

{
    "editor.codeActionsOnSave": {
        "source.fixAll": true
    }
}

解決策

もちろんsource.fixAllfalseにすれば突然消えるということはなくなるのですが、それではESLintのフォーマットが動作しなくなってしまいます。

以下の設定にすればESLintのフォーマットだけ動作するようになりました。

settings.json

{
    "editor.codeActionsOnSave": {
        "source.fixAll.eslint": true
    }
}

参考:Valid "unreachable" code is being aggressively removed as a result of syntax errors when allowUnreachableCode: false #109530

フォーマットされなくても、到達不可能コードはESLintが検知してくれます。
disallow unreachable code after return, throw, continue, and break statements (no-unreachable)

到達不可能コードはESLintが検知
到達不可能コードはESLintが検知

無事、解決できました!

RIT卒業します

初めまして。2020/11からRITでインターンとして参画させていただいた千葉と申します。 今回はRIT卒業にあたり、振り返りの記事を書かせていただきます。

インターン参画前

僕がRITにインターンとして参画する前は、実務経験がなくほぼ未経験者といった状態でした。一応プログラミングスクールで勤務していたので最低限の基礎は習得していましたが、実際のサービス開発は経験したことはありません。(社内のみで使用する小さなアプリケーションは開発したことはありますが)

インターンへの応募理由

プログラミングスクールで実務を学べる機会(開発に進む機会)もありましたが、会社の開発人数や予算の都合といった理由から、その道に進むことが難しい状況でした。そこでインターンとして実務経験を積み、リアルな開発現場というものを味わってみたかったというのがインターンへの応募理由です。そこで偶然、Wantedly経由でRITに出会い参画させていただくことになりました。

インターンの良かった点

未経験の言語に携われたこと

これまで触ったことのなかったフロントエンドの言語に携わらせていただきました。具体的にはReactとTypeScriptで、この開発を機にフロント周りの学習を始め、徐々に知識も身に付いてきたのかなと思います。

実際の案件に携われたこと

始めの1~2ヶ月は自社サービスの改修に携わりましたが、その後は実際の案件に携わらせていただきました。自身の転職事情でリリースまで携わることができなかったことに申し訳なさと後悔がありますが、1つのサービスを1から携われたことは自分にとって大きな成長機会になりました。

好きな時間で開発ができる

案件の納期等はありますが、基本的には自分の都合で開発を進めることができました。僕の場合は本業もあったので、平日夜や土日にメインで作業を進めていました。

インターン生でも気軽に発言できる

僕含め他のインターン生を見ていても、上下関係があまりなく、誰でも気軽に発言できる雰囲気があると感じました。インターン生と正社員の間には壁ができがちですが(少なくとも本業で働いていた会社ではありました)、交流会を開いていただいたり、MTGに参加させていただいたりと、できるだけこういった壁をなくす工夫がされていてとてもありがたかったです。

後悔していること

  • 携わっているプロジェクトを完遂できなかったこと
  • ReactとTypeScriptの案件に携わる期間が短かったこと

僕が原因で出来なかったことなのですが、後悔している点を挙げるとすると上記2つになります。携わっているプロジェクトがなくならない限り、おそらくは基本的に最後まで携わることができると思います、多分。

今後の進路

今後はヘルステックの企業でRailsエンジニアとして勤務予定です。転職活動の際にReactとTypeScriptを用いたポートフォリオを作成しましたが、これはRITで開発業務に携わらせていただく機会がなければ作成できなかったと思います。今後も引き続きサーバーサイドだけでなくフロントのスキルも高めていきたいと考えています。

最後に

RITではエンジニアを募集しています。 興味のある方はぜひ覗いてみてください。 https://www.wantedly.com/companies/rit-inc/projects

GCPでドメイン買えるようになってた(まだプレビュー)

GCPでサービスを構築するときにドメイン(Google Domains)とメール(SendGrid)周りだけ外部サービス使うのが面倒でなかなかクライアントに提案しづらかった福田です。

いつもの外部サービスの設定面倒だなーと思ってなんとなくdomainで検索してたら謎のCloud Domainsというサービスがあったので試してみました。

f:id:rit-inc:20201122171341p:plain

結論から言うと、AWSのRoute 53みたいにドメインの購入からDNSへの紐付けまでGCP上で一気通貫で完結するサービスでした。すごい嬉しい。

2020/11/25 追記

Cloud Runからでも買えるようになってたみたい

f:id:rit-inc:20201125230524j:plain

作業ログ

f:id:rit-inc:20201122171433p:plain

ドメインを登録ってリンクが気になる

まさかRoute53みたいにGCPだけでドメインの購入からDNSの設定まで完結するようになる?

もしそうなら超嬉しい

f:id:rit-inc:20201122171458p:plain

買えそうなのでカートに入れて続行

f:id:rit-inc:20201122171521p:plain

そのままCloud DNSまでつながってくれるっぽい

別のラジオボタンクリックしたら"Cloud DNSを使用する"に戻せなくなるので注意(1敗)

DNS APIを有効にしてから公開DNSゾーンを作れって書いてあるのに作らずに進むと最後に登録に失敗するので注意(1敗)

f:id:rit-inc:20201122171539p:plain

Cloud DNSにゾーン作ってから続行

f:id:rit-inc:20201122171638p:plain

whoisは保護しとく

f:id:rit-inc:20201122171707p:plain

情報を登録

f:id:rit-inc:20201122171600p:plain

できた!

さいごに

新規サービス立ち上げのためにAWSGCPでインフラを構築することが多いんですが、これでGCPで構築するのが更に楽になりますね。

特にクライアントによっては契約が一本増えるだけで手続きが複雑になったりもするので、その点でも嬉しいです。

RITでは、新しいサービスを活用した効率的なプロダクト開発でサービスの0~1をリードするエンジニアを募集しています!

herp.careers

コーポレートサイトのNext.js 10対応ついでにパフォーマンス改善してみた

f:id:rit-inc:20201107133327p:plain

最近京都に引っ越した福田です。 全然更新できてないですがエンジニアも増えてきたので頻度上げたいとは思ってます。

Next.js 10リリースされましたね

next/imageが出てきたりhrefasが要らなくなったりi18n系の新機能だったり、すぐにでも使ってみたい機能が色々ありましたね。 RITのコーポレートサイトではNext.js使ってるんですが、特に画像の最適化をサボってたせいでLighthouseのスコアがよろしくなくて、丁度いい機会だったので主にnext/image使うためにバージョンアップしてみました。

変更点

インフラ構成の変更

旧構成

github actions + firebase
旧構成

図にするほどのものでもないですがGitHub Actionsでbuildとexportしたファイルをfirebase hostingにデプロイしてます。

新構成

github actions + firebase + cloud run
新構成

Cloud Runが追加されてますね。 これは微妙にハマったポイントでもあるんですが、現状next/imageSSR必須っぽいのでnextを動かすサーバが必要になります(違ったらごめんなさい)。 なので、firebaseでキャッシュされてるリソースはfirebaseから返しつつ、キャッシュされてないリソースは裏のCloud Runにリクエスト投げてビルドしてもらうような構成に変更しました。 実際のfirebase.jsonは↓みたいな感じ(rewritesのところがCloud Runにリクエスト投げる設定)。

{
  "hosting": {
    "public": "public",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
        {
            "source": "**",
            "run": {
                "serviceId": "<Cloud Runのサービス名>",
                "region": "asia-northeast1"
            }
        }
    ]
  }
}

Cloud Runはリクエストがなければ0までスケールダウンしてくれるので、適切にキャッシュしておけばCloud Runの費用はほぼかからないはず。 この"適切にキャッシュ"というのが厄介なんですが、firebaseはCloud RunからのレスポンスのpublicなCache-Controlヘッダを見てfirebaseへのキャッシュを行う(参考)ので、next.jsのレスポンスでCache-Controlヘッダを返してやる必要があります。 特にキャッシュしちゃいけないリソースもないので、ここでは適当にすべてのパスへのリクエストで1日キャッシュするように設定しておきました。 nextのheader周りあんまりちゃんと理解してないのでもっといい設定方法があるかも。

module.exports = {
    async headers() {
        return [
            {
                source: '/',
                headers: [
                    {
                        key: 'Cache-Control',
                        value: 'public, max-age=86400',
                    },
                ],
            },
            {
                source: '/:path*',
                headers: [
                    {
                        key: 'Cache-Control',
                        value: 'public, max-age=86400',
                    },
                ],
            },
        ];
    },
};

next/imageの導入

一番面倒だったのはlayout指定しないと必須になるwidthとheightの指定です。 ただ、これはそもそもnext/image使わずただのimgタグだったとしてもUX的にちゃんとしといたほうがいいので頑張って指定しましょう。 他はimgタグと同じ感覚で大丈夫でした。

<Image
    src='/static/img/top/service.jpg'
    alt='service'
    width={612}
    height={510}
/>

レスポンスの画像がwebpになってる
webp確認

ちゃんとwebpになってますね。

結果

(Next.js関係なくページ遷移時のアニメーション消したのも影響してますが許してください)

変更前

Performanceが低い
Lighthouse結果

よろしくない数字ですね。

変更後

Performanceが91に改善
Lighthouse結果

だいぶ改善しました。 画像の最適化という面倒な問題をnext/image使うだけでいい感じにやってくれるのは便利ですね。

さいごに

最初はnext/image使って高速化するだけのつもりだったんですが、Lighthouseちゃんと見てみたらNext関係ない部分で最適化できる箇所とかも割とあったので、適宜パフォーマンスチェックするのは大事ですね。

RITでは、新しい技術をキャッチアップしてユーザに最適なパフォーマンスで価値を提供できるエンジニアを募集しています!

herp.careers