1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

ISR でプチ魚拓を作ってみる

Last updated at Posted at 2020-12-20

この記事は、Next.js Advent Calendar 2020 の20日目の記事です。

今回は、 Next.js の今年のアップデートのうちでも話題になった ISR (Incremental Static Regeneration) を使って、プチ魚拓アプリをつくりたいと思います。

できたアプリ

プチ魚拓

image.png

アプリ設計・実装

今回のアプリで、魚拓にする「魚」はリアルタイム性があるものほうが分かりやすいので、この時期、頻繁に更新されてリアルタイム性のある、Qiita の投稿記事の一覧を「魚」にしたいと思います。

API

今回使用する Qiita の API は /api/v2/items です。

ドキュメントはこちら → https://qiita.com/api/v2/docs#get-apiv2items

コード

pages以下には、 index ページ と API Routes として api/posts を用意しています。

.
├── api
│   └── posts.ts
├── index.tsx

今回、大事なところは index.tsx に書いている。以下の部分です。

export const getStaticProps: GetStaticProps = async () => {
  const now = utcToZonedTime(new Date(), 'Asia/Tokyo');
  const nowDateTimeString = format(now, 'yyyy/MM/dd HH:mm:ss', {
    timeZone: 'Asia/Tokyo',
  });

  const posts = await getData();

  return {
    props: { nowDateTimeString, posts },
    revalidate: 3600,
  };
};

ページ自体の更新日時等を叩き込むために、現在日時を取得したりもしていますが、 getData() をしているところが Qiita API 投稿一覧を取得しに行っている処理です。このコードでは、返却値の revalidate3600 に設定されているので、少なくとも 1時間 はページが再生成されないことになります。

この挙動がプチ魚拓っぽいですね。というところがこの記事の肝です。

コード全体も載せておきますが、後述のおまけ用のコードも入っているので初見分かりにくいです。(すみません)

api/posts.ts
import { NextApiRequest, NextApiResponse } from 'next';
import { format, parseISO } from 'date-fns';
import { utcToZonedTime } from 'date-fns-tz';

type Post = {
  title: string;
  createdAt: string;
  url: string;
};

export const getData = async () => {
  const res = await fetch('https://qiita.com/api/v2/items');

  if (!res.ok) {
    throw new Error("can't fetch");
  }
  const data = await res.json();

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const posts = data.map((post: any) => {
    const dt = parseISO(post.created_at);
    const localDt = utcToZonedTime(dt, 'Asia/Tokyo');

    return {
      title: post.title,
      createdAt: format(localDt, 'yyyy/MM/dd hh:mm:ss'),
      url: post.url,
    };
  });

  return posts;
};

const handler = async (req: NextApiRequest, res: NextApiResponse<Post>) => {
  const posts = await getData();
  res.status(200).json(posts);
};

export default handler;
index.tsx
import React, { VFC } from 'react';
import { NextPage, GetStaticProps } from 'next';
import { useRouter } from 'next/router';
import tw, { styled } from 'twin.macro';
import { format } from 'date-fns';
import { utcToZonedTime } from 'date-fns-tz';
import { useForm, UseFormMethods, SubmitHandler } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { getData } from 'pages/api/posts';
import * as z from 'zod';

type IndexPageProps = {
  className?: string;
  nowDateTimeString: string;
  posts: { title: string; createdAt: string; url: string }[];
};

type Props = {
  register: UseFormMethods['register'];
  handleSubmit: UseFormMethods['handleSubmit'];
  errors: UseFormMethods['errors'];
  onValid: SubmitHandler<FormValues>;
} & IndexPageProps;

const Component: VFC<Props> = (props) => {
  const {
    className,
    register,
    handleSubmit,
    errors,
    onValid,
    nowDateTimeString,
    posts,
  } = props;

  return (
    <div className={className}>
      <div className="qiita">
        <div className="title">
          Qiita Posts
          <div className="updatedTime">{nowDateTimeString} 更新</div>
        </div>
        <ul className="posts">
          {posts.map((post) => (
            <li key={post.createdAt}>
              <div className="createdAt">{post.createdAt}</div>
              <div className="postTitle">
                <a href={post.url}>{post.title}</a>
              </div>
            </li>
          ))}
        </ul>
      </div>
      <div className="calendar">
        <form className="form" onSubmit={handleSubmit(onValid)}>
          <input type="date" name="date" ref={register} />
          {errors.date && (
            <div className="errorMessage">{errors.date.message}</div>
          )}
          <button className="submitButton" type="submit">
            検索
          </button>
        </form>
      </div>
    </div>
  );
};

const StyledComponent = styled(Component)`
  & > .qiita {
    ${tw`mb-20`}

    & > .title {
      ${tw`flex items-center justify-center font-bold`}

      & > .updatedTime {
        ${tw`ml-2`}
      }
    }

    & > .posts {
      & > li {
        ${tw`list-none mb-2 flex items-center`}

        & > .createdAt {
          ${tw`mr-4 flex-none`}
        }
      }
    }
  }

  & > .calendar {
    & > .form {
      ${tw`flex flex-col items-center`}

      .errorMessage {
        ${tw`text-red-500`}
      }

      & > .submitButton {
        ${tw`mt-6 mb-10`}
      }
    }
  }
`;

const schema = z.object({
  date: z.string().length(10, { message: 'Should choose a date' }),
});

type FormValues = z.infer<typeof schema>;

const IndexPage: NextPage<IndexPageProps> = (props) => {
  const { push } = useRouter();

  const { register, handleSubmit, errors } = useForm<FormValues>({
    resolver: zodResolver(schema),
  });

  const onValid = async (data: FormValues) => {
    const { date } = data;
    // console.log(data);
    push(`/date/${date}`);
  };

  return (
    <StyledComponent
      {...props}
      {...{ register, handleSubmit, errors, onValid }}
    />
  );
};

export const getStaticProps: GetStaticProps = async () => {
  const now = utcToZonedTime(new Date(), 'Asia/Tokyo');
  const nowDateTimeString = format(now, 'yyyy/MM/dd HH:mm:ss');

  const posts = await getData();

  return {
    props: { nowDateTimeString, posts },
    revalidate: 3600,
  };
};

export default IndexPage;

他、細かいコードには、こちらをご参照ください。

https://github.com/uitspitss/advent-calendar-2020-demo-app

動作確認

  • デプロイ後のアクセスでは、 revalidate がいくつに設定されていてもページ生成が走ります。
  • 生成後、1時間はページの再生成がされないので、 「Qiita Posts」の後の更新日時が更新されず、新しい記事も入ってこないはず…

image.png

おまけ

image.png

Qiita 記事一覧の下に謎に置かれているカレンダーフィールドのフォームは、元々作ろうとしていた「◯日前はいつ?」みたいなアプリを作ろうとしていた名残です。今回、 Qiita の記事一覧を「魚」に使いましたが、元々は Wikipedia の日単位のページの一部を拾ってきて、叩き込もうとしていました。ただ、APIを叩いて情報を見ているうちに、拾ってきたデータ自体にリアルタイム性がほぼなかったので、 Qiita の記事一覧に切り替えました。

Qiita 記事一覧のページが1時間の再生成間隔で、1時間後に確認してみるということ難しいかなと思いましたので、 pages/date/[date].tsx で Dynamic Routes のページを用意しています。こちらは revalidate: 100 なので、100秒後には再生成が可能になります。
適当な日付を選ぶと、読者のあなたがアクセスしたページになるはずなので、100秒後に再生成可能になっていて、再生成されるのか確認できるかと思います。

適当な日付を選んで「検索」ボタン押下

image.png

ページが生成された日が叩き込まれる

image.png

↓ 100秒後以降

image.png

参考にした記事等

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?