2023年11月ごろからはじめるNext.js
仕事&プライベートでNext.jsを使うかもしれないから勉強しようと思った
これはその過程で得た知識やメモを残すスクラップである
場合によっては間違っていたり古いこともあるかもしれないので突っ込んでくれると助かる
環境
- OS: Windows
- 開発環境: WSL - Ubuntu
- Editor: VSCode
各種バージョン
$ node --version
v18.18.2
$ npm --version
9.8.1
Nodeとnpmは新しいバージョンにしとけばよかった(昔入れたのを忘れてそのままやってた
幸い最新のNext.jsのバージョンを見るに node が 18.17 以上なら大丈夫っぽいのでそのまま進める
プロジェクトを作る
$ npx create-next-app
これでいい。問題はこのコマンドを叩いたあとどんな設定したか忘れたこと(1週間ぐらい前に打った
環境としては
- TypeScript を使用する
- ESLint を使用する
- /src ディレクトリを作成する
- tailwindcss を使用する
- App Router を使用する
あたりはあとから見て思い出せる
書いてない設定はデフォルトだった気がする
Next.jsのバージョン書き忘れてた
{
"name": "my-page",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"react": "^18",
"react-dom": "^18",
"next": "14.0.1"
},
"devDependencies": {
"typescript": "^5",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"postcss": "^8",
"tailwindcss": "^3.3.0",
"eslint": "^8",
"eslint-config-next": "14.0.1"
}
}
こんな感じ
Next.js 14.0.1
React 18
加えていくつか設定
- .gitignore に .vscode を追加
- .vscode/settings.json に以下の設定追加
{
"files.associations": {
"*.css": "tailwindcss"
},
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
}
設定の内容は見ればわかるが、不要なエディターの警告やエラーを消すため
拡張機能としてはESLintとTypeScript、Tailwindのやつを入れる
Next.js/Reactの拡張機能はとりあえず後回しにする(なんかいい感じのが見つからなかった
とりあえず起動してみる(VSCodeとかの設定の前にそっちだったわ
$ npm run dev
> my-page@0.1.0 dev
> next dev
▲ Next.js 14.0.1
- Local: http://localhost:3000
✓ Ready in 2.7s
この状態で localhost:3000 にブラウザでアクセス(起動はWSL上でやっているが、普通にWindowsのブラウザからアクセスして問題ない
○ Compiling /page ...
✓ Compiled /page in 2.3s (499 modules)
✓ Compiled in 184ms (237 modules)
○ Compiling /favicon.ico/route ...
✓ Compiled /favicon.ico/route in 1007ms (504 modules)
アクセスするとこんなロゴが出ながらブラウザにはページが表示される。問題なし
で、いろいろ作っていく
まず大前提として機能面を調べたところ、日本語版とかはちょっと古い情報があって混乱した
- Page Router と App Router の2つのページング機能がある
- これは App Router のほうが新しい
- 併用は可能
- てきとーに調べてるとApp Routerでは使えない機能が出てきたりするので注意する必要がある
- 後でまとめながらどの機能を使うかもメモっておく
- 特に日本語版のドキュメントは最新のものに追従できてないようなので見ないほうがマシかも知れない
ルーティングについて
- Next 13 以降 App Routerが入り現状こっちが推奨されてるっぽい
- のでまあ基本はこっちでやる
- サーバーサイドコンポーネントに当たるらしい
- レンダリングと処理に関係するところは後々触れると思う
- ページ構成は app/ 下(あるいは src/app/ 下)のファイル構造がそのままページのパス構成になる
- app/hoge/fuga -> example.com/hoge/fuga みたいな
- よくあるやつなんで詳細は省略
- ファイルはいくつか特別扱いされるものがある。それらは名前で決定される
- layout
- page
- loading
- not-found
- など(https://nextjs.org/docs/app/building-your-application/routing#file-conventions
- ページの定義は上記の page.tsx でやるっぽい
というわけでデフォルトで作成された app/page.tsx をいい感じに変えてみる
export default function Home() {
return (
<h1>Hello World!</h1>
)
}
で、 npm run dev
デフォルトのCSSがlayout.tsxで効いてるからか変な縞模様があるがまあできた
縞自体は globals.css の body 部分を消せば消えるのでとりあえずそれで。現状いらないし
せっかくなのでこんなことをしてみる
export default function Mainpage() {
console.log("ここにログを出してみる")
return (
<div>
<h1>Hello Next.js World!</h1>
</div>
)
}
上記と同じ app/page.tsx
このログはどっちに出るか。まあわかり切っているが、これはサーバサイドで実行されているのでブラウザのログではなくnpm run devしたコンソールのログに出てくる
イメージしやすくなる
page.tsx の export default function Page() のメソッド名はなんでもいい?様子
要は export default された関数が表示される という認識。間違ってたらごめん
あとコンポーネントとして使用するときに呼び出し元が判別するためという感じだろうか
app/subpage.tsx というファイルを作ってみる。中身はこんな感じ
export default function SubPage(){
return (
<div>
<h2>Sub Page</h2>
</div>
)
}
んで app/page.tsx はこんな感じにする
import SubPage from "./subpage";
export default function Mainpage() {
return (
<div>
<h1>Hello Next.js World!</h1>
{SubPage()}
</div>
)
}
これで localhost:3000 にアクセスすると、 subpage.tsx の中身も表示される
んで、 localhost:3000/subpage とかにアクセスしても特に何も表示されない(404のページが出る)
そういう仕組みだと理解できる
……で、ページのルーティングがわかったところでイメージ&思考実験
表示したいページのディレクトリを作り(例: /dashboard )、そこに page.tsx を作り、そこにページの記述をする。コンポーネントは、 /dashboard 内でしか使わないものと、汎用的に使いたいものがあるとする。そうなった場合、コンポーネントはどこに記述すべきか
例えば絶対 /dashboard 内でしか使わないな、というものであれば dashboard ディレクトリ内に記述するか?となるが、往々にしてプロジェクトの進行でそういうのは変わるので、やっぱり app ディレクトリと同階層に components ディレクトリを用意してそこに記述する気がする
こういうのは処理系だけ持っていくのが正解かもしれない。Nuxt.jsで言うところの <sript> タグの中身とか。UIと処理を分離したいという欲求に答えられるはず
そういえば
export default function Mainpage() {
console.log("test log")
return (
<div>
<h1>Hello Next.js World!</h1>
</div>
)
}
これでログを出したときに、ページを更新するたびにログが出る
てっきりキャッシュされるから出ないと思っていたが、キャッシュされるのはレンダリング結果であってスクリプトではないのか。ちょっと調べる
とりあえずこのページを読んでみる
サーバーコンポーネントのレンダリングに関するの項目(How are Server Components rendered)にそれっぽいのが合ったので仮訳
On the server, Next.js uses React's APIs to orchestrate rendering. The rendering work is split into chunks: by individual route segments and Suspense Boundaries.
サーバ上で、Next.jsはレンダリングを統合するためにReactのAPIを使用します。レンダリング処理は複数のチャンクに分けて行われます。チャンクは個々のルートセグメントとサスペンスバウンダリー(サスペンスタグ?)によって分割されます。
Each chunk is rendered in two steps:
それぞれのチャンクは以下の2ステップでレンダリングされます
- React renders Server Components into a special data format called the React Server Component Payload (RSC Payload).
- ReactがReact Server Component Payloadと呼ばれる特定のデータフォーマットにサーバーコンポーネントを描写します。
- Next.js uses the RSC Payload and Client Component JavaScript instructions to render HTML on the server.
- Next.jsが描写されたReact Server Component PayloadとClient Component JavaScriptの内容を元にHTMLにサーバー上でレンダリングします
この続きはクライアントの話。一旦止めてここまでの内容をまとめると
- Reactがサーバサイドコンポーネントとして書かれた内容をRSC Payloadに変換する
- Next.jsがRSC Payloadとクライアントコンポーネントとして書かれたJavaScriptを処理してHTMLにする
ということらしい。キャッシュの話にまだ届いてないが、とりあえずRSC Payloadを知っておいたほうが良さそう
で、ちょうどいいことにこの項目の注釈にWhat is the React Server Component Payload(RSC)?というのがあるのでそれを読む。長くなったので次の投稿で
The RSC Payload is a compact binary representation of the rendered React Server Components tree. It's used by React on the client to update the browser's DOM. The RSC Payload contains:
RSCペイロードは、React Server Componentsツリーの軽量なバイナリ出力です。ReactがクライアントサイドでブラウザのDOMをアップデートする際に使われます。RSC Payloadは以下の要素を含みます
- The rendered result of Server Components
- サーバコンポーネントのレンダリング結果
- Placeholders for where Client Components should be rendered and references to their JavaScript files
クライアントコンポーネントがJavaScriptによってレンダリングしたり参照するためのプレースホルダー
- Any props passed from a Server Component to a Client Component
- サーバコンポーネントからクライアントコンポーネントに渡すプロパティ
要するにReactのサーバサイドコンポーネントの出力結果ということなのだろう
正直これだけじゃわからんのでReactのドキュメントを見るべきか
思い当たるフシがあったのでそこを当たったら正解だった
npm run dev
(すなわち next dev
)は毎回アクセス時に生成していたようで、アクセス時にログが出ていたが、npm run build
からの npm run start
(すなわち next build
からの next start
)はビルド時にログが出て、startによって開始したサーバにアクセスするタイミングではログが出なかった
試しに
export default function Mainpage() {
const now = new Date()
console.log("test log: ", now.toISOString())
return (
<div>
<h1>Hello Next.js World!</h1>
<h2>Current time {now.toISOString()}</h2>
</div>
)
}
このコードを実行したところ、ビルド→スタートの場合は時間がビルド時のもので固定されていた
このあたりはキャッシュされているというより、正確に言うと「ビルド時に生成・実行されたもの」であって、キャッシュ可能なファイルといったほうがいいかもしれない
とにかくドキュメントの内容が理解できた&動作がわかったので一安心
で、じゃあこれちゃんと更新したい(アクセスタイミングの時間が出るようにしたい)場合どうするの?という方向で調べていく。ついでにfetch関係も触るかも
大前提として
- static rendering(静的レンダリング)
- dynamic rendering(動的レンダリング)
がある。静的レンダリングはビルド時にレンダリングされ、動的レンダリングはリクエスト時にレンダリングされる
export default function Mainpage() {
const now = new Date()
console.log("test log: ", now.toISOString())
return (
<div>
<h1>Hello Next.js World!</h1>
<h2>Current time {now.toISOString()}</h2>
</div>
)
}
これは静的レンダリングなので、ビルド時の時間が出ていたし、ログもビルドのタイミングで出ている(devビルドの場合はリクエスト時にレンダリングされている)
Next.jsのLearnのChapter 8で、このレンダリングが扱われている
ここでも静的レンダリングの場合ビルド時にレンダリングされると書いているし、実際リアルタイム性が必要なデータや、リクエスト時に内容が決定するもの(Queryとか)は動的レンダリングで処理する必要があると書いている
ではどうすればいいかというと、上記のLearnの中に書いているものだと unstable_noStore()
を使う例が書かれている
他にもいくつか当たってみた感じでは、fetch API の cache オプションを適切に設定するといいっぽい
基本は静的レンダリングで、ビルド時に生成される。というのは覚えておいたほうが良さそう。というかそこまで理解してまずスタートラインっぽい印象
というわけでとりあえずこうしてみる
import { unstable_noStore } from "next/cache"
export default function Mainpage() {
unstable_noStore()
const now = new Date()
console.log("test log: ", now.toISOString())
return (
<div>
<h1>Hello Next.js World!</h1>
<h2>Current time {now.toISOString()}</h2>
</div>
)
}
当然 npm run build
してから npm run start
する
するとログはビルド時に出ず、アクセス時に出た。ページもアクセスした瞬間の時間が表示された
この unstable_noStore()
が有効な範囲がどこまでなのか気になるのでちょっとこんなこともしてみる
import { unstable_noStore } from "next/cache"
export default function Mainpage() {
const now = new Date()
const noStoreNow = getTime()
console.log("time: ", now.toISOString())
console.log("noStore time: ", noStoreNow)
return (
<div>
<h1>Hello Next.js World!</h1>
<h2>Current time {now.toISOString()}</h2>
<h2>Current noStore time {noStoreNow}</h2>
</div>
)
}
function getTime(){
unstable_noStore()
return new Date().toISOString()
}
この場合、どちらもアクセスした時刻が出た。Mainpage()
関数も unstable_noStore()
が効いていることになる
今度は src/lib/timer.ts というファイルを作ってそっちに分けてみる
import { unstable_noStore } from 'next/cache'
export default function getTime(){
unstable_noStore()
return new Date().toISOString()
}
んで、これを呼び出す
import getTime from "@/lib/timer"
export default function Mainpage() {
const now = new Date()
const noStoreNow = getTime()
console.log("time: ", now.toISOString())
console.log("noStore time: ", noStoreNow)
return (
<div>
<h1>Hello Next.js World!</h1>
<h2>Current time {now.toISOString()}</h2>
<h2>Current noStore time {noStoreNow}</h2>
</div>
)
}
これを実行した場合でもやっぱり同じように時間が出る
次はこんなことをしてみる
import Subpage from "./subpage"
export default function Mainpage() {
const now = new Date().toISOString()
console.log("mainpage time: ", now)
return (
<div>
<h1>Hello Next.js World!</h1>
<h2>Current time {now}</h2>
{Subpage()}
</div>
)
}
import { unstable_noStore } from "next/cache";
export default function Subpage(){
unstable_noStore()
const subNow = new Date().toISOString()
console.log("subpage time: ", subNow)
return (
<div>
<h2>Subpage time : {subNow}</h2>
</div>
)
}
page.tsx と subpage.tsx は同じ階層のファイルである
ビルドしてスタートすると、ビルド時には mainpage の方のログが出力され、subpage の方のログは出なかった。アクセスしてみると、どちらもログが出て、どちらも現在時刻が出た
page.tsx はビルド時にレンダリングされ、 subpage.tsx はリクエスト時にレンダリングされると思っていたが(実際 page.tsx のログが出るタイミングを見るにビルドで一旦レンダリングされているっぽい)、リクエストするとレンダリングされ直されている
現状の理解では subpage.tsx の方だけアクセス時にレンダリングされると思っていたが違うらしい
次はこのあたりを掘ってみることにする
import Subpage from "./subpage"
export default function Mainpage() {
const now = new Date().toISOString()
console.log("mainpage time: ", now)
return (
<div>
<h1>Hello Next.js World!</h1>
<h2>Current time {now}</h2>
<Subpage />
</div>
)
}
Subpageの呼び出し方を変えても結果は同じだった
ドキュメントを読んでみる
コンポーネントごとで影響範囲が決まるらしい
……コンポーネントの定義間違えて覚えてね?って思ったのでそっちを当たってみる。これはReactのコンポーネントの方を見ればいいんだろうか
どうも調べた感じ、入れ子構造でStatic RenderingとDynamic Renderingが混ざった場合、ルート単位でStatic/Dynamicが決定する と言っているのを見つけた
公式ドキュメントではないのでまだ断言は避ける
ひとまずルートごとに判定されるというのを把握した上でこんなふうにしてみる
src
|-app
|-dynamicpage
| |-page.tsx
|-staticpage
| |-page.tsx
|-page.tsx
で、それぞれこんな感じでやってみる
import { getTimeNoCache } from "@/lib/timer"
export default function DynamicPage(): JSX.Element{
const time = getTimeNoCache()
return (
<div>
<h1>Dynamic Page</h1>
<h2>Current time {time}</h2>
</div>
)
}
import { getTime } from "@/lib/timer";
export default function StaticPage(): JSX.Element{
const time = getTime()
return (
<div>
<h1>Static Page</h1>
<h2>Current time: {time}</h2>
</div>
)
}
import Link from "next/link"
export default function Mainpage() {
return (
<div>
<h1>Hello Next.js World!</h1>
<Link href="/staticpage" >Static Page</Link>
<Link href="/dynamicpage" >Dynamic Page</Link>
</div>
)
}
これでビルドして動かしたところ、staticpageの方はビルド時の時間、dynamicpageの方はアクセス時の時間で表示された。この下に静的レンダリングのコンポーネントをおいてみる
忘れてた
import { unstable_noStore } from 'next/cache'
export function getTimeNoCache(){
unstable_noStore()
return new Date().toISOString()
}
export function getTime(){
return new Date().toISOString()
}
これは src/lib をおいてそこに書いた
こんなサブコンポーネントを用意する
import { getTime } from "@/lib/timer"
export default function Subpage(): JSX.Element{
const subNow = getTime()
console.log("subpage time: ", subNow)
return (
<div>
<h2>Subpage time : {subNow}</h2>
</div>
)
}
それをstaticpageとdynamicpageの双方で呼び出す
import { getTimeNoCache } from "@/lib/timer"
import Subpage from "../subpage"
export default function DynamicPage(): JSX.Element{
const time = getTimeNoCache()
return (
<div>
<h1>Dynamic Page</h1>
<h2>Current time {time}</h2>
<Subpage />
</div>
)
}
import { getTime } from "@/lib/timer";
import Subpage from "../subpage";
export default function StaticPage(): JSX.Element{
const time = getTime()
return (
<div>
<h1>Static Page</h1>
<h2>Current time: {time}</h2>
<Subpage />
</div>
)
}
他は一個前のやつと同じ。ビルドしてスタートする
staticpageのパスではビルド時の時間が表示され、アクセス時もログは出ない
dynamicpageのパスではアクセス時の時間が表示され、アクセス時のログも出る
どうやら正しいらしい。ページ内でDynamic Renderingのコンポーネントが入っていたりすると、そのページの中身はすべてDynamic Rendering扱いになるようだ
これはつまるところ、気をつけないと同じコンポーネントでもDynamicで処理されるものとStaticで処理されるものの2つ出てくるわけだ
いまさらだけど初学者がいきなりNext.jsの13以上のバージョン触れてわかるんかこれ
ここから先で抑えておかないといけなさそうなポイント
- Dynamic Renderingの条件
- このあたりはあれこれ読んでる間に割と頻繁に出てきてるからまとめるだけで良さそう
- Client ComponentとServer Componentの併用
- ビルド時の時間とアクセス時の時間を両方出すのは、Server Componentだけだと無理っぽいがClient Componentと併用すると行けるっぽいので実際にやってみる
まずは改めてまとめる
ページは基本はStatic Rendering
有効な場所は
- SEO関係で有効に使いたい(サーチエンジンのクローラー対応が楽
- より高速なWebページを実現したい
- サーバの処理を削減したいとき
また、UIにデータが関わらない、あるいはユーザーごとに共通したデータのみである場合有効。こちらはCDN配信等も対応できる
Dynamic Renderingは
- リアルタイム性のあるデータを扱うとき
- ユーザ別のデータを扱うとき
- リクエスト時のデータを必要とするとき
に向いている。向いているというかそういうときに使えって感じ
ではどういうふうに作るとどちら側になるのか。いい感じの表が公式ドキュメントにあったので訳して転載
Dynamic Function(動的関数?) | データ | ルート |
---|---|---|
なし | キャッシュあり | Static Rendered |
あり | キャッシュあり | Dynamic Rendered |
なし | キャッシュ不可 | Dynamic Rendered |
あり | キャッシュ不可 | Dynamic Rendered |
ルートでStatic/Dynamicが書かれているが、これはちょっと前までいろいろ触って調べたのでその部分にふれる必要はあるまいて
つまるところ
- 動的関数がなく、データのキャッシュが有効な場合のみStatic Rendered(静的レンダリング)
- それ以外全部Dynamic Rendered(動的レンダリング)
という感じの様子
動的関数というのはさっきまで触れていた unstable_noStore()
のような関数のことを指していると思われる。Next.jsのAPIの分類であるのかもしれない
動的関数?は後で調べることにしようと思った(というかまとまってはない?っぽい
ドキュメントの各関数の項目を見るしかなさそう
ただ、サーバサイドでのfetchとかは該当するっぽい。そしてfetchにはfetchでcacheの設定でまた何か色々変わるぽいことが書かれてる。たいへーん
一旦離れて client component を見ることにする
Client Component の特徴
- ブラウザのAPIが使える
- サーバサイドからだと使えない
- 例:localstorageとか
- インタラクティブなページ作成ができる
- state, effect とかが使える
- ReactのuseStateとか使いたかったらServer Componentでは無理
こんな感じか
ちなみに既存のpages routerはクライアントコンポーネントだったっぽい?(詳しく調べてない
ではapp routerでクライアントコンポーネントを作るのはどうするかというと
'use client'
// 以下省略
とすればいいらしい。このuse client宣言はServer ComponentとClient Componentの境界で宣言するそうだがその境界ってのがどこなのか若干わかりにくい
コードを書いて実際に動かしたほうがはやそう
書いてる途中で import 文のあとに 'use client'
を入れたところ、ファイルの頭に書けとビルド時に怒られたのでそういうことらしい
上の方でやっていた「ビルド時の時間」と「リクエスト時の時間」を表示するページを作ってみる
当然だがビルド時の時間はServer Componentでないと実現できず、リクエスト時の時間はClient Componentでないと実現できない はず
import ServerSide from "./serverside"
import ClientSide from "./clientside"
export default function Mainpage() {
return (
<div>
<h1>Hello Next.js World!</h1>
<ServerSide />
<ClientSide />
</div>
)
}
import { getTime } from "@/lib/timer"
export default function ServerSide() {
const buildtime = getTime()
return (
<div>
<h2>build time : {buildtime}</h2>
</div>
)
}
'use client'
import { getTime } from "@/lib/timer"
export default function ClientSide() {
const requesttime = getTime()
return (
<div>
<h2>request time : {requesttime}</h2>
</div>
)
}
getTime()
はいくつか前の処理と同じで、new Date().toISOString()
を返すだけの関数
結論として確かにサーバサイドの buildtime はビルド時の時間を、requesttime はリクエスト時の時間を表示している。更新したら当然ながら requesttime の方だけ変化するというのがわかった
ただ、エラーがコンソールに出ているのでちょっとそっちもたどる
どうやら同じメソッドが使われているのに返す値が合ってないエラーっぽい
Text content does not match server-rendered HTML
らしい
メソッドじゃなくてサーバサイドでの描画結果とクライアントサイドでの描画結果がズレていることのエラーっぽい
まあずらすのが本望なことをしているのでこのエラーは一旦スルーする
ちなみにHTMLの記述が不正だったりすると起きるケースが多いっぽい(pタグの中にdivタグをおくとか
解決法として、クライアントサイドでやる処理を useState で変数定義し、処理を useEffect で行うことで回避できる
'use client'
import { useEffect, useState } from "react"
export default function ClientSide() {
const [requesttime, setRequesttime] = useState('')
useEffect(() => {
const requesttime = new Date().toISOString()
setRequesttime(requesttime)
}, [])
return (
<div>
<h2>request time : {requesttime}</h2>
</div>
)
}
これならサーバサイドでレンダリングした初期状態は ''
で一致して、クライアントサイドでコンポーネントを表示するタイミングで useEffect
が発火し requesttime
が書き換えられ表示が更新される という理屈なんだと思う
いややっぱこれ初学者混乱しそうだな……難しいフレームワークだと思う
Server ComponentとClient Componentで、どちらを使うべきかというのは公式がまとめてくれている
とりあえず触りで出てくる表だけまとめると
用途 | Server Component | Client Component |
---|---|---|
データ取得 | x | |
バックエンドリソースへの直接のアクセス(DBとか) | x | |
センシティブな情報(APIキーとか)を扱う | x | |
大きな依存関係を持つ / クライアントサイドでのJavaScriptを減らしたい | x | |
インタラクティブ性を入れたり、イベントリスナーを使いたい | x | |
stateのライフサイクル(useState, useEffectとか)を使いたい | x | |
ブラウザAPIを使いたい | x | |
state, effect, ブラウザAPIに依存したカスタムフックを使う | x | |
React Class componentsを使う | x |
という感じらしい(カスタムフックの項目とかは当然では……?って思うのだが
公式ドキュメントのこの項は表に続いて、Server ComponentやClient Componentでのパターンが載っているのでそこも抑えておくべきだろうし次はこれを読む
Server Components Patterns を軽くまとめ
- コンポーネント間でのデータ共有
- React Contextの代用(React Contextはクライアントサイドでしか使えない
- fetchのメモ化
- サーバコンポーネントでのfetchは同一のAPI相手だった場合自動的に結果を共有しAPIへのアクセス回数を減らしてくれる
- 例えばヘッダー、メイン部分、サイドパネルのようにUI別にデータが必要だけど場合によってはあったりなかったりするパーツに対してすべてのコンポーネントでユーザーデータを取りに行くように書いても勝手に効率化してくれる という感じっぽい
- サーバコンポーネントでのfetchは同一のAPI相手だった場合自動的に結果を共有しAPIへのアクセス回数を減らしてくれる
- サーバでしか動かしたくないコードをユーザ環境に持っていかない
- APIキーとか
- これらの処理がクライアントサイドにもれないように、
server-only
というパッケージがある- そのうちファイル先頭に
'server only'
って書くようになりそう(これはパッケージなのでimport 'server-only'
と書く
- そのうちファイル先頭に
- サードパーティのコンポーネントやパッケージを利用する際は、Server Componentが新しい技術であることも含めてクライアントサイドでしか動かないケースが多い
- そのため、サードパーティのコンポーネントをラップしてClient ComponentにしてからUIに組み込むと無駄が出にくい
ってところだろうか
実際UIでよく使われるサードパーティのコンポーネントはほとんどがServer Componentに対応してないらしいので工夫する必要がありそう
続いてClient Component Patternsのまとめ
- できる限りコンポーネントツリーの下層におくこと
- JavaScriptのバンドルサイズを減らすため
- Server ComponentとClient Componentは疎結合にする
- 例えばUIサブツリー上で、Client Componentのコンポーネントツリーの下にServer ComponentやServer Actionsの呼び出しをネストするのは、可能だが注意点がある
- Request/Responseライフサイクルにおいて、まずClient Componentがサーバからクライアントに渡され、そこでのサーバサイドへの呼び出しは再度新規のリクエストを送ることになる
- 詳しくはサポートしてないパターンとかを見ろ
- 例えばUIサブツリー上で、Client Componentのコンポーネントツリーの下にServer ComponentやServer Actionsの呼び出しをネストするのは、可能だが注意点がある
新規リクエストのくだりはちょっと訳がわからず省略
ただあまり良くない理由は察することができる
というわけで次はUnsupported Patternsを見る
ちょっと逸れた話で雑談&考えをまとめる
zennのトップページとかでいろいろ見ているとNext.js13以降の記事をみつけたのでせっかくだしいろいろ見てみた
当然ながらNext.js13以降のバージョンではサーバコンポーネントをうまく使えと言っており、これによりキャッシュを利かせページを高速化させサーバの負荷も軽くしようという意識が見える。いろいろな見方はあるが、コンテンツを軽量にしクライアントに負荷をかけず、かつサーバも負荷をかけないのは全面的に正しいことである。昨今某社の携帯回線が遅いとか端末のバッテリーの問題とかもあるので通信も処理も軽いに越したことはない(正確にはそのあたりの問題は動画等のコンテンツが主な要因だが、とはいえ、だからといって湯水の如くリソースを使用することを是とするのはエンジニア・デベロッパを名乗るものとしては醜悪な態度である)。
で、今までの(バージョン13以前の)Next.jsやReactを触っていた感じからして、処理の中には state を利用していたものが少なくなかった。例えばページ内タブなどでどのタブを選択しているかは state の中に情報を持たせていたし、ユーザ情報なども layout の内部で init を走らせてグローバルなステートに保存し、それを各コンポーネントが参照するという使い方も多かった(と思う。よくよく考えると自分はフロントのエンジニア経験が少ないのでぶっちゃけ間違っているかも)。
しかし、サーバコンポーネントではこれらの機能は使えない。なんせ state はクライアント限定である。加えて、前のポストにある通りクライアントコンポーネント内にサーバコンポーネントをもたせるのはあまりいいパターンではないように見える。事実、タブ分けだけクライアントコンポーネントで state をもたせ、stateに従ってサーバコンポーネントを取りに行くというのはあまり良い実装には思えない。
じゃあどうやってんの?となると、どうもパスパラメータやクエリパラメータを利用するのが王道のようで、要はURLに状態をもたせようということらしい(ただしうまくやってあげないと静的なサーバコンポーネントにならない)。妥当でもありつつ、ある意味Webの原点回帰のような実装だが、そもそも現在のWebページにおけるインタラクティブ性は上がり過ぎであり、同じURLが同一の内容を指していないように見える(厳密にはGETの内容は同一で冪等性が保たれている)点は問題があった気もする。なんというか直感的ではない。
ただし、URLに状態をもたせるとしても限度はある。ページ内タブとか、セレクトボックスとか、ラジオボタンとかのチェックは可能でもユーザ情報などはそこに置くことはできない。とはいえ、ユーザ情報に関してはAPIをすべてのコンポーネントで叩いても問題ない(サーバコンポーネントのfetchは自動的にメモ化されるので)し、やはりそこにある障害は別解による解決可能な障害という結論になる。なんなら、サーバコンポーネントは実質バックエンドなのでKVS系のDBを併用すれば state 管理などというめんどくさい要素を全部データベースに逃がすこともできるわけで、無理やりグローバル変数に近いデータ置き場を作るよりむしろそっちのほうがアーキテクチャとしては遥かに正しい気もする(無論、これはデータベースをグローバル変数的に扱えと言っているわけではない)。
とはいえ、方法が変わるためこれは今までReact/Next.jsを触っていた人もそれなりに大変な思いをすることになりそうだなぁとも思ったが。そのうち闇の魔術で state と似たようなことをするメソッドを持ち込む輩が出てくるのだろうか。
今まで何度か初学者は絶対苦労すると言っていたが、ぶっちゃけ歴戦の開発者も苦労すると思う。
Unsupported Patternの項目はSupported Patternとセットになってた
- サポートしていないパターン
- Server ComponentをClient Componentで呼び出すこと
- サポートしているパターン
- Server ComponentがPropsとしてClient Componentに入っていること
という違いがあるらしい。サンプルコードは公式ドキュメントの通りなのでそっちを見てねって感じ
噛み砕いていくと、Client Componentのファイルの中で import ServerComponent from ...
とやってはだめよという話
コンポーネントの構造上でネストはできるが直接組み込んではいけないよと言った感じ。子要素としてchildrenの構造体に渡すのは問題ない様子(公式のサンプルコード参照
まあ大体わかったので次
とりあえず大雑把にNext.jsとReactの動きがわかったのでそろそろものを作る方に移ってもいい気がする
なんとなく取りこぼしてる感のある部分は以下の感じ
- fetch関係
- プラスそれのキャッシュ
- 動的ルートの静的レンダリング条件
- 例えばブログページ作るとして新規ページっていつレンダリングされんの?とか
- デプロイ関係
- これはまあそのタイミングでいいでしょうと思って後回しにしてる
ページを作りながら何か知見があったら書き足していく形で運用する
ブログ的なページを作るとして考えたいこと
- コンテンツ管理
- これはヘッドレスCMS使おうと思う
- UIライブラリ
- App Routerに対応してるやつってどのくらいあるの?という疑問
このあたりが引っかかるが、とりあえず簡易的なサイトだけ作ってしまってなんとかしよう
そういや env ファイルってどう読み込むのと思ったらどうやらおいてあるものを勝手に読んでくれるらしい
プロジェクト内の .env とか .env.local を勝手に読んでくれるほか、.env.prd.local みたいに node_env がついている場合も対応している
呼び出すときは process.env.HOGE みたいにすればおk
優先順はここの通り
ちなみに create-next-app を使うと .gitignore に .env*.local が最初から入っている。当然だけど env ファイルを git push しないように
そういや当然だけど process.env の呼び出しは client side ではできない
とりあえずブログっぽい感じにしたいので記事IDのようなものをパスパラメータに入れる形にしたい
https://example.com/blog/[article_id]
みたいなイメージ
/blog
のパスの部分は別のページも置く想定だけどここは事前に想定しているもので考える。ひとまずは/blog
だけ作る
src/app
├── blog
│ └── [article_id]
│ └── page.tsx
├── favicon.ico
├── globals.css
├── layout.tsx
└── page.tsx
こんな感じ
とりあえず適当は article_id を流し込んだらそれを表示するページを作ってみる
とりあえずあっさりめに
export default function BlogArticlePage({
params,
}: {
params: {article_id: string}
}) {
return (
<div>
<h1>ブログページ</h1>
<p>{params.article_id}</p>
</div>
)
}
公式のサンプルコードに従ってこうなりますよといった感じ。実際はarticle_idからブログの内容を取ってくる必要があるけどとりあえずこれで動的ルーティングが動く感じ
試しに generateStaticParams を使ってみる
export default function BlogArticlePage({
params,
}: {
params: {article_id: string}
}) {
return (
<div>
<h1>ブログページ</h1>
<p>{params.article_id}</p>
</div>
)
}
export async function generateStaticParams(){
return [
{article_id: "test_id_1"},
{article_id: "test_id_2"},
{article_id: "test_id_3"},
]
}
これでビルドしてスタートすると、blog/test_id_1
などにアクセスしたときはログに時間は出ないものの、blog/test_id_4
など設定していないパスにアクセスするとログが出る
ちなみに一度ログが出ると更新してもログが出ないので、キャッシュが効くみたいである(キャッシュでいいのかな?
どのタイミングでキャッシュが切れるかは要確認だが、ブログ記事をCMSから取ってくるに当たって更新時にちゃんと対応しないといつまでもキャッシュが残ることになる。キャッシュ設定はデプロイ時などに見ようと思うので今はとりあえずそのままで
ただ、 generateStaticParams を無理に使う必要はない(一度アクセスがあればキャッシュが残るのでそっちに任せてもいい)気がする
ui components には shadcn/ui を使ってみた。Server Side Components にそれなりに対応してそうだったので
コンポーネントの追加が手動なのが面倒だけど必要以上に入れないようにすれば問題なさそう
ページの状態を元に動作させたい場合、基本的に Client Side Components になる。たとえば下みたいな機能を使う場合
- useState
- useEffect
- useContext
- usePathname
- useParamas
- useSearchParams
- useRouter
などなど。「ページがこの状態だったらボタンはdisableで……」みたいなことをする場合、全部クライアント側で処理させることになる
この考えを頭に入れると「ページの状態によって動作が変わるコンポーネント」というのはSSGとは相性が悪い。もちろん完全に排除できるものでもないが
たとえば「共通のヘッダーを作ってページごとで動作を変えて……」というのは相性が悪いっぽい。「共通のヘッダーなら全ページで共通の動きをさせろ」という感じだろうか。アンカー的なものも、アンカーで現在の項目部分の色を変えるとかは向いてないという理解でいいのだろうか(アンカー自体はURLに対応させればSSGでもいけるはず
今までのSPA的な考えで物を作っていたのでかなり修正が必要なのを感じる
コンポーネントをいわゆるアトミックデザイン的に考えていたが、あんまり相性がよろしくない気がしてきたので切り替える。とりあえず調べようか
再利用するコンポーネントとしないコンポーネントを考える
そもそもApp Routerでは components/... みたいなディレクトリを置かずとも、ファイルベースのルーティング下に置いてしまえば問題ない。app/Component.tsx みたいなファイルを作り、それを app/page.tsx から呼び出せばいいわけである
そうなると使いまわさないものはルーティングのディレクトリ(app下)から分ける必要はなく、使うところと同じ階層で定義してあげればいい
逆に使い回すコンポーネントがあった場合、そのコンポーネントは別の components/ 下に置くようなことをしてもいい。ただし、その場合でもServer Side ComponentsなのかClient Side Componentsなのかが影響する気がするのであっさり目に済むものでもない きがする
fetch処理を行う場合、クライアントから行うのとサーバから行うのでは形が違う
サーバサイドの場合サーバ同士で通信できればいいが、クライアントからの通信ではクライアント側からのアクセスになり、これはブラウザの各種機能が動くことになる
その場合、CORSの問題とかクライアント側だけで発生する。発生してるどうしよう
Next.jsのサーバがプロキシになってくれると嬉しいんだけどそんな機能あるんだろうか
クライアントからのアクセスをプロキシ的に処理したい場合、使うのは nextConfig の rewrites の方で redirect の方ではない
/** @type {import('next').NextConfig} */
const nextConfig = {
async rewrites() {
return [
{
source: '/api/:path*',
destination: 'http://admin.example.com/api/:path*',
}
]
}
}
module.exports = nextConfig
これでプロキシ的に動いてくれる
余談だがhttp-proxyを使う場合バグがあるらしくちゃんと動いてくれないらしい
ちなみにこれ、環境次第で良し悪しがある気がする
たとえばAWSとかGCPのコンテナ環境で動かす場合、Next.jsのサーバーで転送するよりURLマッピングがある場所で振り分けた方がいいだろうし(ロードバランサとか
そっちの方が効率が良くコンテナに負荷がかからないだろうしレスポンスも早くなるのでは
ただバックエンドのコンテナとNext.jsのコンテナ(フロントのコンテナ)が別れてない場合はNext.js側で一旦受け取ってあげてという処理はありな気はする
この処理はキャッシュ効かない(と思う)が、キャッシュを効かせたいならバックエンドからアクセスするように作るべきなんだろう おそらく
とりあえず色々やって色々作ってる(個人サイトだけじゃなく仕事でもNext.js14以降が使えるタイミングがあったのでそっちも含みながら)が、現状発生した問題は以下の2つ
Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?
まずこれ。本来 fowardRef() を使ってrefを渡してあげないといけないところを直接渡してしまってる問題。ただしどこで起きてるかわからない(refを渡しているコンポーネントはない)
多分ライブラリとの兼ね合いか、どっかの設定ミス
あと普通に ref がよくわかってないからそこも含めて要勉強
Warning: A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components
Inputが uncontrolled なのに controlled になっちゃったよ問題。un/controlled の違いは Input の中身をDOMが持つ(uncontrolled)かreactのstateが持つか(controlled)と認識してる(これは間違いないはず
react-hook-form を使っているのでこれもどこかしらの設定か、何かしらのフラグが抜けてるのだと思う
あと現状エラーログを見てもどのコンポーネントで発生した事象かわからないケースが多いのでデバッグツール等をちゃんと導入すべきな気がする。ひとまず一通り実装を終えたらそっちを調べてエラーを解消する
shadcn/ui の form を使用した場合、テキスト入力の数値が文字列型扱いになってバリデータが働きうまく通らないケースがあった
const FormSchema = z.object({
param: z.coerce.number()
})
フォームの内容を定義する際にこのように数値型を強制するように定義してあげると通るっぽい。本来はFormの設定を使うようだが、これは shadcn/ui は対応してないっぽい?
ログに出てたエラーを全部消せたので各種解決法
Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?
まずこれは Button
コンポーネントをラップしたコンポーネントを作っていたのだが(今後の互換性等でラップした方がいいと思った)、それが悪さしていたらしい。とりあえずラップをやめた(そもそもなんでUIライブラリ使ってんだよってなるし
それで解決
Warning: A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components
こっちは少し手こずったのだが、react-hook-formを利用した際に、defaultValuesを () => Promise<FieldValues>
で渡した場合、(おそらく)非同期での処理完了より先にフォームの描画が実行されるのが原因だった様子
ちなみにこのエラーは defaultValues が null や undefined だった場合に起こるらしいので、通常は初期値を当てはめるだけで大丈夫。ただ今回の場合は非同期処理の実行があったため少しややこしいことになった
解決法としては loading の state を用意し、ロード中はフォームが描画されないようにして、データの fetch が完了次第フォームを描画するように変更した。それでエラーの表示がなくなったので解決
ビルド時にfetchする部分をどうするか問題がある。Next.jsはfetchのタイミングがページ生成に合わせて3つのタイミングで存在していて
- ビルド時
- レンダー時
- レンダー後
となっている。上二つがサーバサイドでのアクセスで、一番下がクライアントでのアクセス
サーバサイドのアクセスは、文字通りビルド時はビルドのタイミングでアクセス、レンダー時はサーバサイドでアクセスがあったタイミングでサーバサイドからアクセス、レンダー後はクライアントサイドからのアクセスとなっている
問題はビルド時のアクセスで、APIがプライベートなもの・認証が必要なものだった場合、ビルド環境が認証できていないといけない
さてどうする(認証できてないCI環境でビルドしている
アプローチ案
- 認証を通す
- GCP環境だしCIもGithub Actionsだしデプロイ関係の認証があるので楽
- APIサーバの呼び出し権限あげれば解決する
- レンダー時・レンダー後のアクセスに切り替える
- 対応は楽
- ページの速度が落ちちゃう
とりあえず1で対応してみることにする
1.がそこそこめんどそうだと分かったので2.に移行する
サーバサイドでクライアントからリクエストが来るタイミングで動く処理は 'use server'
を入れるらしい
getServerSideProps みたいなメソッドがあったがこれは page router の機能だったらしい
とりあえずこれを読んでみる
'use server'
はモジュール単位と関数単位の二つの範囲指定ができる
モジュール(ファイル?)の先頭で 'use server'
をおくか、関数の1行目に 'use server'
を置くかで区別される
サーバコンポーネントから呼び出す場合はどちらでも対応しているが、クライアントコンポーネントから呼び出す場合はモジュール単位のみしか対応していないらしい
とりあえず fetch が実行される部分を関数化して 'use server'
を入れたら解決したので次
で、ビルドは成功してデプロイ段階だがうまくいってないのでなんとかしないといけない
現状の問題として
-
npm run build
で実行したビルド成果をどう扱えばいいのか -
npm run start
で開始するために必要なものがわからない
という問題にぶち当たっている
next.config.jsにstandaloneの設定を入れないとダメっぽい
next.config.js の Config に以下のような設定を入れる
const nextConfig = {
...
output: "standalone"
}
んで、ビルドする。成果物ファイルは標準では .next/
ディレクトリの下にできるものがそれらしい。その中で、上記の設定を入れると .next/standalone
ができているはずなので、それを持ってくれば動くらしい。実行時はそのディレクトリの中の server.js を叩いてあげれば起動する
つまりDockerfile的に書くとこんな感じ
FROM node:20 as builder
...(いろいろ設定とかCOPYとか)
WORKDIR /app
RUN npm run build
FROM node:20 as runner
COPY /app/.next/standalone ./
CMD ["node", "server.js"]
なぜか公式ドキュメントだと .static を別途コピーする必要があるっぽいので(これは standalone のディレクトリに含まれているっぽいのだが)より厳密に公式ドキュメントと同じように書くと
FROM node:20 as builder
...(いろいろ設定とかCOPYとか)
WORKDIR /app
RUN npm run build
FROM node:20 as runner
COPY /app/.next/standalone ./
COPY /app/.next/static ./.next/static
CMD ["node", "server.js"]
となる
これは完全に余談だけど
現状の環境がGCPのCloud Runを利用する想定で、デプロイ環境にはGCPの公式イメージで用意しているdistrolessのコンテナを使用しているのだけどあのコンテナは最後の CMD に渡すパラメータは実行する server.js
だけでいい
知らなかった
環境変数をいい感じに扱いたいところが出てきたのだがなんか調べてると standalone のビルドだと明確な仕様がないっぽくて下手なことをすると不意に動かなくなったりしそうで怖い
とりあえずこのスクラップを参考にしていろいろ試すことにする
- まずなぜ環境変数の話になったか
- APIサーバへの問い合わせを考えているため
- APIサーバは、開発時はDocker内のネットワークを参照し、SaaS環境ではINTERNALなネットワークでのアクセスを想定している
- さらに、APIサーバはクライアント側からのアクセスも発生する
- 解決法は環境変数でいいのか?
- よくない気がする(真顔
- というかよくない変に無理矢理な解決をしようとして沼ってる気がする
じゃあ違う解決法を考えましょう
- 前提
- Next.jsはAPIにアクセスする(fetchする)タイミングが3つある
- 前のポストにもあるが、クライアント、サーバー(リクエスト時)、サーバー(ビルド時)
- それぞれで扱いも目的も違う
- Next.jsはAPIにアクセスする(fetchする)タイミングが3つある
- アクセス経路を考える
- クライアントからのアクセス
- →通常のアプリケーションのドメイン経由
- 認証が必要だが、何かしらのサービスで実装するためインフラ寄りの考えで対応できる
- 開発時もSaaS時も、同様にホストを叩く(localhostだったりapiサーバのドメインだったり
- サーバー(リクエスト時)のアクセス
- →サーバだけが知れる経路での呼び出しで可
- 開発環境のDocker Composeならコンテナにつけた名前でいい
- SaaSなら内部用の呼び出しURLでいい
- サーバー(ビルド時)のアクセス
- →CI等が呼び出すなら認証や名前解決が必要になる
- 面倒だしで上記ではリクエスト時のアクセスに逃げた
- クライアントからのアクセス
なんとなく古い慣習でURLのホスト部分等を抜いたパスのみでの指定で書いていたが、それが良くなかった気がする
そう言えばドキュメントに乗ってる fetch のサンプルは大体ホスト名やプロトコルの部分も全部書いてたしそれが推奨されるということなのでは
で、ローカルから呼び出す時に別のportのlocalhostを指定するとCORSのエラーが起きる
忘れてた
ローカルは nextConfig の rewrites を使う
基本的に、全ての環境において
- ClientからのアクセスはNext.jsのページもAPIサーバの処理も同一ドメイン
- サーバは特別にAPIサーバを名前解決する必要がある
この点において基本仕様は変わらないと判断する
もう一個わかったこと
'use server'
のモジュール(関数)がビルド時に実行されてた
リクエスト時の処理になってなかったっぽい。この辺の厳密な分岐わからんな。多分コントロールし切れるようになってないんだと思うけど
しょうがないのでその部分は client の処理に切り替えて対応する。クローズドだったり認証が必要なAPIからのデータ取得は client に任せるべきかも(なんかそういうのがドキュメントにあった気もする
とりあえず standalone でビルドした Next.js プロジェクトをデプロイするところまでできた
意外と問題が起きなかった印象
server.js と static のデータだけ用意してあげれば問題ないっぽい
ただenv周りはややめんどそうだったので今後原因にならないことを願いつつ
だいたいうまくいってもう書くこともないからクローズしようかなと思ったけど今度は認証を実装する必要が出てきたのでまだまだ続く
Next.js14において認証を実装するなら素直にNextAuthかauth0あたりを利用すべきなんだろうけど今回はfirebase authを利用する
そして以下の記事が出てくる
一筋縄では行かないかもしれない
幸いなことに、認証が必要なリクエストは全てクライアントサイドに実装している(先の問題の都合)。サーバコンポーネント等で実装されている部分は認証がなくても問題ないためとりあえずそこは解決できそう
ではクライアントサイドから認証処理を通し、トークン等を持ってバックエンドにアクセスする処理を書けばいいわけだ
とりあえずそういう処理を書くならカスタムフックだよなぁと思い次からカスタムフックでログイン回りのロジックを実装していく
ひとまず layout.tsx に auth 関係の処理を入れたらエラーになった。そりゃそうだ。クライアントで動かさないといけないんだからクライアントコンポーネントに持っていかないといけない
そもそもフックはクライアント側じゃないと動かん
公式ドキュメントにいい感じに認証関係の説明があったで読んでる
サーバサイドの処理もNext.jsに持たせる感じで書いてるので情報の取捨選択が大変
どうもカスタムフックを使うのではなく middleware.ts と言うファイルを置いてそこでログイン状態をとって処理を流している
ついでに見た感じまともに認証を作るならライブラリを素直に使った方が良さそう
middlewareでの処理で実装すればいいのだろうか、と思いつつ調べていくとこれの動作はEdge Runtimeというやつらしい。ここにきて新しいのが出てくるの何
ここでEdge Runtimeがあるが、どうもこれはEdgeコンピューティングのプラットフォームのことっぽい
うまく処理する方法ってもしかしてないのでは
とりあえず middleware.ts を書いてどこで動くか確認する
middleware.ts は app ディレクトリと同じ階層に設置する必要があり、 src ディレクトリを使用していたらその中、使用していなかったら(おそらく十中八九で)プロジェクトのルートの場所になるっぽい
少なくともdev環境ではサーバサイド扱いのようで、それは先頭に 'use client'
を書いても同じだった。また、すべてのアクセスで動くようで、favicon等の静的ファイルのリクエスト時も動いていた。ちゃんと動かすならパスを指定してページアクセスの時だけ処理をするのが適切っぽい
ただ、いずれにせよこれはクライアント側ではないのでここに firebase auth の処理はかけないはず。ここからどうするか考えるが、考えた方法の一つとして
- middleware.ts で認証されていないかどうかを判断
- 認証されていない場合、 /login のようなパスに転送する
- /login のクライアントコンポーネントで auth 処理を動かす
とかなら行けるのではないか
ここまで書いてて思ったけど今の最終目標がちょっとぶれていて調べづらいので先にそっちをまとめ切ろう
そもそもどの認証をどう組み込みたいのか
- 組み込みたいもの
- GCPのIdentity Platform
- やりたいこと
- これ を Next.js 上に組み込みたい
補足
- なぜIdentity Platformなのか
- 諸事情でOIDCを使ってのログインを実装したい
- OIDCのクライアントをSaaSに寄せたい
- バックエンドの環境がGCP
- できるのか?
- 理屈上できるはず
前提
- Client Component で諸々の処理を実装してあげる必要がある
- 必要な処理自体はあまり多くない
- 今回行うのはOIDCでリダイレクトを用いた認証タイプ
- 問題はどこで呼び出すか
- 必要な処理自体はあまり多くない
解決法として今思いついたのは「すべてのページで参照されるクライアントコンポーネントの中で実装する」という方法
そんなコンポーネントあるんか?って感じたが、たとえばヘッダー等でユーザーを表示するならそのコンポーネントが処理を持てばいい。データはクッキーに置けば再描画等での問題もないのでは
そういう UserStateComponent
みたいなコンポーネントを用意してあげれば良さそう
どのページにも置かれるようなコンポーネントに処理を実装したところ、ちゃんと転送された
が、リダイレクトから帰ってきたあとにうまく処理が通らないようで、認証情報を読めず再度ログインページに転送しようとする挙動が発生する
今回の処理の話と若干離れるのでとりあえずその辺りのあれこれはここでは省く
省くって言ったけどとりあえず原因は
これっぽい
standaloneだとenvを読み取ってくれない問題に当たった
当たったというかちょっとクセがあるっぽかった
状況
- 実行環境: dockerのコンテナ
- ビルド: standalone mode
- 環境変数は
NEXT_PUBLIC
のものと通常のもの
やったこと
- 実行環境のコンテナ内に定義した環境変数
- →読まれず
- ビルド時に環境変数を設定
- →読まれず
- 実行環境の server.js と同じ階層に .env ファイルを設定する
- →読まれた
standalone じゃない場合はもうちょっと楽になるはず(どうも調べた感じそうっぽい
standalone だった場合、ビルド時にいい感じに .env ファイルを作成してあげてそこに環境変数を格納するのが確実っぽい
呼び出し時にレンダリングされるServer ComponentからClient Componentを呼び出すように実装した時、子コンポーネントにパラメータを渡さないようにしたら高速化した(気がする
今までDynamic RoutingのパラメータをServer Componentから渡すようにしていたがuseParamsを使ったらそうなった。多分これは普通に悪い使い方だったっぽい
一応メモで残しておく
もうそろそろクローズして良さそうだけどたまに気づきがあるので追記していく
'use client'
で定義したコンポーネントも一旦サーバサイドでレンダリングされる(ビルド時とか)
その際、location.href のようなブラウザAPIを使用するとエラーが発生する。具体的には
ReferenceError: location is not defined
が起きる。
process.browser あたりで一旦誤魔化してあげるしかないだろうか(参考元と同じ解決法)
参考
process.browser
は TypeScript では非推奨だった
if(typeof window !== 'undefined'){
... // ブラウザ内でのみ実行させたい内容
}
こうすべきらしい
これを踏んだ。ビルド条件によっては Image コンポーネントの仕様で sharp というパッケージを入れる必要がある
パッケージを入れるだけなので踏んだ後は npm i sharp
すればいい
エラー処理について調査&実装
標準でNext.js app routerにはエラーハンドリング機能がある
これがドキュメントのページ
エラーを実装する場合、 page.tsx と同じ階層に error.[js|jsx|ts|tsx] を置いてあげると自動的にReact Error Boundary と同様の動きをしてくれる(というかNext.jsがラップして提供してくれてる?)
ただコールバック内でエラーを投げてもうまく表示されない、と思ったらどうやらイベント内での処理では動いてくれないっぽい
ここに書いてたのを受け売りで見てるだけだが
あくまでコンポーネントの描写時のエラーを拾うためで、処理のエラーは自分で実装すべきっぽい気がする
ちなみに個人的見解だが、サーバーコンポーネントでもエラー時に拾ってくれることを考えるとどっちかというとその利用方法の方がメインというか、あるべき姿の可能性はある
ただ引用した記事にも書いているが、自分でやった方がわかりやすくない?というのは妥当な気がする
厳密にはNext.js関係ないが一応現状の標準?CSSパッケージなので
CSSを書いていたのだがnestでtailwindcssに怒られた
曰く、プラグインを追加する必要があるらしい
.hoge {
h1 {
@apply underline;
}
h2 {
@apply underline;
}
}
こんなのを書いたところ
Nested CSS was detected, but CSS nesting has not been configured correctly.
Please enable a CSS nesting plugin *before* Tailwind in your configuration.
See how here: https://tailwindcss.com/docs/using-with-preprocessors#nesting
とコンソールに出てきた(devビルド)
書いてあるとおり、 postcss.config.js
のpluginsにnestingのプラグインを記入すれば良いらしい。記入場所はtailwindcssの「前」だそうだ
module.exports = {
plugins: {
'tailwindcss/nesting': {},
tailwindcss: {},
autoprefixer: {},
},
}
とりあえずこれで怒られることはなくなった
これもドキュメントに書いているが、tailwindcss/nestingはtailwindcssの中に最初から入っているので追加でnpm installなどは必要ない
ちなみに中身は postcss-nested か postcss-nesting で、厳密に「こっちが使いたい」みたいな意思がない場合は上記で終わり。意思があったらドキュメントの通り明示することで指定できる様子
ページをCloudfrare Pagesにデプロイしようとしたところエラー
設定はほぼ標準でやったところこんな感じでエラーがおきた
⚡️ ERROR: Failed to produce a Cloudflare Pages build from the project.
中略
⚡️ Please make sure that all your non-static routes export the following edge runtime route segment config:
⚡️ export const runtime = 'edge';
⚡️
⚡️ You can read more about the Edge Runtime on the Next.js documentation:
⚡️ https://nextjs.org/docs/app/building-your-application/rendering/edge-and-nodejs-runtimes
どうやらedge向けのビルドにしないと駄目な様子
引用部分のリンクといっしょに以下のやつ
も見たところ設定しないと標準だとNode.js環境サーバ環境のビルドになるらしい
export const runtime = 'edge'
これを書く
▲ Module not found: Can't resolve 'http'
こんな感じのエラーが出た
edge runtimeの項を見たら最後の段落に
For example, "Module not found: Can't resolve 'fs'" or similar errors.
と書いてるのでその類っぽい。とりあえず項を訳す
第一段落
In Next.js, the lightweight Edge Runtime is a subset of available Node.js APIs.
Next.jsでの軽量Edge RuntimeはNode.js APIの部分セットです
次
The Edge Runtime is ideal if you need to deliver dynamic, personalized content at low latency with small, simple functions. The Edge Runtime's speed comes from its minimal use of resources, but that can be limiting in many scenarios.
Edge Runtimeは動的に個別なコンテンツを低レンエンシかつ軽量シンプルな機能で提供したい場合理想的です。Edge Runtimeのスピードは最小のリソース利用から始めることができ、しかし多くのシナリオまで対応できます(この辺訳微妙かも
次
For example, code executed in the Edge Runtime on Vercel cannot exceed between 1 MB and 4 MB, this limit includes imported packages, fonts and files, and will vary depending on your deployment infrastructure. In addition, the Edge Runtime does not support all Node.js APIs meaning some npm packages may not work. For example, "Module not found: Can't resolve 'fs'" or similar errors. We recommend using the Node.js runtime if you need to use these APIs or packages.
例えば、……ってこの段落は例の話なので訳す必要なしと判断
つまるところ、Node.jsのAPIを使っていると一部のAPIが実装されていないのでエラーが起きるということだった
調べたところ、どうもJSDOMが使っている一部のパッケージがnode:httpやnode:httpsを利用しているっぽい
worker内でDOMのパーサーを動かしたいというコミュニティの投稿を見つけた。やりたいこととしては同じなのでちょっと読む(ぶっちゃけ必須ではないので一旦この部分を外してデプロイしてもいいのだが
どうも cloudflare の HTMLRewriter でどうにかできるっぽい
DOMパースしてちょっと書き換える処理をしていたのでこっちでできるか確認していく
これ別途記事がかけそうだなぁという感じ
ひとまずJSDOMをHTMLRewriterに置き換えることに成功
しかし、Node.JS Compatibility Errorが出た
これはNode.jsのAPIを叩いているとエラーが起きるもの。ただし互換性フラグを設定すると、Edge RuntimeのAPIに自動置換してくれるためそれで解決できる。コンソール画面から設定できるようだが、wrangler.tomlなるファイルで設定もできるようなのでそっちで解決したい
IaaCは正義なので
やっぱ一旦コンソールで設定して飛ばす(環境変数等もそうやったのを忘れてた
ちなみに環境変数とかも wrangler.toml で設定できるっぽいが、APIキーとかはどうすれば秘匿性が確保できるのだろうか。Github Actionsからデプロイするとか?
コンソールからの設定は、
設定 > Functions > 互換性フラグ
で移動して nodejs_compat
を入力する
デプロイはできたがエラーが起きる
Cloudflare Pagesの話になるので厳密にはNext.jsは関係ないが、一応ログの見方(最初わからなかったので
デプロイしたバージョンのデプロイの詳細で「リアルタイムログ」の項でストリーミングをオンにしてページを見てエラーを出す。するとエラーログが出てくる
必要なときしかログが見れない?と思われる
いよいよ真面目にfetchとcacheを調べるときが来たかもしれない
edge runtimeで動かすとfetch利用時にcacheオプションが使えないっぽい
というのも
"Error: Network Error.\n Details: The 'cache' field on 'RequestInitializerDict' is not implemented."
というエラーが出ていたから(1個上のエラーログで見つけた
fetch APIは基本的に使えるが、cacheオプションはどうやらクライアントサイドの場合だけになるようで、それが原因っぽい
これの第二段落
In the browser, the cache option indicates how a fetch request will interact with the browser's HTTP cache. With this extension, cache indicates how a server-side fetch request will interact with the framework's persistent HTTP cache.
ブラウザでは cacheオプションはfetchリクエストがどのくらいブラウザのHTTPキャッシュに相互作用しているかを示しています。(後略
つまるところおそらくこれはブラウザでしか使えない(あるいはもしかしたらNode.jsサーバでは使えるのかもしれない
そしてついでに調べたところ、revalidateオプションは Node.js サーバでしか使えないらしい
上記のページの Good to know に書いてる
edge runtime でキャッシュを動作させるには?という感じでちょっと調査しつつ次
疑問点として、そもそもedge runtimeでキャッシュってできるの?と思ったので(だってedgeで動作してて短期間で動作環境とか破棄されるんじゃないの?
修正
fetchでのrevalidateはedgeでも使える
上記の使えないというのはページ生成のオプションの設定だった
個人サイトで画像ビューアを実装したいので調べたところIntercepting Routesの機能が使えそうなので調べる
動作自体はおそらくTwitterとかでの動きと一緒
画像付きのツイートのページから画像をクリック(タップ)すると画像ビューアのモーダルが出てきて画像を表示、同時にURLが書き換わる( [tweetのURL]/photo/1 というURL。最後の数字は画像の順番に対応しているっぽい)。このURLを直接コピペしてアドレス欄等に入れて移動すると自動でツイートのURLに転送される
一応サンプルサイトもある
コードはこっち
実装しながらいろいろ書こうと思ったが前提となる部分ができてなかったことが判明したので一旦パス
触ってる過程でいくつか新しくわかったことや気づいたことがあったのだが、いちいち記述してると終わらない+そもそも初めてやってみるぜ的なニュアンスが強かったのでこのスクラップはクローズする
1年で社内プロダクト3つ分、個人的な趣味プロジェクト(Next.jsで作った個人サイト)1つと4つ分ページ作成やらコンポーネント設計をしたのでそれなりに書けるようにはなったはず
今後は何か知ったら個別に記事にしておこうと思う