我們聊聊從頭學服務器組件:在導航間保留狀態(tài)
存在的問題
到目前為止,訪問每個頁面,服務器返回的都是一個 HTML 字符串:
async function sendHTML(res, jsx) {
const html = await renderJSXToHTML(jsx);
res.setHeader("Content-Type", "text/html");
res.end(html);
}
這對瀏覽器首次加載是非常友好的——瀏覽器有針對 HTML 渲染做針對性優(yōu)化,會盡可能快地展示——但對于頁面導航就不太理想了。我們希望頁面支持布局刷新,即只更新 "發(fā)生變化的部分",頁面其他部分不受影響,也讓交互變得流暢了。
為了說明這個問題,我們向 BlogLayout 組件的 <nav> 中添加一個 <input />:
<nav>
<a href="/">Home</a>
<hr />
<input />
<hr />
</nav>
請注意,每次瀏覽博客時,輸入框里的內容(或叫“狀態(tài)”)都會消失:
圖片
這對一個簡單的博客來說可能沒有問題,但如果要構建具有復雜交互的應用程序,這種行為在某些時候就會讓人頭疼。因此,你需要讓用戶在應用程序中隨意瀏覽時,不會丟失頁面狀態(tài)。
接下來,我們將分 3 步驟來解決這個問題:
- 添加一些客戶端 JS 邏輯攔截導航,修改默認行為(這樣我們就可以手動重新獲取內容,而無需重新加載整個頁面)
- 修改服務端代碼,支持在隨后的導航中需要提供返回 JSX 響應(而不是 HTML)的支持
- 修改客戶端代碼,在不破壞 DOM 的情況下執(zhí)行 JSX 更新(提示:這部分將使用 React)
局部更新支持
步驟 1:攔截導航
我們需要一些客戶端邏輯,因此增加一個 client.js 文件。先修改服務端代碼(即 server.js),增加靜態(tài)資源的路由請求支持(即"/client.js")。
createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${req.headers.host}`);
// 增加靜態(tài)資源 "/client.js" 的路由支持
if (url.pathname === "/client.js") {
await sendScript(res, "./client.js"); // 返回服務端本地的 client.js 文件
} else {
await sendHTML(res, <Router url={url} />);
}
} catch (err) {
// ...
}
}).listen(8080)
async function sendScript(res, filename) {
const content = await readFile(filename, "utf8");
res.setHeader("Content-Type", "text/javascript");
res.end(content);
}
另外,在 sendHTML() 中為響應的 HTML 文本增加 <script> 標簽,引用 client.js 文件。
async function sendHTML(res, jsx) {
let html = await renderJSXToHTML(jsx);
html += `<script type="module" src="/client.js"></script>`;
res.setHeader("Content-Type", "text/html");
res.end(html);
}
現(xiàn)在說回 client.js。在這個文件中,我們會覆蓋網(wǎng)站內導航的默認行為(譯注:此處指<a> 標簽鏈接能力),改調我們自己的 navigate() 函數(shù):
async function navigate(pathname) {
// TODO
}
window.addEventListener("click", (e) => {
// 只處理 <a> 標簽的點擊事件
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;
}
// 這里是核心代碼。首先,阻止 <a> 標簽默認的跳轉行為
e.preventDefault();
// 然后,將跳轉地址推入瀏覽歷史,瀏覽器地址欄的地址也同步更新
window.history.pushState(null, null, href);
// 最后,執(zhí)行自定義的導航行為
navigate(href);
}, true);
window.addEventListener("popstate", () => {
// When the user presses Back/Forward, call our custom logic too.
navigate(window.location.pathname);
});
navigate() 函數(shù)就是編寫自定義導航行為的地方。在 navigate() 函數(shù)中,我們使用 fetch API 請求下一個路由的 HTML 響應,并使用這個響應更新頁面:
let currentPathname = window.location.pathname;
async function navigate(pathname) {
currentPathname = pathname;
// 獲取當前導航路由下的 HTML 數(shù)據(jù)
const response = await fetch(pathname);
const html = await response.text();
if (pathname === currentPathname) {
// 獲取 HTML 響應數(shù)據(jù) <body> 標簽里的內容
const bodyStartIndex = html.indexOf("<body>") + "<body>".length;
const bodyEndIndex = html.lastIndexOf("</body>");
const bodyHTML = html.slice(bodyStartIndex, bodyEndIndex);
// 將拿到的 HTML 數(shù)據(jù)替換掉當前 <body> 里的內容
document.body.innerHTML = bodyHTML;
}
}
點擊這里的 線上 demo[7] 查看效果。
可以看到,在頁面不刷新的情況下,頁面內容被替換了,表示我們已經(jīng)成功覆蓋了瀏覽器默認導航行為。不過,由于目前是針對 <body> 元素的整體覆蓋,所以<input> 里的數(shù)據(jù)仍然會丟失。接下來,我們會修改服務器部分,增加返回 JSX 響應的支持。
步驟 2:增加返回 JSX 響應的支持
還記得我們之前使用 JSX 組成的對象樹結構吧?
{
$$typeof: Symbol.for("react.element"),
type: 'html',
props: {
children: [
{
$$typeof: Symbol.for("react.element"),
type: 'head',
props: {
// ... And so on ...
接下來,我們將為服務器增加以 ?jsx 結尾的請求支持。?jsx 請求返回的是一個 JSX 樹結構,而不是 HTML,這更容易讓客戶端確定哪些部分發(fā)生了變化,并只在必要時更新 DOM,這將直接解決 <input> 狀態(tài)在每次導航時丟失的問題,但這并不是我們這樣做的唯一原因。接下來,你將看到我們如何將新信息(不僅僅是 HTML)從服務器傳遞到客戶端。
首先修改服務器代碼,支持請求中攜帶 ?jsx 參數(shù)時調用一個新的 sendJSX() 函數(shù):
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"); // 確保傳遞給 <Router> 的 url 是干凈的
await sendJSX(res, <Router url={url} />); // 返回 JSX 數(shù)據(jù)
} else {
await sendHTML(res, <Router url={url} />);
}
// ...
在 sendJSX() 中,我們使用 JSON.stringify(jsx) 把的 JSX 樹轉換成一個 JSON 字符串,并通過網(wǎng)絡返回:
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);
}
要注意的是,我們并不是發(fā)送 JSX 語法本身(如 "<Foo />" ),只是將 JSX 生成的樹結構轉換成 JSON 字符串。當然,真正的 RSC 實現(xiàn)使用的是并不是 JSON 格式,這里只是為了方便理解。真正的實現(xiàn)方式我們將在下一個系列進行探討。
修改客戶端代碼,看看目前返回的數(shù)據(jù):
async function navigate(pathname) {
currentPathname = pathname;
const response = await fetch(pathname + "?jsx");
const jsonString = await response.text();
if (pathname === currentPathname) {
alert(jsonString);
}
}
點擊這里[8],加載首頁,然后點擊一個鏈接會看到一個提示,類似下面這樣:
{
"key": null,
"ref": null,
"props": {
"url": "http://localhost:3000/hello-world"
},
// ...
}
這并不是我們想要的結構,我們希望看到類似 <html>...</html> 的 JSX 樹。那是哪里出問題了呢?
我們分析一下,現(xiàn)在的 JSX 看起來是這樣的:
<Router url="http://localhost:3000/hello-world" />
// {
// $$typeof: Symbol.for('react.element'),
// type: Router,
// props: { url: "http://localhost:3000/hello-world" } },
// ...
// }
這種結構發(fā)送給客戶端還“為時過早”,我們不知道 Router 具體的 JSX 內容,因為 Router 只存在服務器上。我們需要在服務端調用 Router 組件,才能得到需要發(fā)送給客戶端的 JSX 內容。
如果,我們使用 { url: "http://localhost:3000/hello-world" } } 作為 prop 調用 Router 函數(shù),會得到這樣一段 JSX:
<BlogLayout>
<BlogIndexPage />
</BlogLayout>
一樣的,把這個結果發(fā)送回客戶端還為時過早,因為我們不知道 BlogLayout 的內容,而且它只存在于服務器上,因此我們還必須調用 BlogLayout,得到最終傳遞給客戶端的 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() 并發(fā)送給客戶端。
首先,編寫一個 renderJSXToClientJSX() 函數(shù)。接收一段 JSX 作為參數(shù),"解析 "服務器專有部分(通過調用相應組件獲得),直到只剩下客戶端可以理解的 JSX。
結構上看,這個函數(shù)類似于 renderJSXToHTML(),但它遞歸遍歷返回的是對象,而不是 HTML:
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");
}
接下來編輯 sendJSX(),先將類似 <Router /> 的 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);
}
點擊這里的 線上 demo[9] 查看效果。
現(xiàn)在點擊鏈接就會顯示一個與 HTML 很類似的樹狀結構提示,這表示我們可以對它進行差異化處理了。
注意:目前我們的目標是能最低要求的工作起來,但在實現(xiàn)過程中還有很多不盡如人意的地方。例如,格式本身非常冗長和重復,真正的 RSC 使用的是更簡潔的格式。就像前面生成 HTML 一樣,整個響應被 await 是很糟糕的體驗。理想情況下,我們希望將 JSX 分塊進行流式傳輸,并在客戶端進行拼接。同樣,共享布局的部分內容(如 <html> 和 <nav> )沒有發(fā)生變化,現(xiàn)在也要重新發(fā)送這些內容。生產就緒的 RSC 實現(xiàn)并不存在這些缺陷,不過我們暫時接受這些缺陷,讓代碼看起來更容易理解。
步驟 3:在客戶端執(zhí)行 JSX 更新
嚴格來說,我們不必使用 React 來擴展 JSX。
到目前為止,我們的 JSX 節(jié)點只包含瀏覽器內置組件(例如:<nav>、<footer>)。為了實現(xiàn)對服務端返回的 JSX 數(shù)據(jù),執(zhí)行差異化處理和更新,我們會直接使用 React。
我們?yōu)?React 提供的 JSX 數(shù)據(jù),會幫助它知道要創(chuàng)建的 DOM 節(jié)點信息,也方便將來安全地進行更改。同樣,React 處理過程·中,也會遍歷 DOM,查看每個 DOM 節(jié)點對應的 JSX 信息。這樣,React 就能將事件處理程序附加到 DOM 節(jié)點,讓節(jié)點具備交互性,我們把這個過程叫水合(hydration)。
傳統(tǒng)上,要將服務器渲染的標記水合,需要調用 hydrateRoot(),需要為 React 提供一個掛載 DOM 節(jié)點,還有服務器上創(chuàng)建的初始 JSX。類似這樣:
// Traditionally, you would hydrate like this
hydrateRoot(document, <App />);
問題是我們在客戶端根本沒有像 <App /> 這樣的根組件!從客戶端的角度來看,目前我們的整個應用程序就是一大塊 JSX,沒有任何 React 組件。然而,React 真正需要的只是與初始 HTML 相對應的 JSX 樹。這個樹類似 <html>...</html> 這樣的:
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
}
這個過程會非??欤驗榭蛻舳朔祷氐?JSX 樹中沒有任何自定義組件。React 將會以近乎瞬時的速度遍歷 DOM 樹和 JSX 樹,并構建稍后更新樹時所需要的一些內部數(shù)據(jù)結構。
然后,在每次用戶導航時,我們獲取下一頁的 JSX,并通過 root.render[10] 更新 DOM:
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
}
補充完 getInitialClientJSX() 和 fetchClientJS() 的內容后。React 就會按照我們預期的方式,創(chuàng)建并托管我們的項目,同時也不會丟失狀態(tài)。
現(xiàn)在,就讓我們來看看如何實現(xiàn)這兩個功能。
從服務器獲取 JSX
我們先從實現(xiàn) fetchClientJSX() 開始,因為它更容易實現(xiàn)。
首先,讓我們回顧一下 ?jsx 服務器端的實現(xiàn)。
async function sendJSX(res, jsx) {
const clientJSX = await renderJSXToClientJSX(jsx);
const clientJSXString = JSON.stringify(clientJSX);
res.setHeader("Content-Type", "application/json");
res.end(clientJSXString);
}
我們在客戶端向服務端發(fā)送 JSX 請求,然后將響應代入 JSON.parse(),再將其轉換為 JSX:
async function fetchClientJSX(pathname) {
const response = await fetch(pathname + "?jsx");
const clientJSXString = await response.text();
const clientJSX = JSON.parse(clientJSXString);
return clientJSX;
}
這樣實現(xiàn)之后[11],點擊鏈接渲染 JSX 時就會報錯:
Objects are not valid as a React child (found: object with keys {type, key, ref, props, _owner, _store}).
原因是我們傳遞給 JSON.stringify() 的對象是這樣的:
{
$$typeof: Symbol.for("react.element"),
type: 'html',
props: {
// ...
但是查看客戶端 JSON.parse() 結果,$$typeof 屬性在傳輸過程中丟失了:
{
type: 'html',
props: {
// ...
對 React 來說,如果 JSX 節(jié)點中沒有 $$typeof: Symbol.for("react.element") 屬性,就不會被看作是有效節(jié)點。這是一種有意為之的安全機制——默認情況下,React 不會隨意將一個任意 JSON 對象視為 JSX 標記。因此,就利用了 Symbol 值無法在 JSON 序列化過程中存在的特性,為 JSX 增加了一個Symbol.for('react.element') 屬性。
不過,我們確實在服務器上創(chuàng)建了這些 JSX 節(jié)點,而且確實希望在客戶端上呈現(xiàn)它們。因此,我們需要調整邏輯,以 "保留" $$typeof: Symbol.for("react.element") 屬性。
幸運的是,這個問題并不難解決。JSON.stringify() 接受一個替換函數(shù)[12],可以讓我們自定義 JSON 的生成行為。在服務器上,我們將用 "$RE" 這樣的特殊字符串替換 Symbol.for('react.element'):
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;
}
}
在客戶端,我們將向 JSON.parse() 傳遞一個 reviver 函數(shù),將 "$RE" 替換為 Symbol.for('react.element') :
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;
}
}
點擊這里的 線上 demo[13] 查看效果。
現(xiàn)在,在頁面間進行導航,不過更新全部是以 JSX 形式獲取并在客戶端應用的!
如果你在輸入框中輸入內容,然后點擊鏈接,會發(fā)現(xiàn) <input> 狀態(tài)在所有導航中都得到了保留,除第一個導航除外。這是因為我們還沒有告訴 React 頁面的初始 JSX 是什么,所以它無法正確關聯(lián)服務器返回的 HTML。
將初始 JSX 內聯(lián)到 HTML 中
還有這段代碼:
const root = hydrateRoot(document, getInitialClientJSX());
function getInitialClientJSX() {
return null; // TODO
}
我們需要將根節(jié)點與初始客戶端 JSX 相結合,但客戶端上的 JSX 怎么來?
為了解決這個問題,我們可以把初始 JSX 字符串作為客戶端的全局變量:
const root = hydrateRoot(document, getInitialClientJSX());
function getInitialClientJSX() {
const clientJSX = JSON.parse(window.__INITIAL_CLIENT_JSX_STRING__, reviveJSX);
return clientJSX;
}
在服務器上,我們修改 sendHTML() 函數(shù),以便將應用程序渲染為客戶端 JSX,并將其內聯(lián)到 HTML 末尾:
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>`;
// ...
最后,我們需要對文本節(jié)點生成 HTML 的方式進行一些小調整[14],以便 React 可以將它們水合。
點擊這里的 線上 demo[15] 查看效果。
現(xiàn)在你可以輸入一些內容,其數(shù)據(jù)不會在導航間丟失:
圖片
這就是我們最初設定的目標!當然,保存這個輸入框數(shù)據(jù)并不是重點,重要的是,我們的應用程序實現(xiàn)了“就地 ”刷新和導航,而不必丟失任何狀態(tài)。
注意:雖然真正的 RSC 實現(xiàn)確實會在 HTML payload 對 JSX 進行編碼,但仍存在一些重要的差異。生產就緒的 RSC 設置會在生成 JSX 塊時發(fā)送它們,而不是在最后發(fā)送單個大 Blob 數(shù)據(jù)。當 React 加載時,水合作用可以立即開始——React 使用已經(jīng)可用的 JSX 塊遍歷樹,而不是非要等到它們全部到達。 RSC 還允許將某些組件標記為客戶端組件,這時候它們仍然會 SSR 到 HTML 中,但它們的代碼會包含在捆綁包中。對于客戶端組件,只有其 props 的 JSON 被序列化。將來,React 可能會添加額外的機制來刪除 HTML 和嵌入的 payload之間的重復內容。
總結
本文,我們?yōu)榉斩嗽黾恿朔祷?JSX 數(shù)據(jù)的支持,并使用 React 在客戶端進行消費,實現(xiàn)基于 JSX 結構的頁面初始化和頁面局部更新。出于安全考慮,React 要求 JSX 節(jié)點中需要包含一個 $$typeof: Symbol.for("react.element") 屬性。為此,我們在序列化和解析的時候,對 $$typeof 做了特殊的轉換處理。
至此,現(xiàn)在我們的代碼實際上可以工作了,而且架構已經(jīng)很接近真正的 RSC 了(雖然沒有引入流式傳輸這樣的復雜機制)。在下一篇,也就是本系列的終篇,我們將對現(xiàn)在的代碼做一些清理工作,修復一些缺陷,并為下一波功能做好準備。