Closed51

React&Next.js 初心者なので調べていく

Wataru TaguchiWataru Taguchi

調べていくこと

  • ビルド先
    • Vercel
    • Firebase
  • SPA, ISR モード?など
  • SPA にしたときのバックエンドのリリース先
    • Vercel
    • Firebase
Wataru TaguchiWataru Taguchi
npx create-next-app nextjs-blog --use-npm --example "https://github.com/vercel/next-learn-starter/tree/master/learn-starter"
cd nextjs-blog
npm run dev

npx create-next-app したら git の初期設定までされて驚いた

http://localhost:3000/

Wataru TaguchiWataru Taguchi
  • pages ディレクトリ配下のディレクトリやファイル名がURLになる
  • Link タグで a タグを囲って遷移させる
  • クライアント側での遷移なので、html タグの背景色を変えた場合維持されて遷移する
import Link from 'next/link'

export default function FirstPost() {
  return (
    <>
      <h1>First Post</h1>
      <h2>
        <Link href="/">
          <a>Back to home</a>
        </Link>
      </h2>
    </>
  )
}

<> に違和感がある

Wataru TaguchiWataru Taguchi
import Link from 'next/link'
import Head from 'next/head'

export default function FirstPost() {
  return (
    <>
      <Head>
        <title>First Post</title>
      </Head>
      <h1>First Post</h1>
      <h2>
        <Link href="/">
          <a>Back to home</a>
        </Link>
      </h2>
    </>
  )
}

Head を利用すれば head タグ内に挿入ができる

Wataru TaguchiWataru Taguchi
  • components をトップレベルディレクトリに作る
    • コンポーネントを作れる
    • components/layout.js
    • components/layout.module.css
import styles from './layout.module.css'
export default function Layout({ children }) {
  return <div className={styles.container}>{children}</div>
}
.container {
  max-width: 36rem;
  padding: 0 1rem;
  margin: 3rem auto 6rem;
}
import Link from 'next/link'
import Head from 'next/head'
import Layout from '../../components/layout'

export default function FirstPost() {
  return (
    <Layout>
      <Head>
        <title>First Post</title>
      </Head>
      <h1>First Post</h1>
      <h2>
        <Link href="/">
          <a>Back to home</a>
        </Link>
      </h2>
    </Layout>
  )
}

CSS Modules を初めて書いたけど不思議な感じがする

Wataru TaguchiWataru Taguchi
  • グローバルCSSを定義したいときは pages/_app.js を作る
    • npm run dev を再実行
  • styles/global.css を作成し読み込む
import '../styles/global.css'
export default function App({ Component, pageProps }) {
  return <Component {...pageProps} />
}
html,
body {
  padding: 0;
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu,
    Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
  line-height: 1.6;
  font-size: 18px;
}

* {
  box-sizing: border-box;
}

a {
  color: #0070f3;
  text-decoration: none;
}

a:hover {
  text-decoration: underline;
}

img {
  max-width: 100%;
  display: block;
}
Wataru TaguchiWataru Taguchi
export default function Layout({ children, home }) {
  return (
    <div className={styles.container}>
      <main>{children}</main>
      {!home && (
        <div className={styles.backToHome}>
          <Link href="/">
            <a>Back to home</a>
          </Link>
        </div>
      )}
    </div>
  )
}

Layout の引数でトグルできる

export default function Home() {
  return (
    <Layout home>
      <Head>
        <title>{siteTitle}</title>
      </Head>
      <section className={utilStyles.headingMd}>
        <p>[Your Self Introduction]</p>
        <p>
          (This is a sample website - you’ll be building a site like this on{' '}
          <a href="https://nextjs.org/learn">our Next.js tutorial</a>.)
        </p>
      </section>
    </Layout>
  )
}
Wataru TaguchiWataru Taguchi
  • Next.js はデフォルトですべてのページを事前にレンダリングする
    • 最小限のJSが含まれている
    • hydration
  • Static Generation と Server-side Rendering の2パターンある
    • ビルド時に生成するか、リクエスト毎に生成する
  • npm run dev では Static Generation
  • ページによって、選ぶことができる
Wataru TaguchiWataru Taguchi
  • 外部データが不要であれば、ビルド時に自動的に生成される
  • 外部データが必要な場合もサポートしている
  • getStaticProps をページコンポーネント内で定義できる
    • ビルド時に実行され外部データを取得し、props で渡すことができる
    • npm run dev のときはリクエスト毎に実行される
Wataru TaguchiWataru Taguchi
  • posts ディレクトリを作成し markdown ファイルを設置
---
title: 'Two Forms of Pre-rendering'
date: '2020-01-01'
---

Next.js has two forms of pre-rendering: **Static Generation** and **Server-side Rendering**. The difference is in **when** it generates the HTML for a page.
---
title: 'When to Use Static Generation v.s. Server-side Rendering'
date: '2020-01-02'
---

We recommend using **Static Generation** (with and without data) whenever possible because your page can be built once and served by CDN, which makes it much faster than having a server render the page on every request.
Wataru TaguchiWataru Taguchi
  • メタデータを利用するために npm install gray-matter を実行
  • lib/posts.js でマークダウンファイルを取得し、日付順に並び替える関数を作成
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'

const postsDirectory = path.join(process.cwd(), 'posts')

export function getSortedPostsData() {
  // Get file names under /posts
  const fileNames = fs.readdirSync(postsDirectory)
  const allPostsData = fileNames.map(fileName => {
    // Remove ".md" from file name to get id
    const id = fileName.replace(/\.md$/, '')

    // Read markdown file as string
    const fullPath = path.join(postsDirectory, fileName)
    const fileContents = fs.readFileSync(fullPath, 'utf8')

    // Use gray-matter to parse the post metadata section
    const matterResult = matter(fileContents)

    // Combine the data with the id
    return {
      id,
      ...matterResult.data
    }
  })
  // Sort posts by date
  return allPostsData.sort((a, b) => {
    if (a.date < b.date) {
      return 1
    } else {
      return -1
    }
  })
}
Wataru TaguchiWataru Taguchi
  • 引数で受け取って map でまわして表示ができる
import Head from 'next/head'
import Layout, { siteTitle } from '../components/layout'
import utilStyles from '../styles/utils.module.css'
import { getSortedPostsData } from '../lib/posts'

export default function Home({allPostsData}) {
  return (
    <Layout home>
      <Head>
        <title>{siteTitle}</title>
      </Head>
      <section className={utilStyles.headingMd}></section>
      <section className={`${utilStyles.headingMd} ${utilStyles.padding1px}`}>
        <h2 className={utilStyles.headingLg}>Blog</h2>
        <ul className={utilStyles.list}>
          {allPostsData.map(({ id, date, title }) => (
            <li className={utilStyles.listItem} key={id}>
              {title}
              <br />
              {id}
              <br />
              {date}
            </li>
          ))}
        </ul>
      </section>
    </Layout>
  )
}

export async function getStaticProps() {
  const allPostsData = getSortedPostsData()
  return {
    props: {
      allPostsData
    }
  }
}

Wataru TaguchiWataru Taguchi
  • getStaticProps 内で外部APIの実行もできる
  • DB から直接データを取得できる
    • getStaticProps はサーバサイドでしか動かないため
  • ビルド時で動かすのを想定しているのでクエリパラメータなどは利用できない
  • ページコンポーネント内にしか定義ができない
Wataru TaguchiWataru Taguchi
  • リクエスト時にデータをフェッチする場合はSSR
  • getStaticProps の代わりに getServerSidePropsが利用できる
  • context にパラメーターなどが含まれている
  • CDNでキャッシュができない
export async function getServerSideProps(context) {
  return {
    props: {
      // props for your component
    }
  }
}
Wataru TaguchiWataru Taguchi
  • 事前にレンダリングする必要がない場合は、クライアント側でレンダリングをする
    • 外部データを必要としないページの部分を静的に生成
    • ページが読み込まれたらJSで外部データを取得する
    • 例えばユーザーのダッシュボード画面など
  • SWR (たぶん Service Worker?) でクライアント側でデータを取得するのがおすすめ
import useSWR from 'swr'

function Profile() {
  const { data, error } = useSWR('/api/user', fetch)

  if (error) return <div>failed to load</div>
  if (!data) return <div>loading...</div>
  return <div>hello {data.name}!</div>
}
Wataru TaguchiWataru Taguchi
  • /posts/<id> といった動的URLに対応する
  • pages/posts/[id].js ファイルを作成
    • getStaticPathsgetStaticProps を実装する
  • lib/posts.jsparams を持ったオブジェクトの配列を返す関数を作成
  • lib/posts.jsid でマークダウンを返す関数を作成
  • http://localhost:3000/posts/pre-rendering などで表示できる
export function getAllPostIds() {
  const fileNames = fs.readdirSync(postsDirectory)

  // Returns an array that looks like this:
  // [
  //   {
  //     params: {
  //       id: 'ssg-ssr'
  //     }
  //   },
  //   {
  //     params: {
  //       id: 'pre-rendering'
  //     }
  //   }
  // ]
  return fileNames.map(fileName => {
    return {
      params: {
        id: fileName.replace(/\.md$/, '')
      }
    }
  })
}

export function getPostData(id) {
  const fullPath = path.join(postsDirectory, `${id}.md`)
  const fileContents = fs.readFileSync(fullPath, 'utf8')

  // Use gray-matter to parse the post metadata section
  const matterResult = matter(fileContents)

  // Combine the data with the id
  return {
    id,
    ...matterResult.data
  }
}
import { getAllPostIds, getPostData } from '../../lib/posts'
import Layout from '../../components/layout'

export default function Post({ postData }) {
  return (
    <Layout>
      {postData.title}
      <br />
      {postData.id}
      <br />
      {postData.date}
    </Layout>
  )
}

export async function getStaticPaths() {
  const paths = getAllPostIds()
  return {
    paths,
    fallback: false
  }
}

export async function getStaticProps({ params }) {
  const postData = getPostData(params.id)
  return {
    props: {
      postData
    }
  }
}
Wataru TaguchiWataru Taguchi
  • マークダウンのレンダリングには remark を利用する
    • npm install remark remark-html
  • dangerouslySetInnerHTML で表示できる
import remark from 'remark'
import html from 'remark-html'

export async function getPostData(id) {
  const fullPath = path.join(postsDirectory, `${id}.md`)
  const fileContents = fs.readFileSync(fullPath, 'utf8')

  // Use gray-matter to parse the post metadata section
  const matterResult = matter(fileContents)

  // Use remark to convert markdown into HTML string
  const processedContent = await remark()
    .use(html)
    .process(matterResult.content)
  const contentHtml = processedContent.toString()

  // Combine the data with the id and contentHtml
  return {
    id,
    contentHtml,
    ...matterResult.data
  }
}
export default function Post({ postData }) {
  return (
    <Layout>
      {postData.title}
      <br />
      {postData.id}
      <br />
      {postData.date}
      <br />
      <div dangerouslySetInnerHTML={{ __html: postData.contentHtml }} />
    </Layout>
  )
}
Wataru TaguchiWataru Taguchi
  • getStaticPaths 内で外部APIを叩くことができる
    • 開発時はリクエスト毎に getStaticPaths が実行される
    • 本番はビルド時に実行される
  • fallback: false はパスが無いときは 404 を返す
  • true の時は、404 ではなく、fallback バージョンのページを最初のリクエストで返す
    • 裏側で要求されたパスのHTMLを静的に生成する
    • 後続のリクエストは他のページと同様に、生成されたHTMLを返す
  • pages/posts/[...id].js と書けば、/posts/a, /posts/a/b, /posts/a/b/c など全てに対応できる
  • pages/404.js が 404 画面
export async function getAllPostIds() {
  // Instead of the file system,
  // fetch post data from an external API endpoint
  const res = await fetch('..')
  const posts = await res.json()
  return posts.map(post => {
    return {
      params: {
        id: post.id
      }
    }
  })
}
export async function getStaticPaths() {
  const paths = getAllPostIds()
  return {
    paths,
    fallback: false
  }
}
Wataru TaguchiWataru Taguchi
  • pages/api ディレクトリに API を作れる
    • pages/api/hello.js
  • getStaticPropsgetStaticPaths から叩いてはいけない
  • 良い使い方はフォーム入力処理

http://localhost:3000/api/hello で表示できる

export default (req, res) => {
  res.status(200).json({ text: 'Hello' })
}
Wataru TaguchiWataru Taguchi
  • TypeScript の利用
    • touch tsconfig.json
    • npm install --save-dev typescript @types/react @types/node
  • コンポーネントやページ配下にコンポーネント は .tsx に変換
Wataru TaguchiWataru Taguchi

Error: A required parameter (id) was not provided as a string in getStaticPaths for /animes/[id]

int だとエラーになる

export async function getStaticPaths() {
  const paths = [
    {
      params: {
        id: 1,
      }
    },
    {
      params: {
        id: 2,
      }
    },
    {
      params: {
        id: 3,
      }
    }
  ];
  return {
    paths,
    fallback: false
  }
}

String にすることで動く

export async function getStaticPaths() {
  const paths = [
    {
      params: {
        id: '1',
      }
    },
    {
      params: {
        id: '2',
      }
    },
    {
      params: {
        id: '3',
      }
    }
  ];
  return {
    paths,
    fallback: false
  }
}

Wataru TaguchiWataru Taguchi

ESLint の導入

インストールと初期設定(適当に選びました)

npm install -D eslint

npx eslint --init
✔ How would you like to use ESLint? · style
✔ What type of modules does your project use? · esm
✔ Which framework does your project use? · react
✔ Does your project use TypeScript? · No / Yes
✔ Where does your code run? · browser
✔ How would you like to define a style for your project? · guide
✔ Which style guide do you want to follow? · airbnb
✔ What format do you want your config file to be in? · JavaScript

lint 系を追加

  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint . --ext .ts,.tsx",
    "lint:fix": "npm lint --fix"
  },
Wataru TaguchiWataru Taguchi

--fix オプションを実行してもエラーがかなり残った

ここらへんは無視させました(react/prop-typesprop の型バリデーションっぽいので後ほど使ってみたほうが良さそう)

  rules: {
    'import/extensions': 'off',
    'import/no-unresolved': 'off',
    'react/react-in-jsx-scope': 'off',
    'react/jsx-filename-extension': 'off',
    'react/prop-types': 'off',
    'react/jsx-props-no-spreading': 'off',
  },
Wataru TaguchiWataru Taguchi

https://ja.reactjs.org/docs/hooks-intro.html
https://qiita.com/hiraike32/items/71b14755f56208a8a133

入力画面のサンプルコード

import { useState } from 'react';
import Link from 'next/link';
import HppLayout from '../components/layout/hpp-layout';

export default function Create() {
  const [count, setCount] = useState(0);
  const [todos, setTodos] = useState([]);
  const [tmpTodo, setTmpTodo] = useState('');

  const addTodo = () => {
    setTodos([...todos, tmpTodo]);
    setTmpTodo('');
  };

  return (
    <HppLayout>
      <h1>投稿画面</h1>
      <Link href="/">トップ</Link>

      <input
        type="text"
        name="todo"
          // formの入力値をtmpTodoで持っておく
        onChange={(e) => setTmpTodo(e.target.value)}
        value={tmpTodo}
      />
      <button type="button" onClick={addTodo}>Add</button>
      <ul>
        {todos.map((todo, index) => <li key={index}>{todo}</li>)}
      </ul>
      <p>
        You clicked
        {count}
        {' '}
        times
      </p>
      <button type="button" onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </HppLayout>
  );
}

Wataru TaguchiWataru Taguchi

Firestore との連携

普通に Firestore プロジェクトを作る

npm install --save firebase

Next.js は .env.local を自動的に読み込むので、firebase 系の情報は .env.local に記述する

https://nextjs.org/docs/basic-features/environment-variables

デフォルトでは Node.js 環境のみで使用できる

ブラウザで利用するためにはNEXT_PUBLIC_ を付ける必要がある

NEXT_PUBLIC_FIREBASE_API_KEY=
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=
NEXT_PUBLIC_FIREBASE_DATABASE_URL=
NEXT_PUBLIC_FIREBASE_PROJECT_ID=
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=
NEXT_PUBLIC_FIREBASE_APP_ID=

lib/firebase.ts に作成

import firebase from 'firebase/app';
import 'firebase/firestore';

const config = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  databaseURL: process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
};

!firebase.apps.length
  ? firebase.initializeApp(config)
  : firebase.app();

export const db = firebase.firestore();
Wataru TaguchiWataru Taguchi

Vercel で環境変数を利用する

Plaintext を選択肢、key=value を設定

どの環境で利用するかと問われているが、本番だけで利用するので Production を選択

Wataru TaguchiWataru Taguchi

Next Image

Firebase Storage の画像を Next Image で表示させるためにはドメインの指定が必要

https://nextjs.org/docs/api-reference/next/image

<Image
  src="https://firebasestorage.googleapis.com/v0/b/hoge.jpg?alt=media"
  alt="Picture of the author"
  width={600}
  height={300}
/>
module.exports = {
  images: {
    domains: ['firebasestorage.googleapis.com'],
  },
};
Wataru TaguchiWataru Taguchi

Firebase Storage への画像アップロード

React では input file には ref 経由でアクセスが推奨されている

https://ja.reactjs.org/docs/uncontrolled-components.html#the-file-input-tag

hooks では useRef を利用するとことでした

https://ja.reactjs.org/docs/hooks-reference.html#useref

最終的には下記のような感じで画像アップロードが完成

import { useRef } from 'react';
import { storageRef } from '../lib/firebase';

export default function Create() {
  const photo = useRef(null);

  const upload = async () => {
    const snapshot = await storageRef.child(photo.current.files[0].name).put(photo.current.files[0]);
  };

  return (
      <input
        type="file"
        name="photo"
        ref={photo}
      />
      <button type="button" onClick={upload}>upload</button>
  );
}

Wataru TaguchiWataru Taguchi

Select

map でまわして表示するのにまだ慣れない・・・

import { useState } from 'react';
import { getAllAnimes } from '../lib/animes';

export default function Create() {
  const [animeId, setAnimeId] = useState('');
  const animes = getAllAnimes();

  return (
      <select onChange={(e) => setAnimeId(e.target.value)}>
        {animes.map((a) => (
          <option value={anime.id} key={anime.id}>{a.title}</option>
        ))}
      </select>
  );
}

Wataru TaguchiWataru Taguchi

Firebase Auth

とりあえず投稿画面での Firebase Auth Login は下記のようになった

原因は不明だが input に値を入力する度に onAuthStateChanged 発火していた🤔

全画面で user 情報を保持する方法は思いついていないが(onAuthStateChanged を各コンポーネントに記述する?)一旦完成

import { useState } from 'react';
import {
  auth, db, storageRef, TitterAuthProvider,
} from '../lib/firebase';

export default function Create() {
  const [user, setUser] = useState(null);

  const login = () => {
    auth.signInWithRedirect(new TitterAuthProvider());
    auth
      .getRedirectResult()
      .then(() => {
        console.log('success');
      })
      .catch((error) => {
        console.log(error);
      });
  };

  const addTodo = async () => {
    const uid = user.uid;
    // add todo
  };

  auth.onAuthStateChanged(async (u) => {
    if (u && !user) {
      setUser(u);
    }
  });

  return (
    <button type="button" onClick={login}>Login</button>
    <button type="button" onClick={addTodo}>Add</button>
  );
}

Wataru TaguchiWataru Taguchi

Google Map の表示

https://qiita.com/miyabiya/items/98c8a177e6077ee50971
を参考に

google-map-react が一番盛り上がっているっぽいので、google-map-react を採用
https://www.npmjs.com/package/google-map-react
https://github.com/google-map-react/google-map-react

表示とクリック位置の緯度経度の取得

import GoogleMapReact from 'google-map-react';

export default function Create() {
  const [lat, setLat] = useState(null);
  const [lng, setLng] = useState(null);

  const setLatLng = (obj) => {
    setLat(obj.lat);
    setLng(obj.lng);
  };

  return (
      <div style={{ height: '300px', width: '300px' }}>
        <GoogleMapReact
          bootstrapURLKeys={{ key: process.env.NEXT_PUBLIC_GOOGLE_MAP_KEY }}
          defaultCenter={{
            lat: 35.6698324,
            lng: 139.48197549999998,
          }}
          defaultZoom={16}
          onClick={setLatLng}
        />
      </div>
  );
}
Wataru TaguchiWataru Taguchi

ピンの表示

https://github.com/google-map-react/google-map-react/tree/a783cd62767f824a6eb072e523c8d06ea660c7ee#use-google-maps-api

直接 Google map api を触る必要があるっぽい

地図のクリック時

onGoogleApiLoadedmapmap を保持しておく

onClick 時に、緯度経度オブジェクト(LatLng) を作成して、マーカーを作成する

その後クリックした位置を中心に表示するため、panTo を利用

import { useState } from 'react';
import GoogleMapReact from 'google-map-react';

export default function Create() {
  const [lat, setLat] = useState(null);
  const [lng, setLng] = useState(null);
  const [map, setMap] = useState(null);
  const [maps, setMaps] = useState(null);
  const [marker, setMarker] = useState(null);

  const handleApiLoaded = (obj) => {
    setMap(obj.map);
    setMaps(obj.maps);
  };

  const setLatLng = (obj) => {
    setLat(obj.lat);
    setLng(obj.lng);
    if (marker) {
      marker.setMap(null);
    }
    const latLng = new maps.LatLng(obj.lat, obj.lng);
    setMarker(new maps.Marker({
      map,
      position: latLng,
    }));
    map.panTo(latLng);
  };

  return (
      <div>
        <p>
          緯度 : {lat}
        </p>
        <p>
          経度 : {lng}
        </p>
      </div>
      <div style={{ height: '300px', width: '300px' }}>
        <GoogleMapReact
          bootstrapURLKeys={{ key: process.env.NEXT_PUBLIC_GOOGLE_MAP_KEY }}
          defaultCenter={{
            lat: 35.6698324,
            lng: 139.48197549999998,
          }}
          defaultZoom={16}
          onClick={setLatLng}
          onGoogleApiLoaded={handleApiLoaded}
        />
      </div>
  );
}

表示時

こちらも同じく、onGoogleApiLoaded で受け取ってマーカーを表示させる

import GoogleMapReact from 'google-map-react';

export default function Photo({ photo }) {
  const handleApiLoaded = (obj) => {
    const { map } = obj;
    const { maps } = obj;
    const latLng = new maps.LatLng(photo.lat, photo.lng);
    new maps.Marker({
      map,
      position: latLng,
    });
  };

  return (
      <div style={{ height: '300px', width: '300px' }}>
        <GoogleMapReact
          bootstrapURLKeys={{ key: process.env.NEXT_PUBLIC_GOOGLE_MAP_KEY }}
          defaultCenter={{
            lat: photo.lat,
            lng: photo.lng,
          }}
          defaultZoom={16}
          onGoogleApiLoaded={handleApiLoaded}
        />
      </div>
  );
}

Wataru TaguchiWataru Taguchi

複数マーカー(ピン)を表示して画面内に表示させる

こちらも onGoogleApiLoaded 経由で直接 Google Map API を触る

LatLngBounds を作成後、緯度経度で Marker を作成しピンを表示させ

LatLngBounds に追加して、fitBounds を実行すれば画面内にピンが収まるように自動的に調整される

export default function Anime({ photos }) {
  const handleApiLoaded = (obj) => {
    const { map } = obj;
    const { maps } = obj;
    const bounds = new maps.LatLngBounds();
    photos.forEach((photo) => {
        const marker = new maps.Marker({
          position: {
            lat: photo.lat,
            lng: photo.lng,
          },
          map,
        });
        bounds.extend(marker.position);
    });
    map.fitBounds(bounds);
  };

  return (
      <div style={{ height: '300px', width: '300px' }}>
        <GoogleMapReact
          bootstrapURLKeys={{ key: process.env.NEXT_PUBLIC_GOOGLE_MAP_KEY }}
          defaultZoom={16}
          defaultCenter={{
            lat: 35.6698324,
            lng: 139.48197549999998,
          }}
          onGoogleApiLoaded={handleApiLoaded}
        />
      </div>
  );
}

Wataru TaguchiWataru Taguchi

地名で検索

https://developers.google.com/maps/documentation/javascript/geocoding

Geocoding Service を利用して地名の検索をします

Geocoder を作成後、geocode 関数に address を渡して検索を実行します

results の先頭から緯度経度の情報を取り出して、setCenter を利用し中心に表示し、Marker を作ってピンを表示させることができます

export default function Create() {
  const [lat, setLat] = useState(null);
  const [lng, setLng] = useState(null);
  const [map, setMap] = useState(null);
  const [maps, setMaps] = useState(null);
  const [geocoder, setGeocoder] = useState(null);
  const [address, setAddress] = useState(null);
  const [marker, setMarker] = useState(null);

  const handleApiLoaded = (obj) => {
    setMap(obj.map);
    setMaps(obj.maps);
    setGeocoder(new obj.maps.Geocoder());
  };

  const search = () => {
    geocoder.geocode({
      address,
    }, (results, status) => {
      if (status === maps.GeocoderStatus.OK) {
        map.setCenter(results[0].geometry.location);
        if (marker) {
          marker.setMap(null);
        }
        setMarker(new maps.Marker({
          map,
          position: results[0].geometry.location,
        }));
        setLat(results[0].geometry.location.lat());
        setLng(results[0].geometry.location.lng());
      }
    });
  };

  return (
      <div>
        <input type="text" onChange={(e) => setAddress(e.target.value)} />
        <button type="button" onClick={search}>Search</button>
      </div>
      <div style={{ height: '300px', width: '300px' }}>
        <GoogleMapReact
          bootstrapURLKeys={{ key: process.env.NEXT_PUBLIC_GOOGLE_MAP_KEY }}
          defaultCenter={{
            lat: 35.6698324,
            lng: 139.48197549999998,
          }}
          defaultZoom={16}
          onGoogleApiLoaded={handleApiLoaded}
        />
      </div>
  )
}

Wataru TaguchiWataru Taguchi

画像のプレビュー

https://developer.mozilla.org/ja/docs/Web/API/URL/createObjectURL

よくある画像のプレビューは、createObjectURL を利用します

ファイルを丸ごと引数で渡して返ってきた値をそのまま、imgsrc に渡せば画像が表示されます

https://github.com/vercel/next.js/blob/master/errors/next-image-unconfigured-host.md

ちなみに Next Image を利用すると、createObjectURLblob:http://localhost:3000/b2e0e749-3cda-4917-bee9-53a9f91ca59e のような形式を返し、Next Image 側がドメインをうまくパースできずエラーになり利用することはできませんでした

export default function Create() {
  const [preview, setPreview] = useState('');

  const handleChangeFile = (e) => {
    const { files } = e.target;
    setPreview(window.URL.createObjectURL(files[0]));
  };

  return (
    <img src={preview} alt="preview" />
    <input
      type="file"
      name="photo"
      onChange={handleChangeFile}
    />
  )
}

<input type="file"> を非表示にしつつ、No Image を出していい感じ見せる場合はこんな感じになります

.preview {
  width: 300px;
  height: 300px;
}

.previewImg {
  object-fit: contain;
  width: 100%;
  height: 100%;
}

.inputPhoto {
  display: none;
}
import styles from './create.module.css';

export default function Create() {
  const [preview, setPreview] = useState('/img/no_image.png');

  const handleChangeFile = (e) => {
    const { files } = e.target;
    setPreview(window.URL.createObjectURL(files[0]));
  };

  return (
    <label htmlFor="photo">
      <div className={styles.preview}>
        <img src={preview} alt="preview" className={styles.previewImg} />
      </div>
      <input
        id="photo"
        type="file"
        name="photo"
        onChange={handleChangeFile}
        className={styles.inputPhoto}
      />
    </label>
  )
}
Wataru TaguchiWataru Taguchi

画像圧縮&Firebase Storage へのアップロード

https://github.com/blueimp/JavaScript-Load-Image

https://github.com/fengyuanchen/compressorjs

https://github.com/Donaldcwl/browser-image-compression

blueimp-load-image を採用

https://firebase.google.com/docs/storage/web/upload-files?hl=ja#upload_from_a_blob_or_file

https://developer.mozilla.org/ja/docs/Web/API/HTMLCanvasElement/toBlob

Firebase Storage は Blob のアップロードができるので、圧縮した画像を Canvas で受け取り、toBlobBlob に変換してアップロードする

import loadImage from 'blueimp-load-image';
import firebase from 'firebase/app';

export default function Create() {
 const handleChangeFile = async (e) => {
    const { files } = e.target;
    const canvas = await loadImage(files[0], {
      maxWidth: 1200,
      canvas: true,
    });
    canvas.image.toBlob((blob) => {
      firebase.storage().ref().child(`/${files[0].name}`).put(blob);
    }, files[0].type);
  };

  return (
      <input
        type="file"
        onChange={handleChangeFile}
      />
  )
}

圧縮は 1.6MB の画像が、520KB くらいになるので満足

Wataru TaguchiWataru Taguchi

Firebase でログインされているかローディングを出しながら待って、ログイン・非ログインで表示するコンポーネントを分岐させるのがいまいちきれいに書けない

export default function Create() {
  const [user, setUser] = useState(null);
  const [doneAuth, setDoneAuth] = useState(false);

  auth.onAuthStateChanged((u) => {
    if (u && !user) {
      setUser(u);
    }
    setDoneAuth(true);
  });

  const authResult = user ? <Form /> : <NoLogin />;
  const loading = <Loading />;

  return (
      <div className={styles.create}>
        <h1>投稿</h1>
        {doneAuth ? authResult : loading}
      </div>
  );
}
Wataru TaguchiWataru Taguchi

<html lang=>

<html lang> が付与できないので、./pages/_document.tsx を作る必要がある

https://nextjs.org/docs/advanced-features/custom-document

import Document, {
  Html, Head, Main, NextScript,
} from 'next/document';

class MyDocument extends Document {
  render() {
    return (
      <Html lang="ja">
        <Head />
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

export default MyDocument;
Wataru TaguchiWataru Taguchi

Google Analytics 連携

https://github.com/vercel/next.js/tree/canary/examples/with-google-analytics

公式にチュートリアルがある

/lib/gtag.ts

GA の PV, イベント発火の関数を実装

https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/gtag.js

npm install --save-dev @types/gtag.js を読み込んで、TypeScript 対応する

export const GA_TRACKING_ID = '<YOUR_GA_TRACKING_ID>'

export const pageview = (url) => {
  window.gtag('config', GA_TRACKING_ID, {
    page_path: url,
  })
}

export const event = ({ action, category, label, value }) => {
  window.gtag('event', action, {
    event_category: category,
    event_label: label,
    value: value,
  })
}

/pages/document.tsx

GA のライブラリを読み込む

/* eslint-disable react/no-danger */
import Document, {
  Html, Head, Main, NextScript,
} from 'next/document';
import { GA_TRACKING_ID } from '../lib/gtag';

class MyDocument extends Document {
  render() {
    return (
      <Html lang="ja">
        <Head>
          <script
            async
            src={`https://www.googletagmanager.com/gtag/js?id=${GA_TRACKING_ID}`}
          />
          <script
            dangerouslySetInnerHTML={{
              __html: `
            window.dataLayer = window.dataLayer || [];
            function gtag(){dataLayer.push(arguments);}
            gtag('js', new Date());
            gtag('config', '${GA_TRACKING_ID}', {
              page_path: window.location.pathname,
            });
          `,
            }}
          />
        </Head>
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

export default MyDocument;

pages/_app.tsx

ページを遷移する毎に PV を送信する

import { useEffect } from 'react';
import { useRouter } from 'next/router';
import { pageview } from '../lib/gtag';

function MyApp({ Component, pageProps }) {
  const router = useRouter();
  useEffect(() => {
    const handleRouteChange = (url) => {
      pageview(url);
    };
    router.events.on('routeChangeComplete', handleRouteChange);
    return () => {
      router.events.off('routeChangeComplete', handleRouteChange);
    };
  }, [router.events]);

  return <Component {...pageProps} />;
}

export default MyApp;
Wataru TaguchiWataru Taguchi

画面表示時にデータをAPI経由で取得

useEffect のイマイチいい使い方がわからずこうなったんだが、これでいいのだろか・・・

export default function Create({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    (async () => {
      setUser(await getUserFromAPI(userId));
    })();
  });

  return (
      <div>
        <h1>{user.name}</h1>
      </div>
  );
}
Wataru TaguchiWataru Taguchi

React Modal

https://github.com/reactjs/react-modal

http://reactcommunity.org/react-modal/

setAppElement でルート?の要素を設定するっぽかったので、とりあえず body を指定してみた

isOpen で表示非表示に利用する変数を設定

onRequestClose でモーダルの背景をクリックしたときの挙動を設定
デフォルトでは背景をクリックしてもモーダルは非表示にならなかった

style.overlay.zindex は、他に fixed 要素があると、z-index で負けてしまったので設定

style.content はデフォルトのモーダルは大きすぎたので、サイズを指定して中央になるようにスタイルを記述した

import { useState } from 'react';
import Modal from 'react-modal';

export default function Add() {
  Modal.setAppElement('body');
  const [modalOpen, setModalOpen] = useState(false);

  const modalClose = () => {
    setModalOpen(false);
  };

  const open = () => {
    setModalOpen(true);
  };

  return (
    <div>
      <Modal
        isOpen={modalOpen}
        onRequestClose={modalClose}
        style={{
          overlay: {
            zIndex: 100,
          },
          content: {
            width: '300px',
            height: '300px',
            top: '50%',
            left: '50%',
            transform: 'translate(-50%, -50%)',
          },
        }}
      >
        <p>hoge</p>
      </Modal>
      <Button onClick={open}>open</Button>
    </div>
  );
}

Wataru TaguchiWataru Taguchi

Sitemap

普通にページとして作って、SSR でXML として返せば良い

import { GetServerSideProps } from 'next';
import { getPhotos } from '../lib/photos';

export default function Sitemap() {
}

const generateSitemap = (photos) => {
  const origin = 'https://hoge.com/';
  let xml: string = '';

  xml += `<url>
  <loc>${origin}</loc>
  <lastmod>2020-12-28</lastmod>
</url>`;

  photos.forEach((photo) => {
    const date = new Date(photo.createdAt).toISOString().split('T')[0];

    xml += `<url>
        <loc>${origin}photos/${photo.id}</loc>
        <lastmod>${date}</lastmod>
      </url>`;
  });

  return `<?xml version="1.0" encoding="UTF-8"?>
    <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
      ${xml}
    </urlset>`;
};

export const getServerSideProps: GetServerSideProps = async ({ res }) => {
  const photos = await getPhotos();
  const sitemap = generateSitemap(photos, animes);
  res.setHeader('content-type', 'application/xml; charset=utf-8');
  res.write(sitemap);
  res.end();

  return {
    props: {},
  };
};

Wataru TaguchiWataru Taguchi

AMP

https://nextjs.org/docs/advanced-features/amp-support/introduction
https://nextjs.org/docs/api-reference/next/amp

export const config = { amp: true }

true or 'hybrid' の設定がある

Tag 'html' marked with attribute 'data-ampdevmode'. Validator will suppress errors regarding any other tag with this attribute.
The parent tag of tag 'img' is 'div', but it can only be 'i-amphtml-sizer-intrinsic'
Custom JavaScript is not allowed.

単純に静的なページに突っ込んでみたら、いろいろエラーが出た

'data-ampdevmode'. これは開発環境だけっぽいので無視してOK

CSS

CSS-in-JS しかサポートしていないので、 CSS モジュールで書いている CSS は全て消滅する

Wataru TaguchiWataru Taguchi

動的なパスで存在しない時

https://nextjs.org/docs/basic-features/data-fetching#getstaticpaths-static-generation

fallback を利用

ブログなど動的に追加された時は、isFallbacktrue になっており、 getStaticProps の中が実行され?(データの取得処理が動いていた)、SSG を行う

export default function Photo({ photo }) {
  const router = useRouter();
  if (router.isFallback) {
    return <div>Loading...</div>;
  }

  return (
    // 略
  );
}

export async function getStaticPaths() {
  const paths = await getAllPhotosParams();
  return {
    paths,
    fallback: true,
  };
}

export async function getStaticProps({ params }) {
  const photo = await getPhotoById(params.id);
  return {
    props: {
      photo,
    },
  };
}

このスクラップは2021/01/02にクローズされました