🤖

【翻訳】Webの未来(と過去)はSSRだ。

2023/09/22に公開

元記事

https://deno.com/blog/the-future-and-past-is-server-side-rendering

以下から翻訳になります。


はじめに

サーバーがスイスの地下にあった時[1]は、ラッキーだったら画像があるくらいで全て静的なHTMLが配信されていました。

今では複数の場所からデータを取ってきて、操作ができて、ユーザーと相互性のある最高のアプリケーションになりました。それによってWebの実用性が飛躍的に向上した代わりに、容量や帯域幅[2]、スピードが残念なことになってしまっています。過去10年でデスクトップ[3]でのWebページサイズの中央値は468KBから2284KBと4倍近くまで増加し、モバイルだともっと酷くて145KBから2010KBと約13倍にも膨れ上がっています 。

通信のデータ量が特にモバイルで増えたことで、ひどいUXや長いローディング、おまけにページが完全にレンダリングされるまでの時間は双方向性を失ったまま[4]といった結果を産んでしまいました。でもそれはコードに無駄があるという訳ではなく、必要なコードのみでもその問題が起こっています。

これが今のフロントエンド開発の問題です。最初はあらゆる最新の技術[5]を駆使してイケてるサイトを作るのが楽しかったフロントエンド開発者も、その楽しみを失ってしまいました。今ではブラウザ間のサポートや、遅いネットワーク、そして断続的なモバイル通信との対応という大きな悩みの種と戦っている始末です。

どうやってこの無理難題に挑めば良いのでしょうか?
そう。サーバーに戻りましょう (スイスの地下にまで戻る必要はないけれど)。

ここまでの道のり

最初はPHPがあって、それはそれは素晴らしかった[6]。はてなマークが好きなんだったらね。

Webは静的なHTMLのネットワークとして始まったけれど、PerlやPHPみたいなバックエンドのデータをHTMLとしてレンダリングできるCGIスクリプト言語[7]を使ってユーザーごとに動的に表示できる様になりました。

つまり、動的なサイトを作ってリアルタイムなデータやDBからのデータをユーザーに提供できるようになったという訳です(#, !, $, ?のキーが動いていればね)。

サーバーはネットワークの中でも強力な部分だったのでPHPはサーバー上で動いていました。サーバーでデータを取得してそれをサーバー上でレンダリングし、それをブラウザに配信します。ブラウザの仕事は配信されたものを解釈してページとして見せるだけ。それでも良いのですが、あくまで情報を「見せる」だけであってインタラクティブではありませんでした。

そしてJavaScriptの改善とブラウザの性能向上が起こりました。

それによって私達はクライアント側で直接面白いことができる様になりました。
基礎的なHTMLをJSと一緒にブラウザに送るだけでクライアントに全てを任せられるにに、わざわざ全部を最初にサーバーでレンダリングしてから送ったりする意味はないでしょう?

これがシングルページアプリケーション(SPA)とクライアントサイドレンダリング(CSR)の誕生です。

クライアントサイドレンダリング

CSRまたの名をダイナミックレンダリングでは、大体のコードをユーザーのブラウザであるクライアントサイド上で実行します。クライアントのブラウザは必要なHTMLやJacaScript、その他リソースをダウンロードしてきてUIを表示するためにコードを実行します。

image
出典:Walmart

このアプローチは2つの利点があります。

  1. 優れたユーザー体験。超高速ネットワークと速いバンドル、ダウンロードができれば、超スピーディーなサイトができます。サーバーに追加のリクエストを送る必要がないので全てのページやデータの変更が一瞬でできます。
  2. キャッシング。サーバーを使っていないためHTMLのコア部分とJSのバンドルをCDNにキャッシュできます。それによって高速にユーザーがアクセスでき、企業側のコストも減ります。

Webがよりインタラクティブになるにつれ(ありがとうJavaScriptとブラウザ)、クライアントサイドレンダリングとSPAはデフォルトになっていきました。Webは、特にデスクトップだったり人気のブラウザを使っていたり有線だったりした場合に、高速で凄まじく感じられました。

でもその他の人にとって、Webは這うように遅かったのです。Webが成熟するにつれてより多くのデバイスや違った接続方法でも利用可能になったせいで、SPAで一貫したUXを確保するのが難しくなりました。開発者はIEでChromeと同じように表示されることを確認することだけでなく、都会の中心にいるバスの中の携帯でどのように表示されるかを考えなければならなくなりました。データ接続がキャッシュからJSのバンドルをダウンロードできなかったら何も表示されません。

どうやって簡単に多種多様なデバイスと帯域幅で一貫性を確保できるのでしょうか?答え:サーバーに戻りましょう。

サーバーサイドレンダリング

ブラウザがWebサイトを表示するためにする仕事をサーバーに移行することには多くの利点があります:

  1. HTMLが予め生成されていてページが読み込まれたときには表示の準備ができているため、パフォーマンスが高い。
  2. HTMLがサーバーで生成されているため互換性が高く、ユーザーのブラウザに依存しない。
  3. サーバーがHTMLを生成する処理の大半を行うため複雑性が低く、よりシンプルで小さいコードで実行することができる。

image
出典:Walmart

SSRをサポートしているisomorphic JavaScriptフレームワーク[8]は多くあり、サーバーでHTMLをJavaScriptとともにレンダリングして双方向性のためのJavaScriptと一緒にバンドルされたものをクライアントに送信します。isomorphic Javascriptを書くことは、より推論しやすく最小化されたコードだということを意味します。

これらの機能を備えたNextJSやRemixのようなフレームワークのいくつかはReactに搭載されています。デフォルトでは[9]Reactはクライアントサイドレンダリングのフレームワークですが、renderToString(やその他バージョンではrenderToPipeableStreamrenderToReadableStreamなど)を使うことでSSRとしても使うことができます。NextJSとRemixはrenderToStringより高度な抽象化を提供して、SSRサイトのビルドを簡単にしています。

SSRはトレードオフな点もあります。コントロールができる範囲が拡大しより高速に配信することができますが、SSRの限界はインタラクティブなWebサイトの場合、JSを送信する必要があり、それはハイドレーション[10]と呼ばれるプロセスの中で静的なHTMLと組み合わされます。

ハイドレーションのためにJSを送信すると複雑な問題が発生します:

  • リクエストごとに全てのJSを送信するのか?もしくはルートに基づいて送信するのか?
  • ハイドレーションはトップダウンで行われるのか、そしてどれくらいの時間がかかるのか?
  • 開発者がどの様にコードを管理するのか?

クライアントサイドでは、巨大なバンドルはメモリの問題を引き起こしますし、ハイドレーションが終わるまではインタラクティブな要素は操作不能のため「なんで何も起こらないんだ...?」とユーザーに思わせてしまいます。

Denoにいる私達が好んでいる1つのアプローチは、サーバーサイドでレンダリングされた静的なHTMLの海にインタラクティブコンテンツの島が浮かんでいる様な、islands architectureです。(デフォルトではクライアントへJavaScriptを全く送らない、私達のモダンなWebフレームワークであるFreshがislandを使用していることはご存知でしょう)

SSRで実現したいことはHTMLが高速で配信されてレンダリングされること、そして個別のコンポーネントそれぞれが独立に配信されてレンダリングされることです。これによってより小さなJavaScriptのチャンクを送信してクライアントでレンダリングできます。

これがislandsのはたらきです。
image
出典:元記事

islandは徐々にレンダリングされず、別々にレンダリングされます。islandのレンダリングはそれ以前のコンポーネントのレンダリングにも依存せず、仮想DOM上の他のパーツの更新も個々のislandの再レンダリングを引き起こしません。

islandはSSR全体の利点を保持しつつ、巨大なハイドレーションバンドルのトレードオフを排除しました。
偉大な成功です。

サーバーからのレンダリングの仕方

全てのサーバーサイドレンダリングが等しいわけではありません。server side renderingもあればServer-Side Renderingもあります[11]

ここでDenoでのサーバーからレンダリングの差異の例をお見せしましょう。
Jonas Galvezのintroduction to server renderingをDeno, Oak[12], Handlebars[13]に移植して3通りの同一アプリケーションの差異を紹介します。

  1. 簡潔でテンプレート的な、クライアントサイドの双方向性がないサーバーでレンダリングされたHTMLの例
  2. 最初にサーバーでレンダリングされた後クライアントサイドで更新される例
  3. isomorphic JSと共有データモデルを含んだ、完全にサーバーでレンダリングされた例

ここから全ての例を見ることができます。

真の意味でのSSRは3つ目の例です。サーバーとクライアント両方で利用される単一のJavaScriptファイルがあり、リストへの更新はデータモデルの更新によって作成されます。

でもその前に、テンプレート化をしましょう。最初の例では、リストをレンダリングするだけです。
以下が主なserver.tsです。

import { Application, Router } from "https://deno.land/x/oak@v11.1.0/mod.ts";
import { Handlebars } from "https://deno.land/x/handlebars@v0.9.0/mod.ts";

const dinos = ["Allosaur", "T-Rex", "Deno"];
const handle = new Handlebars();
const router = new Router();

router.get("/", async (context) => {
  context.response.body = await handle.renderView("index", { dinos: dinos });
});

const app = new Application();

app.use(router.routes());
app.use(router.allowedMethods());

await app.listen({ port: 8000 });

client.tsは必要なく、代わりにHandlebarsが以下のファイル構造を作成します。

|--Views
|    |--Layouts
|    |     |
|    |     |--main.hbs
|    |
|    |--Partials
|    |
|    |--index.hbs

main.hbs{{{body}}}というプレイスホルダーを含むメインのHTMLレイアウトを保持しています。

<html lang="en">
 <head>
   <meta charset="UTF-8" />
   <meta name="viewport" content="width=device-width, initial-scale=1.0" />
   <title>Dinosaurs</title>
 </head>
 <body>
   <div>
     <!--content-->
     {{{body}}}
   </div>
 </body>
</html>

{{{body}}}index.hbsから来ています。この場合リストを反復するためにHandlebarsの構文を使用しています。

<ul>
 {{#each dinos}}
   <li>{{this}}</li>
 {{/each}}
</ul>

起こっていることとしては、

  • ルートがクライアントから呼び出される。
  • サーバーが配列dinosをHandlebarsのレンダラーに渡す。
  • 配列の各要素がindex.hbsの中の配列の中でレンダリングされる。
  • index.hbsの配列全体がmain.hbs内でレンダリングされる。
  • 全てのHTMLがクライアントへのレスポンスに渡される。

サーバーサイドレンダリング!まぁそんな感じです。サーバーでレンダリングされるけどインタラクティブではありません。

配列にアイテムを追加できる双方向性を足してみましょう。SPAをベースにした、古来のクライアントサイドレンダリングの利用例です。サーバーは/addのエンドポイントでアイテムが配列に追加される以外はほとんど変更しません。

import { Application, Router } from "https://deno.land/x/oak@v11.1.0/mod.ts";
import { Handlebars } from "https://deno.land/x/handlebars@v0.9.0/mod.ts";

const dinos = ["Allosaur", "T-Rex", "Deno"];
const handle = new Handlebars();
const router = new Router();

router.get("/", async (context) => {
  context.response.body = await handle.renderView("index", { dinos: dinos });
});

router.post("/add", async (context) => {
  const { value } = await context.request.body({ type: "json" });
  const { item } = await value;
  dinos.push(item);
  context.response.status = 200;
});

const app = new Application();

app.use(router.routes());
app.use(router.allowedMethods());

await app.listen({ port: 8000 });

ですがHandlebarsのコードは今回大きく変更されています。まだHTMLの配列を生成するためにHandlebarsを使っていますが、main.hbsは下記のイベントリスナーを割り当てられているAddボタンを扱うために自身のJavaScriptを含んでいます。

  • /addエンドポイントで新しい配列のアイテムをPOSTすること
  • HTMLの配列にアイテムをを追加すること
[...]
  <input />
  <button>Add</button>
 </body>
</html>

<script>
 document.querySelector("button").addEventListener("click", async () => {
   const item = document.querySelector("input").value;
   const response = await fetch("/add", {
     method: "POST",
     headers: {
       "Content-Type": "application/json",
     },
     body: JSON.stringify({ item }),
   });
   const status = await response.status;
   if (status === 200) {
     const li = document.createElement("li");
     li.innerText = item;
     document.querySelector("ul").appendChild(li);
     document.querySelector("input").value = "";
   }
 });
</script>

ですがこれでは真の意味でサーバーサイドレンダリングとは呼べません。SSRでは同じisomorphic JSをクライアントとサーバーで実行して、実行する場所によって振る舞いを変えています。上記の例だとJSはクライアントとサーバーで実行されていますが、これらは独立して動いています。

それでは真のSSRです。Handlebarsとテンプレートを廃止して代わりにDinosaursで更新するDOMを作成します。3つのファイルを生成して、最初はserver.tsを作り直します。

import { Application } from "https://deno.land/x/oak@v11.1.0/mod.ts";
import { Router } from "https://deno.land/x/oak@v11.1.0/mod.ts";
import { DOMParser } from "https://deno.land/x/deno_dom@v0.1.36-alpha/deno-dom-wasm.ts";
import { render } from "./client.js";

const html = await Deno.readTextFile("./client.html");
const dinos = ["Allosaur", "T-Rex", "Deno"];
const router = new Router();

router.get("/client.js", async (context) => {
  await context.send({
    root: Deno.cwd(),
    index: "client.js",
  });
});

router.get("/", (context) => {
  const document = new DOMParser().parseFromString(
    "<!DOCTYPE html>",
    "text/html",
  );
  render(document, { dinos });
  context.response.type = "text/html";
  context.response.body = `${document.body.innerHTML}${html}`;
});

router.get("/data", (context) => {
  context.response.body = dinos;
});

router.post("/add", async (context) => {
  const { value } = await context.request.body({ type: "json" });
  const { item } = await value;
  dinos.push(item);
  context.response.status = 200;
});

const app = new Application();

app.use(router.routes());
app.use(router.allowedMethods());

await app.listen({ port: 8000 });

かなり変更しています。最初に新しいエンドポイントを追加しました。

  • client.jsを提供するためのGETエンドポイント
  • データを提供するためのGETエンドポイント

ですがルートエンドポイントにも大きな変更があります。今はdeno_domDOMParserを使ってDOMを生成しています。DOMParserモジュールはReactDOMのような働きで、サーバーでDOMを再生成することができます。次に作成されたdocumentを使って配列をレンダリングしますが、handlebarsテンプレートを使う代わりに新しく作成したclient.jsからレンダリング関数を取得します。

let isFirstRender = true;

// Simple HTML sanitization to prevent XSS vulnerabilities.
function sanitizeHtml(text) {
  return text
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#039;");
}

export async function render(document, dinos) {
  if (isFirstRender) {
    const jsonResponse = await fetch("http://localhost:8000/data");
    if (jsonResponse.ok) {
      const jsonData = await jsonResponse.json();
      const dinos = jsonData;
      let html = "<html><ul>";
      for (const item of dinos) {
        html += `<li>${sanitizeHtml(item)}</li>`;
      }
      html += "</ul><input>";
      html += "<button>Add</button></html>";
      document.body.innerHTML = html;
      isFirstRender = false;
    } else {
      document.body.innerHTML = "<html><p>Something went wrong.</p></html>";
    }
  } else {
    let html = "<ul>";
    for (const item of dinos) {
      html += `<li>${sanitizeHtml(item)}</li>`;
    }
    html += "</ul>";
    document.querySelector("ul").outerHTML = html;
  }
}

export function addEventListeners() {
  document.querySelector("button").addEventListener("click", async () => {
    const item = document.querySelector("input").value;
    const dinos = Array.from(
      document.querySelectorAll("li"),
      (e) => e.innerText,
    );
    dinos.push(item);
    const response = await fetch("/add", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ item }),
    });
    if (response.ok) {
      render(document, dinos);
    } else {
      // In a real app, you'd want better error handling.
      console.error("Something went wrong.");
    }
  });
}

最初にクライアントがclient.jsを呼び出した時にHTMLがハイドレーションされて、その後にclient.jsが今後のレンダリングに必要なデータを取得するために/dataエンドポイントを呼び出します。ハイドレーションは巨大なSSRのページを取得する際に遅く複雑になることがあります(そしてislandsがその助けになります)。

以下がSSRの仕組みです。

  • サーバーでDOMが再生成される
  • isomorphic JSがサーバーとクライアントの両方でデータのレンダリングに利用可能であり、完全な双方向性を実現するのに必要な全てのデータを確保するためにハイドレーションがクライアントの初期ロードをセットする。

SSRで複雑なWebをシンプルにする

私達は、全ての画面サイズと帯域幅に対応するために複雑なアプリケーションを開発しています。人々はトンネルの中の電車であなたのサイトを見ているかもしれません。どんな状況でも一貫したユーザー体験を実現して、コードを小さく保ち推測しやすくするための最善策はSSRです。

ユーザー体験に関心を払っている高性能フレームワークはクライアントに必要なものだけを送信しています。よりレンテンシを最小化するためにはSSRアプリケーションをエッジのユーザーの近くに配置しましょう。今回のこと全てはFreshDeno Deployで実現できます。

詰まりましたか?私達のDiscordで質問に答えてもらいましょう。

脚注
  1. スイスの欧州原子核研究機構(CERN)でWebが発明されたという歴史的経緯から恐らく「Webが誕生した時」という意味で用いられている。 ↩︎

  2. 通信の際の周波数の幅のこと。一般的に帯域幅が大きいほど高速に通信ができると言われている。参考リンク ↩︎

  3. PC版という意味だと思われる ↩︎

  4. 例:読み込みが終わるまで操作ができないなど。 ↩︎

  5. 翻訳元は The bells and whistlesという語で、以前はオプション機能みたいな意味だったのが最近では最新技術的な意味も持つ様になったらしい ↩︎

  6. 元はlo it was greatという表現がされていたがloの意味が判別できなかったので訳では無視している ↩︎

  7. Common Gateway Interfaceの略で、Webブラウザから要求があった際に別のプログラムを呼び出してその処理をブラウザに送れる仕組み ↩︎

  8. クライアントとサーバーの両方で同じコードをレンダリングできる手法のこと。
    https://en.wikipedia.org/wiki/Isomorphic_JavaScript
    https://qiita.com/kyrieleison/items/4ac5bcc331aee6394440
    ↩︎

  9. 翻訳元はOut-of-the-boxで、直訳では「箱から取り出した状態」となるがここでは「デフォルトでは」と訳している ↩︎

  10. サーバー側で事前に生成された静的なHTMLを先にクライアントに配信し、後からJSなどを注入する手法。先に静的要素を配信することで初期描画を高速化することができる。
    https://qiita.com/kmfj/items/e88a2ec70a0e5d6eb3f4
    ↩︎

  11. 恐らくサーバーサイドレンダリングの中でもいくつか段階があるという意味。 ↩︎

  12. DenoのHTTPサーバー向けミドルウェアフレームワーク。DenoでWebアプリケーションを構築する際には一般的に選ばれている。出典 ↩︎

  13. JavaScriptのテンプレートエンジンでJSのデータから動的にHTMLを生成したりヘルパーを定義するなどの機能を持っている。 ↩︎

Discussion