从零开始实现 RSC. Part 1: Server Components

原文: github.com/reactwg/ser... 作者: Dan Abramov


原文不太好翻译成中文, 本文做了些许改动删减
文中 "注:" 开头的部分不是原作者写的

强调一下

本文不是解释 React Server Components 有什么好处, 也不是使用 RSC 写一个 app 或做一个框架. 本文是介绍 RSC 是如何一步步"发明"出来的, 并带你从零实现每一个过程.

回到过去

回到 2003 年, Web 开发还处于起步阶段. 假如我们想创建一个博客网站, 需要返回服务器上的文本文件内容. 用 PHP, 可能会这样写*(为了保持可读性, 使用了现代化 tag)*:

php 复制代码
<?php
  $author = "Jae Doe";
  $post_content = @file_get_contents("./posts/hello-world.txt");
?>
<html>
  <head>
    <title>My blog</title>
  </head>
  <body>
    <nav>
      <a href="/">Home</a>
      <hr>
    </nav>
    <article>
      <?php echo htmlspecialchars($post_content); ?>
    </article>
    <footer>
      <hr>
      <p><i>(c) <?php echo htmlspecialchars($author); ?>, <?php echo date("Y"); ?></i></p>
    </footer>
  </body>
</html>

差不多一样功能的 Node.js 代码*(假设 2003 年的我们手撸了个 Node.js)*:

jsx 复制代码
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.setHeader("Content-Type", "text/html");
  res.end(html);
}

Open this example in a sandbox.

现在我们准备把代码改成 React 风格, 应该怎么一步步实现?

Step 1: 发明 JSX

上面的代码有 2 个不理想的地方:

  1. 直接字符串拼接, 难以确认标签的闭合
  2. 手动转义 HTML

为了解决这种问题, 我们直接发明一种类似模板的语法:

jsx 复制代码
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);

不再是拼接字符串, 而是直接在 JavaScript 中使用标签. 至此, 我们 "发明" 了 JSX.

不像字符串拼接, JSX 的渲染逻辑保证了标签的闭合, 并且不需要关注内容的转义.

在底层, JSX 会产出一个类似这样的树:

json 复制代码
// Slightly simplified
{
  $$typeof: Symbol.for("react.element"), // Tells React it's a JSX element (e.g. <html>)
  type: 'html',
  props: {
    children: [
      {
        $$typeof: Symbol.for("react.element"),
        type: 'head',
        props: {
          children: {
            $$typeof: Symbol.for("react.element"),
            type: 'title',
            props: { children: 'My blog' }
          }
        }
      },
      {
        $$typeof: Symbol.for("react.element"),
        type: 'body',
        props: {
          children: [
            {
              $$typeof: Symbol.for("react.element"),
              type: 'nav',
              props: {
                children: [{
                  $$typeof: Symbol.for("react.element"),
                  type: 'a',
                  props: { href: '/', children: 'Home' }
                }, {
                  $$typeof: Symbol.for("react.element"),
                  type: 'hr',
                  props: null
                }]
              }
            },
            {
              $$typeof: Symbol.for("react.element"),
              type: 'article',
              props: {
                children: postContent
              }
            },
            {
              $$typeof: Symbol.for("react.element"),
              type: 'footer',
              props: {
                /* ...And so on... */
              }              
            }
          ]
        }
      }
    ]
  }
}

注: nodejs 并不认识 JSX, 这里使用 babel 解析一下:

jsx 复制代码
import babel from "@babel/core";

const babelOptions = {
  babelrc: false,
  ignore: [/\/(build|node_modules)\//],
  plugins: [["@babel/plugin-transform-react-jsx", { runtime: "automatic" }]],
};

export async function load(url, context, defaultLoad) {
  const result = await defaultLoad(url, context, defaultLoad);
  if (result.format === "module") {
    const opt = Object.assign({ filename: url }, babelOptions);
    const newResult = await babel.transformAsync(result.source, opt);
    if (!newResult) {
      if (typeof result.source === "string") {
        return result;
      }
      return {
        source: Buffer.from(result.source).toString("utf8"),
        format: "module",
      };
    }
    return { source: newResult.code, format: "module" };
  }
  return defaultLoad(url, context, defaultLoad);
}

启动的命令加一下参数: "start": "nodemon -- --experimental-loader ./node-jsx-loader.js ./server.js"

不过, 我们的目的是生成 HTML 返回给客户端而不是上面的 JSON.

写一个方法把上面的 JSON 转为 HTML:

json 复制代码
function renderJSXToHTML(jsx) {
  if (typeof jsx === "string" || typeof jsx === "number") {
    // This is a string. Escape it and put it into HTML directly.
    return escapeHtml(jsx);
  } else if (jsx == null || typeof jsx === "boolean") {
    // This is an empty node. Don't emit anything in HTML for it.
    return "";
  } else if (Array.isArray(jsx)) {
    // This is an array of nodes. Render each into HTML and concatenate.
    return jsx.map((child) => renderJSXToHTML(child)).join("");
  } 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;
    } else throw new Error("Cannot render an object.");
  } else throw new Error("Not implemented.");
}

Open this example in a sandbox.

在服务端把 JSX 转为 HTML, 这其实就是服务端渲染.

注: Next.js 的 SSR/SSG 也就是类似的过程

Step 2: 发明组件

把 UI 拆分为不同的部分, 也就是不同的组件, 是很有意义的: 给组件命名, 通过 props 传递信息.

我们把前面的例子拆分为 2 个组件: BlogPostPageFooter

jsx 复制代码
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>
  );
}

之前的一大段 JSX 可以替换成组件的写法:

jsx 复制代码
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);

不过, 如果 renderJSXToHTML 的实现不做任何改动, <BlogPostPage postContent={postContent} author={author} /> 这段 jsx 会被转成这样:

jsx 复制代码
<!-- This doesn't look like valid at HTML at all... -->
<function BlogPostPage({postContent,author}) {...}>
</function BlogPostPage({postContent,author}) {...}>

问题出在 renderJSXToHTML 的实现中, 我们假设 jsx.type 是一个 HTML 标签名(比如 html footer p)

jsx 复制代码
if (jsx.$$typeof === Symbol.for("react.element")) {
  // Existing code that handles HTML tags (like <p>).
  let html = "<" + jsx.type;
  // ...
  html += "</" + jsx.type + ">";
  return html;
} 

但是现在, 组件 BlogPostPage 其实是一个 function, jsx.type 的值是函数的源码. 显然, 这不是我们想要的. 我们应该调用这个函数 - 然后把函数返回的 JSX 序列化为 HTML:

jsx 复制代码
if (jsx.$$typeof === Symbol.for("react.element")) {
  if (typeof jsx.type === "string") { // Is this a tag like <div>?
    // Existing code that handles HTML tags (like <p>).
    let html = "<" + jsx.type;
    // ...
    html += "</" + jsx.type + ">";
    return html;
  } 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); 
  } else throw new Error("Not implemented.");
}

现在, 在生成 HTML 的时候, 如果再遇到类似 <BlogPostPage author="Jae Doe" /> 的JSX, 我们会把 BlogPostPage 当做函数调用 , 然后把 props { author: "Jae Doe" } 传给这个函数. 函数会返回 JSX. 然后就是一个递归调用 renderJSXToHTML 的过程.

这些改动就足以支持组件和传参. 看看完整的代码:

Open this example in a sandbox.

Step 3: 添加路由

我们的现在代码只能返回 hello-world 一个 blog 对应的一个 blog page, 我们来支持更多的 blog 以及更多的 page.

我们的逻辑是, /hello-world 这样的 URL, 我们返回独立的 blog post 页面, 内容是 ./posts/hello-world.txt ; / 这个根 URL, 我们返回一个索引页面, 展示所有 blog 及其内容. 也就是说, 我们需要添加一个新的 BlogIndexPage 组件, 和 BlogPostPage 有一样的布局, 但内容不一样.

前面的代码里, BlogPostPage 组件从根节点 html 展现了整个页面. 我们把 BlogPostPage 中共享的那一部分 UI (header footer) 摘出来到可复用的组件 BlogLayout 中:

jsx 复制代码
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>
  );
}

这样, BlogPostPage 只需要内容部分, 然后插入到 layout 中:

jsx 复制代码
function BlogPostPage({ postSlug, postContent }) {
  return (
    <section>
      <h2>
        <a href={"/" + postSlug}>{postSlug}</a>
      </h2>
      <article>{postContent}</article>
    </section>
  );
}

再添加一个新的页面 BlogIndexPage 展示所有的 post:

jsx 复制代码
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>
  );
}

这个页面也嵌到 BlogLayout 里面

最后, 我们改一下 server 的代码, 根据 URL 返回不同的页面, 加载页面对应的数据, 并把页面渲染到 layout 里面:

jsx 复制代码
createServer(async (req, res) => {
  try {
    const url = new URL(req.url, `http://${req.headers.host}`);
    // Match the URL to a page and load the data it needs.
    const page = await matchRoute(url);
    // Wrap the matched page into the shared layout.
    sendHTML(res, <BlogLayout>{page}</BlogLayout>);
  } catch (err) {
    console.error(err);
    res.statusCode = err.statusCode ?? 500;
    res.end();
  }
}).listen(8080);

async function matchRoute(url) {
  if (url.pathname === "/") {
    // We're on the index route which shows every blog post one by one.
    // Read all the files in the posts folder, and load their contents.
    const postFiles = await readdir("./posts");
    const postSlugs = postFiles.map((file) => file.slice(0, file.lastIndexOf(".")));
    const postContents = await Promise.all(
      postSlugs.map((postSlug) =>
        readFile("./posts/" + postSlug + ".txt", "utf8")
      )
    );
    return <BlogIndexPage postSlugs={postSlugs} postContents={postContents} />;
  } else {
    // We're showing an individual blog post.
    // Read the corresponding file from the posts folder.
    const postSlug = sanitizeFilename(url.pathname.slice(1));
    try {
      const postContent = await readFile("./posts/" + postSlug + ".txt", "utf8");
      return <BlogPostPage postSlug={postSlug} postContent={postContent} />;
    } catch (err) {
      throwNotFound(err);
    }
  }
}

function throwNotFound(cause) {
  const notFound = new Error("Not found.", { cause });
  notFound.statusCode = 404;
  throw notFound;
}

现在, 我们可以在 blog 之间导航. 代码看起来有点冗余和笨重, 不过后面会优化.

Open this example in a sandbox.

Step 4: 发明异步组件

前面实现的效果中, 我们可以意识到, BlogIndexPageBlogPostPage 组件中有一部分看起来一模一样:

如果能有一种办法把这部分做成可复用的组件就好了. 即使做到了, 也就是把这部分逻辑抽成一个独立的 Post 组件, 我们仍然需要以某种办法把 post 的内容传递下去:

jsx 复制代码
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 的重复逻辑在 herehere. 因为 readfile API 是异步的, 而我们的组件不是, 所以我们必须在组件外层加载它 - 所以我们不能直接在组件树中使用这个 API. (读取文件确实有同步的 API, 但这里 readfile 代表读取数据的异步过程, 比如读数据库或者调用第三方的异步 API)

那我们应该?...

对于习惯于 client-side React 的我们, 不能调用类似 fs.readFile 这种 Node API 已经是潜意识里的了. 即使是用 SSR, 直觉告诉我们, 我们的组件 需要能够运行在 client-side - 所以, 服务端 API fs.readFile 还是不能用的.

但是, 我们抛弃这些习惯, 只能用 client-side API 的逻辑也太怪了吧? 我们让代码只在 server-side 运行不行吗? 目前为止, 我们上述写的所有代码也都是在服务端环境, 所以我们不必把组件的代码限制在 client-side 也能跑. 这样, 异步的组件也可以很完美的支持, 因为我们可以等待异步数据加载完成, 之后再返回 HTML.

我们对上面的 Post 组件稍作修改, 移除 content 参数, 改为 async 函数, 并在内部通过 await 读取文件内容:

jsx 复制代码
async function Post({ slug }) {
  let content;
  try {
    content = await readFile("./posts/" + slug + ".txt", "utf8");
  } catch (err) {
    throwNotFound(err);
  }
  return (
    <section>
      <h2>
        <a href={"/" + slug}>{slug}</a>
      </h2>
      <article>{content}</article>
    </section>
  )
}

BlogIndexPage 也这样:

jsx 复制代码
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>
  );
}

现在, PostBlogPostPage 可以自行加载对应的数据了, 我们把 matchRoute 改成 Router 组件:

jsx 复制代码
function Router({ url }) {
  let page;
  if (url.pathname === "/") {
    page = <BlogIndexPage />;
  } else {
    const postSlug = sanitizeFilename(url.pathname.slice(1));
    page = <BlogPostPage postSlug={postSlug} />;
  }
  return <BlogLayout>{page}</BlogLayout>;
}

最后, 顶层的 URL 匹配的逻辑全部交给 Router 组件:

jsx 复制代码
createServer(async (req, res) => {
  try {
    const url = new URL(req.url, `http://${req.headers.host}`);
    await sendHTML(res, <Router url={url} />);
  } catch (err) {
    console.error(err);
    res.statusCode = err.statusCode ?? 500;
    res.end();
  }
}).listen(8080);

然后, sendHTML renderJSXToHTML 等都做异步改造:

jsx 复制代码
async function sendHTML(res, jsx)  {
  const html = await renderJSXToHTML(jsx);
  // ...
}
jsx 复制代码
async function renderJSXToHTML(jsx)  {
  // ...
  const returnedJsx = await Component(props);
  // ...
}

这样, 组件树中的组件都可以是异步的了, HTML 渲染结果等待组件 resolve 才返回.

Open this example in a sandbox.

注意, 这个实现并不理想因为每一个 await 都会"阻塞". 就是说, 我们甚至不能开始发送 HTML 直到整个 HTML 生成完成. 理想状况下, 我们应该希望 server payload 在生成时就对它流式传输. 这更复杂, 我们在这篇的安排中不搞这个 - 目前我们只聚焦于数据流. 不过, 必须说明一下, 我们后面不需要修改组件本身就能够支持 streaming. 每一个组件仅仅使用 await 等待它本身的数据(这是不可避免的), 但父组件不需要等待他们的子组件 - 即使子组件是异步的. 这是为什么 React 能够在子组件结束渲染之前流传输父组件的输出

Step 5: 在导航之间保持状态

目前为止, 我们的服务端只能把路由渲染为一个 HTML 字符串:

jsx 复制代码
async function sendHTML(res, jsx) {
  const html = await renderJSXToHTML(jsx);
  res.setHeader("Content-Type", "text/html");
  res.end(html);
}

这对于首次加载没什么毛病, 但路由之间跳转就很不大行了. 我们希望能够在原地"只更新变化的部分", 保持内部和周围的客户端状态(比如 input, video, popup, 等等). 这也能让页面上的突变(比如加一条评论)更流畅.

为了更好阐述这个问题, 我们给 BlogLayout 组件 JSX 里面的 <nav> 添加一个 <input />:

jsx 复制代码
<nav>
  <a href="/">Home</a>
  <hr />
  <input />
  <hr />
</nav>

显然, 这时候每次进行路由跳转的时候, <input/> 里面的输入都会被清空.

简单的博客站问题不大, 但如果我们想让应用有更好的交互体验, 这样子肯定是不行的. 我们希望应用内导航不会导致状态的不断丢失.

我们用三步来修复这个问题:

  1. 添加一些客户端 JS 逻辑来拦截路由(这样我们能够手动的获取新的内容而不是重新加载整个页面).
  2. 对于初次加载之后的导航, 教服务端提供 JSX 而不是 HTML.
  3. 教客户端在不销毁 DOM 的前提下应用 JSX 更新(提示: 这部分用 React 实现).

Step 5.1: 拦截路由

我们在给客户端添加一些逻辑: 给返回的 HTML 添加一个 <script> 标签, 调用新建的文件 client.js . 在这个文件里, 我们会覆写站点内的默认导航行为, 然后调用我们自己的名为 navigate 的方法:

jsx 复制代码
async function navigate(pathname) {
  // TODO
}

window.addEventListener("click", (e) => {
  // Only listen to link clicks.
  if (e.target.tagName !== "A") {
    return;
  }
  // Ignore "open in a new tab".
  if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
    return;
  }
  // Ignore external URLs.
  const href = e.target.getAttribute("href");
  if (!href.startsWith("/")) {
    return;
  }
  // 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);
}, true);

window.addEventListener("popstate", () => {
  // When the user presses Back/Forward, call our custom logic too.
  navigate(window.location.pathname);
});

navigate 方法内, 我们为下一个路由 fetch HTML response, 然后更新 DOM:

jsx 复制代码
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;
  }
}

Open this example in a sandbox.

这些代码对于生产环境还不是很完备(比如, document.title 没有改), 但表明了我们能成功地覆写浏览器导航行为. 目前, 我们还是对下一个路由获取 HTML, 所以 <input> 的状态还是会丢失. 下一步, 我们要教我们的服务端为导航提供 JSX 而不是 HTML. 👀

Step 5.2: 让我们发送 JSX

记得我们之前讲的 JSX 生成的对象树吗:

注: 由 babel 的 JSX loader 生成的

jsx 复制代码
{
  $$typeof: Symbol.for("react.element"),
  type: 'html',
  props: {
    children: [
      {
        $$typeof: Symbol.for("react.element"),
        type: 'head',
        props: {
          // ... And so on ...

我们要在服务端添加新的模式: 当请求以 ?jsx 结尾的时候, 我们要发送一个类似这样的树而不是 HTML. 这个树可以让客户端更简单地决定哪一部分需要更新, 然后只更新必要的 DOM. 这能解决 <input> 状态在每次导航都会丢失的直接问题, 但这不是我们这么做的唯一的目的. 在下一部分(不是现在!), 我们能看到这么做也能让我们从服务端传递新的信息(不仅仅是 HTML)给客户端.

首先, 我们改一下服务端代码, 在遇到 ?jsx search param 的时候, 调用一个新的 sendJSX 函数:

jsx 复制代码
createServer(async (req, res) => {
  try {
    const url = new URL(req.url, `http://${req.headers.host}`);
    if (url.pathname === "/client.js") {
      // ...
    } else 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} />);
    }
    // ...

sendJSX 中, 我们会使用 JSON.stringify(jsx) 把上面提到的对象树转化成能够通过网络传递的 JSON 字符串:

jsx 复制代码
async function sendJSX(res, jsx) {
  const jsxString = JSON.stringify(jsx, null, 2); // Indent with two spaces.
  res.setHeader("Content-Type", "application/json");
  res.end(jsxString);
}

虽然我们把这一步叫做 "send JSX", 但我们发送的不是 JSX 代码(像是 "<Foo />" ). 我们只是拿到 JSX 产生的对象树, 然后转成 JSON 格式的字符串. 不过, 确切的转换格式也会随时间变化 (比如, 真实的 RSC 实现使用了不同的格式, 这个系列后面会加以探索).

我们修改一下客户端代码来看看通过网络传输的是什么东西:

jsx 复制代码
async function navigate(pathname) {
  currentPathname = pathname;
  const response = await fetch(pathname + "?jsx");
  const jsonString = await response.text();
  if (pathname === currentPathname) {
    alert(jsonString);
  }
}

Give this a try. 现在, 如果我们加载索引页 / , 然后点下一个链接, 我们会在 alert 中看到一个像这样的对象:

jsx 复制代码
{
  "key": null,
  "ref": null,
  "props": {
    "url": "http://localhost:3000/hello-world"
  },
  // ...
}

这个东西看起来没啥用 - 我们希望拿到的应该是一个 <html>...</html> 这种结构的 JSX 树. 问题出在哪里?

最开始, 我们的 JSX 看起来是这样的:

jsx 复制代码
<Router url="http://localhost:3000/hello-world" />
// {
//   $$typeof: Symbol.for('react.element'),
//   type: Router,
//   props: { url: "http://localhost:3000/hello-world" } },
//    ...
// }

问题出在, 我们给客户端把这个 JSX 转成 JSON 转的"太早"了, 因为我们并不知道 Router 会渲染出来什么 JSX, 而且 Router 只在服务端存在. 我们需要调用 Router 组件来搞清楚我们需要传给客户端的 JSX.

如果我们调用 Router 函数, props 是 { url: "http://localhost:3000/hello-world" } } , 然后我们会拿到这一部分 JSX:

jsx 复制代码
<BlogLayout>
  <BlogIndexPage />
</BlogLayout>

同样, 在这里把 JSX 转为 JSON 给客户端还是"太早"了, 因为我们不知道 BlogLayout 想要渲染什么东西 - 并且这个组件只存在于服务端. 我们也还是得调用 BlogLayout , 然后搞清楚它想给客户端的是什么 JSX, 以此类推.

(一个有经验的 React 用户可能会反对: 我们不能把代码直接发送给客户端, 这样客户端不就能执行了吗? 把这个想法留这个系列的下一部分! 不过, 这样也只适用于 BlogLayout 因为 BlogIndexPage 调用了 fs.readdir .)

注: 这个系列下一部分是 Client Components, Dan 还没开始写.

在上述递归过程的最后, 我们以一个没有引用任何 server-only 代码的 JSX 树结尾. 比如:

jsx 复制代码
<html>
  <head>...</head>
  <body>
    <nav>
      <a href="/">Home</a>
      <hr />
    </nav>
    <main>
    <section>
      <h1>Welcome to my blog</h1>
      <div>
        ...
      </div>
    </main>
    <footer>
      <hr />
      <p>
        <i>
          (c) Jae Doe 2003
        </i>
      </p>
    </footer>
  </body>
</html>

现在, 是一个我们能够传给 JSON.stringify 然后再传给客户端的树了.

我们写一个函数 renderJSXToClientJSX . 它需要 JSX 片段作为参数, 然后尝试解决 JSX 的 server-only 部分 (通过调用关联的组件) 直到我们只剩下客户端能理解的 JSX.

在结构上, 这个函数类似 renderJSXToHTML , 但它遍历并返回的是对象而不是 HTML:

jsx 复制代码
async function renderJSXToClientJSX(jsx) {
  if (
    typeof jsx === "string" ||
    typeof jsx === "number" ||
    typeof jsx === "boolean" ||
    jsx == null
  ) {
    // Don't need to do anything special with these types.
    return jsx;
  } else if (Array.isArray(jsx)) {
    // Process each item in an array.
    return Promise.all(jsx.map((child) => renderJSXToClientJSX(child)));
  } else if (jsx != null && typeof jsx === "object") {
    if (jsx.$$typeof === Symbol.for("react.element")) {
      if (typeof jsx.type === "string") {
        // This is a component like <div />.
        // Go over its props to make sure they can be turned into JSON.
        return {
          ...jsx,
          props: await renderJSXToClientJSX(jsx.props),
        };
      } else if (typeof jsx.type === "function") {
        // This is a custom React component (like <Footer />).
        // Call its function, and repeat the procedure for the JSX it returns.
        const Component = jsx.type;
        const props = jsx.props;
        const returnedJsx = await Component(props);
        return renderJSXToClientJSX(returnedJsx);
      } else throw new Error("Not implemented.");
    } else {
      // This is an arbitrary object (for example, props, or something inside of them).
      // Go over every value inside, and process it too in case there's some JSX in it.
      return Object.fromEntries(
        await Promise.all(
          Object.entries(jsx).map(async ([propName, value]) => [
            propName,
            await renderJSXToClientJSX(value),
          ])
        )
      );
    }
  } else throw new Error("Not implemented");
}

注: renderJSXToClientJSX 是一个递归的过程, 终止条件是 typeof jsx === "string" || typeof jsx === "number" || typeof jsx === "boolean" || jsx == null

接下来, 我们编辑 sendJSX 让它在 stringifying JSX(比如 <Router /> ) 之前先把 JSX 转为 "client JSX":

jsx 复制代码
async function sendJSX(res, jsx) {
  const clientJSX = await renderJSXToClientJSX(jsx);
  const clientJSXString = JSON.stringify(clientJSX, null, 2); // Indent with two spaces
  res.setHeader("Content-Type", "application/json");
  res.end(clientJSXString);
}

Open this example in a sandbox.

现在, 点击一个链接会看到 alert 展示了一个看着像 HTML 的树 - 这表示我们已经准备好下一步对比变化了.

注意: 目前为止, 我们的目标是让一些东西能跑起来, 但在实现上遗留了很多亟待改进的地方. 这里可以看到 RSC 格式是非常冗余和重复的, 所以真实 RSC 使用了更紧凑的格式. 和前面的生成 HTML 一样, 一次性 await 整个响应是很不好的. 理想情况下, 我们希望能够把 JSX 分为 chunks, 一旦变得可用, 就以流的形式传输, 并在客户端把他们拼起来. 也很不幸的是, 我们重复发送了共享的 layout 部分(像 <html><nav> ), 即使我们知道事实上这部分没有发生变化. 虽然拥有就地刷新整个屏幕的能力 很重要, 但是在默认情况下, 在单一 layout 之间导航的时候重复获取这个 layout 是很不理想的. 一个生产环境就绪的 RSC 实现受不了这些缺点, 但我们现在为了代码更好消化还需要接受一下.

Step 5.3: 我们把 JSX 更新到客户端

严格来说, 我们不是必须用 React 来 diff JSX. 目前为止, 我们的 JSX 节点只包含内置的浏览器组件像 <nav>, <footer>. 我们可以用一个压根没有 client-side 组件这个概念的库来 diff 并应用 JSX 更新. 不过, 我们后面会要丰富的交互性, 所以我们一开始就直接用 React.

我们的应用是在服务端渲染为 HTML 的. 为了让 React 接管一个不是它创建的 DOM 节点(比如浏览器通过 HTML 创建的 DOM 节点), 我们需要把这个节点对应初始的 JSX 提供给 React. 想象一个承包商在装修之前问你要房屋平面图. 他们更希望看到房子原本的平面图, 这样才能让后面的改动更安全. 一样, React 遍历 DOM 以弄清每一个 DOM 节点对应哪一部分 JSX. 这能让 React 往 DOM 节点挂事件 handler, 让他们变得可交互, 或者在之后更新他们. 这样之后, DOM 节点就是 hydrated 的了, 就像植物随水而活.

注: hydrate, 水合物, 使成水化合物, 在这里可译为"注水". 表示 React 将 HTML(SSR) 和 JSX 对应, 并挂载事件的过程

传统上讲, hydrate 服务端生成的 markup, 我们会调用 [hydrateRoot](https://react.dev/reference/react-dom/client/hydrateRoot#usage) , 传入想用 React 管理的 DOM 节点和服务端创建的初始 JSX. 看起来可能是这样:

jsx 复制代码
// Traditionally, you would hydrate like this
hydrateRoot(document, <App />);

但问题是我们在客户端根本没有一个像 <App /> 一样的根组件! 在客户端的视角, 目前我们整个 app 是一个大的 JSX chunk, 而里面没有 丁点 React 组件. 不过, React 真的需要的是和初始 HTML 对应的 JSX 树. 一个像 <html>...</html> 这样的 "client JSX"(我们刚刚教服务端生成的) 就用得上了:

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
}

这会极其快, 因为 client JSX 树现在根本没有组件. React 会瞬间遍历完 DOM 树和 JSX 树, 为之后的 JSX 树更新构建必要内部数据结构.

之后, 一旦用户进行导航, 我们会 fetch 下一个页面的 JSX 树, 然后用 [root.render](https://react.dev/reference/react-dom/client/hydrateRoot#updating-a-hydrated-root-component) 更新 DOM:

jsx 复制代码
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
}

这个代码能完成我们想要的效果 - 跟 React 平常那样更新 DOM 而不销毁状态.

现在, 让我们搞明白怎么实现这两个函数.

Step 5.3.1: 让我们从服务端获取 JSX

我们从 fetchClientJSX 开始, 因为这个比较简单.

首先, 我们回顾一下我们的 ?jsx 服务端点的工作原理:

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);
}

在客户端, 我们调用这个端点, 把 response 喂给 JSON.parse 重新转回 JSX:

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

注: 下面省略原文部分篇幅. 简而言之, 服务端的 JSX 用到了不能 stringify 的类型 Symbol, 需要补充 JSON.stringify 的第二个参数 stringifyJSX :

jsx 复制代码
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;
  }
}

同理, 客户端 JSON.parse 也需要补充第二个参数 parseJSX :

jsx 复制代码
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;
  }
}

注: client.js 中引用了 React 相关代码: import { hydrateRoot } from "react-dom/client" , 需要通过 importmap 让浏览器知道去哪找对应的代码:

jsx 复制代码
async function sendHTML(res, jsx) {
  let html = await renderJSXToHTML(jsx);
  html += `
    <script type="importmap">
      {
        "imports": {
          "react": "https://esm.sh/react@18.3.1",
          "react-dom/client": "https://esm.sh/react-dom@18.3.1/client"
        }
      }
    </script>
    <script type="module" src="/client.js"></script>
  `;
  res.setHeader("Content-Type", "text/html");
  res.end(html);
}

Open this example in a sandbox.

现在, 我们可以再试试在不同页面之间导航 - 不同的是更新的获取是通过 JSX 并且更新的应用是在客户端完成!

如果我们往 input 输入点东西, 然后点击链接, 我们会发现 <input> 的状态可以在所有导航之间保持, 但除了最开始的一个页面. 这是因为我们没告诉 React 页面最初的 JSX 是什么样的, 对于最开始的页面, 服务端返回的 HTML 没有正确地关联相应到的 JSX.

Step 5.3.2: 让我们把初始的 JSX 内置到 HTML

我们还有这样一段代码:

jsx 复制代码
const root = hydrateRoot(document, getInitialClientJSX());

function getInitialClientJSX() {
  return null; // TODO
}

我们需要初始的客户端 JSX 来 hydrate root, 但是我们怎么在客户端拿到这个 JSX?

我们的页面是在服务端渲染为 HTML; 但对于接下来的导航我们需要告诉 React 页面初始的 JSX 是什么. 在某些情况下, 从 HTML 重构出 JSX 到是有可能, 但不总是-尤其是在这个系列后面, 我们会添加交互特性. 我们也不想要 fetch 这个 JSX 因为这可能会创造一个不必要的防火墙.

在传统的 React SSR 中, 我们或许也会遇到一个类似的问题, 不同的是请求的是数据. 我们需要拿到页面的数据, 组件才能 hydrate 并返回他们的初始 JSX. 在我们的例子中, 目前为止页面上没有任何一个组件(至少, 浏览器中没有运行), 所以不需要执行任何东西 - 但是客户端也没有能够生成初始 JSX 的代码.

为了解决这个问题, 我们准备假设初始 JSX 的字符串在客户端是可用的, 作为一个全局变量:

jsx 复制代码
const root = hydrateRoot(document, getInitialClientJSX());

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

在服务端, 我们会修改 sendHTML 函数让它也能把 app 渲染为客户端 JSX, 并内置到 HTML 的后面:

jsx 复制代码
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>`;
  // ...

最后, 我们需要对我们为文本节点生成HTML的逻辑做一些小调整, 这样 React 能够 hydrate 这些文本节点.

Open this example in a sandbox.

现在, 我们再输入一些东西到 input, 可以发现导航的时候状态不再会丢失了:

这就是我们最初设定的目标! 当然, 维持这个 input 的状态不是关键-重要的是我们的应用现在能"就地"刷新, 并且在任意页面之间导航而不需要担心任何状态被销.

注意: 虽然真实的 RSC 实现中确实 encode JSX 到服务端返回的 HTML, 但还是有几个不同的地方. 生产环境就绪的 RSC 设置在 JSX chunks 生成的时候就发送, 而不是最后再发送一个大的 blob. 当 React 加载完, hydration 可以立马开始-React 开始使用已经可用的 JSX chunks 遍历树而不是等所有的 chunks 都到达. React 也让我们把一些组件标记为 Client 组件, 这表示组件虽然一样是被服务端渲染为 HTML, 但组件的代码被包含在了 bundle 中. 对于 Client 组件, 只有组件 props 的 JSON 被序列化. 在未来, React 或许会添加额外的机制来去除 HTML 和 embedded payload 中重复的部分.

Step 6: 我们捋一捋

现在, 我们的代码实际上跑起来了, 我们打算让结构更接近实际的 RSC. 我们现在还不打算实现复杂的机制比如 streaming, 但我们会修几个缺陷, 为下一波功能做准备.

Step 6.1: 让我们避免重复的工作

再看一眼 我们是怎么生成初始 HTML 的:

jsx 复制代码
async function sendHTML(res, jsx) {
  // We need to turn <Router /> into "<html>...</html>" (a string):
  let html = await renderJSXToHTML(jsx);

  // We *also* need to turn <Router /> into <html>...</html> (an object):
  const clientJSX = await renderJSXToClientJSX(jsx);

假设这里的 JSX 是 <Router url="https://localhost:3000" /> .

首先, 我们调用 renderJSXToHTML , 它会递归地调用 Router 和其他组件创建一个 HTML 字符串. 但我们也需要发送初始的客户端 JSX-所以我们之后立马调用 renderJSXToClientJSX , 这 调用 Router 和其他组件. 每个组件我们都调用了两次! 不只是慢, 还会造成潜在的错误 - 比如, 如果我们渲染了一个 Feed 组件, 我们可能从这两个函数拿到不同的输出. 我们需要重新思考数据的流动问题.

假如我们生成 client JSX 呢?

jsx 复制代码
async function sendHTML(res, jsx) {
  // 1. Let's turn <Router /> into <html>...</html> (an object) first:
  const clientJSX = await renderJSXToClientJSX(jsx);

这样, 我们的组件都会执行一次. 之后, 我们用生成的树来生成 HTML:

jsx 复制代码
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);
  // ...

现在每次请求组件只会被调用一次, 理应如此.

Open this example in a sandbox.

注: clientJSX 只包含客户端能直接识别的 tag, 比如 <a> <p> , renderJSXToHTML 退化为了 escapeHtmlprops 拼接

Step 6.2: 让我们使用 React 渲染 HTML

起初, 我们需要一个自定义的 renderJSXToHTML 实现才能控制它执行我们的组件的方式. 比如, 我们当时需要让 renderJSXToHTML 支持 async 函数 . 但是现在我们传了一个提前生成好了的 JSX 树到 renderJSXToHTML , 维护一个自定义的实现就没必要了. 删掉, 使用 React 内置的 [renderToString](https://react.dev/reference/react-dom/server/renderToString) :

jsx 复制代码
import { renderToString } from 'react-dom/server';

// ...

async function sendHTML(res, jsx) {
  const clientJSX = await renderJSXToClientJSX(jsx);
  let html = renderToString(clientJSX);
  // ...

Open this example in a sandbox.

注意与客户端的代码并行. 即使我们已经实现了新的特性(比如 async 组件), 我们仍然能够使用已经存在的 React APIs 像 renderToString 或者 hydrateRoot . 只是使用的方式不太一样.

在传统的服务端渲染 React app 中, 我们用根 <App /> 组件调用 renderToStringhydrateRoot . 但在我们的方法中, 我们先用 renderJSXToClientJSX 执行了 "server" JSX 树, 并把输出传给 React APIs.

在传统的服务端渲染 React app 中, 组件在服务端和客户端都会 用相同的方式执行. 但在我们的方法中, 像 Router , BlogIndexPageFooter 组件实际上是 server-only 的(至少, 现在是).

renderToStringhydrateRoot 而言, Router BlogIndexPageFooter 就好像一开始就不存在, 他们早已"融化"在了树上, 只留下他们的输出.

Step 6.3: 让我们把服务端拆成两个

在先前的步骤中, 我们已经把运行组件和生成 HTML 解耦了:

  • 首先, renderJSXToClientJSX 运行我们的组件产出客户端 JSX.
  • 然后, React 的 renderToString 把客户端 JSX 转为 HTML.

因为这两步是独立的, 他们不需要在同一个进程甚至同一台机器上面完成.

为了证明这一点, 我们打算把 server.js 拆分为 2 个文件:

  • server/rsc.js : 这个服务将运行我们的组件. 它只会输出 JSX - 而不是 HTML. 如果我们的组件连接了一个数据库, 把这个服务运行在离数据中心更近的地方很有意义, 这样延迟会很低.
  • server/ssr.js : 这个服务将生成 HTML. 它活动在"边缘", 生成 HTML 和 serve 静态资源.

我们将在 package.json 并行地运行他们:

json 复制代码
  "scripts": {
    "start": "concurrently \"npm run start:ssr\" \"npm run start:rsc\"",
    "start:rsc": "nodemon -- --experimental-loader ./node-jsx-loader.js ./server/rsc.js",
    "start:ssr": "nodemon -- --experimental-loader ./node-jsx-loader.js ./server/ssr.js"
  },

在我们这个例子中, 他们将会是在同一台机器, 但我们可以独立地部署他们.

RSC 服务是渲染我们的组件的那个. 它的能力仅限于 serve 组件的 JSX 输出:

jsx 复制代码
// server/rsc.js

createServer(async (req, res) => {
  try {
    const url = new URL(req.url, `http://${req.headers.host}`);
    await sendJSX(res, <Router url={url} />);
  } catch (err) {
    console.error(err);
    res.statusCode = err.statusCode ?? 500;
    res.end();
  }
}).listen(8081);

function Router({ url }) {
  // ...
}

// ...
// ... All other components we have so far ...
// ...

async function sendJSX(res, jsx) {
  // ...
}

function stringifyJSX(key, value) {
  // ...
}

async function renderJSXToClientJSX(jsx) {
  // ...
}

另一个服务是 SSR 服务. SSR 服务是用户直连的服务. 它向 RSC 服务请求 JSX, 并把 JSX serve 为一个字符串(用于页面之间的导航), 或者转为 HTML(用于首次加载):

jsx 复制代码
// server/ssr.js

createServer(async (req, res) => {
  try {
    const url = new URL(req.url, `http://${req.headers.host}`);
    if (url.pathname === "/client.js") {
      // ...
    }
    // Get the serialized JSX response from the RSC server
    const response = await fetch("http://127.0.0.1:8081" + url.pathname);
    if (!response.ok) {
      res.statusCode = response.status;
      res.end();
      return;
    }
    const clientJSXString = await response.text();
    if (url.searchParams.has("jsx")) {
      // If the user is navigating between pages, send that serialized JSX as is
      res.setHeader("Content-Type", "application/json");
      res.end(clientJSXString);
    } else {
      // If this is an initial page load, revive the tree and turn it into HTML
      const clientJSX = JSON.parse(clientJSXString, parseJSX);
      let html = renderToString(clientJSX);
      html += `<script>window.__INITIAL_CLIENT_JSX_STRING__ = `;
      html += JSON.stringify(clientJSXString).replace(/</g, "\\u003c");
      html += `</script>`;
      // ...
      res.setHeader("Content-Type", "text/html");
      res.end(html);
    }
  } catch (err) {
    // ...
  }
}).listen(8080);

Open this example in a sandbox.

我们将在这个系列中保持 RSC 和 "世界上剩下的部分"(SSR 和 用户机器) 这种分离. 其中的重要性在系列的下一个部分会变得更清晰, 因为我们将在这两个世界开始添加特性, 并将两个部分捆绑起来.

(严格来讲, 运行 RSC 和 SSR 在同一个进程中技术上是可行的, 但是他们的模块环境必须得彼此隔离. 这是一个进阶话题, 超出这里的讨论范围了)

总结

看看我们今天完成了些什么!

看起来我们写了好多好多代码, 但实际上并没有:

  • [server/rsc.js](https://codesandbox.io/p/sandbox/agitated-swartz-4hs4v1?file=%2Fserver%2Frsc.js) 有 160 行代码, 超过 80 行 是我们的组件.
  • [server/ssr.js](https://codesandbox.io/p/sandbox/agitated-swartz-4hs4v1?file=%2Fserver%2Fssr.js) 有 60 行代码.
  • [client.js](https://codesandbox.io/p/sandbox/agitated-swartz-4hs4v1?file=%2Fclient.js%3A1%2C1) 有 60 行代码.

通读一遍. 为了帮助数据流"沉淀"于我们的大脑, 我们画一些图表.

这是页面首次加载发生了什么:

然后这是在页面之间导航发生了什么:

最后, 我们建立一些术语:

  • React Server (或者就是大写的 Server)仅表示 RSC server 环境. 只存在于 RSC server 的组件(在我们的例子中, 就是我们所有的组件)叫做 Server Components.
  • React Client(或者就是大写的 Client)表示消费 React Server 输出的任意环境. 就像我们刚刚看到过的, SSR 是一个 React Client - 浏览器也是. 我们的代码在客户端目前 还不支持组件 - 下一步我们就会支持! - 但是我们把这叫做 Client Components 也不算是剧透.

挑战

如果你通读了整篇文章但还是喂不饱好奇心, 试试 final code ?

可以试试下面这些 idea:

  • 给页面的 <body> 添加一个随机的背景色, 并给背景色添加一个 transition. 当在页面之间导航时, 应当能够看见背景色动画.
  • 在 RSC 渲染器中实现对 fragments (<>) 的支持. 这应该只需要几行代码, 但你需要弄清代码应该放在哪, 做什么.
  • 搞定之后, 把 blog posts 的格式改为 Markdown, 并使用 react-markdown 中的 <Markdown> 组件. 对的, 我们已有的代码应当能够搞定!
  • react-markdown 组件对不同的 tags 支持指定自定义实现. 比如, 组定义的 Image: <Markdown components={{ img: Image }}> . 写一个 Image 组件来测量图片的尺寸(可以用一些 npm 包)并自动生成 widthheight .
  • 给每个 blog post 添加一个评论 section. 评论保存在硬盘上的 JSON 文件. 你会需要使用 <form> 来提交评论. 作为一个额外的挑战, 继承 client.js 中的逻辑完成拦截表单提交并阻止页面重新加载. 表单提交后, 重新获取页面的 JSX 确保评论列表能够就地更新.
  • 按下后退按钮目前总是会重新获取新的 JSX. 修改 client.js 中的逻辑让 后退/前进 导航复用之前的缓存响应, 点击链接还是获取新的响应. 这样可以保证后退和前进总能瞬间完成, 类似于浏览器对待 full-page 导航的方式.
  • 当你在两个不同的 blog 之间导航的时候, 他们完整的 JSX 会被 diff. 但这不一定就对 - 概念上讲, 这是两篇不同的 posts. 举个例子, 如果你在一篇的评论区打了几个字, 但之后又点了另一个 post 的链接, 你并不希望评论区的内容因为在同一个位置而被保留. 你能想个办法解决吗? (提示: 你或许希望教 Router 组件把不同 URLs 对应的页面当做不同的组件, 通过用什么东西包裹一下 {page} . 之后那你需要保证这个 "什么东西" 不会在传输过程中丢失.)
  • 我们现在序列化 JSX 的格式非常的重复. 你有什么想法让它更紧凑一些吗? 你可以瞅一眼生产环境就绪的 RSC 框架比如 Next.js App Router, 或者看看我们官方的非框架 RSC demo寻求灵感. 即使不实现 streaming, 至少用一种更紧凑的方式表述 JSX 也很好.
  • 想象一下你想要给代码添加 Client Components 的支持. 你会怎么做? 你从哪里开始?

好好玩!


挑战实现

RSC From Scratch. Part 1: Server Components - Challenges

相关推荐
LkeuY16 小时前
react——vite快速搭建一个react项目
前端·react.js
追涨杀跌的小韭菜18 小时前
React快速入门-跟着AI学习react
前端·react.js·ai·前端框架
没那么特别的特别21 小时前
react apollo hooks
前端·javascript·react.js
凯哥爱吃皮皮虾1 天前
前端React、后端NestJs 实现大文件分片上传和下载
前端·react.js·nestjs
渐行渐远君489011 天前
纯前端实现web项目版本发布后用户侧自动刷新
前端·react.js
Ratten1 天前
【taro react】(游戏) ---- 贪吃蛇
react.js
go2coding1 天前
开源 复刻GPT-4o - Moshi;自动定位和解决软件开发中的问题;ComfyUI中使用MimicMotion;自动生成React前端代码
前端·react.js·前端框架
hawk2014bj1 天前
React 打包时如何关闭源代码混淆
前端·react.js·前端框架
IT数据小能手2 天前
如何利用React和Python构建强大的网络爬虫应用
爬虫·python·react.js