挑战实现
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 没什么不同, 所以我们只需要修改 stringifyJSX
和 parseJSX
:
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,
});
欢迎讨论!