React&Next.js 初心者なので調べていく
調べていくこと
- ビルド先
- Vercel
- Firebase
- SPA, ISR モード?など
- SPA にしたときのバックエンドのリリース先
- Vercel
- Firebase
- React で作られたフレームワーク
- hybrid static?
- server rendering
- TypeScript support
- smart bundling?
- route pre-fetching
- No config needed
- v10.0.2 が最新(2020/11/22)
- Vercel 社?が作っている
初心者用ハンズオンページ的なのが公式サイトにある
ハンズオンをやってみる
のようなブログアプリが作れるっぽい
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
の初期設定までされて驚いた
css
のシンタックスハイライトが効いていない
公式ではなさそうだが、↑を入れれば効くようになった
-
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>
</>
)
}
<>
に違和感がある
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
タグ内に挿入ができる
-
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 を初めて書いたけど不思議な感じがする
- グローバル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;
}
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>
)
}
- Next.js はデフォルトですべてのページを事前にレンダリングする
- 最小限のJSが含まれている
- hydration
- Static Generation と Server-side Rendering の2パターンある
- ビルド時に生成するか、リクエスト毎に生成する
-
npm run dev
では Static Generation - ページによって、選ぶことができる
- 外部データが不要であれば、ビルド時に自動的に生成される
- 外部データが必要な場合もサポートしている
-
getStaticProps
をページコンポーネント内で定義できる- ビルド時に実行され外部データを取得し、
props
で渡すことができる -
npm run dev
のときはリクエスト毎に実行される
- ビルド時に実行され外部データを取得し、
-
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.
- メタデータを利用するために
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
}
})
}
- 引数で受け取って
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
}
}
}
-
getStaticProps
内で外部APIの実行もできる - DB から直接データを取得できる
-
getStaticProps
はサーバサイドでしか動かないため
-
- ビルド時で動かすのを想定しているのでクエリパラメータなどは利用できない
- ページコンポーネント内にしか定義ができない
- リクエスト時にデータをフェッチする場合はSSR
-
getStaticProps
の代わりにgetServerSideProps
が利用できる -
context
にパラメーターなどが含まれている - CDNでキャッシュができない
export async function getServerSideProps(context) {
return {
props: {
// props for your component
}
}
}
- 事前にレンダリングする必要がない場合は、クライアント側でレンダリングをする
- 外部データを必要としないページの部分を静的に生成
- ページが読み込まれたら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>
}
-
/posts/<id>
といった動的URLに対応する -
pages/posts/[id].js
ファイルを作成-
getStaticPaths
とgetStaticProps
を実装する
-
-
lib/posts.js
にparams
を持ったオブジェクトの配列を返す関数を作成 -
lib/posts.js
にid
でマークダウンを返す関数を作成 -
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
}
}
}
- マークダウンのレンダリングには
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>
)
}
-
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
}
}
-
pages/api
ディレクトリに API を作れるpages/api/hello.js
-
getStaticProps
やgetStaticPaths
から叩いてはいけない - 良い使い方はフォーム入力処理
http://localhost:3000/api/hello で表示できる
export default (req, res) => {
res.status(200).json({ text: 'Hello' })
}
- GitHub に push
- Vercel でリポジトリをインポートする
- TypeScript の利用
touch tsconfig.json
npm install --save-dev typescript @types/react @types/node
- コンポーネントやページ配下にコンポーネント は
.tsx
に変換
レンダリングについてかなり詳しく書かれている
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
}
}
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"
},
--fix
オプションを実行してもエラーがかなり残った
ここらへんは無視させました(react/prop-types
は prop
の型バリデーションっぽいので後ほど使ってみたほうが良さそう)
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',
},
入力画面のサンプルコード
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>
);
}
Firestore との連携
普通に Firestore プロジェクトを作る
npm install --save firebase
Next.js は .env.local
を自動的に読み込むので、firebase 系の情報は .env.local
に記述する
デフォルトでは 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();
Vercel で環境変数を利用する
Plaintext を選択肢、key=value を設定
どの環境で利用するかと問われているが、本番だけで利用するので Production を選択
Next Image
Firebase Storage の画像を 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'],
},
};
Firebase Storage への画像アップロード
React では input file には ref 経由でアクセスが推奨されている
hooks では 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>
);
}
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>
);
}
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>
);
}
Google Map の表示
を参考に
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>
);
}
ピンの表示
直接 Google map api を触る必要があるっぽい
地図のクリック時
onGoogleApiLoaded
で map
と map
を保持しておく
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>
);
}
複数マーカー(ピン)を表示して画面内に表示させる
こちらも 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>
);
}
地名で検索
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>
)
}
画像のプレビュー
よくある画像のプレビューは、createObjectURL
を利用します
ファイルを丸ごと引数で渡して返ってきた値をそのまま、img
の src
に渡せば画像が表示されます
ちなみに Next Image を利用すると、createObjectURL
は blob: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>
)
}
画像圧縮&Firebase Storage へのアップロード
blueimp-load-image
を採用
Firebase Storage は Blob
のアップロードができるので、圧縮した画像を Canvas
で受け取り、toBlob
で Blob
に変換してアップロードする
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 くらいになるので満足
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>
);
}
<html lang=>
<html lang> が付与できないので、./pages/_document.tsx
を作る必要がある
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;
Google Analytics 連携
公式にチュートリアルがある
/lib/gtag.ts
GA の PV, イベント発火の関数を実装
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;
画面表示時にデータを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>
);
}
fetchするだけならreact queryとかよいよ
状態管理もするならRecoilおすすめ
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>
);
}
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: {},
};
};
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 は全て消滅する
動的なパスで存在しない時
fallback を利用
ブログなど動的に追加された時は、isFallback
が true
になっており、 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,
},
};
}