Closed46

"RSC From Scratch. Part 1: Server Components"を読む

hajimismhajimism

↑の文章の前提

  • RSCの使い方の紹介ではなく、作り方の大枠を説明するもの
  • ここで書かれるプログラムは実行効率よりも概念の理解を優先した教育的な目的のためのもの
hajimismhajimism

こういう感じの、テキストファイルを読み込んで表示するだけのサイトをRSCを作りながら改変していくらしい。

import { createServer } from 'http';
import { readFile } from 'fs/promises';
import escapeHtml from  'escape-html';

createServer(async (req, res) => {
  const author = "Jae Doe";
  const postContent = await readFile("./posts/hello-world.txt", "utf8");
  sendHTML(
    res,
    `<html>
      <head>
        <title>My blog</title>
      </head>
      <body>
        <nav>
          <a href="/">Home</a>
          <hr />
        </nav>
        <article>
          ${escapeHtml(postContent)}
        </article>
        <footer>
          <hr>
          <p><i>(c) ${escapeHtml(author)}, ${new Date().getFullYear()}</i></p>
        </footer>
      </body>
    </html>`
  );
}).listen(8080);

function sendHTML(res, html) {
  res.writeHead(200, { "Content-Type": "text/html" });
  res.end(html);
}
hajimismhajimism

Step 1: Let's invent JSX

HTMLを文字列として直接操作しており、そうでない箇所にはescapeHtml(postContent)などと書かなくてはいけないのがイケてない。

JSXを開発し、templateを文字列として操作するのをやめよう。

createServer(async (req, res) => {
  const author = "Jae Doe";
  const postContent = await readFile("./posts/hello-world.txt", "utf8");
  sendHTML(
    res,
    <html>
      <head>
        <title>My blog</title>
      </head>
      <body>
        <nav>
          <a href="/">Home</a>
          <hr />
        </nav>
        <article>
          {postContent}
        </article>
        <footer>
          <hr />
          <p><i>(c) {author}, {new Date().getFullYear()}</i></p>
        </footer>
      </body>
    </html>
  );
}).listen(8080);
hajimismhajimism

jsxは見慣れた(?)オブジェクトツリーを生成する。それをHTMLに変換するコードを書いてみよう、ということになる。

いかが大トロ

 else if (typeof jsx === "object") {
    // Check if this object is a React JSX element (e.g. <div />).
    if (jsx.$$typeof === Symbol.for("react.element")) {
      // Turn it into an an HTML tag.
      let html = "<" + jsx.type;
      for (const propName in jsx.props) {
        if (jsx.props.hasOwnProperty(propName) && propName !== "children") {
          html += " ";
          html += propName;
          html += "=";
          html += escapeHtml(jsx.props[propName]);
        }
      }
      html += ">";
      html += renderJSXToHTML(jsx.props.children);
      html += "</" + jsx.type + ">";
      return html;
    }
hajimismhajimism

興味深いことが書いてある。

Turning JSX into an HTML string is usually known as "Server-Side Rendering" (SSR). It is important note that RSC and SSR are two very different things (that tend to be used together).

hajimismhajimism

Step 2: Let's invent components

JSXができたらこんどはcomponentsが欲しくなるよねレンダリング環境がクライアントかサーバーかに関わらず、componentに分割していくっていうのは嬉しいはず。ということでcomponentsを作る。

先程のページをBlogPostPageとFooterに書き換え

function BlogPostPage({ postContent, author }) {
  return (
    <html>
      <head>
        <title>My blog</title>
      </head>
      <body>
        <nav>
          <a href="/">Home</a>
          <hr />
        </nav>
        <article>
          {postContent}
        </article>
        <Footer author={author} />
      </body>
    </html>
  );
}

function Footer({ author }) {
  return (
    <footer>
      <hr />
      <p>
        <i>
          (c) {author} {new Date().getFullYear()}
        </i>
      </p>
    </footer>
  );
}

本体はこうなる

createServer(async (req, res) => {
  const author = "Jae Doe";
  const postContent = await readFile("./posts/hello-world.txt", "utf8");
  sendHTML(
    res,
    <BlogPostPage
      postContent={postContent}
      author={author}
    />
  );
}).listen(8080);
hajimismhajimism

ただ、さっきのままだとrenderJSXToHTML が解析できないので、以下の条件分岐を加える。jsx.type === "function"のときに再帰させる。

  } else if (typeof jsx.type === "function") { // Is it a component like <BlogPostPage>?
    // Call the component with its props, and turn its returned JSX into HTML.
    const Component = jsx.type;
    const props = jsx.props;
    const returnedJsx = Component(props);
    return renderJSXToHTML(returnedJsx); 
  } 
hajimismhajimism

Step 3: Let's add some routing

レイアウト部分とコンテンツ部分を分離しようという話になった。

function BlogLayout({ children }) {
  const author = "Jae Doe";
  return (
    <html>
      <head>
        <title>My blog</title>
      </head>
      <body>
        <nav>
          <a href="/">Home</a>
          <hr />
        </nav>
        <main>
          {children}
        </main>
        <Footer author={author} />
      </body>
    </html>
  );
}
hajimismhajimism

navigationのためのコンポーネントをLayoutに差し込むことができる。

function BlogIndexPage({ postSlugs, postContents }) {
  return (
    <section>
      <h1>Welcome to my blog</h1>
      <div>
        {postSlugs.map((postSlug, index) => (
          <section key={postSlug}>
            <h2>
              <a href={"/" + postSlug}>{postSlug}</a>
            </h2>
            <article>{postContents[index]}</article>
          </section>
        ))}
      </div>
    </section>
  );
}
hajimismhajimism

if (url.pathname === "/")ならpage = <BlogIndexPage ... />;

else if (!url.pathname.includes(".")) { // Don't match static files (e.g. favicon.ico)ならpage = <BlogPostPage ... />;

として、

  if (page) {
    // Wrap the matched page into the shared layout.
    sendHTML(res, <BlogLayout>{page}</BlogLayout>);
  }

というふうにすればLayout固定のroutingいっちょあがり

hajimismhajimism

Step 4: Let's invent async components

BlogIndexPageBlogPostPageに共通している部分をPostとして切り出したときに、

function Post({ slug, content }) { // Someone needs to pass down the `content` prop from the file :-(
  return (
    <section>
      <h2>
        <a href={"/" + slug}>{slug}</a>
      </h2>
      <article>{content}</article>
    </section>
  )
}

contentを読み込むロジック(前の項でconst postContent = await readFile("./posts/" + postSlug + ".txt", "utf8");とかしていたところ)も共通化してしまいたい。しかしfs.readFileは非同期関数だからできなさそう...

ほんとに?You、やっちゃいなよ。

async function Post({ slug }) {
  const content = await readFile("./posts/" + slug + ".txt", "utf8");
  return (
    <section>
      <h2>
        <a href={"/" + slug}>{slug}</a>
      </h2>
      <article>{content}</article>
    </section>
  )
}
hajimismhajimism

感想:興味深い説明。client-side Reactに慣れきってるからfs.readFileをコンポーネントで呼べないと思っちゃうけれど、ゼロベースで考えたときにむしろそっちのほうがおかしいでしょ?っていう

If you are used to client-side React, you might be used to the idea that you can't call an API like fs.readFile from a component. Even with traditional React SSR (server rendering), your existing intuition might tell you that each of your components needs to also be able to run in the browser — and so a server-only API like fs.readFile would not work.

But if you tried to explain this to someone in 2003, they would find this limitation rather odd. You can't fs.readFile, really?

Recall that we're approaching everything from the first principles. For now, we are only targeting the server environment, so we don't need to limit our components to code that runs in the browser. It is also perfectly fine for a component to be asynchronous, since the server can just wait with emitting HTML for it until its data has loaded and is ready to display.

hajimismhajimism

shadcnも似たようなこと言ってた。'use client'はむしろ特例である。これはApp Routerに慣れた今では強くそう思う。

If you’ve used React for a while, yes it requires a mental model switch. But for beginners, it makes so much sense: fetch data, render and style it - all in one place. If you need to do something in the browser, "use client".

https://twitter.com/shadcn/status/1660338653305114625

hajimismhajimism

戻る。

ついでにslugsもnavigationコンポーネントの中で生成しちゃおう

async function BlogIndexPage() {
  const postFiles = await readdir("./posts");
  const postSlugs = postFiles.map((file) =>
    file.slice(0, file.lastIndexOf("."))
  );
  return (
    <section>
      <h1>Welcome to my blog</h1>
      <div>
        {postSlugs.map((slug) => (
          <Post key={slug} slug={slug} />
        ))}
      </div>
    </section>
  );
}
hajimismhajimism

そうすればroutingのロジックがすっきりして、1つのRouterコンポーネントとして記述できる。

function Router({ url }) {
  let page;
  if (url.pathname === "/") {
    page = <BlogIndexPage />;
  } else if (!url.pathname.includes(".")) {
    const postSlug = sanitizeFilename(url.pathname.slice(1));
    page = <BlogPostPage postSlug={postSlug} />;
  } else {
    const notFound = new Error("Not found.");
    notFound.statusCode = 404;
    throw notFound;
  }
  return <BlogLayout>{page}</BlogLayout>;
}
hajimismhajimism

あ、renderJSXToHTMLの中でComponentの処理をawaitするのを忘れずに。

    // ...
    const returnedJsx = await Component(props);
    // ...

つまりrenderJSXToHTML全体として非同期関数になる

async function renderJSXToHTML(jsx)  {
  // ...
}
hajimismhajimism

注意:親コンポーネントは子コンポーネントのawaitを待つ必要がないため、実際にはストリーミングをさせるのが良いでしょう

hajimismhajimism

Step 5: Let's preserve state on navigation

クライアントサイドの状態を保持したままナビゲーションを実現したい。
そのための3 steps

  1. クライアントサイドのナビゲーションを妨害し、ページを再読み込みすることなく手動でコンテンツを再取得できるようにする
  2. (初期読み込み以外)でサーバーにHTMLの代わりにJSXを送信するように指示する
  3. DOMを破壊せずにJSXの更新を適用するようにクライアントに教える
hajimismhajimism

Step 5.1: Let's intercept navigations

デフォルトのページ遷移を上書きする

 ...
  // Prevent the browser from reloading the page but update the URL.
  e.preventDefault();
  window.history.pushState(null, null, href);
  // Call our custom logic.
  navigate(href);
 ...
hajimismhajimism

navigateは、ページの内容をfetchしてbodyの内容を差し込むという内容にする。

let currentPathname = window.location.pathname;

async function navigate(pathname) {
  currentPathname = pathname;
  // Fetch HTML for the route we're navigating to.
  const response = await fetch(pathname);
  const html = await response.text();

  if (pathname === currentPathname) {
    // Get the part of HTML inside the <body> tag.
    const bodyStartIndex = html.indexOf("<body>") + "<body>".length;
    const bodyEndIndex = html.lastIndexOf("</body>");
    const bodyHTML = html.slice(bodyStartIndex, bodyEndIndex);

    // Replace the content on the page.
    document.body.innerHTML = bodyHTML;
  }
}
hajimismhajimism

ただしこのままだと次のルートのHTMLをfetchしているので状態を保てない。

hajimismhajimism

Step 5.2: Let's send JSX over the wire

↑の問題を、「HTMLのかわりにJSXから生成されるオブジェクトを送信することで差分検出を可能にする」という方針で解決したい。
この章では、URLの末尾に?jsxがついていたらsendJSXすることにする。

    if (url.searchParams.has("jsx")) {
      url.searchParams.delete("jsx"); // Keep the URL passed to the Router clean.
      await sendJSX(res, <Router url={url} />);
    } else {
      await sendHTML(res, <Router url={url} />);
    }
hajimismhajimism
async function sendJSX(res, jsx) {
  const jsxString = JSON.stringify(jsx, null, 2); // Indent with two spaces.
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(jsxString);
}
hajimismhajimism

It is "too early" to turn this JSX into JSON for the client because we don't know what JSX the Router wants to render, and Router only exists on the server. We need to call the Router component to find out what JSX we need to send to the client.

重要なとこだと思うんだけどよくわからない。jsxをそのままJSONとして送るのは性急で、クライアントで解析できるような形にしてから送信しろということ?

async function sendJSX(res, jsx) {
  const clientJSX = await renderJSXToClientJSX(jsx);
  const clientJSXString = JSON.stringify(clientJSX, null, 2);
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(clientJSXString);
}
hajimismhajimism

あ、そのまま送るとこういう結果になっちゃうよってことか。BlogLayoutとかBlogIndexPageはサーバー側にしか無いのでこれをクライアント側に送ってもしょうがないよと。

<BlogLayout>
  <BlogIndexPage />
</BlogLayout>
hajimismhajimism

Step 5.3: Let's apply JSX updates on the client

SSRされたDOMノードに対応するJSXをReactに提供することで、React側でイベントハンドラを追加したりDOMノードを更新したりできるようにする。いわゆるHydrateの説明。

Our app is server-rendered to HTML. In order to ask React to take over managing a DOM node that it didn't create (such as a DOM node created by the browser from HTML), you need to provide React with the initial JSX corresponding to that DOM node. Imagine a contractor asking you to see the house plan before doing renovations. They prefer to know the original plan to make future changes safely. Similarly, React walks over the DOM to see which part of the JSX every DOM node corresponds to. This lets React attach event handlers to the DOM nodes, making them interactive, or update them later. They're now hydrated, like plants coming alive with water.

hajimismhajimism

従来の方法はこれやけども

hydrateRoot(document, <App />);

いまSSRしている関係でクライアント側にはAppみたいなコンポーネントがない。単なるでかいJSXがサーバー側に置いてあるので、それを伝えるような仕組みを作りましょうね...

import { hydrateRoot } from 'react-dom/client';

const root = hydrateRoot(document, getInitialClientJSX());

function getInitialClientJSX() {
  // TODO: return the <html>...</html> client JSX tree mathching the initial HTML
}

という話だと思ったんだけど、getInitialClientJSX()がasyncじゃなくてもいいのか?というところでわからなくなった。

hajimismhajimism

とりあえず、次のページのJSXを呼び出してhydrateRootに読み込ませるという考え方でnavigateを作ることは間違いなさそう。肝心なところをうまくReactに隠したな。

async function navigate(pathname) {
  currentPathname = pathname;
  const clientJSX = await fetchClientJSX(pathname);
  if (pathname === currentPathname) {
    root.render(clientJSX);
  }
}

async function fetchClientJSX(pathname) {
  // TODO: fetch and return the <html>...</html> client JSX tree for the next route
}
hajimismhajimism

getInitialClientJSX()fetchClientJSX()をどう実装していくか考えていきましょう、と続いていく。

hajimismhajimism

Step 5.3.1: Let's fetch JSX from the server

後ろのfetchClientJSX()の方からいくらしい。

こんな感じでJSXが送られてくるので

async function sendJSX(res, jsx) {
  const clientJSX = await renderJSXToClientJSX(jsx);
  const clientJSXString = JSON.stringify(clientJSX);
  res.setHeader("Content-Type", "application/json");
  res.end(clientJSXString);
}

単純にJSON.parseすればよい

async function fetchClientJSX(pathname) {
  const response = await fetch(pathname + "?jsx");
  const clientJSXString = await response.text();
  const clientJSX = JSON.parse(clientJSXString);
  return clientJSX;
}

と思いきや、このままだとこういうエラーが出るらしい。

Objects are not valid as a React child (found: object with keys {type, key, ref, props, _owner, _store}).
hajimismhajimism

なぜかといえば、$$typeof: Symbol.for("react.element")のところがJSON-serializableでなく、Reactが期待するJSXの形で届かないから。

JSON.stringifyに自前のパーサーを読み込ませれば解決できる。Symbol.for("react.element")"$RE"に変換する。なんか知ってるな〜という感じ。

async function sendJSX(res, jsx) {
  // ...
  const clientJSXString = JSON.stringify(clientJSX, stringifyJSX); // Notice the second argument
  // ...
}

function stringifyJSX(key, value) {
  if (value === Symbol.for("react.element")) {
    // We can't pass a symbol, so pass our magic string instead.
    return "$RE"; // Could be arbitrary. I picked RE for React Element.
  } else if (typeof value === "string" && value.startsWith("$")) {
    // To avoid clashes, prepend an extra $ to any string already starting with $.
    return "$" + value;
  } else {
    return value;
  }
}
hajimismhajimism

client側で復元操作を行ってReactに渡す。

async function fetchClientJSX(pathname) {
  // ...
  const clientJSX = JSON.parse(clientJSXString, parseJSX); // Notice the second argument
  // ...
}

function parseJSX(key, value) {
  if (value === "$RE") {
    // This is our special marker we added on the server.
    // Restore the Symbol to tell React that this is valid JSX.
    return Symbol.for("react.element");
  } else if (typeof value === "string" && value.startsWith("$$")) {
    // This is a string starting with $. Remove the extra $ added by the server.
    return value.slice(1);
  } else {
    return value;
  }
}
hajimismhajimism

ここまでで、最初のページ遷移以外はレイアウトで保持している状態を保てるようになる。初期JSXを含めてhydrateできるようになれば万事快調。

hajimismhajimism

Step 5.3.2: Let's inline the initial JSX into the HTML

さっきの疑問について書かれている。

We also don't want to fetch it since it would create an unnecessary waterfall.

To solve this, we're going to assume that the string with the initial JSX is available as a global variable on the client:

const root = hydrateRoot(document, getInitialClientJSX());

function getInitialClientJSX() {
  const clientJSX = JSON.parse(window.__INITIAL_CLIENT_JSX_STRING__, reviveJSX);
  return clientJSX;
}

ああ〜、ん〜?

hajimismhajimism
async function sendHTML(res, jsx) {
  let html = await renderJSXToHTML(jsx);

  // Serialize the JSX payload after the HTML to avoid blocking paint:
  const clientJSX = await renderJSXToClientJSX(jsx);
  const clientJSXString = JSON.stringify(clientJSX, stringifyJSX);
  html += `<script>window.__INITIAL_CLIENT_JSX_STRING__ = `;
  html += JSON.stringify(clientJSXString).replace(/</g, "\\u003c");
  html += `</script>`;
  // ...

らしい。なんかrenderJSXToHTMLとrenderJSXToClientJSXで二度手間じゃない?renderJSXToClientJSXtoHTMLしたい感。とりあえずこうするらしい...

そうか、hydrateRootが呼び出されるのは内部のnavigateだけだから、

async function navigate(pathname) {
  currentPathname = pathname;
  const clientJSX = await fetchClientJSX(pathname);
  if (pathname === currentPathname) {
    root.render(clientJSX);
  }
}

一番最初のsendHTMLのときに一緒に初期JSXが送りつけられてたら別にawaitする必要ないのか

    const url = new URL(req.url, `http://${req.headers.host}`);
    if (url.pathname === "/client.js") {
      await sendScript(res, "./client.js");
    } else if (url.searchParams.has("jsx")) {
      url.searchParams.delete("jsx");
      await sendJSX(res, <Router url={url} />);
    } else {
      await sendHTML(res, <Router url={url} />);
    }
hajimismhajimism

A production-ready RSC setup sends JSX chunks as they're being produced instead of a single large blob at the end.

本家のRSCはでかい塊を送るのではなくてJSXのチャンクを送って効率を上げているらしい

hajimismhajimism

Step 6: Let's clean things up

いい感じに動くようになったのでブラッシュアップするらしい。上手い人の作り方って感じする。

hajimismhajimism

Step 6.1: Let's avoid duplicating work

まずやっぱりsendHTMLでrenderの2度手間が発生しているのでこうするらしい。

async function sendHTML(res, jsx) {
  // 1. Let's turn <Router /> into <html>...</html> (an object) first:
  const clientJSX = await renderJSXToClientJSX(jsx);
  // 2. Turn that <html>...</html> into "<html>...</html>" (a string):
  let html = await renderJSXToHTML(clientJSX);
  // ...
hajimismhajimism

Step 6.2: Let's use React to render HTML

手前でrenderJSXToClientJSX()しているおかげでasync componentもここで解消されるので、ついでにrenderJSXToHTMLの自前実装やめてReactのrenderToString使うらしい。

async function sendHTML(res, jsx) {
  // 1. Let's turn <Router /> into <html>...</html> (an object) first:
  const clientJSX = await renderJSXToClientJSX(jsx);
  // 2. Turn that <html>...</html> into "<html>...</html>" (a string):
  let html = await renderToString(clientJSX);
  // ...
hajimismhajimism

renderJSXToClientJSXを噛ませるだけでクライアントと同じAPI(renderToStringとかhydrateRootとか)が使えるのはきれいですよねと

hajimismhajimism

As far as renderToString and hydrateRoot are concerned, it's pretty much as if Router, BlogIndexPage and Footer have never existed in the first place. By then, they have already "melted away" from the tree, leaving behind only their output.

おもろい説明。サーバーコンポーネントはサーバーにしか無いの絵d、clientからみたら存在しないも同然、"melted away"なんだと。

hajimismhajimism

Step 6.3: Let's split the server in two

renderJSXToClientJSXとrenderToStringは独立しているため、別々な場所で実行できる。

  • server/rsc.js: This server will run our components. It always outputs JSX — no HTML. If our components were accessing a database, it would make sense to run this server close to the data center so that the latency is low.
  • server/ssr.js: This server will generate HTML. It can live on the "edge", generating HTML and serving static assets.

RSCの方はもしDBへのアクセスがあるのならデータセンターの近くで処理を行いたいし、SSRの方はedgeに配置しておく(=> キャッシュと組み合わせてパフォーマンス向上が見込める?)。

RSCとSSR以降が完全に分離できることがようやくわかった。RSCはJSXを返し、SSRはHTMLを返す機能。

We're going to keep this separation between RSC and "the rest of the world" (SSR and user machine) throughout this series. Its importance will become clearer in the next parts when we start adding features to both of these worlds, and tying them together.

hajimismhajimism

Recap

用語整理の公式見解が提供されている。SSRも(RSCの出力を読み込むという点で)Clientなのは納得。

Finally, let's establish some terminology:

  • We will say React Server (or just capitalized Server) to mean only the RSC server environment. Components that exist only on the RSC server (in this example, that's all our components so far) are called Server Components.
  • We will say React Client (or just capitalized Client) to mean any environment that consumes the React Server output. As you've just seen, SSR is a React Client — and so is the browser. We don't support components on the Client yet — we'll build that next! — but it shouldn't be a huge spoiler to say that we will call them Client Components.
hajimismhajimism

最後にいくつかChallengesが紹介されている。別スクラップでやりたい。

このスクラップは2023/06/06にクローズされました