Hidetoshi Yanagisawa
Full-stack Web Developer
【JAMスタックなブログを作ろう その16】fetchでのWEB通信をリポジトリパターンで整理しよう
2021年05月17日Next.js
microCMS
現在API通信のロジックはそれぞれのページに直書きしています。export const getStaticPaths: GetStaticPaths = async () => { const key: any = { headers: { "X-API-KEY": process.env.API_KEY ?? "" }, }; const res: any = await fetch( `${process.env.NEXT_PUBLIC_API_BASE_URL}blog?fields=id&limit=30`, key ) .then((res) => res) .catch((err) => console.log(err)); const data = await res.json(); const paths = data.contents.map((post: IPost) => `/posts/${post.id}`); return { paths, fallback: false }; }; headersにAPIキーを設定する部分は共通になっており、他の箇所でも同様に記述してしまっております。またAPI通信の具体的な記述が書かれており、ロジック部分が煩雑になってしまっております。ですので、このデータ操作のロジックを切り離し、抽象的なインターフェースを作りソースの見渡しをよくしようと思います。そのためにRepositoryパターンを使ってるためにリファクタしていきます。本記事のソースは以下となっております。適宜確認下さい。https://github.com/yanagisawahidetoshi/nextJS-blog/tree/fetch-repository-patternリポジトリを作ろうAPI通信する部分のベースとなるリポジトリを作成します。microCMSはgetとpostでAPIキーが変わり、headersの中身を少し違うので一旦getとpostでわけました。src/repositories/index.tsexport default { // データ取得用のgetメソッド get: async (path: string) => { const key = { headers: { "X-API-KEY": process.env.API_KEY ?? "" } }; const url = `${process.env.NEXT_PUBLIC_API_BASE_URL}${path}`; const res: any = await fetch(url, key) .then((res) => res) .catch((err) => { throw err; }); return res.json(); }, // データ登録等用のpostメソッド post: async (path: string, payload: {}) => { const key = { headers: { "X-WRITE-API-KEY": process.env.NEXT_PUBLIC_WRITE_API_KEY ?? "", "Content-Type": "application/json", }, method: "POST", ...payload, }; const url = `${process.env.NEXT_PUBLIC_API_BASE_URL}${path}`; const res: any = await fetch(url, key) .then((res) => res) .catch((err) => { throw err; }); return res.json(); }, };次にエンドポイントごとにリポジトリを作っていきます。今回はLaravelのリソースコントローラを参考にCRUDのパターンを作りました。src/repositories/blogRepository.tsimport Repository from "./"; const RESOURCE = "blog"; export default { // ブログ記事一覧取得 index: () => { return Repository.get(`${RESOURCE}?limit=99`); }, // ブログ記事詳細取得 show: (id: string) => { return Repository.get(`${RESOURCE}/${id}`); }, // ブログ記事投稿 create: (body: {}) => { // ここは投稿なのでRepositoryのメソッドがpostに変わってます。 return Repository.post(`${RESOURCE}`, body); }, };src/repositories/userRepository.tsimport Repository from "."; const RESOURCE = "user"; export default { index: (query: string) => { return Repository.get(`${RESOURCE}/?${query}`); }, };これでリポジトリは作成できたので、これを実際に反映していきましょう。リポジトリパターンを適用してこうそれではfetch APIを使用していた箇所をリポジトリに置き換えていきます。src/components/pages/Admin/CreatePost/useForm.tsimport { useCallback, useState, FormEvent } from "react"; import { useSession } from "next-auth/client"; + import blogRepository from "repositories/blogRepository"; export const useForm = () => { const [title, setTitle] = useState(""); 〜省略〜 async (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); const key = { - headers: { - "X-WRITE-API-KEY": process.env.NEXT_PUBLIC_WRITE_API_KEY ?? "", - "Content-Type": "application/json", - }, - method: "POST", body: JSON.stringify({ title, content, user: session?.id, }), }; - await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}blog`, key) - .then((res) => res) - .catch((err) => console.log(err)); + blogRepository.create(key); }, [title, content] );/Users/yanagisawahidetoshi/works/blog/src/pages/index.tsximport { NextPage } from "next"; import PageIndex from "components/pages/Index"; import { IPost } from "models/posts"; + import blogRepository from "repositories/blogRepository"; 〜省略〜 export const getStaticProps = async () => { - const key = { - headers: { "X-API-KEY": process.env.API_KEY ?? "" }, - }; - const res: any = await fetch( - `${process.env.NEXT_PUBLIC_API_BASE_URL}blog?limit=30`, - key - ) - .then((res) => res) - .catch((err) => console.log(err)); - const data = await res.json(); - - return { - props: { - posts: data.contents, - }, - }; + try { + const data = await blogRepository.index(); + return { + props: { + posts: data.contents, + }, + }; + } catch (error) { + return { notFound: true }; + } }; export default Index;src/pages/api/auth/[...nextauth].tsimport NextAuth from "next-auth"; import Providers from "next-auth/providers"; import type { NextApiRequest, NextApiResponse } from "next"; + import userRepository from "repositories/userRepository"; 〜省略〜 const findUserByCredentials = async (credentials: TCredentials) => { - const key = { - headers: { "X-API-KEY": process.env.API_KEY ?? "" }, - }; - const res: any = await fetch( - `${process.env.NEXT_PUBLIC_API_BASE_URL}user?filters=email[equals]${credentials.email}[and]password[equals]${credentials.password}`, - key - ) - .then((res) => res) - .catch((err) => console.log(err)); - const data = await res.json(); - if (data.contents.length > 0) { - return { id: data.contents[0].id, name: data.contents[0].name }; - } else { - return null; - } + try { + const data = await userRepository.index( + `filters=email[equals]${credentials.email}[and]password[equals]${credentials.password}` + ); + if (data.contents.length > 0) { + return { id: data.contents[0].id, name: data.contents[0].name }; + } else { + return null; + } + } catch (error) { + return null; + } };src/pages/posts/[id].tsximport cheerio from "cheerio"; import hljs from "highlight.js"; import "highlight.js/styles/night-owl.css"; + import blogRepository from "repositories/blogRepository"; 〜省略〜 export const getStaticPaths: GetStaticPaths = async () => { - const key: any = { - headers: { "X-API-KEY": process.env.API_KEY ?? "" }, - }; - const res: any = await fetch( - `${process.env.NEXT_PUBLIC_API_BASE_URL}blog?fields=id&limit=30`, - key - ) - .then((res) => res) - .catch((err) => console.log(err)); - const data = await res.json(); - + const data = await blogRepository.index(); const paths = data.contents.map((post: IPost) => `/posts/${post.id}`); return { paths, fallback: false }; }; 〜省略〜 if (!params?.id) { return { notFound: true }; } - - const key: any = { - headers: { "X-API-KEY": process.env.API_KEY ?? "" }, - }; - const res: any = await fetch( - `${process.env.NEXT_PUBLIC_API_BASE_URL}blog/${params.id}`, - key - ) - .then((res) => res) - .catch((err) => console.log(err)); - const post = await res.json(); - + const post = await blogRepository.show(params.id.toString()); if (post === undefined) { return { notFound: true }; }これでリポジトリパターンの適用が出来、ソースコードの見渡しも良くなりました。本記事のソースは以下となっております。適宜確認下さい。https://github.com/yanagisawahidetoshi/nextJS-blog/tree/fetch-repository-pattern
Continue Reading →【JAMスタックなブログを作ろう その15】投稿画面をログイン必須にしよう
2021年05月14日NextAuth.js
JAMスタックなブログを作ろう
Next.js
microCMS
ログイン機能と投稿画面が出来ましたが、現在投稿画面はURLさえ知っていれば誰でもアクセス可能で、投稿時のユーザIDはハードコートされている状態になっているので、未ログインで投稿画面にアクセスしたらトップ画面にリダイレクトされるようにコーディングし、ユーザIDはnext-authのuseSession経由で取得できるようにします。本記事のソースは以下となっております。適宜確認下さい。https://github.com/yanagisawahidetoshi/nextJS-blog/tree/postArticle/createPostuseSessionでユーザIDを取得できるようにしようまずはユーザIDをnext-authのuseSession経由で取得できるようにしましょう。と言っても、useSessionのインターフェースは以下のようになっており、デフォルトではIDは入っていないです。{ user: { name: string, email: string, image: uri }, expires: "YYYY-MM-DDTHH:mm:ss.SSSZ" }ですので、useSessionのインターフェースを拡張する必要があります。useSessionの拡張はCallbacksを使って行います。callbacksはnext-authのoptionに書くので、src/pages/api/auth/[...nextauth].tsに書いていきますsrc/pages/api/auth/[...nextauth].tsconst options = { providers: [ 〜 省略 〜 ], + callbacks: { + session: async (session: any, data: any) => { + return Promise.resolve({ + ...session, // data.subの中にresponseのIDが入っている。 + id: data.sub, + }); + }, + }, }; export default (req: NextApiRequest, res: NextApiResponse) => これでuseSessionのインターフェースが拡張され以下のようにIDが追加されています。{ user: { name: string, email: string, image: uri }, + id: string, expires: "YYYY-MM-DDTHH:mm:ss.SSSZ" }投稿時のユーザIDをuseSessionから取得するように修正しようuseSessionにIDが追加できたので、それを使えるようにしましょう。下層ページでデータを取得するのにReactのuseContextのProviderを使います。src/pages/app.tsximport type { AppProps } from "next/app"; + import { Provider } from "next-auth/client"; import "modern-css-reset/dist/reset.min.css"; import { ThemeProvider } from "styled-components"; import { theme, GlobalStyles } from "ThemeConfig"; 〜省略〜 <> <GlobalStyles /> <ThemeProvider theme={theme}> - <Component {...pageProps} /> // Providerにsessionを追加し、下層ページでsessionを取得できるようにします。 + <Provider session={pageProps.session}> + <Component {...pageProps} /> + </Provider> </ThemeProvider> </> ); カスタムhooksでuseSessionからIDを取得します。これで投稿時に使うユーザのIDを動的に取得できるようになりました。src/pages/admin/create-post/useForm.ts-import { env } from "process"; import { useCallback, useState, FormEvent } from "react"; +import { useSession } from "next-auth/client"; export const useForm = () => { const [title, setTitle] = useState(""); const [content, setContent] = useState(""); + const [session] = useSession(); const handleSubmit = useCallback( async (event: FormEvent<HTMLFormElement>) => { 〜省略〜 body: JSON.stringify({ title, content, - user: process.env.NEXT_PUBLIC_USER_ID, + user: session?.id, }), }; await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}blog`, key) 投稿画面をログイン必須にでは次に投稿画面をログイン必須にし、未ログイン状態ならトップページにリダイレクトされるようにします。今回の投稿ページはCSR(クライアントサイドレンダリング)なので、リダイレクトはnext/routerを使います。src/pages/admin/create-post/index.tsx import { NextPage } from "next"; +import { useRouter } from "next/router"; +import { useSession } from "next-auth/client"; import { useForm } from "./useForm"; import * as S from "./styles"; const Index: NextPage = () => { const { setTitle, setContent, title, content, handleSubmit } = useForm(); + const [session] = useSession(); + const router = useRouter(); + // 未ログインならsessionの中身が空になります。 + if (!session) { // 未ログインならnext/routerでトップにリダイレクトします。 + router.push("/"); + } return ( <S.Form onSubmit={handleSubmit}> <S.InputWrapper> これで未ログイン時には投稿ページにアクセスすることができなくなりました。最後に、ログインしたら投稿ページに遷移するようにしましょう。src/pages/admin.tsx import { signIn, useSession } from "next-auth/client"; +import { useRouter } from "next/router"; import { useEffect } from "react"; const Admin = () => { const [session, loading] = useSession(); + const router = useRouter(); + useEffect(() => { if (loading) { return; } - !session ? signIn() : console.log(session.user?.name); + !session ? signIn() : router.push("admin/create-post"); }); return null; これで投稿画面の一連の流れができました!本記事のソースは以下となっております。適宜確認下さい。https://github.com/yanagisawahidetoshi/nextJS-blog/tree/postArticle/createPost
Continue Reading →ReactNativeで画像が表示されない問題を解消し、patch-packageで修正内容を共有する方法
2021年05月10日ReactNative
iOS 14 + ReactNative環境下でImageが表示されなくなりましたので、その対応方法になります環境React Native(vanilla): v0.62.2iOS:14.4VS Codeyarn対応方法node_modules/react-native/Libraries/Image/RCTUIImageViewAnimated.mに以下修正をします。node_modules/react-native/Libraries/Image/RCTUIImageViewAnimated.m if (_currentFrame) { layer.contentsScale = self.animatedImageScale; layer.contents = (__bridge id)_currentFrame.CGImage; + } else { + [super displayLayer:layer]; } }これでSimulatorを再起動すると無事画像は表示されるようになります。修正の永続化と共有方法修正自体はこれで完了なのですが、修正した箇所がnode_modules下なので、ここは大概.gitignoreに登録されており、git経由で共有出来ずyarnコマンド実行時に元に戻る可能性があります。ですのでpatch-packageというツールを使ってパッチを作成し、それをpost-installで反映させるようにします。patch-packageの導入こちらを参考にインストールします$ yarn add --dev patch-package postinstall-postinstallパッチの作成patch-packageを使って、先ほど修正したnode_modules下のreact-nativeのパッチを作成します。パッチ作成のコマンドはyarn patch-package {モジュール名}となります。$ yarn patch-package react-native上記コマンド実行後に以下のパッチファイルが作成されます。patches/react-native+0.62.2.patchパッチの適用方法パッチは作成しただけでは適用されないので、yarnコマンド実行時にパッチがあたるようにします。それを実現させるためにpostinstall コマンドを使用します。postinstallはyarnコマンド実行後に実行されるコマンドなので、そこにpatch-package を紐付けていればパッチをあててくれるようになるので、package.jsonにscriptを追加しますpackage.json"scripts": { + "postinstall": "patch-package" }これでyarn実行するとパッチがあたるので、永続化+共有することが可能になりました。
Continue Reading →【JAMスタックなブログを作ろう その14】記事を投稿しよう
2021年05月07日NextAuth.js
JAMスタックなブログを作ろう
microCMS
Next.js
前回ログイン機能を実装できたので、投稿できるようにしていきます。※注意こちらのmicroCMSの料金プランにある通り、書き込みは無料プランでは100回/月までなので、注意してください。本記事のソースは以下となっております。適宜確認下さい。https://github.com/yanagisawahidetoshi/nextJS-blog/tree/postArticle/createPostmicroCMSでPOSTできるようにしようmicroCMSではデフォルトではGETのみ可能なので、POST もできるようにします。API設定→HTTPメソッドを選択し、POSTを有効にします。また最低限のセキュリティ対策として、記事を投稿したユーザを入力できるように、ブログAPIに以下のフィールドを追加します。フィールドID:user | 表示名:投稿者 | 種類:コンテンツ参照-ユーザー書き込み用のAPI-KEYを作成しよう取得用のAPI-KEYと書き込み用のAPI-KEYは異なるので、それ用のAPI-KEYを作成しましょう。①サイドバーの歯車アイコンをクリック②API-KEYをクリック③新規作成をクリックこれでAPI-KEYは作成出来ました。投稿ページを作成しましょうNext.jsに投稿用のページを作成します。認証が必要なページなので、分かりやすいようにadminディレクトリ下に作成します。$ mkdir src/pages/admin/create-post $ mkdir src/components/pages/Admin $ touch src/pages/admin/create-post/index.tsx $ touch src/pages/admin/create-post/styles.tsまずはタイトルとコンテンツを登録できるようなページを作成します。src/pages/admin/create-post/index.tsx import { NextPage } from "next"; import * as S from "./styles"; const Index: NextPage = () => { return ( <S.Form> <S.InputWrapper> <S.Label htmlFor="title">タイトル</S.Label> <S.InputText type="text" name="title" id="title" /> </S.InputWrapper> <S.InputWrapper> <S.Label htmlFor="content">コンテンツ</S.Label> <S.TextArea name="content" id="content" rows={10} /> </S.InputWrapper> <S.Button type="submit">登録</S.Button> </S.Form> ); }; export default Index;src/pages/admin/create-post/styles.tsimport styled from "styled-components"; export const Form = styled.form` padding: 8px; font-size: 16px; `; export const Label = styled.label` display: block; margin-bottom: 8px; `; export const InputText = styled.input` width: 100%; `; export const TextArea = styled.textarea` width: 100%; `; export const InputWrapper = styled.div` margin-bottom: 24px; `; export const Button = styled.button``;stateを使って値を管理しよう次にそれぞれのフォームの値を管理するためにstateを使っていきます。stateの話をすると長くなるのですが、以下のような機能を持っています。変数のように後から値を更新できる。初期値も設定可能stateの値が変わればDOMが再描画される。stateは直接更新せずに、setHogeのような関数で更新するstateは以下のように宣言します。const [title, setTitle] = useState<String>("");useState("");でstateの初期化をしています。今回は初期値として空の値を入れています。title が実際のstate名でここに値が入ります。setTitle がこのstateの値を更新する用の関数になります。使用する場合にはsetTitle("hogehoge")のように使用します。今回もロジック部分はカスタムhooksとして外出ししているので、stateもカスタムhooksにて使用するようにします。カスタムhooksを作成します。$ touch src/pages/admin/create-post/useForm.ts 作成したカスタムhookにstateを記述します。このstateがそれぞれのフォームの値と連動します。src/pages/admin/create-post/useForm.tsimport { useCallback, useState } from "react"; export const useForm = () => { const [title, setTitle] = useState(""); const [content, setContent] = useState(""); return { title, content, setTitle, setContent }; };src/pages/admin/create-post/index.tsx import { NextPage } from "next"; +import { ChangeEvent } from "React"; +import { useForm } from "./useForm"; import * as S from "./styles"; const Index: NextPage = () => { // カスタムhooksから関数を呼び出します。 + const { setTitle, setContent, title, content } = useForm(); return ( <S.Form> <S.InputWrapper> <S.Label htmlFor="title">タイトル</S.Label> - <S.InputText type="text" name="title" id="title" /> <S.InputText type="text" name="title" id="title" + required // inputタグのchangeイベントが発火時にstateを更新しています。 + onChange={(event: ChangeEvent<HTMLInputElement>) => { + setTitle(event.target.value); + }} // inputの値はstateから取得します。 + value={title} /> </S.InputWrapper> <S.InputWrapper> <S.Label htmlFor="content">コンテンツ</S.Label> - <S.TextArea name="content" id="content" rows={10} /> <S.TextArea name="content" id="content" rows={10} + required + onChange={(event: ChangeEvent<HTMLTextAreaElement>) => + setContent(event.target.value) + } + value={content} /> </S.InputWrapper> <S.Button type="submit">登録</S.Button> </S.Form>これでstateの更新はできるようになりました。API連携し記事を投稿しましょうそれでは実際にmicroCMSに記事を投稿するようにしましょう。src/pages/admin/create-post/index.tsx import * as S from "./styles"; const Index: NextPage = () => { - const { setTitle, setContent, title, content } = useForm(); - + const { setTitle, setContent, title, content, handleSubmit } = useForm(); return ( - <S.Form> // formのsubmitが発火したらカスタムhooksのイベントをコールします。 + <S.Form onSubmit={handleSubmit}> <S.InputWrapper> <S.Label htmlFor="title">タイトル</S.Label> <S.InputTextsrc/pages/admin/create-post/useForm.ts-import { useCallback, useState } from "react"; +import { useCallback, useState, FormEvent } from "react"; export const useForm = () => { const [title, setTitle] = useState(""); const [content, setContent] = useState(""); - return { title, content, setTitle, setContent }; // formのsubmitイベントが発火した時に呼ばれる関数 + const handleSubmit = useCallback( + async (event: FormEvent<HTMLFormElement>) => { + event.preventDefault(); + const key = { + headers: { + "X-WRITE-API-KEY": process.env.NEXT_PUBLIC_WRITE_API_KEY ?? "", + "Content-Type": "application/json", + }, + method: "POST", + body: JSON.stringify({ title, content, user: "{ユーザID}" }), + }; + await fetch(`{API_URL}`, key) + .then((res) => res) + .catch((err) => console.log(err)); + }, + [title, content] + ); + + return { title, content, setTitle, setContent, handleSubmit }; };今回のポイントは、API呼び出しがSSGなどのサーバサイドで実行するSSGなどではなくクライアント側で実行するのでその場合環境変数を読み込むにはNEXT_PUBLIC_をつける必要があります。ですのでNEXT_PUBLIC_WRITE_API_KEYとしています。もう一点useCallbackの第2引数にtitleとcontent を渡していますが、これはstateが更新されてもその値を取得できるようにするためです。もしこれが渡されていなければ、titleとcontentはstateの初期値である空のままとなります。 これでひとまず投稿するところまで出来ました。本記事のソースは以下となっております。適宜確認下さい。https://github.com/yanagisawahidetoshi/nextJS-blog/tree/postArticle/createPost
Continue Reading →【JAMスタックなブログを作ろう その13】ログイン機能を作ろう
2021年05月07日Next.js
microCMS
JAMスタックなブログを作ろう
NextAuth.js
現在記事の投稿はmicroCMS上で投稿しており、その機能自体にはなんの不満もないのですが、勉強をかねて記事投稿を実装していきます。※Next.js + microCMSでの実装なのでどうしても簡易的なものになってしまうので、その辺はご理解ください。投稿するためにはログインしてから、記事を投稿するのが常なのでまずはログイン機能を実装していきます。Next.jsでログイン機能を組み込むにはNextAuth.jsを使います。本記事のソースは以下となっております。適宜確認下さい。https://github.com/yanagisawahidetoshi/nextJS-blog/tree/postArticle/loginmicroCMSでユーザーを作成しようログインユーザーの管理はmicroCMSで行うので、例によってmicroCMSでAPIを作成します。API名:ユーザー、エンドポイント:userで作成します。スキーマは以下のように作成しました。フィールドID:name 表示名:名前 種類:テキストフィールドフィールドID:email 表示名:メールアドレス 種類:テキストフィールドフィールドID:password 表示名:パスワード 種類:テキストフィールドあとはそれぞれのデータを適宜入力します。ひとまずこれでmicroCMSでの作業は終わりです。ログイン機能を実装しようモジュールを追加する先ほども書いた通りログインの実装にはNextAuth.jsを使用するので、インストールします。$ yarn add next-authAPIルートファイルを作成するNextAuth.js 用のログイン機能やログイン画面の設定する用のファイルを作成します$ mkdir src/pages/api $ mkdir src/pages/api/auth $ touch touch "src/pages/api/auth/[...nextauth].ts"src/pages/api/auth/[...nextauth].tsimport NextAuth from "next-auth"; import Providers from "next-auth/providers"; import type { NextApiRequest, NextApiResponse } from "next"; type TCredentials = { email: string; password: string; }; // ログイン画面から渡ってきたデータを使ってmicroCMSからデータを取得します const findUserByCredentials = async (credentials: TCredentials) => { const key = { headers: { "X-API-KEY": process.env.API_KEY ?? "" }, }; // microCMSのfiltersを使ってユーザを取得します。 const res: any = await fetch( `${process.env.API_BASE_URL}user?filters=email[equals]${credentials.email}[and]password[equals]${credentials.password}`, key ) .then((res) => res) .catch((err) => console.log(err)); const data = await res.json(); // メールとパスワードで検索し見つかればIDと名前を返却します if (data.contents.length > 0) { return { id: data.contents[0].id, name: data.contents[0].name }; } else { return null; } }; const options = { providers: [ Providers.Credentials({ name: "Email", // ログイン画面の設定項目です。ここの内容がNextAuthのログイン画面のテンプレートに使われます。 credentials: { email: { label: "Email", type: "email", placeholder: "email@example.com", }, password: { label: "Password", type: "password" }, }, authorize: async (credentials: TCredentials) => { // ユーザを取得します const user = findUserByCredentials(credentials); if (user) { return Promise.resolve(user); } else { return Promise.resolve(null); } }, }), ], }; export default (req: NextApiRequest, res: NextApiResponse) => NextAuth(req, res, options); これでログインの下準備はできました。ログインページを作成しようまずはログインページを作成します。ログインページのテンプレートはNextAuth.jsのものをそのまま使います。$ touch src/pages/admin/index.tsximport { signIn, useSession } from "next-auth/client"; import { useEffect } from "react"; const Admin = () => { const [session, loading] = useSession(); useEffect(() => { if (loading) { return; } !session ? signIn() : console.log(session.user?.name); }); return null; }; export default Admin;NextAuth.jsのsession でログインしているかを判定しておりsignIn()関数を呼び出すことによって、NextAuth.jsのデフォルトのログイン画面に遷移するようになります。http://localhost:3000/adminにアクセスすると以下のログイン画面にリダイレクトされれば問題ないです。これで正しいEmailとPasswordを使ってログインすると、console.logにユーザ名が表示されれば完成です。本記事のソースは以下となっております。適宜確認下さい。https://github.com/yanagisawahidetoshi/nextJS-blog/tree/postArticle/login
Continue Reading →【JAMスタックなブログを作ろう その12】タグを追加しよう
2021年05月06日Next.js
microCMS
ブログの記事をカテゴライズするためにタグを追加していきましょう。本記事のソースは以下となっております。適宜確認下さい。https://github.com/yanagisawahidetoshi/nextJS-blog/tree/tagsmicroCMSに設定するまずはmicroCMSにてタグAPIを追加します。コンテンツ(API)をクリックし、APIを追加します。API名はタグ、エンドポイントはtagにしました。次にAPIスキーマ(インターフェース)を定義を定義します。フィールドID:name、表示名:名前、種類:テキストフィールドとして設定しました。次にブログAPIにフィールドを追加します。①サイドバーでブログを選択②API設定を選択③APIスキーマを選択フィールドに追加画面にてフィールドIDはtags、表示名はタグに設定し種類は複数コンテンツ参照でタグを選択します。これでmicroCMS側の設定は終わりなので、適宜情報は追加しておいてください。タグを表示させよう次にタグコンポーネントを作成します$ mkdir src/components/atoms/Tag $ touch src/components/atoms/Tag/index.stories.tsx $ touch src/components/atoms/Tag/index.tsx $ touch src/components/atoms/Tag/styles.tssrc/components/atoms/Tag/index.tsximport * as S from "./styles"; type Props = { name: string; }; const Tag: React.FC<Props> = ({ name }) => { return <S.Wrapper>{name}</S.Wrapper>; }; export default Tag;src/components/atoms/Tag/styles.tsimport styled from "styled-components"; export const Wrapper = styled.p` border: 1px solid #bfc8d2; border-radius: 15px; color: #3e465b; padding: 5px 10px; display: inline-block; margin: 8px 8px 0 0; `;src/components/atoms/Tag/index.stories.tsximport Tag from "./"; export default { title: "components/atoms/Tag", component: Tag, }; export const Basic = () => <Tag name="Next.js" />; export const LineUpTwos = () => ( <> <Tag name="Next.js" /> <Tag name="Storybook" /> </> );コンポーネントを組み込んでみようタグコンポーネントが出来たので、それを組み込んでみます。もうすでにblog APIのレスポンスにはtagsは組み込まれているので、その前提でやっていきます。src/components/atoms/Summary/index.stories.tsximport Summary from "./"; + import { mockTags } from "../../../models/tags"; export default { title: "components/atoms/Summary", component: Summary, }; export const Basic = () => ( + <Summary + date="2021-01-01" + title="rタイトル" + excerpt="hogehoge" + slug="slug" + tags={mockTags} + /> );src/components/atoms/Summary/index.tsximport Date from "./Date"; import ContinueReading from "./ContinueReading"; import FormatDate from "../FormatDate"; + import Tag from "../Tag"; + import { ITag } from "models/tags"; type Props = { date: string; 〜省略 width: number; height: number; }; + tags: Array<ITag>; }; -const Summary: React.FC<Props> = ({ date, title, excerpt, slug, image }) => { +const Summary: React.FC<Props> = ({ + date, + title, + excerpt, + slug, + image, + tags, +}) => { return ( <Wrapper> {image && ( 〜省略 〜 <Date> <FormatDate date={date} /> </Date> + {tags.map((tag, index) => { + return <Tag name={tag.name} key={index} />; + })} <Content>{excerpt}</Content> <Link href={slug}> <ContinueReading>Continue Reading &rarr;</ContinueReading>src/components/pages/Index/index.tsx title={post.title} excerpt={removeHtml(post.content)} slug={`./posts/${post.id}`} + tags={post.tags} /> </Card> );src/models/posts.ts+ import { ITag, mockTags } from "./tags"; export interface IPost { id: string; //記事ID title: string; // 記事タイトル 〜省略 〜 createdAt: string; // 記事作成日時 description: string; kv: { url: string; height: number; width: number }; + tags: Array<ITag>; } export const mockPosts: Array<IPost> = [ 〜省略 〜 createdAt: "2021-01-01", description: "ディスクぷション", kv: { url: "http://placehold.jp/150x150.png", width: 150, height: 150 }, tags: mockTags, }, { id: "b2", src/models/tags.tsexport interface ITag { id: string; //記事ID name: string; // 記事タイトル } export const mockTags: Array<ITag> = [ { id: "aaaa", name: "Next.js", }, { id: "BBBB", name: "Storybook", }, ];これで一覧ページにタグが表示されるようになりました。
Continue Reading →Homebrewを使おうとすると「homebrew-cask is a shallow clone. To `brew update` first run:」のエラーを解消
2021年05月04日ある日brew を使おうとすると以下のエラーが出てbrewコマンドが使えなくなったので、その対処法の備忘録です。Error: homebrew-cask is a shallow clone. To `brew update` first run: git -C "/usr/local/Homebrew/Library/Taps/homebrew/homebrew-cask" fetch --unshallow This restriction has been made on GitHub's request because updating shallow clones is an extremely expensive operation due to the tree layout and traffic of Homebrew/homebrew-cask. We don't do this for you automatically to avoid repeatedly performing an expensive unshallow operation in CI systems (which should instead be fixed to not use shallow clones). Sorry for the inconvenience!最初に書いてあるとおり以下のgitコマンドを試してみたのですが、変化はなくエラーは出たままでした。$ git -C "/usr/local/Homebrew/Library/Taps/homebrew/homebrew-cask" fetch --unshallowいろいろ調べてみると以下のコマンドを入力すれば良いとの記事があり、こちらを試してみたところ、無事brew update が成功するようになりました。$ brew untap homebrew/cask $ brew tap homebrew/cask
Continue Reading →Next.js + styled-componentで更新するとCSSが読み込まれなくなるエラーを修正
2021年05月03日Next.js
styled-components
Next.js + styled-component環境でブラウザの更新をすると、以下のようなエラーとなりCSSが適用されない状態になってしまいます。Warning: Prop `className` did not match. Server: "sc-hBURRC ebtTVt" Client: "sc-gsDJrp emRoaR"またChrome DevToolsでclass名を見ると難読化されており、どのコンポーネントかもわからない状態になっています。なぜ?CSSを修正するとローカルでは新しいCSSが作られクラス名が変わるが、クライアントのHTMLが指定しているクラス名は変更前のままなので、クラス名が異なりエラーになってしまいます。対応方法こちらの記事を参考にしましたhttps://github.com/zeit/next.js/issues/7423.babelrc に以下を追記するとエラーは解消され、styled-componentsのコンポーネント名も表示されるようになりデバッグがやりやすくなります。{ "presets": ["next/babel"], + "plugins": [ + [ + "styled-components", + { "ssr": true, "displayName": true, "preprocess": false } + ] + ] }
Continue Reading →Next.js + TypeScriptでモジュールを絶対パスで読み込むには
2021年05月03日Next.js
Storybook
Next.jsでモジュールを読み込む場合、通常では相対パスで以下のように読み込みます。import Layout from "../../Layout";これぐらいなら良いのですが、階層が深くなってくると以下のような感じになり、相対パスで書くのが困難になってきます。import Layout from "../../../../../Layout";これを絶対パスで読み込めれば、可読性が上がるので絶対パスで読み込むように修正します。環境VSCodeNext.jsTypeScriptStorybook 6.2構造は以下のようになっておりsrc 直下にソースコードを入れています。├── src │ ├── components │ │ └── atoms │ │ ├── Layout.tsx絶対パスを有効にするにはtsconfig.jsonに"baseUrl": "src" を追加すれば良いです。tsconfig.json "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve" + "jsx": "preserve", + "baseUrl": "src" },これで以下のようにsrc 以下からのパスで設定できます。import Layout from "Layout";Storybookを使っていた場合の対応このままだとstorybookでは絶対パスが読み込めないので、Storybookも絶対パスで読み込めるように.storybook/main.js を修正します。.storybook/main.js+ const path = require("path"); module.exports = { stories: ["../src/**/*.stories.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"], addons: ["@storybook/addon-links", "@storybook/addon-essentials"], + webpackFinal: async (config) => { + config.resolve.modules = [ + ...(config.resolve.modules || []), + path.resolve(__dirname, "../src"), + ]; + return config; + }, };
Continue Reading →next/image + Storybookで画像が表示されないのでモック化して表示させる
2021年04月26日Next.jsでStorybookを使って開発しているのですが、Storybookでnext/image を使おうとすると画像が表示されませんでした。Storybookでnex/imageを使えるようにしよう色々調べてみるとこちらの記事でnext/imageを使う場合はモック化する必要があると書いてありました。こちらの記事にあるとおり.storybook/preview.jsに追記します。.storybook/preview.jsimport * as nextImage from "next/image"; Object.defineProperty(nextImage, "default", { configurable: true, value: (props) => { const { width, height } = props; const ratio = (height / width) * 100; return ( <div style={{ paddingBottom: `${ratio}%`, position: "relative", }} > <img style={{ objectFit: "cover", position: "absolute", minWidth: "100%", minHeight: "100%", maxWidth: "100%", maxHeight: "100%", }} {...props} /> </div> ); }, });絶対パスの画像を表示させようまずはこれでnext/image はモック化されているのですが、Storybookでサンプル画像を表示させたい場合はhttp://placehold.jp/こちらのようなダミー画像作成サービスを使った方が便利です。しかしデフォルトでは絶対パスで指定されているURLは表示されないので、許可するドメインを設定する必要があります。参照:https://nextjs.org/docs/basic-features/image-optimizationこの設定はnext.configで指定可能です。デフォルトではこのファイルは無いので、無ければ作ってください。next.config module.exports = { images: { domains: ["placehold.jp"], }, };これでStorybookにアクセスすると画像が表示されていることが確認できました。
Continue Reading →Next.js + TypeScript環境でStorybookを導入する
2021年04月25日Next.js + TypeScriptの環境にStorybookを導入する手順です。Storybookは簡潔に説明するとスタイルガイドのようなもので、コンポーネント郡をカタログ化させ、一覧で閲覧することが可能です。本記事のソースは以下となっております。適宜確認下さい。https://github.com/yanagisawahidetoshi/nextJS-blog/tree/storybookStorybookをインストールstorybookをインストールします。$ npx sb initインストールが完了すると必要なモジュールのインストール、サンプルのコンポーネントが追加され、package.jsonにStorybookの起動用のscriptsが追記されています。package.json "scripts": { "dev": "next dev", "build": "next build", - "start": "next start" + "start": "next start", + "storybook": "start-storybook -p 6006", + "build-storybook": "build-storybook" }, "dependencies": { "next": "10.1.2",Storybookを起動すると、http://localhost:6006/でStorybookが表示されます。$ yarn storybook以上でStorybookのインストールは完了です。npx sb init コマンドのおかげで割とあっさりとインストールは完了できました。
Continue Reading →Next.js+styled-components+StorybookでThemeProviderを使う
2021年04月27日プロダクトで全体で共通の色設定(プライマリーやワーニング)や文字サイズなどを設定したいことってよくあります。styled-componentではThemeProviderを使ってそれを管理することが可能です。それをNext.js + Storybook で連携しようとすると少しハマったので、手順を共有します。Next.jsでの設定Themeファイルの作成共通の設定(Theme)はThemeConfig.js に書いていくので、それをsrc/ThemeConfig.js に作ります。$ touch src/ThemeConfig.js作成したファイルに共通の設定を書くthemeと全ページ共通となるスタイルをあてれるcreateGlobalStyle を実装します。今回は例としてthemeにprimaryカラー と共通のスタイルを実装しました。import { createGlobalStyle } from "styled-components"; export const theme = { colors: { primary: "#3498db", }, }; export const GlobalStyles = createGlobalStyle` *, *:before, *:after { box-sizing: inherit; } html { font-size: 62.5%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; box-sizing: border-box; } body { background: #f9fafc; font-family: 'Open Sans', sans-serif; line-height: 1.5; padding: 50px 0; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } img { max-width: 100%; } `;_app.tsxに設定次に作成したtheme とGlobalStyles をNext.jsに適用させるために_app.tsx で設定します。import { theme, GlobalStyles } from "../ThemeConfig"; 作成したThemeConfigを読み込んでいます。theme はThemeProvider を介して各コンポーネントに渡されるので ThemeProvider に渡しています。import type { AppProps } from "next/app"; import { ThemeProvider } from "styled-components"; import { theme, GlobalStyles } from "../ThemeConfig"; export default function App({ Component, pageProps }: AppProps) { return ( <> <GlobalStyles /> <ThemeProvider theme={theme}> <Component {...pageProps} /> </ThemeProvider> </> ); }これでNext.jsではGlobalStyleが適用され、themeも使用することができます。以下のようにGlobalStyle が適用されているのが確認できます。themeは以下のように${(props) => props.theme.colors.primary}; で使用することが可能です。 a { color: ${(props) => props.theme.colors.primary}; font-weight: 700; text-decoration: none; }Storybookでも使えるようにするこれでNext.jsではthemeを使用することができました。しかしStorybookでthemeを使っているコンポーネント を表示しようとすると、エラーになってしまうので別途Storybookの設定が必要になります。Storybookではpreview.js で指定していきます。storybook/preview.js import { ThemeProvider } from "styled-components"; import { theme, GlobalStyles } from "../src/ThemeConfig"; import { addDecorator } from "@storybook/react"; addDecorator((storyFn) => ( <ThemeProvider theme={theme}> <GlobalStyles /> {storyFn()} </ThemeProvider> )); _app.tsx と同じように、ThemeProvider とThemeConfig をimportし、theme はThemeProviderに渡します。Storybookではデコレータ を使うことにより、値を渡せるようになるので、addDecorator で読み込みます。そうするとStorybookでもGlobalStyleとthemeが読み込めるようになります。以上です。
Continue Reading →【JAMスタックなブログを作ろう その11】ページごとにタイトルやディスクリプションを設定しよう
2021年05月01日現在タイトルでディスクリプションが設定されていないので、設定していきましょう。Next.jsでタイトルなどを設定するにはnext/headを使って設定していきます。本記事のソースは以下となっております。適宜確認下さい。https://github.com/yanagisawahidetoshi/nextJS-blog/tree/add-headmicroCMSでフィールドを追加descriptionとogp画像用のフィールドをmicroCMSのスキーマに追加します。インターフェース を修正フィールドを追加したので、interfaceを修正します。 export interface IPost { - id: number; //記事ID + id: string; //記事ID title: string; // 記事タイトル content: string; // 記事の内容 createdAt: string; // 記事作成日時 + description: string; + kv: string; } カスタムHeadタグを作成しようnext/headをそのまま使っても良いのですが、共通化できる箇所もあるのでカスタムコンポーネントを作成してそれを使うようにします。src/components/atoms/Head/index.tsximport * as React from "react"; import Head from "next/head"; interface Props { title: string; description: string; keyword?: string; image?: string; url: string; } export default ({ title, description, keyword = "Next.js,TypeScript,JAMスタック,ブログ,microCMS", image, url, }: Props): JSX.Element => { const absolutePath = `${process.env.HOST}${url}`; return ( <Head> <title>{title}</title> <meta property="og:title" content={title} /> <meta property="og:description" content={description} /> <meta name="keywords" content={keyword} /> <meta property="og:type" content="blog" /> <meta property="og:url" content={absolutePath} /> <meta property="og:image" content={image} /> <meta property="og:site_name" content={title} /> <meta name="twitter:card" content="summary" /> <meta name="twitter:site" content="@tcr_jp" /> <meta name="twitter:url" content={absolutePath} /> <meta name="twitter:title" content={title} /> <meta name="twitter:description" content={description} /> <meta name="twitter:image" content={image} /> <link rel="canonical" href={absolutePath} /> <link href={`${process.env.HOST}favicon.ico`} /> <link rel="apple-touch-icon" href={`${process.env.HOST}apple-touch-icon`} /> </Head> ); }; 上記で.envファイルにURLを設定していますので、それを作成します。今回はURLなので、githubにアップしても問題ないので、.env を作成しそこに記述します.envHOST=https://next-js-blog-yanagisawahidetoshi.vercel.app/ページコンポーネントに反映させる先ほど作成したHead コンポーネント をそれぞれのページコンポーネントに読み込んでいきます。src/components/pages/Index/index.tsximport Container from "../../atoms/Container"; import Pagination from "../../atoms/Pagination"; import Summary from "../../atoms/Summary"; + import Head from "../../atoms/Head"; import { IPost } from "../../../models/posts"; 〜省略〜 const PageIndex: React.FC<Props> = ({ posts }) => { return ( <Layout> + <Head + description="Next.js/TypeScript/microCMSでJAMスタックなブログをReact未経験でもわかるように書いています。。" + title="JAMスタックでブログを作ろう" + url={""} + /> <Container> {posts.map((post: IPost) => { return ( src/components/pages/Posts/index.tsx+ import Head from "../../atoms/Head"; import Article from "../../../components/atoms/Article"; import ArticleHeader from "../../../components/atoms/ArticleHeader"; import Card from "../../../components/atoms/Card"; interface Props { + id: string; title: string; createdAt: string; highlightedBody: string; + description: string; + kv: string; } -const PagePost: React.FC<Props> = ({ title, createdAt, highlightedBody }) => { +const PagePost: React.FC<Props> = ({ + id, + title, + createdAt, + highlightedBody, + description, + kv, +}) => { return ( <Layout> + <Head + description={description} + title={title} + url={`posts/${id}`} + image={kv} + /> <section> <Container> <Card>src/pages/posts/[id].tsx highlightedBody: string; } -const Post: NextPage<Props> = ({ title, createdAt, highlightedBody }) => { +const Post: NextPage<Props> = ({ + id, + title, + createdAt, + description, + kv, + highlightedBody, +}) => { return ( <PagePost + id={id} title={title} createdAt={createdAt} highlightedBody={highlightedBody} + description={description} + kv={kv} /> ); };これで各ページにmetaタグを設定することができました。
Continue Reading →【JAMスタックなブログを作ろう その10】テストを書いてみよう〜Storybook〜
2021年04月30日前回に引き続きJest でテストをやっていきましょう。今回はStorybookに対してテスを書いていきます。今回はこのタイムゾーンが入っていたりでフォーマットが変な日付の箇所を改修します。本記事のソースは以下となっております。適宜確認下さい。https://github.com/yanagisawahidetoshi/nextJS-blog/tree/jest/createdat日付フォーマット変更用のコンポーネントを作る前回はhooksで実装していましたが、今回はフォーマット変更用のコンポーネントを作ります。日付変更だけなら、わざわざコンポーネント作らなくても直接やれば良くね?と思われるかもしれませんが例えば昔よく使われてた日付用のライブラリでmoment.js がありましたが、最近はファイルサイズが大きいのとメンテナンスが止まっておりdayjs などの別のライブラリに置き換わっています。そうなると直接書いているとかなり改修箇所が多くなり、改修後の確認作業もかなりの工数を使うことになります。そこで、今回のようにコンポーネントを作って薄いラッパーとして使えば、今後またライブラリの置き換えがあった場合には、そのコンポーネントのライブラリだけ変更すれば良いので大幅にメンテナンスコストが下がります。コンポーネントを作ろうまずはdayjsをインストールします。$ yarn add dayjsコンポーネントを作成します。src/components/atoms/FormatDate/index.tsximport dayjs from "dayjs"; dayjs.locale("ja"); type Props = { date?: string; format?: string; }; const FormatDate: React.FC<Props> = ({ date = dayjs(), format = "YYYY年MM月DD日", }) => { return <>{dayjs(date).format(format)}</>; }; export default FormatDate;日付を渡せば指定したフォーマットに変換するだけのコンポーネント です。念の為デフォルト値も指定しています。storybookに読み込む作成したフィアるをstorybookに読み込みます。確認用に3パターン作っているので、パターンの担保もできました。src/components/atoms/FormatDate/index.stories.tsximport FormatDate from "./"; export default { title: "components/atoms/FormatDate", component: FormatDate, }; export const Basic = () => <FormatDate date="2021-01-01 10:00:00" />; export const WithTimeZone = () => ( <FormatDate date="2021-04-26T18:16:47.316Z" /> ); export const EmptyDate = () => <FormatDate />;storyshotsの導入$ yarn add -D @storybook/addon-storyshots react-test-renderer諸々設定します。.babelrc{ "presets": ["next/babel"] }jest.transform.jsmodule.exports = require("babel-jest").createTransformer({ presets: [ [ "@babel/preset-env", { targets: { esmodules: true, }, }, ], "@babel/preset-react", "@babel/preset-typescript", ], });src/storyshots.test.jsimport initStoryshots from "@storybook/addon-storyshots"; initStoryshots();jest.config.js module.exports = { - roots: ["<rootDir>/src"], - testMatch: [ - "**/__tests__/**/*.+(ts|tsx|js)", - "**/?(*.)+(spec|test).+(ts|tsx|js)", + roots: ["<rootDir>"], + moduleFileExtensions: ["js", "ts", "tsx", "json"], + testPathIgnorePatterns: ["<rootDir>[/\\\\](node_modules|.next)[/\\\\]"], + transformIgnorePatterns: [ + "[/\\\\]node_modules[/\\\\].+\\.(ts|tsx)$", + "node_modules/(?!@shotgunjed)/", ], transform: { - "^.+\\.(ts|tsx)$": "ts-jest", + "^.+\\.[t|j]sx?$": "babel-jest", + "^.+\\.[tj]sx?$": "./jest.transform.js", + "^.+\\.mdx?$": "@storybook/addon-docs/jest-transform-mdx", }, + watchPlugins: [ + "jest-watch-typeahead/filename", + "jest-watch-typeahead/testname", + ], + moduleNameMapper: { + "\\.(css|less|sass|scss)$": "identity-obj-proxy", + "\\.(gif|ttf|eot|svg|png)$": "<rootDir>/test/__mocks__/fileMock.js", + }, + testEnvironment: "jsdom", };これで設定はできたので、このタイミングで$ yarn testを実行するとsrc/__snapshots__/storyshots.test.js.snapが作成されStorybookで読み込んでいるコンテンツのスナップショットが作られ、それと同時にスナップショットテストが実行されます。Snapshot Summary › 17 snapshots written from 1 test suite. Test Suites: 1 passed, 1 total Tests: 17 passed, 17 total Snapshots: 17 written, 17 total Time: 2.972 s, estimated 13 s Ran all test suites. ✨ Done in 3.89s.storyshotのみ実行するようにしようyarn storyshotsで storyshotsのみ実行できるようにjest config を作成します。jest.storyshots.config.jsconst baseConfig = require("./jest.config"); module.exports = { ...baseConfig, name: "Storyshots", displayName: "storyshots", testMatch: ["<rootDir>/src/storyshots.test.js"], }; package.jsonに作成したconfigファイルを読み込んだscriptを追加します。package.json "start": "next start", "storybook": "start-storybook -p 6006", "build-storybook": "build-storybook", + "storyshots": "jest --config ./jest.storyshots.config.js", "test": "jest --collect-coverage" }, "dependencies": { これでstoryshotsのみ動作することができました。$ yarn storyshotsスナップショットを更新するコンポーネントを修正した場合に、テストがこけるのを確認してみましょう。src/components/atoms/FormatDate/index.tsx date = dayjs(), format = "YYYY年MM月DD日", }) => { - return <>{dayjs(date).format(format)}</>; + return <span>{dayjs(date).format(format)}</span>; }; export default FormatDate;これでyarn storyshotsを実行すると以下のようにテストが失敗します。Snapshot Summary › 3 snapshots failed from 1 test suite. Inspect your code changes or run `yarn run storyshots -u` to update them. Test Suites: 1 failed, 1 total Tests: 3 failed, 14 passed, 17 total Snapshots: 3 failed, 14 passed, 17 total Time: 3.993 s Ran all test suites.src/snapshots/storyshots.test.js.snap は前にスナップショットをとったタイミングの内容なので、テストは失敗してしまいます。ですので、もう一度スナップショットを撮り直しましょう。スナップショットを更新するには先ほど作成したyarn storyshots に--updateSnapshot をつけて実行します。スナップショットの更新は頻繁に行うので、yarnで実行できるようにpackage.json に追記しましょう。package.json "start": "next start", "storybook": "start-storybook -p 6006", "build-storybook": "build-storybook", "storyshots": "jest --config ./jest.storyshots.config.js", + "update-snapshot": "yarn storyshots --updateSnapshot", "test": "jest --collect-coverage" }, "dependencies": {これでyarn update-snapshot を実行するとsrc/snapshots/storyshots.test.js.snapが更新されるので、再度yarn storyshots を実行するとテストが通ることが確認できます。日付をモック化しようsrc/components/atoms/FormatDate/index.tsxに引数を何も渡さない場合には当日の日付が表示されるようになっています。そのパターンをStorybookに組み込んでいます。src/components/atoms/FormatDate/index.stories.tsxexport const EmptyDate = () => <FormatDate /> これでスナップショットを撮ると、その日の日付でスナップショットが撮られるので、日付が変わってjestを実行するとテストが失敗します。ですのでjestでは日付をモック化し、いつでも固定の日付を返すようにします。まずはライブラリを追加します。$ yarn mockdatejest実行時に日付をモック化させるためにjest.setup.jsを作成し、モック化させます。jest.setup.jsimport MockDate from "mockdate"; import dayjs from "dayjs"; beforeAll(() => { MockDate.set(dayjs("2019-06-24")); });作成した設定ファイルをjest.config.jsで読み込みます。jest.config.js "[/\\\\]node_modules[/\\\\].+\\.(ts|tsx)$", "node_modules/(?!@shotgunjed)/", ], + setupFilesAfterEnv: ["<rootDir>/jest.setup.js"], transform: { "^.+\\.[t|j]sx?$": "babel-jest", "^.+\\.[tj]sx?$": "./jest.transform.js", これでyarn update-snapshotを実行し、src/__snapshots__/storyshots.test.js.snap を確認すると日付が2019-06-24 が固定されていることが確認できました。exports[`Storyshots components/atoms/FormatDate Empty Date 1`] = `"2019年06月24日"`; これで日付をモック化でき、テストも無事成功することが確認できました。コンポーネントを組み込む最後にできたコンポーネントを組み込みましょうsrc/components/atoms/Summary/index.tsximport Anchor from "./Anchor"; import Date from "./Date"; import ContinueReading from "./ContinueReading"; + import FormatDate from "../FormatDate"; 〜省略〜 <Anchor>{title}</Anchor> </Link> </H1> - <Date>{date}</Date> + <Date> + <FormatDate date={date} /> + </Date> <Content>{excerpt}</Content> <Link href={slug}> <ContinueReading>Continue Reading &rarr;</ContinueReading> import Card from "../../../components/atoms/Card"; import Container from "../../../components/atoms/Container"; import Share from "../../../components/atoms/Share"; + import FormatDate from "../../atoms/FormatDate"; 〜省略〜 <Card> <ArticleHeader> <h1>{title}</h1> - <p>{createdAt}</p> + <p> + <FormatDate date={createdAt} /> + </p> <span /> </ArticleHeader> <Article>日付のフォーマットも変更されていますね
Continue Reading →【JAMスタックなブログを作ろう その9】テストを書いてみよう
2021年04月28日前回の記事でデカい部分での依存の切り離しはできました。次は小さいところでのロジックを切り離し、それにテストを書いてみようと思います。本記事のソースは以下となっております。適宜確認下さい。https://github.com/yanagisawahidetoshi/nextJS-blog/tree/jest/remove-htmljestの導入JSでテストを書くために、jestを導入します。最低限TypeScript上でtestができる状態だけ作っておきます。$ yarn add -D jest @types/jest ts-jestjestの設定以下の設定をjest.config.jsに記述しています。テストのルートディレクトリの設定テストに使うファイル場所と拡張子ts-jestを使ってねの設定jest.config.jsmodule.exports = { roots: ["<rootDir>/src"], testMatch: [ "**/__tests__/**/*.+(ts|tsx|js)", "**/?(*.)+(spec|test).+(ts|tsx|js)", ], transform: { "^.+\\.(ts|tsx)$": "ts-jest", }, };jest.config.jsに書いたようにテストは__tests__に配置するので__tests__を作ります。$ midir src/__tests____tests__ディレクトリにjest用のtsconfig.jsonを作成します。src/__tests__/tsconfig.json { "extends": "../tsconfig.json", "include": ["./**/*"], "compilerOptions": { "jsx": "react-jsx" } }これで設定はできたので、仮のテストを書いて動作するか確認しましょう。src/__tests__/hoge.ts import React from "react"; test("Hello test", () => { expect(true).toBeTruthy(); });常に成功するテストです。これを動かしてみましょう。テストを動かすにはpackage.json にscriptを追記します。package.json"scripts": { "dev": "next dev", "build": "next build", "start": "next start", "storybook": "start-storybook -p 6006", - "build-storybook": "build-storybook" + "build-storybook": "build-storybook", + "test": "jest --collect-coverage" }, これでyarn test でテストが動いて以下のように表示されれば、jestは正常に動いています。 PASS src/__tests__/hoge.ts ✓ Hello test (1 ms) ----------|---------|----------|---------|---------|------------------- File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s ----------|---------|----------|---------|---------|------------------- All files | 0 | 0 | 0 | 0 | ----------|---------|----------|---------|---------|------------------- Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total Time: 2.498 s Ran all test suites. ✨ Done in 3.60s.テストを書いてみよう準備もできたので、実際にテストを書いていきましょう。今回はsrc/components/pages/Index/index.tsxの以下の箇所を対象にします。excerpt={post.content.replace( /<("[^"]*"|'[^']*'|[^'">])*>/g, "" )}ここはブログの本文のHTMLを削除している箇所ですが、ロジックをViewに書いてしまっています。もちろんこのままではテストも出来ません。カスタムフックこれぐらいの処理ならユーティリティー関数みたいなのを作って、そこでやるのが一般的だと思うのですが、今回はReactが提供している標準機能であるカスタムフックを使います。カスタムフックは公式から引用すると以下のように説明があります。まさに今からやろうとしていること叶えれる内容ですね。自分独自のフックを作成することで、コンポーネントからロジックを抽出して再利用可能な関数を作ることが可能です。まずはカスタムフック用のファイルを作ります。フックスはファイル名の前にuse をつけるのが慣習となっていますので、それに倣いuseSummary としています。$ touch src/hooks/useSummary.ts作成したフックスにHTMLを削除する関数を作り、それを返します。src/hooks/useSummary.tsimport { useCallback } from "react"; export const useSummary = () => { const removeHtml = useCallback((data: string) => { return data.replace(/<("[^"]*"|'[^']*'|[^'">])*>/g, ""); }, []); return {removeHtml} }; useCallback というのが出てきましたが、話すと長くのなるので、フックスの中で関数を定義する場合はこれで括るぐらいの認識で今はいてください。関数の中身はsrc/components/pages/Index/index.tsxで書いているものと同じです。カスタムフックに対してテストを書こうフックスが出来たので、これをそのままコンポーネントに読み込んでも良いのですが、その前にテストを書いて動いてるかどうか試してみましょう。テストディレクトリにこのファイル用のテストファイルを作成します$ touch src/__tests__/hooks/useSummary.test.tsカスタムフックスのテストをするときに@testing-library/react-hooks が必要なので追加します。$ yarn add -D @testing-library/react-hooks 必要なライブラリとhooksファイルを読み込みます。*src/__tests__/hooks/useSummary.test.ts*import { renderHook } from "@testing-library/react-hooks"; import { useSummary } from "../../hooks/useSummary";renderHookのテストimport { renderHook } from "@testing-library/react-hooks"; import { useSummary } from "../../hooks/useSummary"; describe("removeHtml", () => { it("HTMLが含まれている場合削除されるた内容が返されること", () => { const { result } = renderHook(() => useSummary()); expect( result.current.removeHtml('<a href="https://google.com">ああああ</a>') ).toBe("ああああ"); }); });まずはdescribeを設定し、なんのテストをするのかを書きます。今回はremoveHtml のテストをするので、そう書いています。describe自体は説明だけなので、特に意味はなさないです。次にit 構文で具体的なテストを書いていきます。it には条件と結果を書いています。もしテストが落ちた場合にデバッグがやりやすくなります。const { result } = renderHook(() => useSummary()); renderHookでhooksを呼び出し、jestでも使えるようにしています。result.current.removeHtml('<a href="https://google.com">ああああ</a>') 呼び出したhooksから関数を実行しています。これもjestでの書き方なので実際に使う場合には少し違うので、片隅に置いておいてください。expect( result.current.removeHtml('<a href="https://google.com">ああああ</a>') ).toBe("ああああ");呼び出した関数をjestのexpectで括り、この関数の結果に対してどういった結果を期待しているかを書きます。今回は結果がああああ になることを期待しています。toBe は=== と似たようなものだと思ってもらえれば良いです。詳しくはこちらのドキュメントを参照ください これでyarn test を実行すると以下のようにテストが実行され、1つのテストがpassされたとなっています。$ jest --collect-coverage PASS src/__tests__/hooks/useSummary.test.ts removeHtml ✓ HTMLが含まれている場合削除されるた内容が返されること (15 ms) ---------------|---------|----------|---------|---------|------------------- File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s ---------------|---------|----------|---------|---------|------------------- All files | 100 | 100 | 100 | 100 | useSummary.ts | 100 | 100 | 100 | 100 | ---------------|---------|----------|---------|---------|------------------- Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total Time: 2.829 s, estimated 3 s Ran all test suites. ✨ Done in 4.01s.テストを拡充しようこれで一つのテストを書くことができました。ただ先ほどのパターンだけだと実際にこの関数がちゃんと動いているかは確認できたとは言えないです。ですので想定されるケースでテストを作り、チェックしましょう。とりあえず3パターンほど追加してみました。import { renderHook } from "@testing-library/react-hooks"; import { useSummary } from "../../hooks/useSummary"; describe("removeHtml", () => { it("HTMLが含まれている場合削除されるた内容が返されること", () => { const { result } = renderHook(() => useSummary()); expect( result.current.removeHtml('<a href="https://google.com">ああああ</a>') ).toBe("ああああ"); }); it("HTMLが含まれていない場合そのまま返されること", () => { const { result } = renderHook(() => useSummary()); expect(result.current.removeHtml("あいうえお")).toBe("あいうえお"); }); it("HTMLだけの場合そのまま空になること", () => { const { result } = renderHook(() => useSummary()); expect(result.current.removeHtml('<im src="hoge.jpg" />')).toBe(""); }); it("空の場合そのまま返されること", () => { const { result } = renderHook(() => useSummary()); expect(result.current.removeHtml("")).toBe(""); }); });コンポーネントにhooksを組み込んでみようテストで動作保証もできたのでhooksをコンポーネントに組み込んでみましょうimport Summary from "../../atoms/Summary"; import { IPost } from "../../../models/posts"; + import { useSummary } from "../../../hooks/useSummary"; type Props = { posts: Array<IPost>; }; const PageIndex: React.FC<Props> = ({ posts }) => { + const { removeHtml } = useSummary(); return ( <Layout> <Container> 〜省略〜 <Summary date={post.createdAt} title={post.title} - excerpt={post.content.replace( - /<("[^"]*"|'[^']*'|[^'">])*>/g, - "" - )} + excerpt={removeHtml(post.content)} slug={`./posts/${post.id}`} /> </Card> const { removeHtml } = useSummary(); jestでの関数呼び出しの方法とは違って、通常はこのように関数は呼び出せます。これでロジックも切り離せたので、Viewがまた少しスッキリしました。ついでにトップページ確認し、HTMLが削除されてることも確認できました本記事のソースは以下となっております。適宜確認下さい。https://github.com/yanagisawahidetoshi/nextJS-blog/tree/jest/remove-html
Continue Reading →【JAMスタックなブログを作ろう その8】トップページのリファクタしてみよう
2021年04月28日前回で一通りの機能は実装できたのですが、ひとまずの完成を目指していたので実装的によろしくないところが多々あるので、リファクタをしていきます。本記事のソースは以下となっております。適宜確認下さい。https://github.com/yanagisawahidetoshi/nextJS-blog/tree/refactor/page%2Fpresentationsrc/pages/index.tsxこのページコンポーネントを見ると、データの取得とページの表示どちらもやってしまっています。この状態でコンポーネントのテストをやろうとした場合にどうすれば良いでしょうか。APIをモック化しgetStaticPropsもモックする必要がありそうです。そうなると考えることが多くて、この段階で心が折れそうですね。なのでテストしやすいように修正しましょう。それによりコードの見通しも良くなるのでメリットは多いですね。テストしやすいコンポーネントとは?テストしやすい状態とはどんな状態でしょうか。上記にも書きましたが、現状やとモック化するところが多いです。と言うことはコンポーネント以外のところに依存している箇所が多くなっています。上記のことから、依存が少ないコンポーネントはテストしやすいコンポーネントになります。依存関係を切り離し独立した状態を作るまずは一番大きい依存関係である、ロジックとViewの依存関係を切り離します。ロジックを切り離すことにより、View側にはProps で値を渡すだけにすれば独立した状態を作り出せます。src/componentsにページ用のコンポーネントを置くようにしたいのでそのディレクトリを作ります。$ mkdir src/components/pagesそこにindex.tsx用のファイルを作成します。$ touch src/components/pages/index.tsx作成したファイルにView側の部分を移動しました。ちなみに移行したことによりこのコンポーネントはプレーンなReactコンポーネントになったので、React.FC になっています。src/components/pages/Index/index.tsximport Layout from "../../Layout"; import Card from "../../atoms/Card"; import Container from "../../atoms/Container"; import Pagination from "../../atoms/Pagination"; import Summary from "../../atoms/Summary"; import { IPost } from "../../../models/posts"; type Props = { posts: Array<IPost>; }; const PageIndex: React.FC<Props> = ({ posts }) => { return ( <Layout> <Container> {posts.map((post: IPost) => { return ( <Card key={post.id}> <Summary date={post.createdAt} title={post.title} excerpt={post.content.replace( /<("[^"]*"|'[^']*'|[^'">])*>/g, "" )} slug={`./posts/${post.id}`} /> </Card> ); })} </Container> </Layout> ); }; export default PageIndex; Viewを移行し読み込むだけにしたので、ページコンポーネントは以下のようになりました。かなりスッキリしました。src/pages/index.tsximport { NextPage } from "next"; import PageIndex from "../components/pages/Index"; import { IPost } from "../models/posts"; type Props = { posts: Array<IPost>; }; const Index: NextPage<Props> = ({ posts }) => { return <PageIndex posts={posts} />; }; export const getStaticProps = async () => { const key = { headers: { "X-API-KEY": process.env.API_KEY ?? "" }, }; const res: any = await fetch(`${process.env.API_BASE_URL}blog`, key) .then((res) => res) .catch((err) => console.log(err)); const data = await res.json(); return { props: { posts: data.contents, }, }; }; export default Index;この状態でページを表示し、問題ないことを確認しておきます。Storybookに表示させてみようNextPage の状態ではStorybookに入れることは困難でしたが、プレーンなコンポーネントになったのでStorybookに反映することができそうです。一度やってみましょうsrc/components/pages/index.stories.tsximport PageIndex from "./"; import { mockPosts } from "../../../models/posts"; export default { title: "components/pages/PageIndex", component: PageIndex, }; export const Basic = () => <PageIndex posts={mockPosts} />;Storybookでも問題なく表示されていますね。記事詳細も移動しようトップページができたので、引き続いて記事詳細ページも移行します。基本的にはやることは同じですね。src/components/pages/Posts/index.tsximport Layout from "../../../components/Layout"; import { IPost, mockPosts } from "../../../models/posts"; import Article from "../../../components/atoms/Article"; import ArticleHeader from "../../../components/atoms/ArticleHeader"; import Card from "../../../components/atoms/Card"; import Container from "../../../components/atoms/Container"; import Share from "../../../components/atoms/Share"; interface Props { title: string; createdAt: string; highlightedBody: string; } const PagePost: React.FC<Props> = ({ title, createdAt, highlightedBody }) => { return ( <Layout> <section> <Container> <Card> <ArticleHeader> <h1>{title}</h1> <p>{createdAt}</p> <span /> </ArticleHeader> <Article> <div dangerouslySetInnerHTML={{ __html: highlightedBody }} /> </Article> </Card> </Container> </section> </Layout> ); }; export default PagePost; src/pages/posts/[id].tsximport { NextPage, GetStaticPaths, GetStaticProps } from "next"; import { IPost } from "../../models/posts"; import cheerio from "cheerio"; import hljs from "highlight.js"; import "highlight.js/styles/night-owl.css"; import PagePost from "../../components/pages/Posts"; interface Props extends IPost { highlightedBody: string; } const Post: NextPage<Props> = ({ title, createdAt, highlightedBody }) => { return ( <PagePost title={title} createdAt={createdAt} highlightedBody={highlightedBody} /> ); }; export const getStaticPaths: GetStaticPaths = async () => { const key: any = { headers: { "X-API-KEY": process.env.API_KEY ?? "" }, }; const res: any = await fetch(`${process.env.API_BASE_URL}blog`, key) .then((res) => res) .catch((err) => console.log(err)); const data = await res.json(); const paths = data.contents.map((post: IPost) => `/posts/${post.id}`); return { paths, fallback: false }; }; export const getStaticProps: GetStaticProps = async ({ params }) => { if (!params?.id) { return { notFound: true }; } const key: any = { headers: { "X-API-KEY": process.env.API_KEY ?? "" }, }; const res: any = await fetch( `${process.env.API_BASE_URL}blog/${params.id}`, key ) .then((res) => res) .catch((err) => console.log(err)); const post = await res.json(); if (post === undefined) { return { notFound: true }; } const $ = cheerio.load(post.content); $("pre code").each((_, elm) => { const result = hljs.highlightAuto($(elm).text()); $(elm).html(result.value); $(elm).addClass("hljs"); }); return { props: { post, highlightedBody: $.html() } }; }; export default Post; src/components/pages/Posts/index.stories.tsximport PagePost from "./"; import { mockPosts } from "../../../models/posts"; export default { title: "components/pages/PagePost", component: PagePost, }; export const Basic = () => ( <PagePost title={mockPosts[0].title} createdAt={mockPosts[0].createdAt} highlightedBody="テキスト" /> );これでページコンポーネントのリファクタは一旦完了です。
Continue Reading →【JAMスタックなブログを作ろう その7】デザインを適用させよう〜レイアウト編〜
2021年04月27日前回でコンポーネントの作成はできたので、次はレイアウトとページのデザインを調整していきます。今回もこちらのデザインを使用していきます。本記事のソースは以下となっております。適宜確認下さい。https://github.com/yanagisawahidetoshi/nextJS-blog/tree/fix-designレイアウトを作ろうNext.jsで共通のレイアウトを使いたい場合は、公式のチュートリアルにもあるようにLayout.tsxを作成し、それを使用します。作成したレイアウトは各ページで読み込んでいきます。src/components/Layout.tsximport Header from "./atoms/Header"; const Layout: React.FC = ({ children }) => { return ( <main> <Header /> {children} </main> ); }; export default Layout;上記ファイルをpageコンポーネント で読み込んでいきます。src/pages/index.tsx import { NextPage } from "next"; import Link from "next/link"; +import Layout from "../components/Layout"; import styles from "../styles/index.module.css"; import { IPost, mockPosts } from "../models/posts"; const Index: NextPage<any> = ({ posts }) => { return ( - <> + <Layout> <h1 className={styles.title}>BLOG</h1> <div> {posts.map((post: IPost) => { 〜省略〜 </div> - </> + </Layout> ); }; src/pages/posts/[id].tsx import { NextPage, GetStaticPaths, GetStaticProps } from "next"; +import Layout from "../../components/Layout"; import { IPost, mockPosts } from "../../models/posts"; const Post: NextPage<IPost> = ({ title, content, createdAt }) => { return ( + <Layout> <section> <h1>{title}</h1> <div dangerouslySetInnerHTML={{ __html: content, }} /> <p>{createdAt}</p> </section> + </Layout>ページコンポーネントにデザインを適用していくレイアウトも適用できたので、こちらを参考にデザインとコンポーネントをあてていきましょう。src/pages/index.tsx import { NextPage } from "next"; import Link from "next/link"; import Layout from "../components/Layout"; +import Card from "../components/atoms/Card"; +import Container from "../components/atoms/Container"; +import Pagination from "../components/atoms/Pagination"; +import Summary from "../components/atoms/Summary"; import styles from "../styles/index.module.css"; import { IPost, mockPosts } from "../models/posts"; const Index: NextPage<any> = ({ posts }) => { return ( <Layout> - <h1 className={styles.title}>BLOG</h1> - <div> + <Container> {posts.map((post: IPost) => { return ( - <Link href={`/posts/${post.id}`} key={post.id}> - <a> - <section> - <h2>{post.title}</h2> - <p className={styles.content}> - {post.content.replace(/<("[^"]*"|'[^']*'|[^'">])*>/g, "")} - </p> - <p>{post.createdAt}</p> - </section> - </a> - </Link> + <Card key={post.id}> + <Summary + date={post.createdAt} + title={post.title} + excerpt={post.content.replace( + /<("[^"]*"|'[^']*'|[^'">])*>/g, + "" + )} + slug={`./posts/${post.id}`} + /> + </Card> ); })} - </div> + </Container> </Layout> ); };src/pages/posts/[id].tsx import Layout from "../../components/Layout"; import { IPost, mockPosts } from "../../models/posts"; +import Article from "../../components/atoms/Article"; +import ArticleHeader from "../../components/atoms/ArticleHeader"; +import Button from "../../components/atoms/Button"; +import Card from "../../components/atoms/Card"; +import Container from "../../components/atoms/Container"; +import FeaturedImage from "../../components/atoms/FeaturedImage"; +import PageNav from "../../components/atoms/PageNav"; +import Share from "../../components/atoms/Share"; const Post: NextPage<IPost> = ({ title, content, createdAt }) => { return ( <Layout> <section> - <h1>{title}</h1> - <div - dangerouslySetInnerHTML={{ - __html: content, - }} - /> - <p>{createdAt}</p> + <Container> + <Card> + <ArticleHeader> + <h1>{title}</h1> + <p>{createdAt}</p> + <span /> + </ArticleHeader> + <Article> + <div dangerouslySetInnerHTML={{ __html: content }} /> + </Article> + </Card> + </Container> </section> </Layout> );このようにデザインが適用されていれば問題ないです。サンプルコードにシンタックスハイライトをあてよう今の段階では<pre><code> で括られている箇所がスタイルがあたっておらず、ものすごくわかりにくくなっています。なので、シンタックスハイライトをあてましょう。調べているとnext.js+microcmsでシンタックスハイライトの導入というまさにそのままの記事があったので、こちらを参考にというかほぼそのままなんですが実装していきます。実装イメージとしてはcheerioというライブラリを使って、HTMLを抜き出しhilight.jsでシンタックスハイライトをしていきます。まずはcheerioとhilight.jsをインストールします。$ yarn add cheerio highlight.js参考の記事通りgetStaticProps の中で実装していきます。 const $ = cheerio.load(post.content); $("pre code").each((_, elm) => { const result = hljs.highlightAuto($(elm).text()); $(elm).html(result.value); $(elm).addClass("hljs"); }); return { props: { post, highlightedBody: $.html() } };post.content からHTMLを抜き出しして、そこにhighlight.js でハイライトをあてていっていきます。上記修正でpropsの型が変わってしまったので、interface を修正します。interface Props extends IPost { highlightedBody: any; }元々使っていたIPost を継承して、それにhighlightedBody を追加しています。修正内容は以下のようになっています。import { NextPage, GetStaticPaths, GetStaticProps } from "next"; import Layout from "../../components/Layout"; import { IPost, mockPosts } from "../../models/posts"; + import cheerio from "cheerio"; + import hljs from "highlight.js"; + import "highlight.js/styles/night-owl.css"; import Article from "../../components/atoms/Article"; import ArticleHeader from "../../components/atoms/ArticleHeader"; 〜省略〜 import PageNav from "../../components/atoms/PageNav"; import Share from "../../components/atoms/Share"; -const Post: NextPage<IPost> = ({ title, content, createdAt }) => { +interface Props extends IPost { + highlightedBody: any; +} + +const Post: NextPage<Props> = ({ title, createdAt, highlightedBody }) => { return ( <Layout> <section> 〜省略〜 <span /> </ArticleHeader> <Article> - <div dangerouslySetInnerHTML={{ __html: content }} /> + <div dangerouslySetInnerHTML={{ __html: highlightedBody }} /> </Article> </Card> </Container> 〜省略〜 - return { props: post }; + const $ = cheerio.load(post.content); + $("pre code").each((_, elm) => { + const result = hljs.highlightAuto($(elm).text()); + $(elm).html(result.value); + $(elm).addClass("hljs"); + }); + + return { props: { post, highlightedBody: $.html() } }; }; export default Post;これで以下のようにシンタックスハイライトが適用されています。これでデザインも一通りあてられたので完成です。
Continue Reading →【JAMスタックなブログを作ろう その6】デザインを適用させよう〜コンポーネント編〜
2021年04月25日Next.js
microCMS
Storybook
JAMスタックなブログを作ろう
API連携まで出来たので、次はデザインを適用させてブログっぽい感じにしましょう。デザイン適用はStorybook上で作業していきます。Storybookの導入がまだの場合はこの記事を参考に導入しておいてください。本記事のソースは以下となっております。適宜確認下さい。https://github.com/yanagisawahidetoshi/nextJS-blog/tree/fix-designデザインは開発者用ブログのフリーテンプレートのコチラをhttps://github.com/RyanFitzgerald/devblog使用します。ライブデモはコチラstyled-componentsの導入今回使用するデザインサンプルはstyled-conponentsを使用しているので、導入します。ちなみにstyled-componentsとはCSSにclassを付ける代わりに、コンポーネントで管理していくものになります。$ yarn add styled-components $ yarn add -D @types/styled-componentsコンポーネントを追加https://github.com/RyanFitzgerald/devblogコチラからソース一式をダウンロードしますソースの中身は以下のようになっているので、src/components をNext.jsのsrc/components/atomsに移動します。持ってきたファイルはjsファイルだったので、機械的に拡張子を.jsから.tsに変更します。共通のThemeの設定styled-componentsにはprimary-colorなど共通の設定をthemeとThemeProviderを使って実現できます。Next.jsではThemeProviderは共通のレイアウトを指定できる_app.tsx に記述することになります。設定の方法はこちらの記事を参考にしてください。リセットCSSの導入共通の設定ができたので、ついでにリセットCSSを導入します。リセットCSSにはmodern-css-resetを使用します。$ yarn add modern-css-resetyarn でインストールし、src/pages/_app.tsx でimportします。src/pages/app.tsx import type { AppProps } from "next/app"; + import "modern-css-reset/dist/reset.min.css"; import { createGlobalStyle, ThemeProvider } from "styled-components"; const GlobalStyle = createGlobalStyle``;これでReset.cssが有効になっていることを確認しました。themeを適用しよう持ってきたソースにcolor: ${userConfig.primaryColor};このような感じでconfigファイルから色を指定いる箇所があるのでそこを、全て先ほどのthemeを適用させましょう。themeは${(props) => props.theme.colors.primary}; のように指定するので、${userConfig.primaryColor}; を全置換すれば良さそうなので、全置換します。ついでにimport userConfig from "../../../config"; このconfigを読み込んでいる箇所は不要なので削除しておきましょう。Storybookに表示させてみようこれで最低限コンポーネントは使えるようになったので、ちゃんと表示されるか確認してみましょう。ここで少し考えて欲しいのですが、コンポーネントの表示確認だけのために既存のページにコンポーネントを埋め込んで、確認するのはどうでしょうか。間違ってコミットして本番に適用してしまったらどうでしょうか?確認だけだから忘れない!と思っていてもついついやらかしてしまうかも知れません。また、ログインが必要なサービスの場合だとログインしてから確認するとかなると、その時間分かかりますよね?ID/PWを入力する時間、API通信する時間。軽く3秒ぐらいはかかり、開発に時間がかかる要因になります。上記はStorybookで開発することにより解消されるので、コンポーネントは極力Storybookで開発するようにします。.storiesファイルを追加しようまずはやりやすいsrc/components/atoms/H1/index.ts このファイルをStorybookに追加しましょう。同じディレクトリに.stories ファイルを追加します。$ touhc src/components/atoms/H1/index.stories.tsx 追加したファイルにコンポーネントを読み込んで、Storybookに表示させるようにします。src/components/atoms/H1/index.stories.tsximport H1 from "./"; export default { title: "components/atoms/H1", component: H1, }; export const Basic = () => <H1>タイトル</H1>;これで一度yarn storybook でstorybookを起動してみましょう。以下のように表示されるはずです。サイドメニューを見ると、title に記入したパス通りに階層構造となっていることがわかると思います。それでは次にsrc/components/atoms/H2/index.ts にもStorybookを追加してみましょう。src/components/atoms/H2/index.tsimport H2 from "./"; export default { title: "components/atoms/H2", component: H2, }; export const Basic = () => <H2>タイトル</H2>;この通りatomsにH2が追加されました。コンポーネントを作ってみようsrc/components/atoms/Button/index.tsimport styled from 'styled-components'; import Link from 'gatsby-link'; const Button = styled(Link)` border: 1px solid #bfc8d2; border-radius: 25px; color: #3e465b; display: inline-block; font-size: 10px; font-weight: 700; margin: 0 10px; padding: 5px 15px; text-decoration: none; text-transform: uppercase; &:hover { border-color: #000; } `; export default Button;このコンポーネントなんですが、元々がgatsby.jsで作られているので、そのAPIを使われてしまっておりこのままでは使えないので、見た目はそのままでReact用にリファクタします。試しにこれをStorybookに追加してみます。src/components/atoms/Button/index.stories.tsx import Button from "./"; export default { title: "components/atoms/Button", component: Button, }; export const Basic = () => <Button>ボタン</Button>;案の定やはりModule not found: Error: Can't resolve 'gatsby-link' のエラーとなりました。リファクタ前に思い出して欲しいんですが、この記事で書いたように、Next.jsのLink を使う場合は<Link><a></a></Link>このようにaタグをLinkで括る必要がありました。ですので既存のファイルのLink をNextのAPIに置き換えるだけでは無理なんですね。このネストしたものにしないといけないです。しかしながら、このファイルはstyled-componentsのファイルになっているので、これをJSXに変える必要があります。まずは拡張子を.tsx に変換しましょう$ mv src/components/atoms/Button/index.ts src/components/atoms/Button/index.tsx.tsと.tsxの違いは.tsxの方がリッチで、いまはHTMLをそのまま使えたりするぐらいの認識で良いかと。ちなみにstyled-componentsはCSSなので、拡張子は.ts になっています。まずはCSSとHTMLの責務の分離に則り、styled-components を外出しします。CSSファイルを作り、そこにstyled-componentsを移します。$ touch src/components/atoms/Button/styles.tssrc/components/atoms/Button/styles.ts import styled from 'styled-components'; import Link from 'gatsby-link'; export const Button = styled(Link)` border: 1px solid #bfc8d2; border-radius: 25px; color: #3e465b; display: inline-block; font-size: 10px; font-weight: 700; margin: 0 10px; padding: 5px 15px; text-decoration: none; text-transform: uppercase; &:hover { border-color: #000; } `;src/components/atoms/Button/index.tsximport * as S from "./styles"; const Button: React.FC = ({ children }) => { return <S.Button>{children}</S.Button>; }; export default Button;外出ししたstyled-componentsをimportし、それを表示させています。今回初出であるchildren は呼び出し元tのタグの間に入れられた要素を表示するもので、React.FC で定義すると使えるようになります。これはよく使うものなので覚えておいてください。Next.js のLinkコンポーネントをスタイリングさせたい場合はLinkにスタイルをあてるのではなく、aタグにあてる必要があります。ですのでsrc/components/atoms/Button/styles.ts を以下のように修正しますimport styled from "styled-components"; - import Link from "gatsby-link"; - export const Button = styled(Link)` + export const Button = styled.a` border: 1px solid #bfc8d2; border-radius: 25px; color: #3e465b; display: inline-block; font-size: 10px; font-weight: 700; margin: 0 10px; padding: 5px 15px; text-decoration: none; text-transform: uppercase; &:hover { border-color: #000; } `; これでStorybookにアクセスすると正常に表示されるようになりました。では次にLink タグで括り、propsを定義します。src/components/atoms/Button/index.tsximport * as S from "./styles"; import Link from "next/link"; type Props = { href: string; }; const Button: React.FC<Props> = ({ children, href }) => { return ( <Link href={href}> <S.Button>{children}</S.Button> </Link> ); }; export default Button;propsが追加されたのでstorybookも修正します。import Button from "./"; export default { title: "components/atoms/Button", component: Button, }; - export const Basic = () => <Button>ボタン</Button>; + export const Basic = () => <Button href="./">ボタン</Button>;これでButtonコンポーネント のリファクタは完成です。これ以降のコンポーネントについては細かい修正になるので、こちらのPRを参照してください。途中next/imageをモック化しているのですが、その方法はこちらの記事に書いたので、参照お願いします。長くなってきたので、今回はこの辺で。
Continue Reading →【JAMスタックなブログを作ろう その5】Vercelで公開しよう
2021年04月25日Next.js
microCMS
JAMスタックなブログを作ろう
Vercel
APIの連携も出来たので、いよいよブログを公開してみましょう。公開先はNext.jsの開発元でもあるVercel社が運営しているホスティングサービスを使用します。本記事のソースは以下となっております。適宜確認下さい。https://github.com/yanagisawahidetoshi/nextJS-blog/tree/publish-vercelローカルでビルドをしてみようまずはテストとしてローカルでビルドをしてみましょうちなみにpackage.json のコマンドの違いは以下のようになっています。いままではずっとyarn dev を実行していました。 next dev : 開発環境でNext.jsを起動next build : 本番環境で使用するアプリケーションをビルドnext start : 本番環境でNext.jsを起動今回は本番公開用のファイルを作るので$ yarn buildを実行します。無事にいけるかと思ったんですが、早々うまくはいきませんでした。以下のTypeScriptのエラーが表示されてます。Type error: Property 'posts' does not exist on type '{ children?: ReactNode; }'. 4 | import { IPost, mockPosts } from "../models/posts"; 5 | > 6 | const Index: NextPage = ({ posts }) => { | ^ 7 | return ( 8 | <> 9 | <h1 className={styles.title}>BLOG</h1> Type error: Argument of type '{ headers: { "X-API-KEY": string | undefined; }; }' is not assignable to parameter of type 'RequestInit'. Types of property 'headers' are incompatible. Type '{ "X-API-KEY": string | undefined; }' is not assignable to type 'HeadersInit | undefined'. Type '{ "X-API-KEY": string | undefined; }' is not assignable to type 'Record<string, string>'. Property '"X-API-KEY"' is incompatible with index signature. Type 'string | undefined' is not assignable to type 'string'. Type 'undefined' is not assignable to type 'string'. headers: { "X-API-KEY": process.env.API_KEY }, 34 | }; > 35 | const res = await fetch(`${process.env.API_BASE_URL}blog`, key) | ^ 36 | .then((res) => res) 37 | .catch((err) => console.log(err)); 38 | const data = await res.json(); Type error: Property 'json' does not exist on type 'void | Response'. Property 'json' does not exist on type 'void'. 36 | .then((res) => res) 37 | .catch((err) => console.log(err)); > 38 | const data = await res.json(); | ^ 39 | 40 | return { 41 | props: {const res = await fetch(`${process.env.APIBASEURL}blog`, key)ここのkey が怒られてるのですが、どうやらprocess.env.API_KEY がundefinedになる可能性があるらしく、それが原因でエラーとなっていました。なのでTypeScriptの?? を使って、解消します。 ?? は左辺が null または undefined の場合に右辺の式が返されるようになります。似たものにJavaScriptの|| (OR演算子) があるのですが、違いはOR演算子は左辺が false として判定される場合は右辺が返るのに対し、?? はnullまたはundefinedに限定されます。const key = { headers: { "X-API-KEY": process.env.API_KEY ?? "" }, };これでkeyのエラーはなくなりました。一緒にsrc/pages/posts/[id].tsx での呼び出してる箇所も修正しておきます。次にこの箇所でのエラーなんですがconst res = await fetch( `${process.env.API_BASE_URL}blog/${params.id}`, key )API関連の型定義が関わってくるので、時間がかかりそうなので今回はanyで逃げることにしました。src/pages/index.tsx import styles from "../styles/index.module.css"; import { IPost, mockPosts } from "../models/posts"; -const Index: NextPage = ({ posts }) => { +const Index: NextPage<any> = ({ posts }) => { return ( <> <h1 className={styles.title}>BLOG</h1> 〜省略〜 export const getStaticProps = async () => { - const res = await fetch(`${process.env.API_BASE_URL}blog`, key) + const res: any = await fetch(`${process.env.API_BASE_URL}blog`, key) .then((res) => res) .catch((err) => console.log(err)); const data = await res.json();src/pages/posts/[id].tsx export const getStaticPaths: GetStaticPaths = async () => { - const res = await fetch(`${process.env.API_BASE_URL}blog`, key) + const res: any = await fetch(`${process.env.API_BASE_URL}blog`, key) .then((res) => res) .catch((err) => console.log(err)); const data = await res.json(); 〜省略〜 - const res = await fetch(`${process.env.API_BASE_URL}blog/${params.id}`, key) + const res: any = await fetch( + `${process.env.API_BASE_URL}blog/${params.id}`, + key + ) .then((res) => res) .catch((err) => console.log(err)); const post = await res.json();修正し再度yarn build を実行すると以下のように表示され、無事ビルド出来ました。 Page Size First Load JS ┌ ● / 1.81 kB 66.1 kB ├ └ css/055f289445ee19593cf1.css 194 B ├ ○ /404 3.46 kB 67.8 kB └ ● /posts/[id] 366 B 64.7 kB ├ /posts/oux3ap5enstj ├ /posts/a52akyv-z2 ├ /posts/mw92oirpqf └ /posts/w9xqzt4p4k2 + First Load JS shared by all 64.3 kB ├ chunks/3a3f75626beeb6412aba5e6564dc5dac4d3e7fd6.4da9a0.js 13.4 kB ├ chunks/framework.e3de07.js 41.8 kB ├ chunks/main.330fe0.js 7.12 kB ├ chunks/pages/_app.0a7f69.js 1.28 kB └ chunks/webpack.50bee0.js 751 B λ (Server) server-side renders at runtime (uses getInitialProps or getServerSideProps) ○ (Static) automatically rendered as static HTML (uses no initial props) ● (SSG) automatically generated as static HTML + JSON (uses getStaticProps) (ISR) incremental static regeneration (uses revalidate in getStaticProps) ✨ Done in 13.39s.Vercelにデプロイしようまずは既存のソースをgithubにpushしておいてください。Vercelにアカウントを作成しようSign Up画面でGitHubアカウントを選択します。サインアップ後の画面でAdd Github Org or Account を選択し、自分のお使いのGitHubアカウントを選択します。GitHubにVercelをインストールする画面に遷移します。今回はOnly select repositoriesを選択し、作ったブログのリポジトリを選択しました。そうすると先ほどの画面に戻り、選択したリポジトリを選択することができるのでImport をクリックしますSelect Vercel Scope ではPERSONAL ACCOUNTを選択します。プロジェクトの設定画面になるので、Environment Variablesに以前に.env.local に設定したAPI_KEY とAPI_BASE_URLを入力します。 Deploy ボタンをクリックするとビルドされていき、数分後に以下の画面が表示されたら無事完了です。Visitボタンをクリックするとページが表示されるので確認してみましょう。記事が更新されたらサイトも更新されるようにしよう現在の状態だとmicroCMS で生地を追加したり更新しても、本番環境には反映されません。なぜかと言うと、SSGでページを作成しているため更新のたびに、ビルドを行う必要があるからです。ですのでVercelのWebhookを利用し、記事が更新されたらタイミングでビルドされるようにしますWebhookはVecelのダッシュボードからsettingsからGit → Deploy Hooksにあります。Hook NameはmicroCMS 、Git Branch Nameにはmain(もしくはmaster) ブランチを指定しCreate Hookをクリックすると、Web HookのURLが発行されるのでコピーしておきます。次にmicroCMSに移動しAPI設定→Webhookに遷移し、追加をクリックしカスタム通知を選択します。Webhook画面でURL欄に先ほどのVercelのWebHookのURLを入力します。追知のタイミングはコンテンツの公開時・更新時 とコンテンツの削除時を選択しておいたら良いかと思います。設定が終わったら確定 をクリックします。これで記事を更新するとビルドされ変更が反映されます。
Continue Reading →【JAMスタックなブログを作ろう その4】API連携しよう
2021年04月24日Next.js
microCMS
JAMスタックなブログを作ろう
前回の記事作成で、一通りブログの体裁は整えられました。今まではモックデータで静的な情報を表示していましたが、いよいよAPI連携して動的にデータを表示するようにします。本記事のソースは以下となっております。適宜確認下さい。https://github.com/yanagisawahidetoshi/nextJS-blog/tree/apimicroCMSAPIの作成は日本製のヘッドレスCMSのmicroCMSを使います。こちらは日本製なので、ドキュメントなどが日本語なのでとても使いやすいですね。サービス作成までの手順は下記のmicroCMSの公式ドキュメントを参照してください。アカウント登録ログインサービスの作成APIの作成上記手順でサービス作成まで完成したら、APIの基本情報を入力画面に遷移します。ここでブログ用のAPIを作成していきます。API名はブログ、エンドポイントはblogとしました。APIの方はリスト形式 を選択APIスキーマは以下の通りに設定しました。contentはHTMLが入るリッチエディタ を選択しています。完了を押すとこの画面に遷移するのでコンテンツを追加するために追加 ボタンを押し、入力画面に遷移します。タイトル、コンテンツ に適当な値を入力し、公開 を押します。APIキーの取得microCMS のAPIを利用する時にAPIキーが必要になるので、それを確認します。右上にあるAPI設定 をクリックし、遷移した先でサイドメニューにあるAPIリファレンスをクリックします。遷移した先にX-API-KEY があるので、それがAPIキー になります。APIと連携.env.localにAPIキーを記入APIキー をそのままソースコードに書くと、githubなどにアップすると見えてしまい、セキュリティー上よろしくありません。ですので.env.local に記入することにより、git監視下から外れ安全に使うことができます。まずはファイルを作成します。$ touch .env.local作成したファイルに以下のようにAPIキーを設定します。ちなみにxxxxxxxxxxxx この部分に先ほどのX-API-KEYを書いてください。それとついでにmicroCMSのURLも設定しておきましょう。URLはAPI設定 の基本情報 にあるエンドポイントにあります。API_KEY=xxxxxxxxxxxx API_BASE_URL=https://{サービスID}.microcms.io/api/v1/念のためgit statusで.env.local が表示されないことを確認しておいてください。.env.localを設定したら一度Node.jsを再起動してください。そうしないと.env.localが反映されません。データを取得しようAPIキーも設定できたので、実際にデータを取得しましょう。今回はJavaScriptのFetch APIを利用します。src/pages/index.tsx を開いて追記していきます。import { NextPage } from "next"; import Link from "next/link"; import styles from "../styles/index.module.css"; import { IPost, mockPosts } from "../models/posts"; const Index: NextPage = ({ posts }) => { return ( <> <h1 className={styles.title}>BLOG</h1> <div> {posts.map((post: IPost) => { return ( <Link href={`/posts/${post.id}`} key={post.id}> <a> <section> <h2>{post.title}</h2> <p>{post.content}</p> <p>{post.createdAt}</p> </section> </a> </Link> ); })} </div> </> ); }; export const getStaticProps = async () => { const key = { headers: { "X-API-KEY": process.env.API_KEY }, }; const res = await fetch(`${process.env.API_BASE_URL}blog`, key) .then((res) => res) .catch((err) => console.log(err)); const data = await res.json(); return { props: { posts: data.contents, }, }; }; export default Index; 記事詳細を作った時のことを思い出して欲しいのですが、データをページに渡すときはgetStaticPropsを使いましたね。今回もgetStaticPropsを使用し、その中でAPI通信を行なっていきます。 const key = { headers: { "X-API-KEY": process.env.API_KEY }, }; const res = await fetch(`${process.env.API_BASE_URL}blog`, key)microCMSのAPI接続にはheaders にX-API-KEY を指定する必要があり、X-API-KEYにはAPIキーを指定します。それをfetch APIの第2引数として渡しています。データを取得したらそれをPropsでIndexコンポーネントに渡し、以前にモックで使っていたところをPropsに修正しています。データ構造は予め合わせていたので、中身は修正する必要はないですね。- const Index: NextPage = () => { + const Index: NextPage = ({ posts }) => { return ( <> <h1 className={styles.title}>BLOG</h1> <div> - {mockPosts.map((post: IPost) => { + {posts.map((post: IPost) => { return ( <Link href={`/posts/${post.id}`} key={post.id}> <a> <section> <h2>{post.title}</h2> <p>{post.content}</p> <p>{post.createdAt}</p> </section> </a> </Link> ); })} </div> </> ); };HTMLはそのまま表示させてたり、字切りもしていないのですごいことになっていますがこんな感じで表示されていたら、トップページはひとまず完成です。記事ページもAPI連携しようトップのAPI通信ができたので、続いて記事ページも対応しましょう。src/pages/posts/[id].tsx まずはgetStaticPaths から修正しましょう。やることは先ほどのトップとあまり変わらず、API連携でデータを取得し、そのデータとモックデータを変更すれば良いですね。 export const getStaticPaths: GetStaticPaths = async () => { - const paths = mockPosts.map((post) => `/posts/${post.id}`); + const key = { + headers: { "X-API-KEY": process.env.API_KEY }, + }; + const res = await fetch(`${process.env.API_BASE_URL}blog`, key) + .then((res) => res) + .catch((err) => console.log(err)); + const data = await res.json(); + + const paths = data.contents.map((post: IPost) => `/posts/${post.id}`); return { paths, fallback: false }; };次にgetStaticProps もAPI連携しましょう。こちらはparams のIDを使ってAPIでデータを取得し、そのデータとモックデータを入れ替えます。export const getStaticProps: GetStaticProps = async ({ params }) => { if (!params?.id) { return { notFound: true }; } - const post: IPost | undefined = mockPosts.find( - (post) => post.id.toString() === params.id - ); + const key = { + headers: { "X-API-KEY": process.env.API_KEY }, + }; + const res = await fetch(`${process.env.API_BASE_URL}blog/${params.id}`, key) + .then((res) => res) + .catch((err) => console.log(err)); + const post = await res.json(); if (post === undefined) { return { notFound: true }; } return { props: post }; };HTMLがそのまま表示されていますが、このように表示されれば一旦完成です。HTMLを表示しようcontentの中身はHTMLなのですが、今はその中身がプレーンテキストで表示されてしまっています。なのでこれをちゃんとHTMLとして表示させます。HTMLを表示するにはdangerouslySetInnerHTMLを使用します。最初は<p> タグで 括っていたのですが、それだとHTMLの構造的にエラーとなるので、<div> に変更しています。その<div> タグにdangerouslySetInnerHTMLを設定し、content の中身を展開しています。 - <p>{content}</p> + <div + dangerouslySetInnerHTML={{ + __html: content, + }} + />このようにHTMLも展開されました。トップページの見た目も修正しようトップの記事一覧が、HTMLが表示され、かつ全ての要素も表示されてしまっています。一覧にはHTMLは不要ですし、行数も2〜3行ほど見えていたら良いはずなので、そのように修正します。行数で字切りをしよう行数での字切りはCSSの-webkit-line-clampを使っていきます。post.content をラップしているp タグにclassを設定し、以前に作成したsrc/styles/index.module.css にプロパティを追加します。src/pages/index.tsx const Index: NextPage = ({ posts }) => { return ( <> <h1 className={styles.title}>BLOG</h1> <div> {posts.map((post: IPost) => { return ( <Link href={`/posts/${post.id}`} key={post.id}> <a> <section> <h2>{post.title}</h2> - <p>{post.content}</p> + <p className={styles.content}>{post.content}</p> <p>{post.createdAt}</p> </section> </a> </Link> ); })} </div> </> ); };src/styles/index.module.css.content { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; text-overflow: ellipsis; }このように2行で字切りがされ、幾分見やすくなりました。HTML要素を削除しよう一覧ページにはHTMLは不要で、プレーンなテキストを表示できたら良いのでHTMLを削除します。HTMLの削除は愚直に正規表現でやっていきましょうHTML削除の正規表現は/<("[^"]"|'[^']'|[^'">])*>/g になります。const Index: NextPage = ({ posts }) => { return ( <> <h1 className={styles.title}>BLOG</h1> <div> {posts.map((post: IPost) => { return ( <Link href={`/posts/${post.id}`} key={post.id}> <a> <section> <h2>{post.title}</h2> - <p className={styles.content}>{post.content}</p> + <p className={styles.content}> + {post.content.replace(/<("[^"]*"|'[^']*'|[^'">])*>/g, "")} + </p> <p>{post.createdAt}</p> </section> </a> </Link> ); })} </div> </> ); };これでHTMLも綺麗になくなりました。fetchのところは同じ記述が複数あって冗長だったり、viewとロジックが混同したりでお世辞にも綺麗なコードとは言えないのですがこれはまた後ほどリファクタしていくので、まずはこれでAPI連携も完成です。
Continue Reading →【JAMスタックなブログを作ろう その3】記事ページを作ってみよう
2021年04月22日Next.js
JAMスタックなブログを作ろう
前回はトップページに記事一覧を表示させれたので、今回はリンク先である記事ページを作っていきましょう。本記事のソースは以下にアップしていますので、適宜確認してください。https://github.com/yanagisawahidetoshi/nextJS-blog/tree/posts-pageファイルを作ろう記事ページのURLは/posts/${id} を想定しているので、Next.jsのルールに則り/src/pages/posts/ に[id].tsxファイルを作成します。$ mkdir src/pages/post $ touch src/pages/posts/\[id\].tsx作成したファイルに以下のように仮のHTMLを入れます。import { NextPage } from "next"; const Post: NextPage = () => { return ( <section> <h1></h1> <p></p> <p></p> </section> ); }; export default Post;データを表示してみよう詳細ページも一覧と同じく、とりあえずモックデータを使って表示させます。import { NextPage, GetStaticPaths, GetStaticProps } from "next"; import { IPost, mockPosts } from "../../models/posts"; const Post: NextPage<IPost> = ({ title, content, createdAt }) => { return ( <section> <h1>{title}</h1> <p>{content}</p> <p>{createdAt}</p> </section> ); }; export const getStaticPaths: GetStaticPaths = async () => { const paths = mockPosts.map((post) => `/posts/${post.id}`); return { paths, fallback: false }; }; export const getStaticProps: GetStaticProps = async ({ params }) => { const post: IPost = mockPosts.find( (post) => post.id.toString() === params.id ); return { props: post }; }; export default Post;いきなり大量の更新があって、すでに心折れかけてるかもしれませんので、順番に説明していきますね。import { IPost, mockPosts } from "../../models/posts";これはもうお分かりかと思いますが、前回作成したモックデータとインターフェースを呼び出してます。Propsとインターフェースconst Post: NextPage<IPost> = ({ title, content, createdAt }) => {const Post: NextPage<IPost> この部分は前回のトップページと同じく、 PostページはNextPageの型を定義しています。さらに<IPost>これはPostに与えられる引数(Propsと言います)の型を定義しています。前回定義したように以下の値が渡される想定ですね。{ id: number; //記事ID title: string; // 記事タイトル content: string; // 記事の内容 createdAt: string; // 記事作成日時 }({ title, content, createdAt }) さらにここではPropsの中かからこのページで使うものだけを指定しています。SSGとは今回はSSGで詳細ページを作成していくのですが、SSGとはStatic Site Generationの略で静的サイトジェネレーターの意味です。あらかじめHTMLファイルを作成し、アクセス時にはそのHTMLを返却するイメージです。SSGと一緒によく言われるSSRは、アクセスのたびにページを作成しそれを表示します。今回はSSGを使用するので、そのためのNext.jsの機能であるgetStaticPathsとgetStaticPropsを使います。getStaticPaths先ほども書いたようにSSGは事前にファイルを作成する必要があります。現状使っているモックデータはIDが1〜3までの3つがあるので、URLもposts/1 posts/2 posts/3 が必要になります。それを叶えるのがgetStaticPaths になります。const paths = mockPosts.map((post) => `/posts/${post.id}`);ここではモックデータを回して、格納されているデータでURLを作っています。return { paths, fallback: false };そして先ほど作ったpaths を返しています。さらにgetStaticPathsは第2引数としてfallback が必要で、これはpaths にないURLにアクセスした場合の挙動を指定します。false に設定した場合は404ページにリダイレクトされ、true に設定した場合はそのタイミングでページが作成されます。今回はデータにないIDにアクセスする想定はないため、false にしています。getStaticProps次にページで使う用のデータを作成します。これもSSG用のNext.jsの機能であるgetStaticPropsを使います。export const getStaticProps: GetStaticProps = async ({ params }) => {{ params } はルーティング情報が入ったデータです。今回で言うとparams の中にはid が入っていますね。ファイル名に指定している[id] です。URLがposts/1 のようになっているので、この1 がidとなります。const post: IPost = mockPosts.find( (post) => post.id.toString() === parseInt(params.id) ); return { props: post };paramsに入っているidを使って、モックデータから記事を取得し、それを返しています。key名がpropsなのは getStaticPropsの ルールと思ってください。こうすることにより最初に書いたこの部分で、データを取得できるようになります。const Post: NextPage<IPost> = ({ title, content, createdAt }) => {この時点でhttp://localhost:3000/posts/1にアクセスするとこのように表示されてるはずなので、確認してみてください。またhttp://localhost:3000/posts/2にアクセスしても表示が問題ないかも確認してみましょう。ついでに存在しないIDであるhttp://localhost:3000/posts/4にアクセスすると404になることも確認してみましょう。例外処理をしようこの段階では、ページも表示され問題なさそうですがVSCodeなどで見ると、怒られている箇所がいくつかあります。(post) => post.id.toString() === params.idここのparams.id でこんな警告が表示されています。Object is possibly 'undefined'.ts(2532)まずはこの警告。params.id がundefinedの可能性があると言われています。paramsが空の場合にidにアクセスしようとするとundefinedになり怒られますObject is possibly 'undefined'.ts(2532)なので、例外処理を入れましょうexport const getStaticProps: GetStaticProps = async ({ params }) => { if (!params?.id) { return { notFound: true }; } const post: IPost = mockPosts.find( (post) => post.id.toString() === params.id ); return { props: post }; }; if (!params?.id) { return { notFound: true }; }この処理を追加しました。? はオプショナルチェイニング演算子 と言って、params がnullやundefinedの場合にプロパティーにアクセスしてもエラーにならないようになります。そして!params?.id が空の場合はreturn { notFound: true }; で404ページを表示するようにしています。次にconst post: IPost = mockPosts.find( ここで以下の警告が表示されています。Type 'IPost | undefined' is not assignable to type 'IPost'. Type 'undefined' is not assignable to type 'IPost'.ts(2322)これはfind した結果見つからずundefinedになる可能性があり、この型定義だとundefinedは格納できないよと言われています。なので、まずは型にundefinedを追加しましょう。複数の型を設定したい場合は| で繋げれば良いです。const post: IPost | undefined = mockPosts.find(これでpost にundefinedが入ることも許容され、post の警告はなくなりました。その代わり今度はgetStaticProps に警告が表示されるようになりました。エラーの内容は長いので割愛しますが、getStaticPropsのレスポンスにはundefinedは許容できないと言われています。なので先ほどと似たような処理になりますが、post がundefinedの場合は404になるようにしましょう。 if (post === undefined) { return { notFound: true }; }こちらを追記すれば、無事警告がなくなりました。import { NextPage, GetStaticPaths, GetStaticProps } from "next"; import { IPost, mockPosts } from "../../models/posts"; const Post: NextPage<IPost> = ({ title, content, createdAt }) => { return ( <section> <h1>{title}</h1> <p>{content}</p> <p>{createdAt}</p> </section> ); }; export const getStaticPaths: GetStaticPaths = async () => { const paths = mockPosts.map((post) => `/posts/${post.id}`); return { paths, fallback: false }; }; export const getStaticProps: GetStaticProps = async ({ params }) => { if (!params?.id) { return { notFound: true }; } const post: IPost | undefined = mockPosts.find( (post) => post.id.toString() === params.id ); if (post === undefined) { return { notFound: true }; } return { props: post }; }; export default Post;最終的にはソースはこのようになりました。またページも問題なく表示されていれば、記事ページも完成です。
Continue Reading →【JAMスタックなブログを作ろう その2】トップページを作ってみよう
2021年04月19日Next.js
JAMスタックなブログを作ろう
前回で環境は作れたので、さっそくページを作っていきましょう。この記事のソースコードは以下となってます。適宜確認してください。https://github.com/yanagisawahidetoshi/nextJS-blog/tree/top-page不要なファイルを削除し、ディレクトリ構造を整えるインストール直後は上記のようなディレクトリ構造になっていますが、ソースファイルはsrcディレクトリに入れることが通例なので、ソースファイルはこれからそこに入れていきましょう。pages/ディレクトリと/stylesディレクトリは不要なので一旦消して、再度srcディレクトリに作りましょう。$ rm -r pages/ $ rm -r styles/ $ mkdir src $ mkdir src/pages $ mkdir src/stylesNext.jsではpagesディレクトリにある.tsxファイルをページとして認識し自動的にルーティングされます。なのでこの状態でhttp://localhost:3000/にアクセスすると以下のように404になっているのが確認できます。一度このタイミングでyarn devを再起動しておいてください。トップページを作ってみようそれではさっそくトップページを作ってみましょう/src/pages/ディレクトリにindex.tsxファイルを作成しましょう$ touch src/pages/index.tsx作成したページでまずはタイトルだけを表示させてみましょうimport { NextPage } from "next"; const Index: NextPage = () => { return <h1>BLOG</h1>; }; export default Index;src/pages/index.tsxに上記記述し、http://localhost:3000/にアクセスするとこのように表示され、無事トップページが表示されていることが確認できました。ソースの中身を見てみようimport { NextPage } from "next";1行目の↑はTypeScriptの型定義ファイルを読み込んでいます。これを3行目でconst Index: NextPage = () => {このように設定しております。TypeScriptでは:型定義で型を定義することができます。なので、const Index: NextPage = () => {これはIndexはNextPageと言う型を持っていると意味します。今の段階ではページファイルを定義するときはNextPageの型を持ったコンポーネント を使うと理解してもらえれば良いかと思います。またconst Indexはファイル名と合わせることにより、ページのURLとなります。今回はindexなので省略されURLはhttp://localhost:3000/となっています。スタイルをあててみようNext.jsはデフォルトでCSS Modulesをサポートしているのでそれを使って、先ほどのタイトルにスタイルをあててみましょう。ちなみにCSS Modulesとは簡単に言えば、CSSの影響反映をそのコンポーネントだけに閉じ込めるものだと思ってください。CSSで一番厄介なスタイル適用の影響範囲を考えずに済むので、どれほど有用かはHTML書いたことがある方はわかるかと。src/stylesディレクトリ にindexページ用のcssを作りましょう。touch src/styles/index.module.cssこんな感じで、ファイル名を合わせています。ほんまはディレクトリ構造も合わせてる方が良いんですが、また後でリファクタするのでとりあえずな感じでおきました。.title { padding: 16px; color: #3498db; text-decoration: none; font-weight: bold; font-size: 35px; text-align: center; }cssファイルに.titleクラスを作って、こんな感じでスタイルを書きます。出来たCSSをsrc/pages/index.tsxで読み込みます。import { NextPage } from "next"; + import styles from "../styles/index.module.css" const Index: NextPage = () => { return <h1>BLOG</h1>; }; export default Index;読み込んだCSSはclassNameでHTMLのCSSのように適用することができます。import { NextPage } from "next"; import styles from "../styles/index.module.css"; const Index: NextPage = () => { + return <h1 className={styles.title}>BLOG</h1>; }; export default Index;className={styles.title} で先ほど作成したCSSを適用しましたので、以下のように表示されていれば無事CSSはあたっています。この状態でchromeのデベロッパーツールで見てみると作ったクラス名の語尾にランダムな文字列が付与され.styles_title__1QQVHこのようになっており、クラス名が一意になり他とかぶらないようになり影響範囲が閉じ込められていることがわかると思います。これがCSS Modulesです。データを表示しようタイトルができたので、次は記事一覧を表示させてみましょう。実際にはAPIを叩いて、記事を取得して表示させるのですがそれはまた後に回して今回は性的なjsonデータを表示させます。これは実際の現場でもよく使われる手法で、毎回API接続してコンポーネントを作ってると、時間がかかるので代わりに静的なデータを使ってコンポーネントを開発します。モデルを作るデータを格納する用のmodelsディレクトリ を/srcディレクトリ に作成します$ mkdir src/models作成したディレクトリに記事用のモデルファイルを作ります。ファイル名は一旦posts.tsとします。$ touch src/models/posts.tsインターフェースを定義するモデルを作ったので、次は記事用のインターフェースを定義します。インターフェースとは記事データはどういった構造かを明示的に定義するものです。src/models/posts.ts に以下の様に記述します。export interface IPost { id: number; //記事ID title: string; // 記事タイトル content: string; // 記事の内容 createdAt: string; // 記事作成日時 }インターフェースは名前の前にIをつけることにより、インターフェースとわかるようにしました。そして中身を記事ID、タイトル、内容、作成日を持っていると定義し、それぞれのデータの型も定義しています。モックデータを定義するインターフェースが定義できたので、それに従ってモックデータを作成しましょう。export interface IPost { id: number; //記事ID title: string; // 記事タイトル content: string; // 記事の内容 createdAt: string; // 記事作成日時 } export const mockPosts:IPost = { id: 1, title: "タイトル", content: "内容", createdAt: "2021-01-01" } }記事のモックデータを入れるmockPosts変数を定義し、それに先ほど作ったIPostインターフェースを適用しています。mockPostsの中身はIPostの定義通りの内容になっています。ひとまずこれでモックデータは出来たのですが、データは1つしかなくて一覧に表示するには不十分ですので、複数作るために配列にしてみます。export interface IPost { id: number; //記事ID title: string; // 記事タイトル content: string; // 記事の内容 createdAt: string; // 記事作成日時 } export const mockPosts: IPost = [ { id: 1, title: "タイトル", content: "内容", createdAt: "2021-01-01", }, { id: 2, title: "タイトル2", content: "内容2", createdAt: "2021-02-02", }, { id: 3, title: "タイトル3", content: "内容3", createdAt: "2021-03-03", }, ];この様に複数のデータを作成し配列にしました。ここでVSCodeなどを使っているならお気づきかもしれませんが、このようにエラーが出ます。これは、定義したインターフェースと変数の中身が違うよって怒られています。確かに定義しているインターフェースはオブジェクトですが、mockPost の中身は配列になっています。なので、インターフェースを配列にします。インターフェースを配列にするにはArray<> で括れば良いです。export interface IPost { id: number; //記事ID title: string; // 記事タイトル content: string; // 記事の内容 createdAt: string; // 記事作成日時 } export const mockPosts: Array<IPost> = [ { id: 1, title: "タイトル", content: "内容", createdAt: "2021-01-01", }, { id: 2, title: "タイトル2", content: "内容2", createdAt: "2021-02-02", }, { id: 3, title: "タイトル3", content: "内容3", createdAt: "2021-03-03", }, ];これでエラーはなくなっているはずです。データを表示しようsrc/pages/index.tsx を開いて、先ほど作ったモデルを読み込みます。import { NextPage } from "next"; import styles from "../styles/index.module.css"; + import { IPost, mockPosts } from "../models/posts"; const Index: NextPage = () => { return <h1 className={styles.title}>BLOG</h1>; }; export default Index;次に表示用のHTMLを作ります。import { NextPage } from "next"; import styles from "../styles/index.module.css"; import { IPost, mockPosts } from "../models/posts"; const Index: NextPage = () => { return ( <> <h1 className={styles.title}>BLOG</h1>; <div> <section> <h2></h2> <p></p> <p></p> </section> </div> </> ); }; export default Index; Reactのコンポーネントは一つの要素しか返すことができないので、上記のように返す要素が複数になる場合は、空のタグで括る必要があります。なので、<></>で括っています。試しにこれを外すとエラーになるので、興味ある方は外してみてください。読み込んだモックデータをmap を使ってループし、表示させます。import { NextPage } from "next"; import styles from "../styles/index.module.css"; import { IPost, mockPosts } from "../models/posts"; const Index: NextPage = () => { return ( <> <h1 className={styles.title}>BLOG</h1> <div> {mockPosts.map((post: IPost) => { return ( <section key={post.id}> <h2>{post.title}</h2> <p>{post.content}</p> <p>{post.createdAt}</p> </section> ); })} </div> </> ); }; export default Index; {mockPosts.map((post: IPost) => { mapのコールバックの要素にTypeを適用しています。またコンポーネントの中身はHTMLになっているので、JavaScriptを使いたい場合は{} で括る必要があります。コンポーネントの中で変数を表示させたい場合は{} で括れば変数の中身を表示させることができます。section にkey={post.id}のプロパティがありますが、これはReactのお作法でこれがないとReactに怒られるので、これはつけるようにしてください。つけないと余計な連打林が走りパフォーマンスが悪くなります。詳しくはhttps://ja.reactjs.org/docs/lists-and-keys.html公式のこの記事をご覧ください。余談ですが、map の処理はreturnで返さないと表示されないのでご注意ください。http://localhost:3000/にアクセスし、上記のように表示されていれば、一旦は完成です。リンクを設定しようそれでは最後にそれぞれの記事にリンクを設定しましょう。Next.jsでリンクを設定する場合は<Link> コンポーネントを使います。import { NextPage } from "next"; import Link from "next/link"; import styles from "../styles/index.module.css"; import { IPost, mockPosts } from "../models/posts"; const Index: NextPage = () => { return ( <> <h1 className={styles.title}>BLOG</h1> <div> {mockPosts.map((post: IPost) => { return ( <Link href={`/posts/${post.id}`} key={post.id}> <a> <section> <h2>{post.title}</h2> <p>{post.content}</p> <p>{post.createdAt}</p> </section> </a> </Link> ); })} </div> </> ); }; export default Index;リンクさせたい要素を空のa タグで括り、それをさらに<Link> タグで括ります。リンク先はまだできていないですが、仮でpostsページに設定し引数としてpost.idを渡しています。一旦これでリンクの設定及び一覧表示は完成です。
Continue Reading →【JAMスタックなブログを作ろう その1】next.jsの環境構築
2021年03月31日Next.js
JAMスタックなブログを作ろう
Next.jsの環境を作る以下のコマンドを実行し、Next.jsの環境を作成yarn create next-app途中以下の様にプロジェクト名の入力を求められるので、任意の名前を入力。それがディレクトリ名にもなる。yarn create v1.22.10 [1/4] 🔍 Resolving packages... [2/4] 🚚 Fetching packages... [3/4] 🔗 Linking dependencies... [4/4] 🔨 Building fresh packages... success Installed "create-next-app@10.1.2" with binaries: - create-next-app ? What is your project named? › blog今回はblogとします。以下のように表示されれば、インストールが完了です。Success! Created blog at ./blog Inside that directory, you can run several commands: yarn dev Starts the development server. yarn build Builds the app for production. yarn start Runs the built app in production mode. We suggest that you begin by typing: cd blog yarn dev ✨ Done in 87.29s.動作確認上記でのコマンドであったようにディレクトリに移動し、Next.jsを起動してみます。cd blog yarn dev以下のように表示されたら、書いてるようにhttp://localhost:3000にアクセスします。ready - started server on 0.0.0.0:3000, url: http://localhost:3000 info - Using webpack 4. Reason: future.webpack5 option not enabled https://nextjs.org/docs/messages/webpack5 event - compiled successfully event - build page: / wait - compiling... event - compiled successfullyhttp://localhost:3000にアクセスし以下のようにNext.jsのwelcomeg画面が表示されれば、正常に動作しています。TypeScriptの導入最近のデフォクトスタンダードになりつつある、TypeScriptも最初のうちに導入しておきますtsconfig.jsonの作成プロジェクトのルートにTypeScriptの設定ファイルであるtsconfig.jsonを作成します。まずは空のtsconfig.jsonを作成します。 touch tsconfig.json必要なファイルのインストールこの状態で、yarn devを実行するとIt looks like you're trying to use TypeScript but do not have the required package(s) installed. Please install typescript and @types/react by running: yarn add --dev typescript @types/react If you are not trying to use TypeScript, please remove the tsconfig.json file from your package root (and any TypeScript files in your pages directory).上記のようにエラーがでて、パッケージをインストールするように指示があるので、それに従ってyarn add --dev typescript @types/react @types/node上記インストールを行います。インストール完了後にyarn devを実行しWelcomeページが表示されていれば無事成功です。また、起動後に先ほど作ったtsconfig.jsonを確認すると以下のように内容が追加されてます。{ "compilerOptions": { "target": "es5", "lib": [ "dom", "dom.iterable", "esnext" ], "allowJs": true, "skipLibCheck": true, "strict": false, "forceConsistentCasingInFileNames": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve" }, "include": [ "next-env.d.ts", "**/*.ts", "**/*.tsx" ], "exclude": [ "node_modules" ] } ESLintとPrettierの導入静的検証ツールのESLintとソースコードフォーマットツールのPrettierを導入していきますESLintのインストールyarn add --dev \ eslint \ @typescript-eslint/parser \ @typescript-eslint/eslint-plugin \ eslint-plugin-react \ eslint-plugin-react-hooks \ eslint-plugin-jsx-a11y \ eslint-plugin-import\ eslint-import-resolver-typescriptPrettierのインストールyarn add --dev prettier eslint-plugin-prettier eslint-config-prettierstrictモードの有効化厳格なチェックをするためにtsconfig.jsonのstrictモードを有効にしておくtsconfig.jsonのstricをtrueに変更{ "compilerOptions": { "target": "es5", "lib": [ "dom", "dom.iterable", "esnext" ], "allowJs": true, "skipLibCheck": true, - "strict": false, + "strict": true, "forceConsistentCasingInFileNames": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve" }, "include": [ "next-env.d.ts", "**/*.ts", "**/*.tsx" ], "exclude": [ "node_modules" ] }
Continue Reading →