RSC From Scratch. Part 1: Server Components - Challenges

36 阅读2分钟

挑战实现

1. Random background color

function BlogLayout({ children }) {
  const author = "Jae Doe";
  const randomColor = parseInt(Math.random() * 0xffffff).toString(16);

  return (
    <html>
      <body
        style={{ background: `#${randomColor}`, transition: "background .2s" }}
      >
        <nav>
          <a href="/">Home</a>
          <hr />
          <input />
          <hr />
        </nav>
        <main>{children}</main>
        <Footer author={author} />
      </body>
    </html>
  );
}

2. Fragments implementation

先看看 fragment 对应的 JSX 是什么, 在 renderJSXToClientJSX 中输出 JSX 看看:

if (jsx.$$typeof === Symbol.for("react.element")) {
	console.log(jsx)
...

修改一下 Post 组件:

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>
      <section>Extra section</section>
    </>
  );
}

找一下对应的 JSX console:

{
  '$$typeof': Symbol(react.element),
  type: Symbol(react.fragment),
  key: null,
  ref: null,
  props: { children: [ [Object], [Object] ] },
  _owner: null,
  _store: {}
}

可以看到, 除了 type 和一般的 JSX 没什么不同, 所以我们只需要修改 stringifyJSXparseJSX :

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 (value === Symbol.for("react.fragment")) {
    return "$FG";
  } else if (typeof value === "string" && value.startsWith("$")) {
    // To avoid clashes, prepend an extra $ to any string already starting with $.
    return "$" + value;
  } else {
    return value;
  }
}

function parseJSX(key, value) {
  if (value === "$RE") {
    return Symbol.for("react.element");
  } else if (value === "$FG") {
    return Symbol.for("react.fragment");
  } else if (typeof value === "string" && value.startsWith("$$")) {
    return value.slice(1);
  } else {
    return value;
  }
}

3. Support Markdown

直接用就好:

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>
      <Markdown>{content}</Markdown>
    </section>
  );
}

4. Custom Image for Markdown

这里用到包 [image-size](https://www.npmjs.com/package/image-size) , 下面只处理了图片来源于网络的情况:

import sizeOf from "image-size";

async function Image(props) {
  let imageProps = {
    width: undefined,
    height: 100,
    src: props.src,
  };

  try {
    const { buffer, base64 } = await downloadImage(props.src);
    imageProps = {
      ...sizeOf(buffer),
      // src: `data:image/png;base64,${base64}`,
    };
  } catch (err) {
    console.log(err);
  }

  return <img {...props} {...imageProps} />;
}

function downloadImage(url) {
  return new Promise((resolve, reject) => {
    const protocol = url.startsWith("https") ? https : http;
    protocol
      .get(url, (response) => {
        if (response.statusCode !== 200) {
          reject(
            new Error(
              `Failed to download image. Status Code: ${response.statusCode}`
            )
          );
          return;
        }
        const chunks = [];
        response.on("data", (chunk) => chunks.push(chunk));
        response.on("end", () => {
          const buffer = Buffer.concat(chunks);
          resolve({ buffer, base64: buffer.toString("base64") });
        });
      })
      .on("error", (error) => {
        reject(error);
      });
  });
}

5. Support comment

rsc.js 中, 我们增加两个组件:

async function Comment({ slug }) {
  return (
    <section>
      <form action={`/${slug}/comment/add`} method="post">
        <textarea name="content" />
        <div>
          <button>Submit</button>
        </div>
      </form>
    </section>
  );
}

async function CommentList({ slug }) {
  let commentsJson = "";
  try {
    commentsJson = await readFile("./comments/index.txt", "utf8");
  } catch (err) {
    console.error(err);
  }

  const comments = JSON.parse(commentsJson || "{}")[slug] || [];

  return (
    <section>
      {comments.length ? (
        <ul>
          {comments.map((item) => (
            <li key={item.id}>
              {`${item.author} ${new Date(item.time).toString()} > ${
                item.content
              }`}
            </li>
          ))}
        </ul>
      ) : (
        <div>Waiting for 1st comment...</div>
      )}
    </section>
  );
}

对于 index page 我们不展示评论, 只对单个 post 的页面展示:

function BlogPostPage({ postSlug }) {
  return <Post slug={postSlug} showComment />;
}

async function Post({ slug, showComment }) {
  let content;
  try {
    content = await readFile("./posts/" + slug + ".txt", "utf8");
  } catch (err) {
    throwNotFound(err);
  }
  return (
    <section>
      <h2>
        <a href={"/" + slug}>{slug}</a>
      </h2>
      <Markdown components={{ img: Image }}>{content}</Markdown>
      {showComment && (
        <>
	        <hr />
          <CommentList slug={slug} />
          <Comment slug={slug} />
        </>
      )}
    </section>
  );
}

这时, 可以在 hello-world 试一下效果, 会发现点击提交按钮之后页面自动跳转到了 http://127.0.0.1:8080/hello-world/comment/add .

我们需要在 client.js 添加拦截操作, 同时, 成功后刷新当前页面:

window.addEventListener("submit", async (e) => {
  e.preventDefault();
  const formEle = e.target;
  const formData = new FormData(formEle);

  if (formData.get("content")) {
    try {
      await fetch(formEle.action, {
        method: formEle.method,
        body: JSON.stringify({
          content: formData.get("content"),
          time: +new Date(),
          author: "anonymous",
        }),
      });
      e.target.reset();
      navigate(window.location.pathname);
    } catch (err) {
      console.error(err);
    }
  }
});

ssr.js 对添加评论的请求做处理:

createServer(async (req, res) => {
  try {
    ...
    if (url.pathname.endsWith("/comment/add")) {
      let body = "";

      req.on("data", (chunk) => {
        body += chunk.toString();
      });
      req.on("end", async () => {
        const bodyJson = JSON.parse(body);
        await addComment(url.pathname, bodyJson);
        res.setHeader("Content-Type", "application/json");
        res.end(`{"success":true}`);
      });

      return;
    }
    ...
}).listen(8080);

async function addComment(url, body) {
  const slug = url.split("/").filter(Boolean)[0];
  const commentsFileContent = await readFile("./comments/index.txt", "utf8");
  const commentsJson = JSON.parse(commentsFileContent || "{}");

  commentsJson[slug] = commentsJson[slug] || [];
  commentsJson[slug].push({
    ...body,
    id: Math.random().toString(36).slice(2),
  });

  await writeFile(
    "./comments/index.txt",
    JSON.stringify(commentsJson, null, 2),
    "utf8"
  );
}

6. Back/Forward Caches

缓存是在客户端, 我们创建个缓存 Map, key 就是页面的 pathname, 对每次路由跳转都刷新一下缓存:

let currentPathname = window.location.pathname;
const navigateCache = new Map();
const root = hydrateRoot(document, getInitialClientJSX());

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

对前进后退, 有缓存则用缓存, 没有则正常导航:

window.addEventListener("popstate", () => {
  const cache = navigateCache.get(window.location.pathname);
  if (cache) {
    root.render(cache);
  } else {
    navigate(window.location.pathname);
  }
});

同时, 对初始化的 JSX 也做一下缓存:

function getInitialClientJSX() {
  const clientJSX = JSON.parse(window.__INITIAL_CLIENT_JSX_STRING__, parseJSX);
  navigateCache.set(currentPathname, clientJSX);
  return clientJSX;
}

7. Do not preserve comment content

作者给的提示是给 Router 组件中的 {page} 包个东西, 没太理解什么意思. 应该给个 key 就能告诉 React 这是一个不同的组件, React 也就不会去 diff 了:

function Router({ url }) {
  let page;

  if (url.pathname === "/") {
    page = <BlogIndexPage />;
  } else {
    const postSlug = sanitizeFilename(url.pathname.slice(1));
    // add key to post page
    page = <BlogPostPage postSlug={postSlug} key={postSlug} />;
  }
  return <BlogLayout>{page}</BlogLayout>;
}

不过, 仅仅这点改动还不太行. 因为 diff 是在客户端做的, 而 BlogPostPage 还需要转为客户端 JSX, 在这个过程中, key 这个属性丢失了:

async function renderJSXToClientJSX(jsx) {
  ...
	const Component = jsx.type;
	const props = jsx.props;
	const returnedJsx = await Component(props);
	// Component's key(jsx.key) is missing here
	return renderJSXToClientJSX(returnedJsx);

补充一下即可:

return renderJSXToClientJSX({
  ...returnedJsx,
  key: returnedJsx.key || jsx.key,
});

欢迎讨论!