This is my daily life log.

Next.js, Typescript, NotionでJamstackな個人ブログを開発したときのメモ

この記事 is なに?

Next.js, Typescript, Notionで個人ブログを開発しました!のときの開発メモです。

*あくまでも私のメモですので、私の間違った理解や解釈が含まれている可能性もあります。閲覧される方は、引用元のダブルチェックのもとご参考にしてください。

このブログについて

このブログは、Next.js 12で作られていて、SSG(Static Site Generation)とSSR(Server Side Rendering)の”組み合わせ技”で作られています。

  • そのため、ビルド時に静的サイトを一気に作成し、そのあとブラウザ側でビルド時に作成されたものを基本的には描画し続けます。これがSSGの仕組みです。
  • データベース側で更新、つまりNotion側で値を変更し、ブラウザを更新するとサーバー側でNotionデータベースに値を取得しにいくAPIが叩かれ、差分を更新し、ブラウザで描画します。これがSSRの仕組みです。

SSGとSSRの違いについては、リクエスト時にレンダリングするのか、ビルド時にレンダリングするのかの違いと思っておけばいいかと思います。

*当ブログは、ISR(Incremental Static Regeneration)を実装することで上記の組み合わせ技のようなものを実現しています。

Notionデータベースは、以下のようになっています。

Reload again, if you are not able to see images.

ここでpublishedにチェックマークをいれたものがビルド時にSSGされ、リクエスがきたときにはSSRされてブラウザ上に表示されます。

このまま、一番左にあるPageカラムに記事をそのまま書いてpublishできちゃうので普段notionを使っている人にとっては、超絶楽です。こちらがヘッドレスCMSの代わりになっています。

しかし、開発過程でNextjs, Typescript, nodejsを触っていて、負に落ちない部分が沢山でてきました。

そもそも、レンダリングの仕組みがわからん、と。俺が知ってるレンダリングは、毎回リクエスト投げてjsファイルもらって描画するやつや。そんでブラウザ上でデバックしちゃうやつ。ってことでまずは、ブラウザの仕組みの復習からはじめました。

Chromeに画面が表示されるまでを整理

大枠の前提知識として、WebブラウザがWebサーバへ向けてデータを送信するときは、 OSI参照モデルのアプリケーション層から物理層に向けて処理が行われ、逆にWebサーバがWebブラウザからのデータを受信するときは OSI参照モデルの物理層からアプリケーション層に向けて処理が行われます。

  1. URL をアドレスバーに入力したり、リンクをクリックしたり、またはフォームを送信したり、ページをリクエストする。
  2. URLからページのアセットがどこにあるか見つける。アセットが配置されているPCのIPアドレスを見つけるためにブラウザがDNSルックアップを要求
    1. これまでに一度もそのサイトを訪れたことがなかった場合、DNS ルックアップが必要
    2. 二回目以降であれば、ネームサーバーへ再接続する代わりにキャッシュから IP アドレスを取得する。
  3. サーバーとのコネクションを設定
    1. TCPハンドシェイクにより見つけたIPアドレスのサーバーとのコネクション設定
    2. TLS ネゴシエーションによる通信を暗号化
  4. 最初のリクエストを投げ、レスポンスを受け取る
    1. 一番最初は、HTMLのバイトコードの形でサーバーからもらう。
  5. レスポンスの中身を確認する
    1. どんなタイプのデータか。ex) Content-Type: text/html
    2. データの中身は?ex) pyaload: a0 8b28 3f ......(byte code)
  6. Parsing: Bytecodeのhtmlをパースする。CSSも同様にDOMに変換する。[1]
    1. バイトコード
    2. 文字列
    3. トークン:それ以上細かく分割できないような最小単位に分けた文字列
    4. ノード :一つ一つの文字列の塊をあるルールに沿ってオブジェクトという塊にわける
    5. DOM:各ノードの入れ子関係を表現した木構造の持ち方に変換する。
  7. Parsing: HTMLのパース途中で<script>タグが出現したら、一旦パースを中断し、JavaScriptをnetwork threadによりrequestして読見込んで実行する。*途中中断する理由は、DOMの木構造に大きく影響するから。→ The JavaScript engine pipeline
    1. jsファイルを字句解析
    2. 字句解析した結果をもとに構文解析(ASTに変換)
    3. ASTをコンパイル(JITコンパイラ)して機械語に変換
    4. CPUに渡して実行
  8. Style calculation: DOMとCSSOM(CSSのDOM)を対応付ける。[2]
  9. Layout: 各ノードの平面状の位置を計算するためにレイアウト処理を実行することです。レイアウトはレンダーツリーに含まれるすべてのノードの幅と高さ、位置を決める処理です。さらにページ上のそれぞれオブジェクトのサイズと位置を決定します。
  10. Paint: 個別のノードをスクリーンに描画。ブラウザーはレイアウトフェーズで計算されたそれぞれのボックスをスクリーン上の実際のピクセルに変換します。
上記をより理解するための参考チャート
Reload again, if you are not able to see images.

[1] 引用元:Constructing the Object Model

Reload again, if you are not able to see images.

[2] 引用元:Render-tree Construction, Layout, and Paint

Chromeの仕組みを整理

Reload again, if you are not able to see images.
Image cited from

Browser Process:

  • アドレスバー、ブックマーク、戻る/進むボタンを含むchromeアプリケーションそのものが動くプロセス。
  • また、リクエスを要求したりするネットワーク周りやキャッシュなどのストレージへのアクセスも行う。
  • ブラウザを立ち上げたら最小に立つプロセスでChromeの中で中心となるようなメインプロセスプロセス。
  • 上記のステップで言うと、1から5までを行うプロセス。

Render Process:

  • ウェブサイトが表示されているタブの中全部をハンドルするプロセス。
  • 主にBlinkというレンダリングエンジンが動く。Blinkの役割は、下記。
    • 上記の6から10までを行うエンジン。→ 6 - 10までのステップをクリティカルレンダリングパスという。
    • JavascriptエンジンであるV8を起動するエンジン。Blinkの中のコンポーネントの一部になっている。
    • ネットワークスタックからリソースをリクエスする。
  • 上記のステップで言うと、6から10までを行うプロセス。ほとんどBlinkが担うプロセス。

Plugin Process:

  • フラッシュのようなプラグインが動くプロセス。

GPU:

  • GPUタスクを扱うプロセス。*いつ具体的に利用されるか、いまいちわかっていない。
上記をより理解するための補足資料:
Chromium ではマルチプロセスアーキテクチャが採用されています。まず各タブに相当する Renderer プロセスというものがいます。Blink によるレンダリング処理や JavaScript の実行などは Renderer プロセスの中で行われます。一方、ネットワークやディスクへのアクセスといった特権を必要とする処理はメインプロセスである Browser プロセスで行われます。Browser プロセスと Renderer プロセスは 1:n の関係になっています。この他にも GPU プロセスや Plugin プロセスなどがあります。 ... Browser プロセスと Renderer プロセスはそれぞれ browser/ ディレクトリと renderer/ ディレクトリに対応しています。プロセスモデルに従い、これらディレクトリはソースコードレベルでお互いに依存することはできず、IPC (Inter-Process Communication) 機構によってやりとりを行います。common/ は browser/ と renderer/ で共通して使われるヘッダーファイルなどが格納されていて、例えば IPC でやりとりされる enum や構造体、共通して呼び出されるヘルパー関数などが定義されています。 (引用:Chromium のソースコードの歩き方)
Reload again, if you are not able to see images.
引用:

Chromeのディレクトリは下記

src/
  - base/
  - chrome/
    - browser/
    - common/
    - renderer/
  - content/
    - browser/
    - common/
    - renderer/
  - third_party/
    - WebKit/
    - blink/
  - v8/
引用:

下記のスライドもとても参考になりました。面白くて食い入って読みました。

こちらの動画もどうぞ

レンダリング周りの言葉の整理

レンダリングには、クライアントサイドで行うレンダリングとサーバーサイドでレンダリングする場合の二通りがあります。下記は、Reactベースのフレームワークを前提として書いていきます。

クライアントサイド
CSR (Client Side Rendering)

クライアントPC上の開いているブラウザ上のレンダリングエンジンをつかってレンダリングする。もっというと、Chromeつかっていたら、Blinkでレンダリングする。こちら上記で述べたRender Processで行っている内容です。これをいわゆる、CSR(Client Side Rendering)という。ブラウザの仕組みを理解していたら言葉の通りなだけですね。

サーバーサイド
SSR(Server Side Rendering)

CSRの逆でサーバー側で”レンダリング”をします。ダブルクォーテーションをつけたのは、ちょっとCSRで起きていることと同じような感じと認識していると理解しずらいからです。下記のチャートにあるようにReactDOMserver.renderToStringでReactで要素をHTML へと変換し、ブラウザ側に送り、ブラウザ側でもReactDOM.renderで差分をマウントし、描画しています。結構な処理を実装しなければいけませんし、CPU負荷もあるというボトルネックがありますが、Nextjsでは、そのあたりが改善されています。

Reload again, if you are not able to see images.
引用元:

NextjsでもReactDOMは使われています。ラッピングしている箇所はここです。しかし、微妙に”レンダリング”の仕方が違います。Nextjsの場合は、Next.jsはSSRの結果を画面へ表示する前にCSRを実行します。また、ReactDOM.render ではなく ReactDOM.hydrateを利用します。CSRの過程でSSRによって作成されたHTMLをクライアント側で再利用するReactDOM.hydrate()を利用して実行します。そして、ReactDOM.hydrate()はSSRとCSRのレンダリング結果が一致することを期待し、結果が一致する場合はCSRによる再レンダリングをスキップ、不一致の場合は再レンダリングをします。

ここで言えるのは、クライアント側でもサーバー側でもReactDOMが動いています。前者のJavaScriptの実行環境は、V8です。後者の実行環境は、nodejsです。つまり、実行環境が異なるにもかかわらず、クライアントサイドでもサーバーサイド側でも動くように設計していこうという考えのもとに作られているようです。その考え方がIsomorphicやUniversal JavaScriptと呼ばれるようです。

SSG (Server Side Generater)

アプリのビルド時にあらかじめページ表示に必要なデータを取得し、各ページごとに静的なHTMLファイルを出力しておく。

*Generatorなので「SSGする」とは言えなく言語化が不便なのとNextjs公式ではSSGという単語は使っていないので、以後「SG(Static Generation)する」っていう表現を使います。厳密には、SSがServer SideでSGにはその意味合いが単語には含まれていませんが、日本語文章のコンテキストに沿って英語のまま理解すればいいと思うので一旦厳密な差分については言語化をサボります。

ISR(Incremental Static Regeneration)

英語がちょっと難しいですね。直訳すると段階的な静的再生成です。インクリメンタル静的再生成と呼んだほうがいいというVercelで働いてる方はおっしゃられています。

私も英語呼んだ時に「Re-」の部分が気になりましたが、その点についてもVercelで働いている方の回答がありました。なるほど。

私は、このtweetで大体ISRが想像できました。あー、つまり乱暴ないい方すると、ビルド時ではなく、リクエストごとにSG(Static Generation)する、って話かな、と。

つまり、SGは速いけど内容を更新させられない。ISRはそれを解決する方法で、SSRとSGのハイブリッドといえるものなのかなと。一方で、それって従来のMPAと何が違うんだろうと思ったので深堀りしました。

ISRではSSRのようにリクエストに応じてHTMLを生成するのですが、生成はバックグラウンドでやりつつ、すでに生成されている古いほうのHTMLを返却します。

具体例をみてましょう。こちらは、当ブログの一部のコードを抜粋したものになります。以下のように実装することでISRを実現しています。

export async function getStaticProps({ params: { slug } }) {
  const post = await getPostBySlug(slug)
  const blocks = await getAllBlocksByBlockId(post.PageId)
...
...
  return {
    props: {
      post,
      blocks,
    },
    revalidate: 60,
  }
}

export async function getStaticPaths() {
  const posts = await getAllPosts()
  return {
    paths: posts.map(post => getBlogLink(post.Slug)),
    fallback: 'blocking',
  }
}
Code cited from “This is my daily life log” created by Jesse McFaddin

リクエストがあったときには、getStaticPropsの結果がpropsに格納され、Revalidateで設定した時間、上記のコードでいうと60秒待つとSSRかのごとく、getStaticPathsで取得した[slug]を元にビルドし直し、サーバー側でレンダリングしたHTMLをブラウザ側に返します。同時にCDNのキャッシュも更新されます。

getStaticPaths関数内にあるfallbackオプションでblockingを指定することで、getStaticPaths関数の実行完了を待ちます。

fallbackは、falseだとビルド時に静的ページが作成されなかった場合、その後に静的ページが追加されても404になります。trueだとビルド時に静的ページが作成されなかった場合、その後に静的ページが追加されるとアクセスできます。

*Vercel は Vercel Edge Network という CDN をデフォルトで備えています。Vercel にデプロイすれば、世界各地に存在する Vercel のエッジネットワーク(CDN)に自分のアプリケーションのコンテンツが自動的にキャッシュされ、ユーザーに対して高速にコンテンツを提供することができるようになります。

Pre-rendering

クライアント側に送る前に事前にサーバー側でHTMLファイルをレンダリングしておくこと。文字通りのそのままです。

Pre-renderingについての正確な理解は、Nextjsの公式ドキュメントの以下ののページを一読ください。

Reload again, if you are not able to see images.
引用元:
SPA(Single Page Application)

SPAは、Single Page Applicationといって、英語そのままです。毎回、コンテンツ挿入済みのHTMLをブラウザに返すのではなく、SPAの場合は、HTML、JavaScript、CSSが読み込まれるのは初回アクセス時のみ。ブラウザの履歴でブラウザバックができる。また、必要に応じてデーターを非同期でサーバーに問い合わせ、JavaScriptによってブラウザで動的に描画される。従来のWEBアプリのレンダリングまでのフローを比較すると下記になります。

MPA(Multiple or Multi Page Application): 従来のWEBアプリ

  1. (初回アクセス時&二回目以降のアクセス時)リクエストの送信
  2. サーバ側にあるコンテンツ挿入済みのHTMLをブラウザに返す
  3. ブラウザ側(クライアント側)でHTMLをレンダリング(ユーザーの操作の度に、ブラウザはページ全体の再読み込みを行う)

SPAの場合のWEBアプリ

  1. (初回アクセス時)リクエストの送信
  2. サーバー側にあるHTMLをコンテンツなしでブラウザ側に返す
  3. ブラウザ側(クライアント側)でコンテンツをマウントし、レンダリングされHTMLが動的に生成される。
  4. (二回目以降のアクセス時)もし、差分があれば、必要なデータをサーバー側にリクエストする。
  5. 受け取ったブラウザ側(クライアント側)は、JavascirptのDOM操作により差分更新を行いレンダリングする。

SPAの場合、クライアントサイドでコンテンツ更新があるたびにコンテンツをマウントしてレンダリングをする必要があるため、表示速度に影響がでます。また、SNSのクローラーについては、Javascirptを読み込む前、つまり、コンテンツのないHTMLをもとにインデックスしてしまいます。TwitterやFacebookのシェア時のサムネイル画像なんかは、表示ができません。

その解決策として、上記のNextjsの仕様のようなPre-renderingの仕様があります。事前にサーバー側で一度全ページをビルド時にレンダリングしておくことで、両者の課題を解決できます。しかし、これだと画像を差し替えたり、記事の編集を行ったりなどのコンテンツ側の変更を行った場合に、変更後のリクエスト時に新しいコンテンツが反映がされません。そのため、SSRの仕組みをつかってそれらの差分更新を反映するということをしています。

Nextjsでは、これらを下記のAPIで実現しています。ちょっと説明が雑なので詳細は、それぞれの単語にリンク付けされている公式ドキュメントを参照ください。

  • getInitialProps(*現在では、非推奨なので無視)
    • SSR を実行
    • next/linkを使用してクライアントサイドルーティングした場合にはクライアント側で実行されます
  • getServerSideProps
    • SSRを実行
    • getInitialPropsとの差分は、必ずサーバーサイドで実行される。
  • getStaticProps
    • SSGを実行
    • 再ビルドしない限り、いつアクセスしても同じ
  • getStaticPaths
    • SSGを実行
    • 再ビルドしない限り、いつアクセスしても同じ
    • Revalidateを設定することでSSRが可能。getStaticPropsとセットで利用。

じゃー、getServerSidePropsgetStaticProps+getStaticPaths、どちらがいいの、という問いに対しては、これもVercelの方が答えてくれています。

なるほど。swrについても深堀りたいですが、文字数の関係上省略します。

最後に

BFF(Back End For Front End)について

仕事でバックエンドもTypescript書いてるっていうとおそらく、マイクロサービスとBFFアーキテクチャ前提のもとで言っているのかなと思います。BFFとは、フロントエンドのためのバックエンド(サーバ)で以下のチャートをみるとイメージがわきます。

Reload again, if you are not able to see images.
引用元:

nodejsを起動して、色んなバックエンドから来るデータを受け取りつつ、ユーザーからのインプットデータも受け取りつつ、組み合わせて事前にHTMLレンダリングしておいて好きなように各クライアントごとに表示できる、かつパファーマンスも維持できる設計なのかと解釈しています。WEBブラウザが起動するデバイスが多様化してきた、扱うデータ構造のモーダル化してきたのが起因かなと察しております。

以下は、BFFの代表的な5つのユースケースです。

  • API Gateway
  • Server Side Rendering
  • Session Management
  • File Upload
  • WebSocket、Server Sent Events、Long Polling

引用元: BFF(Backends For Frontends)の5つの便利なユースケース

BFFの実践アンチパターンはこちらの記事

具体的な事例として、ZOZOさんのBFFを適用してでてきた課題については参考になるなと思いました。どれぐらいのDAUとDailyのRPSをみて意思決定をしたのかは気になりました。

ぼやき:マイクロサービス。。。。難しいですね。