「小さな技術メモ」を公開するページをNext.js 13とNotionで作った話
こんにちは。noteでエンジニアをしているKota Nonakaです。
この記事はnote株式会社 Advent Calendar 2022の14日目の記事です。よろしくお願いいたします。
この記事では
「小さな技術メモ」を取るようしたら捗った
ついでに公開できるようにしたら一粒で何度も美味しい感じになった
betaリリースされたばかりのNext.js 13のappディレクトリを使って作ってみたのでその感想など
をお送りしようと思います。(note社での業務とは全然関係ありません)
ぜひ興味のあるところだけでも読んでいただけると嬉しいです。
「小さな技術メモ」
冒頭から何度も登場している「小さな技術メモ」という言葉は私の考えた言葉ではなく、以下の記事で見かけたものです。
記事の筆者さんとは特に知り合いとかではなく、はてブ経由で上記の記事を知りました。
短い記事なのでぜひ読んでいただきたいですが、要旨としては、業務中何度も同じことを調べるのは時間のムダであり、それの繰り返しは長い目で見ると大きなタイムロスとなるので、小さくて十分なのでメモを取ってストックしておきなさい、ということだと理解しました。
私はこの記事を読んだときに、何度も同じことを調べるという行動に心当たりしかありませんでした。
また、今までのキャリアの中で出会った尊敬する方は皆、形は違えど、メモを取ったりブログやQiitaに詰まったことをまとめることを習慣にしている人ばかりだということに気づきました。ググったら同僚のブログやQiitaがヒットした、みたいな経験がある方も多いのではないでしょうか。
そういうわけで、自分もその日からコードを書いていて調べたことや、苦労して解決まで辿り着いたこと、いつも忘れるCLIコマンドのオプションなど、とりあえずなんでもメモするようにしました。
ちなみに、私は元々Notionを個人でも会社でも使っていたのでNotionにメモ用のDBを作ってそこに雑に投げ込むようなスタイルで始めました。
公開できるようにしてみた
元々特に公開するつもりもなかったのでNotionに貯めていたのですが、ふと見返してみると、大抵の情報は他の誰かにとっても価値のあることなのでは、ということに気がつきました。
そこで、これを公開できるページを作ろうと思い立ち作ったのが以下のサイトです。
名前を「tips chips」と言います。英語的に正しいかは定かではないですが、「細かいTipsの切れ端(chip)があるところ」という意味+語感の良さや短さ重視で名付けました。
ちょうどNext.jsの新しいバージョンである13がリリースされ、大きく変わったタイミングで触ってみたいと思っていたところだったので、新しく入った技術を活用して作りました。その辺の話はこのあと述べようと思います。
このサイトは普段書いているNotionのメモをAPI経由で参照しています。全てのメモが公開できるわけではなく、サイト上では非表示にしたいものもあるので、NotionのDBに公開フラグを持たせて、そのフラグがONの時だけサイト上に表示されるようにしています。
この仕組みを作ったことで、
メモをとる行為は普段通り続けていれば良い(し、普段書くメモが自分のサイトを充実させると思うと書くモチベーションにもなる)
有益そうなものはプロパティを切り替えるだけで公開できて、サイトも勝手に充実していく
という一粒で何度もうまい状態を作ることができました。ブログや技術サイトへの定期的なポストを目標にしては挫折したりしていましたが、この仕組みなら無理なく続けられそうと思っています。
あくまでもこのサイトは普段のメモを公開しているだけという位置付けなので、まとまった情報などがあれば引き続き併用していけば良いと考えています。
このサイトを見たことで誰かの役に立てていれば本望ですし、ひとりのエンジニアとしては、ほぼノーコストでアウトプットが増えるというのは良いことだと思うので、引き続きちまちまと公開するコンテンツを増やしていこうと思っています。
技術のはなし
ここまではなぜメモを取ることにしたかや、tips chipsの仕組みなどについて書いてきましたが、ここからは少しだけ技術寄りの話を書いていこうと思います。
以下でソースコードは丸ごと公開しているのでもしよろしければ併せてご覧ください。
ちなみに、技術検証のつもりで触っていたらそのまま公開することになってしまったのでめちゃくちゃなことになっている部分もたくさんありますが(これから頑張って直します)ご承知おきください。
技術スタック
このサイトの主な技術スタックは以下のようになっています
フロントエンド: Next.js 13
appディレクトリを使用
CSSフレームワークはTailwind CSSを利用
バックエンド: Notion API
インフラ: Vercel
今回はNext.jsの検証をしたかったのでそれ以外の部分は徹底的に楽をする、という構成にしています。
この記事では主にNext.js 13のお話をしていければと思います。
React Server Components
Next.js 13が発表されたとき、最も界隈をざわつかせたのがReact Server Components(以下RSC)の採用だったような気がしています。
RSC自体はNext.js特有のものではありませんが、今回、従来のpagesディレクトリの代わりにできたappディレクトリではRSCが標準で使われるようになっています。(従来のクライアントコンポーネントを使う場合はファイル先頭で 'use client' という宣言が必要)
公式のドキュメントや色々な解説記事を読んだりしたものの、実際に使ってみないとわからないだろうということで、今回はRSCを使って作ってみるということにチャレンジしました。
RSCの仕組みをとても大雑把にまとめると、仮想DOMの構築をサーバーサイドで処理し、その結果のツリー情報だけをクライアントに送る→クライアントでは送られてきたツリー情報をもとにDOMを構築するというような仕組みです。
そのため、RSC内では容量の大きなライブラリを使用しても、そのライブラリの実装はクライアントに送信されないというメリットがあります。
例えば、今回自分が作ったサイトでは、codeブロックに対してシンタックスハイライトを適用しています。
シンタックスハイライトを実現するライブラリはいくつかありますが、いずれもサイズが大きくなる傾向にあります。tips chipsではhighlight.jsを使っていますが、これもgzip圧縮された状態で283kbもあります。(参考: https://bundlephobia.com/package/highlight.js@11.7.0)
これをクライアントサイドのバンドルに含めてしまうとFCPなどの各種指標の悪化につながる可能性があります。
しかし、前述の通りこのサイトではRSCを使用しており、シンタックスハイライトの処理はサーバー上で実行されるため、クライアントサイドにはhightlight.jsのCSSのみがダウンロードされ、JS本体は一切ダウンロードされていません。
このようにサイズが大きくなりがちなライブラリもバンドルサイズのことを気にせずに利用できるのはRSCの大きな利点であると感じました。
ちなみに、RSCはではその仕組み上、内部にstateを持つことができないためボタンを押したら●●する、といったインタラクションがあるコンポーネントは作ることができないほか、クライアントコンポーネントからRSCをインポートすることができないためコンポジションを使う必要があるなど、従来のフロントエンド開発に慣れていればいるほど、躓きそうな制約がいくつかあります。
ですが、バンドルに含まれるJSのサイズを想像しながらコードを書くということから解放されるというのは思った以上に体験が良いように感じました。
特にパフォーマンスのことを気にしてコードを書いたわけではありませんが、Lighthouseは脅威の満点でした。(画像もないので気にすることが少ないというのは大いにありますが)
tips chipsは残念なことに今のところはクライアントコンポーネントはひとつもなく、かなり小さなアプリケーションなので、まだ苦しいところを味わえていない説は濃厚だと思いますが、引き続き色々と検証していこうと思います。
Data fetching
データのfetchの仕方についても今回のバージョン(かつappディレクトリを使用した場合)から大きく変更が加えられました。今回の開発で触れることができたasync/awaitを使ったデータフェッチを紹介します。(ちなみにこれはまだRFC段階のもののようです)
RSC限定になりますが以下のようなコードを書くことができます。
async function getData() {
const res = await fetch('https://api.example.com/...');
// The return value is *not* serialized
// You can return Date, Map, Set, etc.
// Recommendation: handle errors
if (!res.ok) {
// This will activate the closest `error.js` Error Boundary
throw new Error('Failed to fetch data');
}
return res.json();
}
export default async function Page() {
const data = await getData();
return <main></main>;
}
(https://beta.nextjs.org/docs/data-fetching/fetching#asyncawait-in-server-components より引用)
関数コンポーネント自体がPromiseを返すようになっており、その中で普通にデータフェッチをしています。
従来のクライアントコンポーネントでは、useEffectを使って取得したデータをstateに保持したり、それらの処理を扱いやすく隠蔽したhookライブラリを使ったりしていましたが、dataがnullだったら…みたいな分岐の入ったコードを書く必要がありました。それと比べるとこちらはかなり直感的にわかりやすいコードになっているように感じます。
tips chipsにおいては、NotionのブロックをDOMに変換するコンポーネントを書く際にこの機能が役に立ったと感じています。
例えば、Notionで2階層以上入れ子になったリストを含むページをレンダリングするケースを考えます。
APIの仕様上、ページ全体のブロックリストを取ってくるAPIは1階層目までのブロックの中身しか返してきません。そのためリストを全てレンダリングするには、1階層目のリスト要素のIDを指定して子要素のリストを取得するためのリクエストを再度投げる必要があるという構造をしています。
これを以下のように実装することができます。(以下かなり簡略化したコードです、実際はもう少し面倒かつ考慮することがある)
const NotionBlock = ({ block }) => {
switch (block.type) {
case 'bulleted_list_item': // Blockがリストの要素だったら
return (
<li>
{block.bulleted_list_item.rich_text.map((richText, i) => <RichText richText={richText} key={i}/>)} { /* 1階層目の内容をrender */}
{/* @ts-ignore Server Components */}
{block.has_children ? <NotionBlocks parentBlockId={block.id} /> : null} {/* さらに子要素があればブロックのリストを取得してrenderするコンポーネントをrender */}
</li>
)
/* 中略 */
case 'unsupported':
return null
}
}
const NotionBlocks = async ({parentBlockId}) => {
const blocks = await getPageBlocks(getClient(), parentBlockId) // blockのリストを取得!
return <>{blocks.map(block => <NotionBlock block={block} key={block.id}/>)}</> // blockごとにrenderする
}
NotionBlocksコンポーネントはNotionBlockコンポーネントをrenderし、NotionBlockコンポーネントからは再帰的にNotionBlocksコンポーネントが呼び出されています。NotionBlocksはPromiseを返しています。
従来のクライアントコンポーネントで同じことをやろうとすると、一度にAPI呼び出しを再帰的に行なって全階層の情報を取得してからレンダリングするといった方法をとる必要があると思うので、かなりわかりやすく書けているのではと思っています。
@vercel/og
だいぶ長くなってきて、ここまで読んでいる人がいるのか定かではありませんが、個人的に一番感動した新機能であるOG画像の動的生成についてです。
ちなみにこの機能に関しては13系の新機能ではなくv12.2.3以降で利用できるようです。
各種メディアプラットフォームでは記事をシェアした時に動的に画像を生成してog:imageとしているサービスがいくつかあります。サクッと思いつくだけでもnote・Qiita・はてなブログ・Zennなどがあります。
それを簡単に実現するのがNext.js 13と同時に発表された@vercel/ogというライブラリです。
JSXとCSSのようなオブジェクトを使ってOG画像を生成することができます。
tips chipsでは以下のような画像が生成されます。
これを実現しているコードは以下のような感じです。
export default function(req: NextRequest) {
const { nextUrl: { searchParams } } = req
const title = searchParams.get('title')
const width = 1200
const height = 630
return new ImageResponse(
(
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%',
background: '#075985'
}}>
<div style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
width: `${width - 64}px`,
height: `${height - 64}px`,
background: 'white',
color: 'black'
}}>
<div style={{lineHeight: '150%', fontSize: '64px', maxWidth: `80%`, textAlign: 'center', marginBottom: '12px'}}>{title}</div>
<div style={{fontSize: '32px', color: '#4b5563'}}>tips chips</div>
</div>
</div>
),
{width, height}
)
}
これをNext.jsのAPI routeに配置してOG画像にAPIのパスを指定することで簡単に動的OGP生成を実装することができます。
CanvasをNode.jsで動かしたりする方法よりもかなり直感的でわかりやすく書けているのではないでしょうか。
今回あまりデザインに凝ることはできませんでしたが(センスがないので)普段使うようなCSSプロパティは大体使用できるようなので、凝った見た目のものも作れるのではないかと思います。
使用できるプロパティは https://github.com/vercel/satori#css から確認することができます。
終わりに
ここまで読んでいただきありがとうございました。いかがだったでしょうか。
後半の技術のセクションは、かなりうんうん悩みながら書いたので、表現がわかりづらいところなどあったかもしれないですが、読んで興味を持った方がいたらぜひ一緒にお話しなどしましょう。(自分もRSCについてはよくわかっていない部分も多いので、教えてやるぜという方も大歓迎です😅)
そしてtips chipsもぜひ覗いてやってください。
この記事では触れていないですが、ISR(Incremental Static Regeneration)を使ってNotionのAPIレスポンスをキャッシュしている上、装飾要素皆無なのでなかなか爆速なサイトに仕上がっていると思います。
ゴリゴリ装飾したサイトも好きなのですが、今の必要最低限な見た目はそれはそれでTHE・技術者のサイトという感じで個人的には気に入っています。
あまり考えずに作りはじめてしまいましたが、案外気に入っているので少しずつアップグレードしていこうと思います。
おしまい。
もしもサポートをいただいたら全額趣味に使います!🚲🧑💻📷